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 other three games use, on a map generated from the topology in
`src/game/map.ts` rather than traced from anybody's board. `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, **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 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. 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 ## The board
Seventy-five provinces, two adjacency graphs — an army walks the land, a 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
}