Files
great-powers/src/game/adjudicate.ts
T
jcoffey-dev 6c388f5bc4 Convoy intent, coasts in support, and what a disrupted convoy is
122 of 139 movement cases now.

Intent, which decides whether two units swapping bounce or sail past each
other: an army goes by convoy if it was ordered to, or if its own power gave
a legal convoy order for that move. Somebody else's fleets offering a route
is not intent however good the route, and your own fleet offering from a sea
it could never do it from is not intent either, because that was not an
order. Once intent exists any ordered route may carry it, foreign or not.

A convoy order is only an order if water could actually carry that army past
that fleet, which is a question about the map and is now asked.

Support is given to a province, not to a coast of one: a fleet in Marseilles
may support an attack on the north coast of Spain although it could never
sail there. And a support is only as legal as the move underneath it.

Two bugs of my own. Convoy routing could not start from or arrive at a
province with two coasts, because the fleet graph has no bare entry for one
-- there is no 'bul', only 'bul/ec' and 'bul/sc' -- so an army boarding in
Bulgaria found no sea and stayed home. And I had read 'disrupted' as the
escort being sunk when it means the army not arriving; in Pandin's Paradox
the fleet survives and the army still does not get there.
2026-09-09 06:19:45 -07:00

433 lines
15 KiB
TypeScript

import { ARMY, base } from './map'
import { canStep, convoyRoute, validate, 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
/**
* 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 {
/** 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'
/**
* Thrown when a convoy paradox turns up, to start the whole resolution again
* with that convoy's army held still. Restarting is not elegant and it is
* provably finite, which matters more: every restart forces one more army to
* stand, and there are only so many armies.
*/
class Paradox extends Error {
stalled: readonly string[]
constructor(stalled: readonly string[]) {
super('convoy paradox')
this.stalled = stalled
}
}
export function adjudicate(board: Board, orderList: readonly Order[]): Outcome {
const forced = new Set<string>()
for (;;) {
try {
return resolveAll(board, orderList, forced)
} catch (e) {
if (!(e instanceof Paradox)) throw e
const before = forced.size
for (const p of e.stalled) forced.add(p)
// No progress would mean looping forever; there is a bug if it happens.
if (forced.size === before) throw new Error('paradox made no progress')
}
}
}
function resolveAll(
board: Board,
orderList: readonly Order[],
/** Convoyed armies a paradox has already forced to stand still. */
forced: ReadonlySet<string>,
): Outcome {
// A unit with no order holds, and so does a unit whose order was refused.
const { orders, illegal } = validate(board, orderList)
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?
*
* Only interesting when both are possible, and then it is a question about
* intent rather than about the map -- and intent decides whether two units
* swapping places bounce off each other or sail past each other.
*
* The rule the published cases settle on: an army goes by convoy if it was
* ordered to, or if its **own power** gave at least one convoy order for
* that move that was a legal order. Somebody else's fleets offering to
* carry you is not intent, however good the route -- and your own fleet
* offering from a sea it could never do it from is not intent either,
* because that was not an order. Once intent exists, any ordered route may
* do the carrying, including a foreign one.
*/
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) return true
if (o.viaConvoy) return true
for (const [q, c] of orders) {
if (c.type !== 'convoy') continue
if (base(c.from) !== p || base(c.to) !== base(o.to)) continue
if (unitAt(q)?.power === unit.power) return true
}
return false
}
/** 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 {
// An army Szykman's rule has told to stand has no route anywhere, and
// therefore no weight: it cuts nothing and prevents nothing. Leaving it
// with a path is what let the paradox re-form on the next pass.
if (forced.has(p)) return false
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)!
// Szykman's rule, already applied: this army was carried into a paradox
// on an earlier pass and has been told to stand.
if (forced.has(p)) return false
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 convoys = cycle.filter((p) => orderAt(p)?.type === 'convoy')
if (convoys.length > 0) {
/*
* A convoy paradox: whether the convoy survives depends on the move the
* convoy is carrying. Szykman's rule settles it -- the convoyed move
* fails -- and it is a convention rather than a deduction, which is why
* it is written down here rather than buried in the arithmetic.
*
* The armies stalled are the ones those convoy orders name, not merely
* the ones that happen to be in the cycle, which in a real paradox is
* often none of them. Settling it by restarting rather than by patching
* the half-resolved state is what makes it terminate.
*/
const stalled: string[] = []
for (const c of convoys) {
const o = orderAt(c)
if (o?.type === 'convoy') stalled.push(base(o.from))
}
throw new Paradox(stalled)
}
{
// A ring of units all moving into each other. Nobody dislodges
// anybody; they all shuffle round, so they all go.
// A ring of units all moving into each other. Nobody dislodges
// anybody; they all shuffle round, so they all go.
for (const p of cycle) {
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))
// An order that was never a legal order did not succeed at anything.
for (const p of illegal) success.set(p, false)
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,
byConvoy: isConvoyed(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 }
}