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.
This commit is contained in:
2026-09-09 00:20:18 -07:00
parent c5cd0a6351
commit f92330b552
3 changed files with 247 additions and 0 deletions
+11
View File
@@ -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
+135
View File
@@ -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<string, Order> =>
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()
})
})
+101
View File
@@ -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<string, Unit>
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<string, Order>,
from: string,
to: string,
/** Convoying fleets to treat as gone, for asking what happens if they are. */
disrupted: ReadonlySet<string> = 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<string>()
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<string>()
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
}