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:
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user