Harnesses for retreating and the winter

All 176 published cases now parse and run. Retreating passes 18 of 18,
building 7 of 7, civil disorder 13 of 13; movement is unchanged at 133 of
139.

The retreat and winter engine code was written before these cases and mostly
held up, but civil disorder was wrong in a way playing would never have shown.

It measured distance to a power's *home* centres. The rule is distance to the
centres it owns -- a Russia driven out of Moscow and holding Sweden measures
from Sweden -- so every power that had lost a home centre was disbanding the
wrong unit.

And it measured over the graph each unit moves on, which is wrong in both
directions. An army counts sea provinces it would have to be convoyed across,
so Albania is two steps from Naples rather than unreachable. A fleet counts
inland provinces it could never enter, so the Baltic is two from Warsaw. That
reads as nonsense until you notice what is being measured: not how the unit
would get home, but how far out of the way it is. A fleet in the Baltic is
still in the middle of Russia's business; one in the North Atlantic is not.

Civil disorder also only applies to the shortfall. A power that disbanded one
of the two it owed has half answered, and the automatic rule picks from what
is left rather than from what it started with.
This commit is contained in:
2026-09-09 06:41:28 -07:00
parent 9a12b46bd1
commit 38f69e9aa6
6 changed files with 2354 additions and 513 deletions
+174
View File
@@ -0,0 +1,174 @@
import { describe, expect, it } from 'vitest'
import cases from './datc.json'
import { adjudicate } from './adjudicate'
import { boardFrom, type Order, type Unit } from './orders'
import type { Power } from './map'
import {
applyAdjustments,
civilDisorderDisbands,
applyMoves,
applyRetreats,
type AdjustOrder,
type Ownership,
type RetreatOrder,
} from './turn'
/**
* The published cases for the two phases that are not movement.
*
* They are shaped differently and so they need their own harness. A retreat
* case plays a movement phase first and then a RETREATS section; a winter
* case states the position with "Germany owns SC Kiel" lines instead of
* ordering it into existence, then gives builds or disbands.
*/
interface Case {
id: string
title: string
units: Unit[]
orders: Order[]
expect: Record<string, string[]>
retreats: Order[]
expectRetreat: Record<string, string[]>
adjust: {
type: 'build' | 'disband' | 'auto'
at: string
unit: 'army' | 'fleet'
power: Power
}[]
expectAdjust: Record<string, string[]>
own: Record<string, Power>
}
const all = cases as unknown as Case[]
const province = (id: string) => id.split('/')[0]!
// ------------------------------------------------------------------ 6.H
describe('DATC retreats', () => {
const retreating = all.filter((c) => c.retreats.length > 0)
it.each(retreating.map((c) => [c.id, c.title, c] as const))('%s %s', (_id, _t, c) => {
const board = boardFrom(c.units)
const outcome = adjudicate(board, c.orders)
const after = applyMoves(board, c.orders, outcome)
for (const [at, marks] of Object.entries(c.expect)) {
if (marks.includes('dislodged')) {
expect(outcome.dislodged.has(at), `${at} dislodged`).toBe(true)
}
if (marks.includes('stands')) {
expect(outcome.dislodged.has(at), `${at} stands`).toBe(false)
}
}
/*
* There are no supports in the retreat phase, and nothing else either --
* a beaten unit goes somewhere it may go, or it is gone. Anything that is
* not a move is read here as a disband, which is what the rules do with
* it.
*/
const orders: RetreatOrder[] = c.retreats.map((o) =>
o.type === 'move' ? { type: 'retreat', at: o.at, to: o.to } : { type: 'disband', at: o.at },
)
const { board: final } = applyRetreats(after, outcome, orders)
for (const [at, marks] of Object.entries(c.expectRetreat)) {
const order = c.retreats.find((o) => province(o.at) === at)
const to = order && order.type === 'move' ? province(order.to) : null
const beaten = outcome.dislodged.get(at)?.unit
/*
* Did the beaten unit end up where it was aimed? Both halves have to
* exist to say yes. A unit that was never dislodged has no retreat to
* make, and an empty destination holds nobody -- and comparing those
* two nothings to each other says "arrived", which is how this harness
* first read a unit ordered to move during the retreat phase as having
* moved.
*/
const arrived =
beaten !== undefined &&
to !== null &&
final.get(to)?.power === beaten.power &&
final.get(to)?.type === beaten.type
if (marks.includes('succeeds')) {
expect(arrived, `${at} retreat succeeds`).toBe(true)
}
if (marks.includes('fails') || marks.includes('illegal')) {
expect(arrived, `${at} retreat ${marks.join(',')}`).toBe(false)
}
}
})
})
// ------------------------------------------------------------- 6.I, 6.J
/**
* Civil disorder: what goes when a power has stopped answering.
*
* The rule is furthest from the supply centres that power *owns* -- not the
* ones it started with. A Russia driven out of Moscow and holding Sweden
* measures from Sweden, and these cases are what say so; the first version
* of this measured from home centres and was quietly wrong for every power
* that had lost one.
*/
describe('DATC civil disorder', () => {
const auto = all.filter((c) => c.adjust.some((a) => a.type === 'auto'))
it.each(auto.map((c) => [c.id, c.title, c] as const))('%s %s', (_id, _t, c) => {
const own: Ownership = new Map(Object.entries(c.own) as [string, Power][])
const power = c.adjust[0]!.power
const wanted = c.adjust.filter((a) => a.type === 'auto')
/*
* Anything the power actually said comes first. Civil disorder is only
* for the shortfall -- a power that disbanded one of the two it owed has
* not stopped answering, it has half answered, and the automatic rule
* picks from what is left rather than from what it started with.
*/
const said: AdjustOrder[] = c.adjust
.filter((a) => a.type !== 'auto')
.map((a) =>
a.type === 'build'
? { type: 'build', at: a.at, unit: a.unit }
: { type: 'disband', at: a.at, unit: a.unit },
)
const { board } = applyAdjustments(own, boardFrom(c.units), power, said)
const removed = civilDisorderDisbands(own, board, power, wanted.length)
expect(removed.map((u) => province(u.at)).sort()).toEqual(
wanted.map((a) => province(a.at)).sort(),
)
})
})
describe('DATC builds and disbands', () => {
const winter = all.filter((c) => c.adjust.some((a) => a.type !== 'auto'))
it.each(winter.map((c) => [c.id, c.title, c] as const))('%s %s', (_id, _t, c) => {
const own: Ownership = new Map(Object.entries(c.own) as [string, Power][])
const board = boardFrom(c.units)
const power = c.adjust[0]!.power
const orders: AdjustOrder[] = c.adjust
.filter((o) => o.type !== 'auto')
.map((o) =>
o.type === 'build'
? { type: 'build', at: o.at, unit: o.unit }
: { type: 'disband', at: o.at, unit: o.unit },
)
const { board: after, illegal } = applyAdjustments(own, board, power, orders)
for (const [at, marks] of Object.entries(c.expectAdjust)) {
const order = c.adjust.find((o) => province(o.at) === at)!
if (marks.includes('illegal')) {
expect(illegal.has(at), `${at} illegal`).toBe(true)
}
if (marks.includes('succeeds')) {
expect(illegal.has(at), `${at} succeeds`).toBe(false)
expect(after.has(at), `${at} ${order.type}`).toBe(order.type === 'build')
}
}
})
})
+1991 -483
View File
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -211,9 +211,10 @@ describe('the winter', () => {
expect(buildOptions(own, boardFrom([]), 'germany').some((o) => o.at === 'ber')).toBe(false)
})
it('removes what is furthest from home when nobody says', () => {
it('removes what is furthest from the centres it owns when nobody says', () => {
const board = boardFrom([A('germany', 'mun'), A('germany', 'ber'), A('germany', 'spa')])
expect(civilDisorderDisbands(board, 'germany', 1)[0]!.at).toBe('spa')
const own = openingOwnership()
expect(civilDisorderDisbands(own, board, 'germany', 1)[0]!.at).toBe('spa')
})
it('knows an eliminated power and a solo when it sees one', () => {
+110 -17
View File
@@ -178,27 +178,49 @@ export function buildOptions(own: Ownership, board: Board, power: Power): BuildO
* 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)
export function civilDisorderDisbands(
own: Ownership,
board: Board,
power: Power,
howMany: number,
): Unit[] {
/*
* Distance is measured to the centres this power *owns*, not to the ones
* it was born with. A Russia driven out of Moscow and holding Sweden
* measures from Sweden -- home is where the supply is, and a unit far from
* any of it is the one that is no longer doing anything.
*/
const homes = [...own.entries()].filter(([, p]) => p === 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
/*
* Distance is measured over the whole map, not over the graph the unit
* itself moves on.
*
* That looks wrong for about a minute. An army counts sea provinces it
* would need convoying across -- Albania is two steps from Naples over the
* Adriatic, not unreachable -- and a fleet counts inland provinces it could
* never enter, so the Baltic is two steps from Warsaw. Both are what the
* published cases require, and both make sense once you see what is being
* measured: not how the unit would get home, but how far out of the way it
* is. A fleet in the Baltic is still in the middle of Russia's business. A
* fleet in the North Atlantic is not.
*/
const seen = new Set<string>()
let edge = homes.map(base)
const away = new Map<string, number>()
for (let step = 0; edge.length > 0; step++) {
const next: string[] = []
for (const id of edge) {
if (seen.has(id)) continue
seen.add(id)
away.set(id, step)
for (const to of neighbours(id)) if (!seen.has(to)) next.push(to)
}
return Number.MAX_SAFE_INTEGER
edge = next
}
const distance = (unit: Unit): number => away.get(base(unit.at)) ?? Number.MAX_SAFE_INTEGER
return [...board.values()]
.filter((u) => u.power === power)
.map((u) => ({ u, d: distance(u) }))
@@ -220,3 +242,74 @@ export function soloWinner(own: Ownership): Power | null {
for (const power of POWERS) if (centreCount(own, power) >= 18) return power
return null
}
export type AdjustOrder =
| { type: 'build'; at: string; unit: UnitType }
| { type: 'disband'; at: string; unit?: UnitType }
export interface AdjustResult {
board: Board
/** Orders that were refused, by province. */
illegal: Set<string>
}
/**
* Builds and disbands, taken one at a time in the order they were given.
*
* One at a time matters. A power owed one unit that orders three builds has
* not made three mistakes, it has made two -- the first order is carried out
* and the rest are refused. Working out the whole set and rejecting it
* wholesale would be tidier and would punish a slip far harder than the
* rules do.
*/
export function applyAdjustments(
own: Ownership,
board: Board,
power: Power,
given: readonly AdjustOrder[],
): AdjustResult {
const next: Board = new Map(board)
const illegal = new Set<string>()
const owed = adjustmentFor(own, next, power)
let done = 0
for (const order of given) {
const at = base(order.at)
if (order.type === 'build') {
const allowed = buildOptions(own, next, power).some(
(o) => o.at === order.at && o.type === order.unit,
)
if (!allowed || done >= owed) {
illegal.add(at)
continue
}
next.set(at, { power, type: order.unit, at: order.at })
done++
continue
}
const unit = next.get(at)
const removable =
unit !== undefined &&
unit.power === power &&
(order.unit === undefined || unit.type === order.unit)
if (!removable || done >= -owed) {
illegal.add(at)
continue
}
next.delete(at)
done++
}
return { board: next, illegal }
}
/** Everywhere touching a province, by land or by water, whoever could go. */
function neighbours(id: string): string[] {
const out = new Set<string>(ARMY[id] ?? [])
const coasts = PROVINCES[id]?.coasts
const keys = coasts ? coasts.map((c) => `${id}/${c}`) : [id]
for (const key of keys) for (const to of FLEET[key] ?? []) out.add(base(to))
return [...out]
}