Files
great-powers/tools/datc.py
T
jcoffey-dev b41cf1c210 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.
2026-09-09 00:36:39 -07:00

144 lines
5.4 KiB
Python

#!/usr/bin/env python3
"""
Turn the published Diplomacy Adjudicator Test Cases into a fixture.
python3 tools/datc.py path/to/datc.html > src/game/datc.json
Only the machine-readable half is taken across: the case number, its title,
the orders, and the outcome each order is annotated with. The document's
explanatory prose is Lucas Kruijswijk's writing and stays where it is -- see
NOTICE.md. What lands here are the cases themselves, which are the thing an
adjudicator is measured against.
"""
import html
import json
import re
import sys
import pathlib
POWERS = ['austria', 'england', 'france', 'germany', 'italy', 'russia', 'turkey']
ANNOTATIONS = [
'succeeds', 'fails', 'illegal', 'dislodged', 'given', 'invalid',
'available', 'cut', 'disrupted', 'no convoy', 'bounce',
'destroyed', 'stands',
]
def province_names(map_ts: pathlib.Path) -> dict[str, str]:
"""id -> full name, read straight out of the board so they cannot drift."""
out = {}
for m in re.finditer(r"^\s+([a-z]{3}): P\('([^']+)'", map_ts.read_text(), re.M):
out[m.group(2)] = m.group(1)
return out
def to_text(raw: str) -> list[str]:
t = re.sub(r'<(script|style)[^>]*>.*?</\1>', '', raw, flags=re.S | re.I)
t = re.sub(r'<br\s*/?>', '\n', t, flags=re.I)
t = re.sub(r'</(p|div|li|h[1-6]|tr)>', '\n', t, flags=re.I)
t = re.sub(r'<[^>]+>', '', t)
return [l.rstrip() for l in html.unescape(t).split('\n')]
def norm(name: str) -> str:
return re.sub(r'\s+', ' ', name.replace('.', '').strip()).lower()
def main() -> int:
doc = pathlib.Path(sys.argv[1])
names = province_names(pathlib.Path(__file__).parent.parent / 'src/game/map.ts')
lookup = {norm(n): i for n, i in names.items()}
def place(text: str) -> str:
"""'Spain(nc)' -> 'spa/nc'. Unknown names raise rather than guess."""
text = text.strip()
coast = None
m = re.match(r'^(.*?)\s*\((nc|sc|ec|wc)\)$', text)
if m:
text, coast = m.group(1), m.group(2)
pid = lookup.get(norm(text))
if pid is None:
raise KeyError(text)
return f'{pid}/{coast}' if coast else pid
lines = to_text(doc.read_text(encoding='utf-8', errors='replace'))
heads = [(k, l) for k, l in enumerate(lines)
if re.match(r'\s*6\.[A-Z]\.\d+\.\s+TEST CASE', l)]
cases, unknown = [], set()
for idx, (start, head) in enumerate(heads):
end = heads[idx + 1][0] if idx + 1 < len(heads) else len(lines)
m = re.match(r'\s*(6\.[A-Z]\.\d+)\.\s+TEST CASE,\s*(.*)', head)
case = {'id': m.group(1), 'title': m.group(2).strip().lower(),
'units': [], 'orders': [], 'expect': {}}
power = None
ok = True
for line in lines[start + 1:end]:
s = line.strip()
if not s:
continue
p = s.rstrip(':').strip().lower()
if s.endswith(':') and p in POWERS:
power = p
continue
if not re.match(r'^[AF]\s', s) or power is None:
continue
body, marks = s, []
while True:
m2 = re.search(r',?\s*(' + '|'.join(ANNOTATIONS) + r')\s*$', body, re.I)
if not m2:
break
marks.insert(0, m2.group(1).lower())
body = body[:m2.start()].rstrip()
try:
unit_type = 'army' if body[0] == 'A' else 'fleet'
rest = body[1:].strip()
if m2 := re.match(r'^(.*?)\s+Convoys\s+A\s+(.*?)\s+-\s+(.*)$', rest, re.I):
at = place(m2.group(1))
order = {'type': 'convoy', 'at': at,
'from': place(m2.group(2)), 'to': place(m2.group(3))}
elif m2 := re.match(r'^(.*?)\s+Supports\s+[AF]\s+(.*?)\s+-\s+(.*)$', rest, re.I):
at = place(m2.group(1))
order = {'type': 'support', 'at': at,
'from': place(m2.group(2)), 'to': place(m2.group(3))}
elif m2 := re.match(r'^(.*?)\s+Supports\s+[AF]\s+(.*)$', rest, re.I):
at = place(m2.group(1))
tgt = place(m2.group(2))
order = {'type': 'support', 'at': at, 'from': tgt, 'to': tgt}
elif m2 := re.match(r'^(.*?)\s+-\s+(.*?)(\s+via\s+convoy)?$', rest, re.I):
at = place(m2.group(1))
order = {'type': 'move', 'at': at, 'to': place(m2.group(2)),
'viaConvoy': bool(m2.group(3))}
elif m2 := re.match(r'^(.*?)\s+Holds?$', rest, re.I):
at = place(m2.group(1))
order = {'type': 'hold', 'at': at}
else:
at = place(rest)
order = {'type': 'hold', 'at': at}
except KeyError as e:
unknown.add(str(e))
ok = False
break
case['units'].append({'power': power, 'type': unit_type, 'at': at})
case['orders'].append({**order, 'power': power})
if marks:
case['expect'][at.split('/')[0]] = marks
if ok and case['orders']:
cases.append(case)
if unknown:
print(f'unmapped province names: {sorted(unknown)}', file=sys.stderr)
print(f'{len(cases)} of {len(heads)} cases parsed', file=sys.stderr)
json.dump(cases, sys.stdout, indent=1)
return 0
if __name__ == '__main__':
raise SystemExit(main())