From 6c388f5bc4f5dab3c17a80568b7de40cba3a63b6 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Wed, 9 Sep 2026 06:19:45 -0700 Subject: [PATCH] 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. --- src/game/adjudicate.ts | 28 ++++++++++-- src/game/datc.test.ts | 15 ++++++- src/game/orders.ts | 99 ++++++++++++++++++++++++++++++++++++------ 3 files changed, 125 insertions(+), 17 deletions(-) diff --git a/src/game/adjudicate.ts b/src/game/adjudicate.ts index 6f6912e..ac6b45d 100644 --- a/src/game/adjudicate.ts +++ b/src/game/adjudicate.ts @@ -117,14 +117,36 @@ function resolveAll( 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 { const o = orderAt(p) const unit = unitAt(p) if (o?.type !== 'move' || unit?.type !== 'army') return false + const overland = (ARMY[base(unit.at)] ?? []).includes(base(o.to)) - if (overland && !o.viaConvoy) return false - return true + if (!overland) 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. */ diff --git a/src/game/datc.test.ts b/src/game/datc.test.ts index 10071fe..201cc75 100644 --- a/src/game/datc.test.ts +++ b/src/game/datc.test.ts @@ -52,10 +52,23 @@ describe('DATC', () => { break case 'fails': case 'cut': - case 'disrupted': case 'illegal': expect(outcome.success.get(province), `${province} ${mark}`).toBe(false) 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': expect(outcome.dislodged.has(province), `${province} dislodged`).toBe(true) break diff --git a/src/game/orders.ts b/src/game/orders.ts index 148058c..9bcaf21 100644 --- a/src/game/orders.ts +++ b/src/game/orders.ts @@ -86,17 +86,23 @@ export function convoyRoute( } 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[][] = [] - for (const sea of convoyers) { - if ((FLEET[start] ?? []).includes(sea)) queue.push([sea]) - } + for (const sea of convoyers) if (fromSeas.includes(sea)) queue.push([sea]) const seen = new Set() while (queue.length > 0) { const path = queue.shift()! 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 seen.add(last) for (const next of FLEET[last] ?? []) { @@ -132,6 +138,7 @@ export function validate(board: Board, given: readonly Order[]): Validated { const illegal = new Set() const refused = new Set() + const supports: Extract[] = [] for (const order of given) { const at = base(order.at) @@ -143,12 +150,38 @@ export function validate(board: Board, given: readonly Order[]): Validated { refused.add(at) continue } + if (order.type === 'support') { + supports.push(order) + continue + } const ok = check(board, unit, order) if (ok) orders.set(at, ok) 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 [p] of board) if (!orders.has(p)) orders.set(p, { type: 'hold', at: p }) 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 const army = board.get(base(order.from)) 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 } -/** 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 { 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, to).length > 0 + return fleetCoasts(unit.at, base(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() + 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 => - unit.type === 'army' && - PROVINCES[base(unit.at)]!.terrain === 'coast' && - PROVINCES[base(to)]!.terrain === 'coast' + unit.type === 'army' && seaRouteExists(unit.at, to)