Run the published test cases, and fix what they found

tools/datc.py turns the Diplomacy Adjudicator Test Cases into a fixture: the
orders and their annotated outcomes, not the document's prose. 139 movement
cases run; 107 pass.

Three real faults so far, none of which the hand-written cases had caught:

Orders were never validated. An illegal order has to be refused and the unit
left holding -- still standing there, still in everybody's way -- and a fleet
told to go to Spain has to be told which coast when both are reachable.
There is now a validation pass, and ordering another country's unit is
refused without disturbing the order its owner actually gave.

The paradox rule did not terminate. Settling only what was already in the
dependency cycle resolves nothing in a real paradox, so the resolver asked
the same question forever -- Pandin's Paradox was a stack overflow rather
than an answer. It now restarts the whole resolution with the convoyed army
held still, which is inelegant and provably finite: each restart forces one
more army to stand, and there are only so many armies.

A held army kept its path. Szykman's rule stops the army; it has to stop the
army's weight too, or the paradox re-forms on the next pass and the restart
never converges.
This commit is contained in:
2026-09-09 00:36:39 -07:00
parent 601b961c88
commit b41cf1c210
6 changed files with 14169 additions and 21 deletions
+76
View File
@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest'
import cases from './datc.json'
import { adjudicate } from './adjudicate'
import { boardFrom, type Order, type Unit } from './orders'
/**
* The published test cases, run against this adjudicator.
*
* These are not my cases. They are the Diplomacy Adjudicator Test Cases,
* which is what the hobby settled on as the specification for what a correct
* adjudicator does, and they exist precisely because everybody's first
* attempt is subtly wrong in a way that plays fine. `tools/datc.py` turns the
* published document into the fixture beside this file; only the orders and
* their annotated outcomes come across. See NOTICE.md.
*
* Sections A to G are the movement phase and are run here. H is retreating
* and I and J are the winter, which need their own harnesses.
*/
interface Case {
id: string
title: string
units: Unit[]
orders: Order[]
expect: Record<string, string[]>
}
const movement = (cases as unknown as Case[]).filter((c) => /^6\.[A-G]\./.test(c.id))
/*
* `invalid` marks an order the adjudicator should refuse to treat as an
* order at all -- a support for a move nobody made, say. It is a statement
* about parsing rather than about the outcome, and this engine reaches the
* same result by never counting such a support for anything, so there is
* nothing here to assert. `stands` and `destroyed` belong to the retreat
* phase, which this harness does not run.
*/
const SKIP = new Set(['invalid', 'stands', 'destroyed', 'no convoy', 'bounce'])
describe('DATC', () => {
it.each(movement.map((c) => [c.id, c.title, c] as const))('%s %s', (_id, _title, c) => {
const outcome = adjudicate(boardFrom(c.units), c.orders)
for (const [province, marks] of Object.entries(c.expect)) {
for (const mark of marks) {
if (SKIP.has(mark)) continue
switch (mark) {
case 'succeeds':
case 'given':
case 'available':
expect(outcome.success.get(province), `${province} ${mark}`).toBe(true)
break
case 'fails':
case 'cut':
case 'disrupted':
case 'illegal':
expect(outcome.success.get(province), `${province} ${mark}`).toBe(false)
break
case 'dislodged':
expect(outcome.dislodged.has(province), `${province} dislodged`).toBe(true)
break
}
}
}
// Nobody unmentioned may be thrown out: a dislodgement the cases do not
// list is as wrong as one they list and this engine misses.
for (const province of outcome.dislodged.keys()) {
const marks = c.expect[province] ?? []
expect(
marks.includes('dislodged') || marks.includes('destroyed'),
`${province} unexpectedly dislodged`,
).toBe(true)
}
})
})