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:
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user