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.
This commit is contained in:
2026-09-09 00:24:14 -07:00
parent f4d60235ea
commit ec3b5143c7
2 changed files with 599 additions and 0 deletions
+256
View File
@@ -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)
})
})
+343
View File
@@ -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<string, boolean>
/** Province -> the unit thrown out of it. */
dislodged: Map<string, Dislodgement>
/** Provinces left empty by a bounce, which nobody may retreat into. */
bounced: Set<string>
}
type State = 'unresolved' | 'guessing' | 'resolved'
export function adjudicate(board: Board, orderList: readonly Order[]): Outcome {
const orders = new Map<string, Order>()
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<string, State>()
const result = new Map<string, boolean>()
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<string> => {
const gone = new Set<string>()
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<string, boolean>()
for (const p of orders.keys()) success.set(p, resolve(p))
const dislodged = new Map<string, Dislodgement>()
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<string>()
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 }
}