Adjudication — the Kruijswijk Fixed-Point Resolver¶
Companion to
fix_plan.md(the M0–M7 engine-rewrite tracker) anddiplomacy_rules.md(rulebook prose). This document covers the algorithm: howsrc/engine/adjudicator/turns a(map, state, orders)triple into a(Resolution, next_state)pair. It is mined from the source docstrings inmovement.py,retreats.py,adjustments.py— those files are the ground truth; this doc explains why they're shaped the way they are and ties the pieces together.Conformance: 144/154 DATC cases green (
tests/datc/), 10 documented hard-tailxfails (listed at the end). Everything below is implemented, not aspirational.
1. Why fixed-point, not a single pass¶
Diplomacy orders are simultaneous and interdependent: whether move A succeeds can
depend on whether support B holds, which depends on whether move C cuts it, which depends
on whether A succeeds. A single linear pass over orders (the pre-rewrite engine's
approach) cannot get this right — see fix_plan.md §"Why a rewrite", defect 1.
The fix is Kruijswijk's algorithm ("The Math of Adjudication"): treat the outcome of every move, support, and convoy order as a boolean predicate and resolve them by mutual recursion with memoization, exactly as you'd resolve a system of mutually recursive functions. Three predicates:
move_succeeds(m)— does movemadvance its unit?support_given(s)— is supportseffective (valid and not cut)?convoy_survives(c)— does convoying fleetcavoid dislodgement?
src/engine/adjudicator/movement.py's _Resolver class implements all three as methods
(_move_succeeds, _support_given, _convoy_survives) dispatched through one entry
point, _resolve(province), keyed by the province of the ordering unit.
2. The resolve loop: UNRESOLVED / GUESSING / RESOLVED¶
Each order's resolvable item (_Item) carries a three-valued state:
- UNRESOLVED — not yet computed.
- GUESSING — computation is in progress; the current
valueis a provisional guess. - RESOLVED — final;
valueis authoritative and memoized.
_resolve(prov):
- If already
RESOLVED, return the memoized value. - If already
GUESSING, we've re-entered an order that's still being computed — that's a dependency cycle. Recordprovinself._deps(the current recursion's dependency trail) and return the current guess. - Otherwise: mark
GUESSINGwith a provisional guess ofFalse, then compute via_adjudicate(prov)(dispatches to_move_succeeds/_support_given/_convoy_survives). - If no cycle was hit (
self._depsdidn't grow), the computed value is definitive: markRESOLVEDand return it. This is the common case — most orders have no cyclic dependency and resolve in one pass. - If a cycle was hit but
provisn't its head (some earlier order re-entered before we did), propagate the guess upward and let the head handle it. - If
provis the cycle's head: this is where the interesting cases live (§3).
3. Breaking cycles: circular movement vs. the Szykman rule¶
When the head of a cycle is reached, the resolver tries both truth values for that order and re-evaluates the whole cycle each time:
- Reset every other member of the cycle to
UNRESOLVED, guess the head isTrue, and re-run_adjudicate. Compare against the first pass (head guessedFalse). - If both guesses produce the same outcome (guess-independent), that shared value is correct — take it and move on. This resolves most cycles: the truth value simply doesn't depend on the guess.
- If the two guesses disagree, the cycle is a genuine paradox and the backup rule
(
_backup_rule) breaks it: - A cycle containing only moves (no convoy, no support) is circular movement — DATC 6.C's army-swap-via-convoy and multi-unit rotation cases. Every move in the cycle succeeds. This is the classic "three armies chase each other around a triangle" case; simultaneity means they all get where they're going.
- A cycle that touches both a convoy and a support is a convoy paradox (DATC 6.F.14–6.F.24, the Szykman-rule cases). The Szykman rule applies: every convoyed move in the cycle fails, as if its convoy had never been ordered. Everything else in the cycle (holds, other supports) is then re-resolved against that fixed outcome.
The distinction is deliberate: a cycle of pure moves (even one routed through a convoy, as in a same-power army swap) is not a paradox — nothing about it is self-referential once you fix "everyone moves." A paradox only arises when a support's cut-or-not status and a convoy's survival-or-not status depend on each other in a loop that has no consistent resolution without a tiebreaker.
This is a single-pass backup rule: once a cycle is broken, its members are marked
RESOLVED and not revisited. The engine does not implement iterative re-resolution for
second-order paradoxes (a paradox whose break exposes a second, dependent paradox) —
see §8, the documented xfails (6.F.16/17/18/23/24).
4. Strength model¶
Every strength number folds in "supports that count," with several exemptions that are the actual substance of DATC §6.D:
- Hold strength of a province (
_hold_strength):0if empty;0if its occupant is ordered to move and that move actually happens (it vacated); otherwise1 + valid uncut hold-supports. A unit ordered to move that fails still holds at strength 1 with no hold support (a unit can't get both — DATC 6.D.7/8/25: a legally moving unit cannot receive hold support even if the move later bounces). - Attack strength of a move (
_attack_strength):0if its convoy path is required and broken;0if it would dislodge a unit of its own power (a unit never dislodges its own side, even head-to-head); otherwise1 + supports, where a support from the defender's own power does not count toward dislodging that defender (fix_plan.mddefect 4 — the old engine got this backwards). - Defend strength (
_defend_strength, used in head-to-head battles) and prevent strength (_prevent_strength, used for standoffs against a third unit's move into the same empty province): both1 + supports, with the same support-source exemptions as attack strength. A move that itself loses a head-to-head battle prevents nothing against third parties (_prevent_strength's head-to-head short-circuit).
A move succeeds (_move_succeeds) when: its convoy path (if any) works; its attack
strength beats every competing move's prevent strength into the same destination
(standoff check); and, if the destination is occupied, either the occupant vacates, or
(in a head-to-head) the attacker's strength beats the occupant's defend strength, or (a
stationary occupant) the attacker's strength beats the occupant's hold strength — and
never against a unit of the attacker's own power.
5. Support cuts¶
A support is void (illegal, reported VOID, contributes nothing) — computed once,
geometrically, before the cut question is even asked — when (_support_is_void):
- it's not a legally reachable support for the supporting unit (
_support_valid), or - it names no real order to support (
_support_has_target) — e.g. a hold-support for an empty province, or a move-support whose named mover isn't actually moving there, or - (
SupportMove) it would help dislodge a holding unit of the supporter's own power at the destination — unless that unit is itself moving away and the destination is independently contested by another attacker (DATC 6.E.12: the support "serves other means" rather than self-dislodgement; a unit that vacates via circular movement, 6.C.2, was never being dislodged at all), or - (
SupportHold) it targets a unit that is itself legally ordered to move (DATC 6.D.7/8/25 again — an illegal/ignored move leaves the unit holding, so support for it is fine, 6.D.28/29).
Given a non-void support, _support_given checks every other order for a move that (a)
targets the supporting unit's own province, (b) isn't the very unit being supported
(a supported unit never cuts its own support), (c) isn't from the same power as the
supporter (DATC 6.D.20 — you cannot cut your own country's support), and (d) isn't a
convoyed move whose convoy path is itself broken (a disrupted convoy attack cuts
nothing). One exception inside that: an attack from the province the support is
targeting against a move only cuts by actually dislodging the supporter — this is
the "attack from the supported-against province" exemption in the rulebook (a unit can
attack a move it's the target of without automatically cutting the support of that
move, unless it wins).
6. Convoys¶
A convoyed move is only recognized as convoyed — as opposed to illegal or a plain land
move — under specific conditions (_uses_convoy):
- Non-adjacent army moves are always convoyed (there's no other way to make the trip).
- For an adjacent move: an explicit
VIAforces convoy semantics whenever some fleet (any power) has actually been ordered to carry it (DATC 6.G.10/6.G.14) — with no matching Convoy order,VIAis ignored and the army walks (6.G.8). WithoutVIA, convoy intent is inferred only for a two-unit swap riding the army's own power's convoy chain — a foreign fleet cannot "kidnap" a friendly army onto a convoy it never asked for (DATC 6.G.2/6.G.4/6.G.7; the corresponding legitimate case is 6.G.1/5/6, and a valid such convoy that also creates a cycle is the 6.G.11 paradox).
The convoy path itself (_convoy_path_works) is a breadth-first search over
currently-surviving convoying fleets (Convoy orders whose origin/dest match the move,
filtered to fleets not dislodged this phase), starting from fleets adjacent to the
army's source coast and searching for one adjacent to the destination. This directly
supports multi-route convoys: if any surviving subset of the ordered fleets forms an
unbroken chain, the move works — losing one fleet in a multi-fleet, multi-route convoy
order no longer fails the whole move (fix_plan.md defect 3).
Three convoy-order result codes reflect fine distinctions DATC cares about:
VOID (no matching army move exists to convoy — 6.D.27), DISLODGED (the fleet itself
was dislodged this phase), and NO_CONVOY (the fleet survived but the chain is broken
elsewhere, e.g. a sibling fleet died) vs. plain OK (chain intact, even if the convoyed
army merely bounced at the far end — the convoy did its job).
Critically, a convoyed army's attack strength is never boosted by the number of
convoying fleets — it's still 1 + supports, same as any other move
(fix_plan.md defect 2; the old engine wrongly counted convoy legs as strength).
7. Assembling the phase result¶
After every order resolves (run()), the resolver derives, in order:
- Standoff provinces (
contested): an empty province targeted by ≥2 moves, none of which succeeded. Only empty-province standoffs are recorded here — a contested occupied province is instead reflected in the occupant's hold/dislodge outcome. - Surviving units: for each pre-phase unit, if its move succeeded it lands at the
destination (coast-resolved via
_move_dest_location— an explicit coast is kept, otherwise inferred when the reachable set is unambiguous); otherwise it's dislodged (_is_dislodged) or stays put. - Dislodged unit records: for each dislodged unit, its
attacker_origin(the province the successful attacker moved from —Noneif that attack was convoyed, since a convoyed attacker crosses no shared border and so imposes no "can't retreat the way you came" block) and its precomputed legalretreats, viaretreats.compute_retreat_options(§8 below) — computed once, here, against post-resolution occupancy, so the retreat phase never has to recompute legality from stale pre-move board state. - Per-order
OrderResults with the appropriateResultCode(OK/BOUNCE/CUT/VOID/NO_CONVOY/DISLODGED), each carrying itsretreat_optionswhen applicable.
The returned GameState has units, dislodged, and contested populated; game.py
(the phase machine, §9) then decides whether the next phase is a retreat phase, the next
movement phase, or an adjustment phase.
8. Retreats¶
src/engine/adjudicator/retreats.py is deliberately split into a pure legality function,
compute_retreat_options, and the phase adjudicator, adjudicate_retreats, because the
legality function is needed twice: once by the movement resolver (to populate
DislodgedUnit.retreats and each order's retreat_options, both surfaced to players
before they choose), and once by the retreat phase itself to validate submitted orders.
Computing it once and reusing it (rather than recomputing from possibly-stale state) is
the whole point — see the module docstring's framing as "the single source of truth."
A destination is legal iff: the unit could reach it by ordinary adjacency (land for
armies, sea/coast for fleets — coasts are first-class, so a fleet retreating into a
split-coast province gets one candidate per reachable coast); it is not
attacker_origin (you can't retreat the way the attack came, unless that attack was
convoyed); no surviving unit already occupies it; and it isn't in contested (a
province that stood off this same movement phase admits no retreats either — too much
traffic).
The retreat phase itself (adjudicate_retreats) resolves each dislodged unit's
attempted destination (a legal Retreat order, or None for an explicit Disband or no
order at all), then applies one more collision rule that only bites here: if two or
more units attempt to retreat into the same province, all of them fail and disband —
not "first submitted wins" (the old engine's defect 7), and not decided by strength
(retreats don't have supports). An unordered or illegally-ordered dislodged unit disbands
outright.
9. Adjustments (builds, disbands, civil disorder)¶
src/engine/adjudicator/adjustments.py reconciles each power's unit count against
its supply-center count independently, power by power:
centers > units(_resolve_builds): entitlement iscenters - units, further capped in practice by how many valid builds exist (a power can be entitled to 3 builds but have only 1 vacant home center). EachBuildorder is validated (orders/validation.py— must be a currently-owned home supply center, must be vacant, a fleet build at a split-coast province must name a coast) up to the entitlement; excess or invalidBuilds areVOID. ExplicitWaiveorders consume a build slot without producing a unit; any entitlement left over after processing all submitted orders — whether from explicit waives or simply unordered — is implicitly waived (silently, no unit appears, reported as a syntheticWAIVEresult). ADisbandsubmitted while a power is owed builds is meaningless andVOID(you can't shrink when you're entitled to grow).centers < units(_resolve_disbands): the power must removeunits - centers. Valid submittedDisbandorders are honoured first, up to the required count; any shortfall is made up by civil disorder — automatic removal, unordered, following the rulebook's distance rule (_civil_disorder_order): farthest from the power's nearest home supply center first (by shortest route — for armies this traverses land and sea steps, since a convoy could in principle carry them home, so_distance_to_home's BFS for armies is not restricted to land adjacency; fleets are restricted to fleet adjacency), fleets removed before armies on a distance tie, and alphabetical by province as the final tiebreak. ABuildorWaivesubmitted while a power owes disbands isVOID(you can't grow while shrinking).centers == units: no adjustment is owed; anyBuild/Disband/Waivesubmitted isVOID.
10. The phase machine¶
src/engine/game.py's Game.adjudicate() dispatches to whichever of the three
adjudicators matches state.phase_type, then computes the next phase
(_transition):
S{y}M → [S{y}R if dislodgements] → F{y}M → [F{y}R if dislodgements]
→ [W{y}A if any power's units ≠ centers] → S{y+1}M → …
Supply-center ownership is recomputed once, after the Fall turn fully settles
(movement plus any retreats) — whoever occupies a center at that instant claims it;
unoccupied centers keep their previous owner. Victory (VICTORY_CENTERS = 18) is
checked at that same point, before deciding whether an adjustment phase is needed. Game
itself is a frozen dataclass (map, current state, history of past snapshots) — every
call to adjudicate() returns a new Game, never mutates the old one.
11. Documented deviations / known gaps¶
Ten DATC cases are xfail with the reason recorded in the test file docstrings — not
silently skipped, and not to be un-xfailed without the corresponding engine work:
- 6.F.16/17/18/23/24 — second-order convoy paradoxes. The single-pass backup rule (§3) resolves first-order paradoxes correctly but does not iterate: breaking one paradox can, in principle, expose a second, dependent paradox that this resolver doesn't re-detect. Needs an iterative Szykman re-resolution loop.
- 6.G.7/11 — convoy-to-adjacent-province intent-inference edge cases at the boundary of the "own-power swap only" rule in §6.
- 6.E.8/6.E.10 — beleaguered-garrison self-dislodgement variants that the current support-void rule (§5) does not distinguish from the legitimate "serves other means" case, 6.E.12.
- 6.D.8 — a DATC case with a stated rule-variant answer; this engine treats a no-fleet convoy move as illegal/ignored, which is the reading consistent with how it already handles 6.D.28/29/31/32.
12. Where to look¶
src/engine/adjudicator/movement.py— the resolver (§2–7).src/engine/adjudicator/retreats.py— retreat legality + phase (§8).src/engine/adjudicator/adjustments.py— builds/disbands/civil disorder (§9).src/engine/game.py— phase machine (§10).tests/datc/— one test per DATC case, tagged with thedatcpytest marker, organized by section (6.A–6.J);tests/datc/harness.pyhas theplace_units/give_orders/adjudicate/assert_result/assert_dislodgedhelpers used throughout.tests/datc/test_properties.py— Hypothesis properties: order-shuffling never changes the outcome (determinism), ≤1 unit per province post-resolution, unit conservation, every dislodged unit has a computed legal retreat set.