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:
2026-09-09 06:19:45 -07:00
parent c27e4cad81
commit 6c388f5bc4
3 changed files with 125 additions and 17 deletions
+25 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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'