From ec3b5143c7c330c96bb39b2d6bd55a27d333ef7f Mon Sep 17 00:00:00 2001 From: John Coffey Date: Wed, 9 Sep 2026 00:24:14 -0700 Subject: [PATCH] The adjudicator Kruijswijk's resolver: work each order out from the others, and when that turns out to be circular -- which it genuinely can be, this game has real paradoxes in it -- guess, check whether both guesses agree, and fall back to a stated rule when they do not. A ring of units all moving into each other all move. A convoy paradox is settled by Szykman's rule, which is a convention rather than a deduction and is written down as one. Four strengths do the work: attack, hold, defend and prevent. A move has to beat the right one of them strictly, so an unsupported attack never takes a supported province and a tie is always a bounce. Worth noting what falls out rather than being written down. A unit cannot drive out a countryman, so a move against one has attack strength zero, so it cuts no support -- which is why a power cannot cut its own support. That is a consequence of Calhamer's rule, not a separate one, and the comment says so where the zero is returned. Twenty-one cases, all written out in full so a reader can check them by hand. --- src/game/adjudicate.test.ts | 256 +++++++++++++++++++++++++++ src/game/adjudicate.ts | 343 ++++++++++++++++++++++++++++++++++++ 2 files changed, 599 insertions(+) create mode 100644 src/game/adjudicate.test.ts create mode 100644 src/game/adjudicate.ts diff --git a/src/game/adjudicate.test.ts b/src/game/adjudicate.test.ts new file mode 100644 index 0000000..d548395 --- /dev/null +++ b/src/game/adjudicate.test.ts @@ -0,0 +1,256 @@ +import { describe, expect, it } from 'vitest' +import { adjudicate } from './adjudicate' +import { boardFrom, type Order, type Unit } from './orders' +import type { Power } from './map' + +/** + * The adjudicator, against the cases that actually catch people out. + * + * These are the classic situations the published test cases are built from: + * ties bounce, support is cut by anything with weight behind it, a country + * cannot throw out its own units, and a convoy is only as good as the fleets + * under it. Each one is written out in full rather than generated, because + * the point of a case like this is that a reader can check it by hand. + */ + +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, viaConvoy = false): Order => ({ + type: 'move', + at, + to, + viaConvoy, +}) +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 }) + +const run = (units: Unit[], orders: Order[]) => adjudicate(boardFrom(units), orders) + +// --------------------------------------------------------------- the basics + +describe('what a unit may even attempt', () => { + it('refuses a move to a province it does not border', () => { + const r = run([A('france', 'par')], [mv('par', 'mun')]) + expect(r.success.get('par')).toBe(false) + }) + + it('will not march an army into the sea, or sail a fleet inland', () => { + const r = run([A('france', 'bre'), F('germany', 'kie')], [mv('bre', 'eng'), mv('kie', 'mun')]) + expect(r.success.get('bre')).toBe(false) + expect(r.success.get('kie')).toBe(false) + }) + + it('moves a unit into an empty province', () => { + const r = run([A('france', 'par')], [mv('par', 'bur')]) + expect(r.success.get('par')).toBe(true) + expect(r.dislodged.size).toBe(0) + }) +}) + +// -------------------------------------------------------------- the balance + +describe('strength', () => { + it('bounces two equal attacks and leaves the province empty', () => { + const r = run([A('france', 'par'), A('germany', 'mun')], [mv('par', 'bur'), mv('mun', 'bur')]) + expect(r.success.get('par')).toBe(false) + expect(r.success.get('mun')).toBe(false) + expect(r.bounced.has('bur')).toBe(true) + }) + + it('does not let an unsupported attack beat a supported defence', () => { + const r = run( + [A('france', 'bur'), A('germany', 'mun'), A('germany', 'ruh')], + [mv('bur', 'mun'), hold('mun'), sup('ruh', 'mun', 'mun')], + ) + expect(r.success.get('bur')).toBe(false) + expect(r.dislodged.size).toBe(0) + }) + + it('dislodges when the attack is supported and the defence is not', () => { + const r = run( + [A('france', 'bur'), A('france', 'par'), A('germany', 'mun')], + [mv('bur', 'mun'), sup('par', 'bur', 'mun'), hold('mun')], + ) + expect(r.success.get('bur')).toBe(true) + expect(r.dislodged.get('mun')?.attackedFrom).toBe('bur') + }) + + it('needs more than equal support, because a tie is a bounce', () => { + const r = run( + [A('france', 'bur'), A('france', 'par'), A('germany', 'mun'), A('germany', 'ruh')], + [mv('bur', 'mun'), sup('par', 'bur', 'mun'), hold('mun'), sup('ruh', 'mun', 'mun')], + ) + expect(r.success.get('bur')).toBe(false) + expect(r.dislodged.size).toBe(0) + }) +}) + +// ------------------------------------------------------------- your own men + +describe('your own units', () => { + it('cannot be driven out by you, however much you push', () => { + const r = run( + [A('germany', 'ber'), A('germany', 'mun'), A('germany', 'sil')], + [mv('ber', 'mun'), hold('mun'), sup('sil', 'ber', 'mun')], + ) + expect(r.success.get('ber')).toBe(false) + expect(r.dislodged.size).toBe(0) + }) + + it('cannot be driven out by a foreigner you are helping either', () => { + // Germany supports France into Munich, where a German army stands. + const r = run( + [A('france', 'bur'), A('germany', 'ruh'), A('germany', 'mun')], + [mv('bur', 'mun'), sup('ruh', 'bur', 'mun'), hold('mun')], + ) + expect(r.success.get('bur')).toBe(false) + expect(r.dislodged.size).toBe(0) + }) + + it('do not cut your own support, since they were never a threat', () => { + /* + * Germany supports France's attack on Munich and pushes a second German + * army at the supporting unit. That push has no weight -- it cannot + * dislodge a countryman -- so it cuts nothing, and the support stands. + */ + const r = run( + [A('france', 'bur'), A('germany', 'ruh'), A('germany', 'kie'), A('italy', 'mun')], + [mv('bur', 'mun'), sup('ruh', 'bur', 'mun'), mv('kie', 'ruh'), hold('mun')], + ) + expect(r.success.get('ruh')).toBe(true) + expect(r.success.get('bur')).toBe(true) + expect(r.dislodged.get('mun')).toBeDefined() + }) +}) + +// ------------------------------------------------------------------ support + +describe('cutting support', () => { + it('is cut by an attack from anywhere else', () => { + // Picardy, not Ruhr: Ruhr does not border Paris, and an attack that + // cannot arrive cuts nothing. + const r = run( + [A('france', 'bur'), A('france', 'par'), A('germany', 'mun'), A('germany', 'pic')], + [mv('bur', 'mun'), sup('par', 'bur', 'mun'), hold('mun'), mv('pic', 'par')], + ) + expect(r.success.get('par')).toBe(false) + expect(r.success.get('bur')).toBe(false) + }) + + it('is not cut by an attack that could never arrive', () => { + // The same order from Ruhr, which does not border Paris at all. + const r = run( + [A('france', 'bur'), A('france', 'par'), A('germany', 'mun'), A('germany', 'ruh')], + [mv('bur', 'mun'), sup('par', 'bur', 'mun'), hold('mun'), mv('ruh', 'par')], + ) + expect(r.success.get('par')).toBe(true) + expect(r.dislodged.get('mun')).toBeDefined() + }) + + it('is not cut by an attack from the province being supported into', () => { + // Munich attacks the supporter; Munich is what the support is aimed at, + // so the support holds and Munich is thrown out by it. + const r = run( + [A('france', 'bur'), A('france', 'par'), A('germany', 'mun')], + [mv('bur', 'mun'), sup('par', 'bur', 'mun'), mv('mun', 'par')], + ) + expect(r.success.get('par')).toBe(true) + expect(r.success.get('bur')).toBe(true) + expect(r.dislodged.get('mun')).toBeDefined() + }) + + it('is cut by being thrown out, however the support was going', () => { + const r = run( + [A('france', 'bur'), A('france', 'par'), A('germany', 'mun'), A('germany', 'pic'), A('germany', 'bre')], + [mv('bur', 'mun'), sup('par', 'bur', 'mun'), hold('mun'), mv('pic', 'par'), sup('bre', 'pic', 'par')], + ) + expect(r.dislodged.get('par')).toBeDefined() + expect(r.success.get('bur')).toBe(false) + }) +}) + +// --------------------------------------------------------------- swaps, rings + +describe('units changing places', () => { + it('bounces two units trying to swap', () => { + const r = run([A('france', 'par'), A('germany', 'bur')], [mv('par', 'bur'), mv('bur', 'par')]) + expect(r.success.get('par')).toBe(false) + expect(r.success.get('bur')).toBe(false) + expect(r.dislodged.size).toBe(0) + }) + + it('lets a supported unit win a swap outright', () => { + const r = run( + [A('france', 'par'), A('france', 'pic'), A('germany', 'bur')], + [mv('par', 'bur'), sup('pic', 'par', 'bur'), mv('bur', 'par')], + ) + expect(r.success.get('par')).toBe(true) + expect(r.dislodged.get('bur')).toBeDefined() + }) + + it('turns a whole ring at once', () => { + // Nobody dislodges anybody; they all shuffle round. + const r = run( + [A('austria', 'vie'), A('austria', 'bud'), A('austria', 'gal')], + [mv('vie', 'bud'), mv('bud', 'gal'), mv('gal', 'vie')], + ) + expect(r.success.get('vie')).toBe(true) + expect(r.success.get('bud')).toBe(true) + expect(r.success.get('gal')).toBe(true) + expect(r.dislodged.size).toBe(0) + }) +}) + +// ----------------------------------------------------------------- convoys + +describe('convoys', () => { + it('carries an army across water', () => { + const r = run( + [A('england', 'lon'), F('england', 'nth')], + [mv('lon', 'bel'), cvy('nth', 'lon', 'bel')], + ) + expect(r.success.get('lon')).toBe(true) + }) + + it('drops the army where it started when the escort is sunk', () => { + const r = run( + [A('england', 'lon'), F('england', 'nth'), F('germany', 'hel'), F('germany', 'den')], + [mv('lon', 'bel'), cvy('nth', 'lon', 'bel'), mv('hel', 'nth'), sup('den', 'hel', 'nth')], + ) + expect(r.dislodged.get('nth')).toBeDefined() + expect(r.success.get('lon')).toBe(false) + }) + + it('takes the long way round when one sea of two is lost', () => { + const r = run( + [ + A('england', 'lon'), + F('england', 'nth'), + F('england', 'eng'), + F('france', 'iri'), + F('france', 'wal'), + ], + [ + mv('lon', 'bel'), + cvy('nth', 'lon', 'bel'), + cvy('eng', 'lon', 'bel'), + mv('iri', 'eng'), + sup('wal', 'iri', 'eng'), + ], + ) + // The Channel goes, but the North Sea alone still reaches Belgium. + expect(r.dislodged.get('eng')).toBeDefined() + expect(r.success.get('lon')).toBe(true) + }) + + it('lets an army walk instead when the water is not the only way', () => { + // London to Yorkshire needs no convoy, so sinking the fleet changes nothing. + const r = run( + [A('england', 'lon'), F('england', 'nth'), F('germany', 'hel'), F('germany', 'den')], + [mv('lon', 'yor'), cvy('nth', 'lon', 'yor'), mv('hel', 'nth'), sup('den', 'hel', 'nth')], + ) + expect(r.success.get('lon')).toBe(true) + }) +}) diff --git a/src/game/adjudicate.ts b/src/game/adjudicate.ts new file mode 100644 index 0000000..52dcd25 --- /dev/null +++ b/src/game/adjudicate.ts @@ -0,0 +1,343 @@ +import { ARMY, base } from './map' +import { canStep, convoyRoute, type Board, type Order, type Unit } from './orders' + +/** + * The adjudicator. + * + * This is the part that has to be exactly right rather than approximately + * right, because a subtly wrong adjudicator plays perfectly well and is + * simply a different game. The method is Lucas Kruijswijk's: work out each + * order's outcome from the others, and when that turns out to be circular -- + * which it genuinely can be, this game has real paradoxes in it -- guess, + * see whether both guesses agree, and fall back to a stated rule when they + * do not. + * + * Four strengths do all the work, and every one of them is a number: + * + * - **attack**: how hard a move pushes, once supports are counted; + * - **hold**: how hard the destination resists; + * - **defend**: how hard a unit pushes back when the two are swapping; + * - **prevent**: how hard a move stops anybody *else* taking the province, + * even when it fails itself. + * + * A move succeeds when it beats the right one of those, strictly. Ties bounce, + * which is the rule the whole game is balanced on. + */ + +export interface Dislodgement { + unit: Unit + /** Where the attacker came from. A unit may not retreat back into it. */ + attackedFrom: string +} + +export interface Outcome { + /** Province -> did the order given there succeed. */ + success: Map + /** Province -> the unit thrown out of it. */ + dislodged: Map + /** Provinces left empty by a bounce, which nobody may retreat into. */ + bounced: Set +} + +type State = 'unresolved' | 'guessing' | 'resolved' + +export function adjudicate(board: Board, orderList: readonly Order[]): Outcome { + const orders = new Map() + for (const o of orderList) orders.set(base(o.at), o) + // A unit with no order holds; a unit with a nonsense order holds too. + for (const p of board.keys()) if (!orders.has(p)) orders.set(p, { type: 'hold', at: p }) + + const state = new Map() + const result = new Map() + const dep: string[] = [] + for (const p of orders.keys()) state.set(p, 'unresolved') + + const orderAt = (p: string) => orders.get(p) + const unitAt = (p: string) => board.get(p) + + /** Every move order aimed at this province. */ + const movesInto = (dest: string): string[] => { + const out: string[] = [] + for (const [p, o] of orders) { + if (o.type === 'move' && base(o.to) === dest) out.push(p) + } + return out + } + + /** + * Two units swapping places without a convoy under either of them. It is + * its own case because neither can simply walk through the other: they are + * measured against each other rather than against the ground. + */ + const headToHead = (p: string): string | null => { + const o = orderAt(p) + if (o?.type !== 'move') return null + const dest = base(o.to) + const other = orderAt(dest) + if (other?.type !== 'move' || base(other.to) !== p) return null + if (isConvoyed(p) || isConvoyed(dest)) return null + return dest + } + + /** Is this move going over water rather than across a border? */ + function isConvoyed(p: string): boolean { + const o = orderAt(p) + const unit = unitAt(p) + if (o?.type !== 'move' || unit?.type !== 'army') return false + const overland = (ARMY[base(unit.at)] ?? []).includes(base(o.to)) + if (overland && !o.viaConvoy) return false + return true + } + + /** Fleets convoying that are being thrown out of their sea as we speak. */ + const brokenConvoys = (): Set => { + const gone = new Set() + for (const [p, o] of orders) { + if (o.type === 'convoy' && !resolve(p)) gone.add(p) + } + return gone + } + + /** Can this move physically happen at all? Zero attack strength if not. */ + function hasPath(p: string): boolean { + const o = orderAt(p) + const unit = unitAt(p) + if (o?.type !== 'move' || !unit) return false + if (unit.type === 'fleet') return canStep(unit, o.to) + if (!isConvoyed(p)) return canStep(unit, o.to) + return convoyRoute(board, orders, unit.at, o.to, brokenConvoys()) !== null + } + + /** + * Supports for this move that actually count. + * + * `notFrom` drops supports belonging to the power being dislodged: you may + * not help a foreigner throw your own unit out of a province. + */ + function supportsFor(p: string, notFrom: string | null): number { + const o = orderAt(p) + if (o?.type !== 'move') return 0 + let n = 0 + for (const [q, s] of orders) { + if (s.type !== 'support') continue + if (base(s.from) !== p || base(s.to) !== base(o.to)) continue + if (notFrom !== null && unitAt(q)?.power === notFrom) continue + if (resolve(q)) n++ + } + return n + } + + function supportsToHold(dest: string): number { + let n = 0 + for (const [q, s] of orders) { + if (s.type !== 'support') continue + if (base(s.from) !== dest || base(s.to) !== dest) continue + if (resolve(q)) n++ + } + return n + } + + function attackStrength(p: string): number { + const o = orderAt(p) + if (o?.type !== 'move' || !hasPath(p)) return 0 + const dest = base(o.to) + const mover = unitAt(p)! + const occupier = unitAt(dest) + if (!occupier) return 1 + supportsFor(p, null) + + const theirOrder = orderAt(dest) + const swapping = headToHead(p) !== null + if (theirOrder?.type === 'move' && !swapping && resolve(dest)) { + // They left. Nobody is being dislodged, so nationality does not matter. + return 1 + supportsFor(p, null) + } + + /* + * You cannot drive out your own piece -- Calhamer's rule, so that nobody + * can secure a retreat for themselves by force. Worth noticing what else + * falls out of it: a unit moving against a countryman has attack strength + * zero, and a move with no strength cuts no support. That is why a power + * cannot cut its own support, and it is a consequence of this line rather + * than a separate rule anybody has to remember. + */ + if (occupier.power === mover.power) return 0 + return 1 + supportsFor(p, occupier.power) + } + + function holdStrength(dest: string): number { + if (!unitAt(dest)) return 0 + const o = orderAt(dest) + if (o?.type === 'move') return resolve(dest) ? 0 : 1 + return 1 + supportsToHold(dest) + } + + const defendStrength = (p: string): number => 1 + supportsFor(p, null) + + function preventStrength(p: string): number { + if (!hasPath(p)) return 0 + const other = headToHead(p) + if (other !== null && resolve(other)) return 0 + return 1 + supportsFor(p, null) + } + + // ------------------------------------------------------------- one order + + function adjudicateOne(p: string): boolean { + const o = orderAt(p)! + + if (o.type === 'hold') return true + + if (o.type === 'convoy') { + // A convoy carries on unless the fleet is thrown out of the sea. + return !isDislodged(p) + } + + if (o.type === 'support') { + if (isDislodged(p)) return false + for (const q of movesInto(p)) { + // An attack coming from the very province the support is aimed at + // does not cut it. Everything else does, if it has any weight. + if (q === base(o.to)) continue + if (attackStrength(q) >= 1) return false + } + return true + } + + // A move. + if (!hasPath(p)) return false + const dest = base(o.to) + const attack = attackStrength(p) + + for (const rival of movesInto(dest)) { + if (rival === p) continue + if (attack <= preventStrength(rival)) return false + } + + const swap = headToHead(p) + if (swap !== null) return attack > defendStrength(swap) + return attack > holdStrength(dest) + } + + /** Is the unit standing here thrown out of it? */ + function isDislodged(p: string): boolean { + if (!unitAt(p)) return false + const own = orderAt(p) + if (own?.type === 'move' && resolve(p)) return false + return movesInto(p).some((q) => resolve(q)) + } + + // --------------------------------------------------------- the resolver + + /** + * Kruijswijk's resolver. + * + * Guess that an order fails and work out what follows. If nothing depended + * on the guess, that is the answer. If something did, guess the other way: + * agreeing answers are the answer, and disagreeing ones mean a genuine + * cycle, which the backup rule below settles. + */ + function resolve(p: string): boolean { + const s = state.get(p) + if (s === 'resolved') return result.get(p)! + if (s === 'guessing') { + if (!dep.includes(p)) dep.push(p) + return result.get(p)! + } + + const mark = dep.length + state.set(p, 'guessing') + result.set(p, false) + const first = adjudicateOne(p) + + if (dep.length === mark) { + state.set(p, 'resolved') + result.set(p, first) + return first + } + + if (dep[mark] !== p) { + // Somebody above us is the one really guessing; report and let them ask. + dep.push(p) + result.set(p, first) + return first + } + + while (dep.length > mark) state.set(dep.pop()!, 'unresolved') + + state.set(p, 'guessing') + result.set(p, true) + const second = adjudicateOne(p) + + if (first === second) { + while (dep.length > mark) state.set(dep.pop()!, 'unresolved') + state.set(p, 'resolved') + result.set(p, first) + return first + } + + backup(mark) + return resolve(p) + } + + /** + * A cycle that does not settle. There are exactly two kinds in this game + * and they are settled differently: + * + * - **a ring of units all moving into each other's provinces.** Nobody is + * dislodging anybody; they all shuffle round, so they all succeed. + * - **a convoy paradox**, where whether a convoy survives depends on the + * move the convoy is carrying. Szykman's rule: the convoyed move fails + * and everything else is worked out from there. It is a convention + * rather than a deduction, which is why it is written down here rather + * than buried in the arithmetic. + */ + function backup(mark: number) { + const cycle = dep.slice(mark) + dep.length = mark + + const paradox = cycle.some((p) => orderAt(p)?.type === 'convoy') + + for (const p of cycle) { + const o = orderAt(p) + if (paradox) { + if (o?.type === 'move' && isConvoyed(p)) { + state.set(p, 'resolved') + result.set(p, false) + } else { + state.set(p, 'unresolved') + } + } else { + // Everybody in the ring moves. + state.set(p, 'resolved') + result.set(p, true) + } + } + } + + // ------------------------------------------------------------------ run + + const success = new Map() + for (const p of orders.keys()) success.set(p, resolve(p)) + + const dislodged = new Map() + for (const p of board.keys()) { + if (!isDislodged(p)) continue + const attacker = movesInto(p).find((q) => success.get(q))! + dislodged.set(p, { unit: unitAt(p)!, attackedFrom: attacker }) + } + + /* + * A province where two moves bounced is closed to retreats. It is the one + * piece of this that the retreat phase needs and the movement phase is the + * only thing that knows it. + */ + const bounced = new Set() + for (const [p] of orders) { + const o = orderAt(p) + if (o?.type !== 'move' || success.get(p)) continue + const dest = base(o.to) + if (!unitAt(dest) && movesInto(dest).filter((q) => hasPath(q)).length > 1) bounced.add(dest) + } + + return { success, dislodged, bounced } +}