Convoy intent, coasts in support, and what a disrupted convoy is
122 of 139 movement cases now. Intent, which decides whether two units swapping bounce or sail past each other: an army goes by convoy if it was ordered to, or if its own power gave a legal convoy order for that move. Somebody else's fleets offering a route is not intent however good the route, and your own fleet offering from a sea it could never do it from is not intent either, because that was not an order. Once intent exists any ordered route may carry it, foreign or not. A convoy order is only an order if water could actually carry that army past that fleet, which is a question about the map and is now asked. Support is given to a province, not to a coast of one: a fleet in Marseilles may support an attack on the north coast of Spain although it could never sail there. And a support is only as legal as the move underneath it. Two bugs of my own. Convoy routing could not start from or arrive at a province with two coasts, because the fleet graph has no bare entry for one -- there is no 'bul', only 'bul/ec' and 'bul/sc' -- so an army boarding in Bulgaria found no sea and stayed home. And I had read 'disrupted' as the escort being sunk when it means the army not arriving; in Pandin's Paradox the fleet survives and the army still does not get there.
This commit is contained in:
+25
-3
@@ -117,14 +117,36 @@ function resolveAll(
|
|||||||
return dest
|
return dest
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Is this move going over water rather than across a border? */
|
/**
|
||||||
|
* Is this move going over water rather than across a border?
|
||||||
|
*
|
||||||
|
* Only interesting when both are possible, and then it is a question about
|
||||||
|
* intent rather than about the map -- and intent decides whether two units
|
||||||
|
* swapping places bounce off each other or sail past each other.
|
||||||
|
*
|
||||||
|
* The rule the published cases settle on: an army goes by convoy if it was
|
||||||
|
* ordered to, or if its **own power** gave at least one convoy order for
|
||||||
|
* that move that was a legal order. Somebody else's fleets offering to
|
||||||
|
* carry you is not intent, however good the route -- and your own fleet
|
||||||
|
* offering from a sea it could never do it from is not intent either,
|
||||||
|
* because that was not an order. Once intent exists, any ordered route may
|
||||||
|
* do the carrying, including a foreign one.
|
||||||
|
*/
|
||||||
function isConvoyed(p: string): boolean {
|
function isConvoyed(p: string): boolean {
|
||||||
const o = orderAt(p)
|
const o = orderAt(p)
|
||||||
const unit = unitAt(p)
|
const unit = unitAt(p)
|
||||||
if (o?.type !== 'move' || unit?.type !== 'army') return false
|
if (o?.type !== 'move' || unit?.type !== 'army') return false
|
||||||
|
|
||||||
const overland = (ARMY[base(unit.at)] ?? []).includes(base(o.to))
|
const overland = (ARMY[base(unit.at)] ?? []).includes(base(o.to))
|
||||||
if (overland && !o.viaConvoy) return false
|
if (!overland) return true
|
||||||
return true
|
if (o.viaConvoy) return true
|
||||||
|
|
||||||
|
for (const [q, c] of orders) {
|
||||||
|
if (c.type !== 'convoy') continue
|
||||||
|
if (base(c.from) !== p || base(c.to) !== base(o.to)) continue
|
||||||
|
if (unitAt(q)?.power === unit.power) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fleets convoying that are being thrown out of their sea as we speak. */
|
/** Fleets convoying that are being thrown out of their sea as we speak. */
|
||||||
|
|||||||
+14
-1
@@ -52,10 +52,23 @@ describe('DATC', () => {
|
|||||||
break
|
break
|
||||||
case 'fails':
|
case 'fails':
|
||||||
case 'cut':
|
case 'cut':
|
||||||
case 'disrupted':
|
|
||||||
case 'illegal':
|
case 'illegal':
|
||||||
expect(outcome.success.get(province), `${province} ${mark}`).toBe(false)
|
expect(outcome.success.get(province), `${province} ${mark}`).toBe(false)
|
||||||
break
|
break
|
||||||
|
case 'disrupted': {
|
||||||
|
/*
|
||||||
|
* A disrupted convoy is one that did not deliver its army. That
|
||||||
|
* is not the same as its fleet being sunk -- in Pandin's Paradox
|
||||||
|
* the fleet survives and the army still does not arrive -- so
|
||||||
|
* the thing to check is the army, not the escort.
|
||||||
|
*/
|
||||||
|
const convoy = c.orders.find(
|
||||||
|
(o) => o.type === 'convoy' && o.at.split('/')[0] === province,
|
||||||
|
)
|
||||||
|
const army = convoy && 'from' in convoy ? convoy.from.split('/')[0] : province
|
||||||
|
expect(outcome.success.get(army), `${province} disrupted`).toBe(false)
|
||||||
|
break
|
||||||
|
}
|
||||||
case 'dislodged':
|
case 'dislodged':
|
||||||
expect(outcome.dislodged.has(province), `${province} dislodged`).toBe(true)
|
expect(outcome.dislodged.has(province), `${province} dislodged`).toBe(true)
|
||||||
break
|
break
|
||||||
|
|||||||
+86
-13
@@ -86,17 +86,23 @@ export function convoyRoute(
|
|||||||
}
|
}
|
||||||
if (convoyers.size === 0) return null
|
if (convoyers.size === 0) return null
|
||||||
|
|
||||||
// Breadth first, so the route found is the shortest one available.
|
/*
|
||||||
|
* Breadth first, so the route found is the shortest one available.
|
||||||
|
*
|
||||||
|
* Both ends are compared by province rather than by key. A two-coasted
|
||||||
|
* province has no bare entry in the fleet graph at all -- there is no
|
||||||
|
* 'bul', only 'bul/ec' and 'bul/sc' -- so an army walking aboard in
|
||||||
|
* Bulgaria found no sea to start from and simply stayed there.
|
||||||
|
*/
|
||||||
|
const fromSeas = coastalSeas(start)
|
||||||
const queue: string[][] = []
|
const queue: string[][] = []
|
||||||
for (const sea of convoyers) {
|
for (const sea of convoyers) if (fromSeas.includes(sea)) queue.push([sea])
|
||||||
if ((FLEET[start] ?? []).includes(sea)) queue.push([sea])
|
|
||||||
}
|
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
|
|
||||||
while (queue.length > 0) {
|
while (queue.length > 0) {
|
||||||
const path = queue.shift()!
|
const path = queue.shift()!
|
||||||
const last = path[path.length - 1]!
|
const last = path[path.length - 1]!
|
||||||
if ((FLEET[last] ?? []).includes(end)) return path
|
if ((FLEET[last] ?? []).some((n) => base(n) === end)) return path
|
||||||
if (seen.has(last)) continue
|
if (seen.has(last)) continue
|
||||||
seen.add(last)
|
seen.add(last)
|
||||||
for (const next of FLEET[last] ?? []) {
|
for (const next of FLEET[last] ?? []) {
|
||||||
@@ -132,6 +138,7 @@ export function validate(board: Board, given: readonly Order[]): Validated {
|
|||||||
const illegal = new Set<string>()
|
const illegal = new Set<string>()
|
||||||
|
|
||||||
const refused = new Set<string>()
|
const refused = new Set<string>()
|
||||||
|
const supports: Extract<Order, { type: 'support' }>[] = []
|
||||||
|
|
||||||
for (const order of given) {
|
for (const order of given) {
|
||||||
const at = base(order.at)
|
const at = base(order.at)
|
||||||
@@ -143,12 +150,38 @@ export function validate(board: Board, given: readonly Order[]): Validated {
|
|||||||
refused.add(at)
|
refused.add(at)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if (order.type === 'support') {
|
||||||
|
supports.push(order)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
const ok = check(board, unit, order)
|
const ok = check(board, unit, order)
|
||||||
if (ok) orders.set(at, ok)
|
if (ok) orders.set(at, ok)
|
||||||
else refused.add(at)
|
else refused.add(at)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Supports last, because a support is only as legal as the move underneath
|
||||||
|
* it. You cannot support a fleet into an inland province by wishing: the
|
||||||
|
* move was never an order, so neither was the support of it.
|
||||||
|
*/
|
||||||
|
for (const order of supports) {
|
||||||
|
const at = base(order.at)
|
||||||
|
const unit = board.get(at)!
|
||||||
|
const ok = check(board, unit, order)
|
||||||
|
if (!ok) {
|
||||||
|
refused.add(at)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const supported = orders.get(base(order.from))
|
||||||
|
const wasRefused = refused.has(base(order.from))
|
||||||
|
if (base(order.from) !== base(order.to) && wasRefused && supported?.type !== 'move') {
|
||||||
|
refused.add(at)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
orders.set(at, ok)
|
||||||
|
}
|
||||||
|
|
||||||
for (const at of refused) if (!orders.has(at)) illegal.add(at)
|
for (const at of refused) if (!orders.has(at)) illegal.add(at)
|
||||||
for (const [p] of board) if (!orders.has(p)) orders.set(p, { type: 'hold', at: p })
|
for (const [p] of board) if (!orders.has(p)) orders.set(p, { type: 'hold', at: p })
|
||||||
return { orders, illegal }
|
return { orders, illegal }
|
||||||
@@ -181,7 +214,7 @@ function check(board: Board, unit: Unit, order: Order): Order | null {
|
|||||||
if (PROVINCES[base(unit.at)]!.terrain !== 'sea') return null
|
if (PROVINCES[base(unit.at)]!.terrain !== 'sea') return null
|
||||||
const army = board.get(base(order.from))
|
const army = board.get(base(order.from))
|
||||||
if (!army || army.type !== 'army') return null
|
if (!army || army.type !== 'army') return null
|
||||||
return convoyable(army, order.to) ? order : null
|
return seaRouteExists(order.from, order.to, base(unit.at)) ? order : null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -196,15 +229,55 @@ function settleCoast(unit: Unit, to: string): string | null {
|
|||||||
return reachable.length === 1 ? reachable[0]! : null
|
return reachable.length === 1 ? reachable[0]! : null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Could this unit ever step here, on any coast? Used for supports. */
|
/**
|
||||||
|
* Could this unit ever step here, on any coast?
|
||||||
|
*
|
||||||
|
* Support is given to a province rather than to a coast of one. A fleet in
|
||||||
|
* Marseilles may support an attack on the north coast of Spain even though it
|
||||||
|
* could never sail there itself -- it is holding the province down, not
|
||||||
|
* sailing round it.
|
||||||
|
*/
|
||||||
function reaches(unit: Unit, to: string): boolean {
|
function reaches(unit: Unit, to: string): boolean {
|
||||||
if (unit.type === 'army') return (ARMY[base(unit.at)] ?? []).includes(base(to))
|
if (unit.type === 'army') return (ARMY[base(unit.at)] ?? []).includes(base(to))
|
||||||
if (to.includes('/')) return (FLEET[unit.at] ?? []).includes(to)
|
return fleetCoasts(unit.at, base(to)).length > 0
|
||||||
return fleetCoasts(unit.at, to).length > 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Both ends ashore, which is the most a convoy can be judged before it sails. */
|
/**
|
||||||
|
* Could water ever carry an army from here to there, whatever anybody has
|
||||||
|
* ordered? A question about the map alone, and the one that makes a convoy
|
||||||
|
* order from a fleet nowhere near the route no order at all.
|
||||||
|
*/
|
||||||
|
export function seaRouteExists(from: string, to: string, through?: string): boolean {
|
||||||
|
const start = base(from)
|
||||||
|
const end = base(to)
|
||||||
|
if (PROVINCES[start]?.terrain !== 'coast' || PROVINCES[end]?.terrain !== 'coast') return false
|
||||||
|
|
||||||
|
const seas = (id: string) =>
|
||||||
|
(FLEET[id] ?? []).filter((n) => PROVINCES[base(n)]!.terrain === 'sea')
|
||||||
|
|
||||||
|
// Walk the seas, remembering whether the required one has been used.
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const queue: [string, boolean][] = []
|
||||||
|
for (const sea of coastalSeas(start)) queue.push([sea, sea === through])
|
||||||
|
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const [sea, used] = queue.shift()!
|
||||||
|
const key = `${sea}:${used}`
|
||||||
|
if (seen.has(key)) continue
|
||||||
|
seen.add(key)
|
||||||
|
if ((FLEET[sea] ?? []).some((n) => base(n) === end) && (!through || used)) return true
|
||||||
|
for (const next of seas(sea)) queue.push([next, used || next === through])
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The seas a coastal province touches, whichever coast they are on. */
|
||||||
|
function coastalSeas(id: string): string[] {
|
||||||
|
const coasts = PROVINCES[id]?.coasts
|
||||||
|
const keys = coasts ? coasts.map((c) => `${id}/${c}`) : [id]
|
||||||
|
return keys.flatMap((k) => (FLEET[k] ?? []).filter((n) => PROVINCES[base(n)]!.terrain === 'sea'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Both ends ashore and some water between them. */
|
||||||
const convoyable = (unit: Unit, to: string): boolean =>
|
const convoyable = (unit: Unit, to: string): boolean =>
|
||||||
unit.type === 'army' &&
|
unit.type === 'army' && seaRouteExists(unit.at, to)
|
||||||
PROVINCES[base(unit.at)]!.terrain === 'coast' &&
|
|
||||||
PROVINCES[base(to)]!.terrain === 'coast'
|
|
||||||
|
|||||||
Reference in New Issue
Block a user