From f92330b552736dd233c7f47e55a0fc3da0745691 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Wed, 9 Sep 2026 00:20:18 -0700 Subject: [PATCH] Units, orders, and finding the water under an army Convoy routing is breadth-first over the fleets actually ordered to convoy this army, so it returns the shortest chain and answers null rather than guessing. It takes a set of fleets to treat as already gone, which is what the adjudicator will need to ask whether a convoy survives being attacked. The tests found a wrong assumption of mine rather than a wrong line of code: taking the Channel out of a London to Brest convoy leaves no route at all. The North Sea does not touch the Mid-Atlantic on this board -- the long way round is over the top through the Norwegian Sea -- so the Channel is the whole western sea route. --- README.md | 11 ++++ src/game/orders.test.ts | 135 ++++++++++++++++++++++++++++++++++++++++ src/game/orders.ts | 101 ++++++++++++++++++++++++++++++ 3 files changed, 247 insertions(+) create mode 100644 src/game/orders.test.ts create mode 100644 src/game/orders.ts diff --git a/README.md b/README.md index faeb0da..55164a3 100644 --- a/README.md +++ b/README.md @@ -39,10 +39,21 @@ would not be. other three games use, on a map generated from the topology in `src/game/map.ts` rather than traced from anybody's board. +**It gets the whole screen.** The other three games live in a four-by-three +cabinet because the machines they are rebuilding did. This one is not +rebuilding a machine, and seventy-five provinces plus somebody trying to talk +you out of Galicia do not fit in a letterbox. The map takes the viewport. + **The music is not cartoony, and that is the point.** It is 1914: serious, and intense. The joke of this game is that something charming to look at is the cruellest thing on the site, and the score is what says so. +**And the turn should be heard.** Orders resolve in silence in every online +version of this game, which is a waste of the one moment anybody cares +about. Armies grinding into each other, guns at sea, a convoy escorting +something across, a unit being built, a unit falling back -- each of those is +a different noise, generated by the synth like everything else on this site. + ## The board Seventy-five provinces, two adjacency graphs — an army walks the land, a diff --git a/src/game/orders.test.ts b/src/game/orders.test.ts new file mode 100644 index 0000000..0219855 --- /dev/null +++ b/src/game/orders.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from 'vitest' +import { boardFrom, canStep, convoyRoute, fleetCoasts, type Order, type Unit } from './orders' + +/** + * Stepping and convoying: the two questions that come before adjudication. + * Neither depends on who wins a fight, so both can be settled on their own. + */ + +const A = (power: Unit['power'], at: string): Unit => ({ power, type: 'army', at }) +const F = (power: Unit['power'], at: string): Unit => ({ power, type: 'fleet', at }) + +const orders = (...list: Order[]): Map => + new Map(list.map((o) => [o.at.split('/')[0]!, o])) + +describe('stepping', () => { + it('lets an army cross a land border and refuses it the sea', () => { + expect(canStep(A('france', 'par'), 'bur')).toBe(true) + expect(canStep(A('france', 'bre'), 'eng')).toBe(false) + }) + + it('lets a fleet round a coast but not walk inland', () => { + expect(canStep(F('england', 'lon'), 'eng')).toBe(true) + expect(canStep(F('france', 'bre'), 'gas')).toBe(true) + expect(canStep(F('germany', 'kie'), 'mun')).toBe(false) + }) + + it('holds a fleet to the coast it is actually on', () => { + // A fleet on the south coast of Spain cannot sail into the Atlantic + // the way one on the north coast can, and vice versa for the Gulf. + expect(canStep(F('france', 'spa/sc'), 'lyo')).toBe(true) + expect(canStep(F('france', 'spa/nc'), 'lyo')).toBe(false) + expect(canStep(F('france', 'spa/nc'), 'gas')).toBe(true) + expect(canStep(F('france', 'spa/sc'), 'gas')).toBe(false) + }) + + it('says which coast an order into a split province must mean', () => { + // From the Gulf of Lyon there is only one coast of Spain to arrive on, + // so the order is unambiguous even when it does not say. + expect(fleetCoasts('lyo', 'spa')).toEqual(['spa/sc']) + expect(fleetCoasts('mao', 'spa').sort()).toEqual(['spa/nc', 'spa/sc']) + }) +}) + +describe('convoying', () => { + const board = boardFrom([ + A('england', 'lon'), + F('england', 'nth'), + F('england', 'eng'), + F('england', 'mao'), + ]) + + it('finds the water under an army', () => { + const route = convoyRoute( + board, + orders( + { type: 'move', at: 'lon', to: 'bel' }, + { type: 'convoy', at: 'nth', from: 'lon', to: 'bel' }, + ), + 'lon', + 'bel', + ) + expect(route).toEqual(['nth']) + }) + + it('chains fleets, and takes the shortest chain there is', () => { + const route = convoyRoute( + board, + orders( + { type: 'move', at: 'lon', to: 'bre' }, + { type: 'convoy', at: 'nth', from: 'lon', to: 'bre' }, + { type: 'convoy', at: 'eng', from: 'lon', to: 'bre' }, + { type: 'convoy', at: 'mao', from: 'lon', to: 'bre' }, + ), + 'lon', + 'bre', + ) + expect(route).toEqual(['eng']) + }) + + it('refuses when a fleet in the chain is not convoying', () => { + // The North Sea alone cannot reach Brest. + expect( + convoyRoute( + board, + orders( + { type: 'move', at: 'lon', to: 'bre' }, + { type: 'convoy', at: 'nth', from: 'lon', to: 'bre' }, + ), + 'lon', + 'bre', + ), + ).toBeNull() + }) + + it('refuses when the fleet convoying the wrong army is asked', () => { + expect( + convoyRoute( + board, + orders({ type: 'convoy', at: 'nth', from: 'yor', to: 'bel' }), + 'lon', + 'bel', + ), + ).toBeNull() + }) + + it('breaks when a fleet in the chain is taken out', () => { + const all = orders( + { type: 'move', at: 'lon', to: 'bre' }, + { type: 'convoy', at: 'nth', from: 'lon', to: 'bre' }, + { type: 'convoy', at: 'eng', from: 'lon', to: 'bre' }, + { type: 'convoy', at: 'mao', from: 'lon', to: 'bre' }, + ) + /* + * Take the Channel out and there is no way round at all, which is worth + * saying because it looks as though there ought to be one. The North Sea + * does not touch the Mid-Atlantic on this board -- getting between them + * means going the long way over the top, through the Norwegian Sea and + * the North Atlantic, and there are no fleets there to do it. The + * Channel is the whole western sea route, which is most of why England + * and France cannot both be comfortable. + */ + expect(convoyRoute(board, all, 'lon', 'bre', new Set(['eng']))).toBeNull() + }) + + it('will not convoy to or from an inland province', () => { + expect( + convoyRoute( + board, + orders({ type: 'convoy', at: 'nth', from: 'lon', to: 'par' }), + 'lon', + 'par', + ), + ).toBeNull() + }) +}) diff --git a/src/game/orders.ts b/src/game/orders.ts new file mode 100644 index 0000000..e1ef8a5 --- /dev/null +++ b/src/game/orders.ts @@ -0,0 +1,101 @@ +import { ARMY, FLEET, PROVINCES, base, type Power } from './map' + +/** + * Units, orders, and what counts as a legal one. + * + * A unit's `at` is a fleet key when it is a fleet on a split coast -- + * `stp/sc` rather than `stp` -- because that is a real fact about where it + * is and which way it can sail. Everything to do with who occupies what uses + * the bare province, since only one unit stands in a province however many + * coasts it has. + */ + +export type UnitType = 'army' | 'fleet' + +export interface Unit { + power: Power + type: UnitType + /** Fleet key or province id. `base(at)` is always the province. */ + at: string +} + +export type Order = + | { type: 'hold'; at: string } + | { type: 'move'; at: string; to: string; viaConvoy?: boolean } + /** `from === to` is a support to hold. */ + | { type: 'support'; at: string; from: string; to: string } + | { type: 'convoy'; at: string; from: string; to: string } + +/** Units by province. One unit to a province, whatever its coast. */ +export type Board = Map + +export const boardFrom = (units: readonly Unit[]): Board => + new Map(units.map((u) => [base(u.at), u])) + +/** + * Can this unit make this step on its own legs? + * + * Fleets are the awkward ones. A fleet on a named coast may only sail along + * that coast, and a fleet ordered into a two-coasted province has to be told + * which coast -- so `spa` is never a fleet destination but `spa/nc` is. + */ +export function canStep(unit: Unit, to: string): boolean { + if (unit.type === 'army') { + return (ARMY[base(unit.at)] ?? []).includes(base(to)) + } + return (FLEET[unit.at] ?? []).includes(to) +} + +/** Every coast of `to` a fleet at `from` could actually reach. */ +export function fleetCoasts(from: string, to: string): string[] { + const coasts = PROVINCES[base(to)]?.coasts + const keys = coasts ? coasts.map((c) => `${base(to)}/${c}`) : [base(to)] + return keys.filter((key) => (FLEET[from] ?? []).includes(key)) +} + +/** + * Sea provinces with a fleet convoying this army, in a chain from `from` to + * `to`. Returns the shortest chain, or null when there is none -- and null + * is the answer that makes an army sit still in the Atlantic. + */ +export function convoyRoute( + board: Board, + orders: Map, + from: string, + to: string, + /** Convoying fleets to treat as gone, for asking what happens if they are. */ + disrupted: ReadonlySet = new Set(), +): string[] | null { + const start = base(from) + const end = base(to) + if (PROVINCES[start]?.terrain === 'land' || PROVINCES[end]?.terrain === 'land') return null + + const convoyers = new Set() + for (const [at, order] of orders) { + if (order.type !== 'convoy') continue + if (base(order.from) !== start || base(order.to) !== end) continue + if (disrupted.has(at)) continue + const unit = board.get(at) + if (unit?.type === 'fleet' && PROVINCES[at]?.terrain === 'sea') convoyers.add(at) + } + if (convoyers.size === 0) return null + + // Breadth first, so the route found is the shortest one available. + const queue: string[][] = [] + for (const sea of convoyers) { + if ((FLEET[start] ?? []).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 (seen.has(last)) continue + seen.add(last) + for (const next of FLEET[last] ?? []) { + if (convoyers.has(next) && !path.includes(next)) queue.push([...path, next]) + } + } + return null +}