Retreats and the winter

Movement is the part everybody thinks about and it is not the part that
decides the game. A beaten unit that finds somewhere to stand is a nuisance
for years; one that does not is gone. And the winter is where a good spring
turns into a bigger army, or does not.

Four things are closed to a retreat: anywhere unreachable, anywhere occupied,
the province the attacker came from, and any province two units bounced out
of -- that last is the one people forget, since a bounce leaves a province
empty and closed rather than empty and open. The exception is an attacker who
arrived by convoy: it never marched through the ground between, so there is
nothing to stop the loser falling back that way. Two units retreating to the
same province destroy each other, there being no support in this phase and
nothing to break the tie with.

Ownership changes only in the autumn and only by standing on a centre. A
centre you walked through in the spring is not yours; one you walked away
from stays yours until somebody else stands on it, which is why a power with
no units can still hold centres.

Civil disorder removes what is furthest from home, fleets first, then
alphabetically -- arbitrary in the last step on purpose, so that two
adjudicators never disagree about a game nobody is playing.
This commit is contained in:
2026-09-09 00:27:07 -07:00
parent ec3b5143c7
commit 601b961c88
3 changed files with 459 additions and 1 deletions
+11 -1
View File
@@ -28,6 +28,12 @@ export interface Dislodgement {
unit: Unit unit: Unit
/** Where the attacker came from. A unit may not retreat back into it. */ /** Where the attacker came from. A unit may not retreat back into it. */
attackedFrom: string attackedFrom: string
/**
* The attacker came over water. That lifts the restriction above: a unit
* put ashore by a convoy never passed through the ground between, so there
* is nothing to say the loser cannot fall back that way.
*/
byConvoy: boolean
} }
export interface Outcome { export interface Outcome {
@@ -323,7 +329,11 @@ export function adjudicate(board: Board, orderList: readonly Order[]): Outcome {
for (const p of board.keys()) { for (const p of board.keys()) {
if (!isDislodged(p)) continue if (!isDislodged(p)) continue
const attacker = movesInto(p).find((q) => success.get(q))! const attacker = movesInto(p).find((q) => success.get(q))!
dislodged.set(p, { unit: unitAt(p)!, attackedFrom: attacker }) dislodged.set(p, {
unit: unitAt(p)!,
attackedFrom: attacker,
byConvoy: isConvoyed(attacker),
})
} }
/* /*
+226
View File
@@ -0,0 +1,226 @@
import { describe, expect, it } from 'vitest'
import { adjudicate } from './adjudicate'
import { boardFrom, type Order, type Unit } from './orders'
import type { Power } from './map'
import {
adjustmentFor,
applyMoves,
applyRetreats,
buildOptions,
centreCount,
civilDisorderDisbands,
eliminated,
openingOwnership,
retreatOptions,
soloWinner,
updateOwnership,
type RetreatOrder,
} from './turn'
const A = (power: Power, at: string): Unit => ({ power, type: 'army', at })
const F = (power: Power, at: string): Unit => ({ power, type: 'fleet', at })
const mv = (at: string, to: string): Order => ({ type: 'move', at, to })
const sup = (at: string, from: string, to: string): Order => ({ type: 'support', at, from, to })
const hold = (at: string): Order => ({ type: 'hold', at })
const cvy = (at: string, from: string, to: string): Order => ({ type: 'convoy', at, from, to })
/** Move, then hand back the board and the outcome the retreat phase needs. */
function movement(units: Unit[], orders: Order[]) {
const board = boardFrom(units)
const outcome = adjudicate(board, orders)
return { outcome, after: applyMoves(board, orders, outcome) }
}
describe('after the moving', () => {
it('puts the winner in and takes the loser off the board', () => {
const { outcome, after } = movement(
[A('france', 'bur'), A('france', 'par'), A('germany', 'mun')],
[mv('bur', 'mun'), sup('par', 'bur', 'mun'), hold('mun')],
)
expect(after.get('mun')?.power).toBe('france')
expect(after.has('bur')).toBe(false)
expect(outcome.dislodged.has('mun')).toBe(true)
})
it('keeps the coast a fleet was ordered to', () => {
const { after } = movement([F('france', 'mao')], [mv('mao', 'spa/nc')])
expect(after.get('spa')?.at).toBe('spa/nc')
})
})
describe('where a beaten unit may go', () => {
const setup = () =>
movement(
[A('france', 'bur'), A('france', 'par'), A('germany', 'mun')],
[mv('bur', 'mun'), sup('par', 'bur', 'mun'), hold('mun')],
)
it('will not go back the way the attacker came', () => {
const { outcome, after } = setup()
expect(retreatOptions(after, outcome, 'mun')).not.toContain('bur')
})
it('will not go where somebody is standing', () => {
const { outcome, after } = setup()
// Paris is where the supporting army still is -- and out of reach anyway.
expect(retreatOptions(after, outcome, 'mun')).not.toContain('par')
expect(retreatOptions(after, outcome, 'mun')).toContain('tyr')
})
it('will not go into a province two other units bounced out of', () => {
const { outcome, after } = movement(
[
A('france', 'bur'),
A('france', 'par'),
A('germany', 'mun'),
A('austria', 'vie'),
A('italy', 'ven'),
],
[
mv('bur', 'mun'),
sup('par', 'bur', 'mun'),
hold('mun'),
mv('vie', 'tyr'),
mv('ven', 'tyr'),
],
)
expect(outcome.bounced.has('tyr')).toBe(true)
expect(retreatOptions(after, outcome, 'mun')).not.toContain('tyr')
})
it('may go back the way the attacker came when the attacker came by sea', () => {
const { outcome, after } = movement(
[A('england', 'lon'), F('england', 'nth'), F('england', 'hel'), A('germany', 'hol')],
[mv('lon', 'hol'), cvy('nth', 'lon', 'hol'), sup('hel', 'lon', 'hol'), hold('hol')],
)
expect(outcome.dislodged.get('hol')?.byConvoy).toBe(true)
// London is empty and was never marched through.
expect(retreatOptions(after, outcome, 'hol')).toContain('kie')
})
it('is disbanded when there is nowhere at all', () => {
// Boxed into a corner: Portugal, attacked from Spain, with the sea taken.
const { outcome, after } = movement(
[A('france', 'spa'), A('france', 'gas'), F('england', 'mao'), A('italy', 'por')],
[mv('spa', 'por'), sup('gas', 'spa', 'por'), hold('mao'), hold('por')],
)
expect(outcome.dislodged.has('por')).toBe(true)
expect(retreatOptions(after, outcome, 'por')).toEqual([])
const { disbanded } = applyRetreats(after, outcome, [])
expect(disbanded).toHaveLength(1)
})
})
describe('retreating', () => {
const setup = () =>
movement(
[A('france', 'bur'), A('france', 'par'), A('germany', 'mun')],
[mv('bur', 'mun'), sup('par', 'bur', 'mun'), hold('mun')],
)
it('puts a unit down where it was told', () => {
const { outcome, after } = setup()
const r: RetreatOrder[] = [{ type: 'retreat', at: 'mun', to: 'tyr' }]
const { board, disbanded } = applyRetreats(after, outcome, r)
expect(board.get('tyr')?.power).toBe('germany')
expect(disbanded).toHaveLength(0)
})
it('destroys both when two fall back on the same province', () => {
const { outcome, after } = movement(
[
A('france', 'bur'),
A('france', 'par'),
A('germany', 'mun'),
A('italy', 'ven'),
A('italy', 'tri'),
A('austria', 'tyr'),
],
[
mv('bur', 'mun'),
sup('par', 'bur', 'mun'),
hold('mun'),
mv('ven', 'tyr'),
sup('tri', 'ven', 'tyr'),
hold('tyr'),
],
)
expect(outcome.dislodged.size).toBe(2)
const { board, disbanded } = applyRetreats(after, outcome, [
{ type: 'retreat', at: 'mun', to: 'boh' },
{ type: 'retreat', at: 'tyr', to: 'boh' },
])
expect(disbanded).toHaveLength(2)
expect(board.has('boh')).toBe(false)
})
})
describe('the winter', () => {
it('starts everybody on their own centres and nobody on the neutrals', () => {
const own = openingOwnership()
expect(own.size).toBe(22)
expect(centreCount(own, 'russia')).toBe(4)
expect(centreCount(own, 'italy')).toBe(3)
expect(own.has('bel')).toBe(false)
})
it('hands a centre over for standing on it', () => {
const own = updateOwnership(openingOwnership(), boardFrom([A('germany', 'bel')]))
expect(own.get('bel')).toBe('germany')
expect(centreCount(own, 'germany')).toBe(4)
})
it('leaves a centre yours after you walk away from it', () => {
const first = updateOwnership(openingOwnership(), boardFrom([A('germany', 'bel')]))
const second = updateOwnership(first, boardFrom([A('germany', 'ruh')]))
expect(second.get('bel')).toBe('germany')
})
it('counts what a power is owed', () => {
const own = updateOwnership(openingOwnership(), boardFrom([A('germany', 'bel')]))
const board = boardFrom([A('germany', 'bel'), A('germany', 'mun'), F('germany', 'kie')])
// Four centres, three units.
expect(adjustmentFor(own, board, 'germany')).toBe(1)
})
it('builds only at home, only in an empty centre, and only on a coast for a fleet', () => {
const own = openingOwnership()
const board = boardFrom([A('germany', 'ber')])
const options = buildOptions(own, board, 'germany')
const at = (id: string) => options.filter((o) => o.at === id).map((o) => o.type)
expect(at('ber')).toEqual([]) // occupied
expect(at('mun')).toEqual(['army']) // inland
expect(at('kie').sort()).toEqual(['army', 'fleet'])
expect(options.some((o) => o.at === 'par')).toBe(false) // not home
})
it('offers both coasts of St Petersburg to a fleet', () => {
const options = buildOptions(openingOwnership(), boardFrom([]), 'russia')
expect(options.filter((o) => o.at.startsWith('stp')).map((o) => o.at).sort()).toEqual([
'stp',
'stp/nc',
'stp/sc',
])
})
it('will not build in a home centre somebody else has taken', () => {
const own = updateOwnership(openingOwnership(), boardFrom([A('russia', 'ber')]))
expect(buildOptions(own, boardFrom([]), 'germany').some((o) => o.at === 'ber')).toBe(false)
})
it('removes what is furthest from home when nobody says', () => {
const board = boardFrom([A('germany', 'mun'), A('germany', 'ber'), A('germany', 'spa')])
expect(civilDisorderDisbands(board, 'germany', 1)[0]!.at).toBe('spa')
})
it('knows an eliminated power and a solo when it sees one', () => {
const own = openingOwnership()
expect(eliminated(own, 'italy')).toBe(false)
expect(soloWinner(own)).toBeNull()
const big: typeof own = new Map(own)
for (const [id] of [...big].slice(0, 18)) big.set(id, 'turkey')
expect(soloWinner(big)).toBe('turkey')
})
})
+222
View File
@@ -0,0 +1,222 @@
import type { Outcome } from './adjudicate'
import { ARMY, FLEET, PROVINCES, POWERS, base, type Power } from './map'
import type { Board, Order, Unit, UnitType } from './orders'
/**
* The rest of the year.
*
* Movement is the part everybody thinks about and it is not the part that
* decides the game. Retreats decide whether a beaten unit survives to be a
* nuisance, and the winter decides whether you get another one at all --
* this is where a good spring turns into a bigger army, or does not.
*/
// ------------------------------------------------------------------ moving
/** The board after the movement phase, with the beaten set aside. */
export function applyMoves(board: Board, orders: readonly Order[], outcome: Outcome): Board {
const next: Board = new Map()
const ordered = new Map<string, Order>()
for (const o of orders) ordered.set(base(o.at), o)
for (const [p, unit] of board) {
if (outcome.dislodged.has(p)) continue
const o = ordered.get(p)
if (o?.type === 'move' && outcome.success.get(p)) {
// A fleet keeps the coast it was ordered to; an army has none to keep.
next.set(base(o.to), { ...unit, at: unit.type === 'fleet' ? o.to : base(o.to) })
} else {
next.set(p, unit)
}
}
return next
}
// --------------------------------------------------------------- retreating
export type RetreatOrder = { type: 'retreat'; at: string; to: string } | { type: 'disband'; at: string }
/**
* Where a beaten unit may go.
*
* Four things are closed to it: anywhere it could not reach anyway, anywhere
* still occupied, the province its attacker came from, and any province left
* empty by a bounce. That last one is the rule people forget -- two armies
* that knocked each other out of a province have not left it open, they have
* left it closed to everybody.
*/
export function retreatOptions(after: Board, outcome: Outcome, at: string): string[] {
const d = outcome.dislodged.get(at)
if (!d) return []
const reachable =
d.unit.type === 'army' ? [...(ARMY[at] ?? [])] : [...(FLEET[d.unit.at] ?? [])]
return reachable.filter((to) => {
if (PROVINCES[base(to)]!.terrain === 'sea' && d.unit.type === 'army') return false
if (after.has(base(to))) return false
if (outcome.bounced.has(base(to))) return false
if (!d.byConvoy && base(to) === d.attackedFrom) return false
return true
})
}
export interface RetreatResult {
board: Board
/** Units that had nowhere to go, or were ordered to go nowhere. */
disbanded: Unit[]
}
/**
* Two units retreating to the same province destroy each other. There is no
* support in this phase and nothing to break a tie with, so the province
* stays empty and both are gone.
*/
export function applyRetreats(
after: Board,
outcome: Outcome,
retreats: readonly RetreatOrder[],
): RetreatResult {
const board: Board = new Map(after)
const disbanded: Unit[] = []
const wanted = new Map<string, { at: string; to: string }[]>()
for (const [at, d] of outcome.dislodged) {
const order = retreats.find((r) => base(r.at) === at)
if (!order || order.type === 'disband') {
disbanded.push(d.unit)
continue
}
if (!retreatOptions(after, outcome, at).includes(order.to)) {
disbanded.push(d.unit)
continue
}
const key = base(order.to)
wanted.set(key, [...(wanted.get(key) ?? []), { at, to: order.to }])
}
for (const [dest, claims] of wanted) {
if (claims.length > 1) {
for (const c of claims) disbanded.push(outcome.dislodged.get(c.at)!.unit)
continue
}
const claim = claims[0]!
const unit = outcome.dislodged.get(claim.at)!.unit
board.set(dest, { ...unit, at: unit.type === 'fleet' ? claim.to : dest })
}
return { board, disbanded }
}
// ------------------------------------------------------------- the winter
export type Ownership = Map<string, Power>
/** Who starts owning what: everybody their own, the neutrals nobody's. */
export function openingOwnership(): Ownership {
const own: Ownership = new Map()
for (const [id, p] of Object.entries(PROVINCES)) if (p.home) own.set(id, p.home)
return own
}
/**
* Ownership only changes in the autumn, and only by standing there. A centre
* you walked through in the spring is not yours, and a centre you left is
* still yours until somebody else stands on it -- which is why a power with
* no units at all can still be holding centres, and why the last thing a
* dying power does is usually spite.
*/
export function updateOwnership(prev: Ownership, board: Board): Ownership {
const own: Ownership = new Map(prev)
for (const [p, unit] of board) if (PROVINCES[p]!.sc) own.set(p, unit.power)
return own
}
export const centreCount = (own: Ownership, power: Power): number =>
[...own.values()].filter((p) => p === power).length
export const unitCount = (board: Board, power: Power): number =>
[...board.values()].filter((u) => u.power === power).length
/** Positive means build that many, negative means remove that many. */
export const adjustmentFor = (own: Ownership, board: Board, power: Power): number =>
centreCount(own, power) - unitCount(board, power)
export interface BuildOption {
at: string
type: UnitType
}
/**
* Where a power may build.
*
* Only in its own home centres, only while it still owns them, and only when
* nothing is standing there. A fleet needs a coast, which is why St
* Petersburg offers two of them and Moscow offers none.
*/
export function buildOptions(own: Ownership, board: Board, power: Power): BuildOption[] {
const out: BuildOption[] = []
for (const [id, province] of Object.entries(PROVINCES)) {
if (province.home !== power) continue
if (own.get(id) !== power) continue
if (board.has(id)) continue
out.push({ at: id, type: 'army' })
if (province.terrain !== 'coast') continue
for (const key of province.coasts ? province.coasts.map((c) => `${id}/${c}`) : [id]) {
out.push({ at: key, type: 'fleet' })
}
}
return out
}
/**
* Which units go when a power will not say.
*
* The published rule for a player who has stopped answering: remove whatever
* is furthest from home, fleets before armies at the same distance, and
* alphabetically after that. It is arbitrary in the last step on purpose --
* what matters is that it is the same arbitrary everywhere, so two
* adjudicators never disagree about a game nobody was playing.
*/
export function civilDisorderDisbands(board: Board, power: Power, howMany: number): Unit[] {
const homes = Object.entries(PROVINCES)
.filter(([, p]) => p.home === power)
.map(([id]) => id)
const distance = (unit: Unit): number => {
const graph = unit.type === 'army' ? ARMY : FLEET
const start = unit.type === 'army' ? base(unit.at) : unit.at
const seen = new Set([start])
let edge = [start]
for (let step = 0; edge.length > 0; step++) {
if (edge.some((id) => homes.includes(base(id)))) return step
const next: string[] = []
for (const id of edge) {
for (const to of graph[id] ?? []) if (!seen.has(to)) (seen.add(to), next.push(to))
}
edge = next
}
return Number.MAX_SAFE_INTEGER
}
return [...board.values()]
.filter((u) => u.power === power)
.map((u) => ({ u, d: distance(u) }))
.sort(
(a, b) =>
b.d - a.d ||
(a.u.type === b.u.type ? 0 : a.u.type === 'fleet' ? -1 : 1) ||
a.u.at.localeCompare(b.u.at),
)
.slice(0, howMany)
.map((x) => x.u)
}
/** Eliminated: no centres left. The units go with them. */
export const eliminated = (own: Ownership, power: Power): boolean => centreCount(own, power) === 0
/** Who, if anybody, has taken eighteen. */
export function soloWinner(own: Ownership): Power | null {
for (const power of POWERS) if (centreCount(own, power) >= 18) return power
return null
}