Files
great-powers/tools/datc.py
T
jcoffey-dev 38f69e9aa6 Harnesses for retreating and the winter
All 176 published cases now parse and run. Retreating passes 18 of 18,
building 7 of 7, civil disorder 13 of 13; movement is unchanged at 133 of
139.

The retreat and winter engine code was written before these cases and mostly
held up, but civil disorder was wrong in a way playing would never have shown.

It measured distance to a power's *home* centres. The rule is distance to the
centres it owns -- a Russia driven out of Moscow and holding Sweden measures
from Sweden -- so every power that had lost a home centre was disbanding the
wrong unit.

And it measured over the graph each unit moves on, which is wrong in both
directions. An army counts sea provinces it would have to be convoyed across,
so Albania is two steps from Naples rather than unreachable. A fleet counts
inland provinces it could never enter, so the Baltic is two from Warsaw. That
reads as nonsense until you notice what is being measured: not how the unit
would get home, but how far out of the way it is. A fleet in the Baltic is
still in the middle of Russia's business; one in the North Atlantic is not.

Civil disorder also only applies to the shortfall. A power that disbanded one
of the two it owed has half answered, and the automatic rule picks from what
is left rather than from what it started with.
2026-09-09 06:41:28 -07:00

200 lines
8.0 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': {},
'retreats': [], 'expectRetreat': {},
'adjust': [], 'expectAdjust': {}, 'own': {}}
power = None
ok = True
# Cases in section H run a movement phase and then a retreat phase.
phase = 'move'
for line in lines[start + 1:end]:
s = line.strip()
if not s:
continue
if s.upper() == 'RETREATS':
phase = 'retreat'
continue
p = s.rstrip(':').strip().lower()
if s.endswith(':') and p in POWERS:
power = p
continue
# "Germany owns SC Kiel" / "Germany owns A Prussia": the winter
# cases state the position this way rather than by ordering it.
if m3 := re.match(r'^(\w+)\s+owns\s+(SC|A|F)\s+(.*?)\s*$', s, re.I):
who = m3.group(1).lower()
if who not in POWERS:
continue
try:
where = place(m3.group(3))
except KeyError as e:
unknown.add(str(e)); ok = False; break
if m3.group(2).upper() == 'SC':
case['own'][where.split('/')[0]] = who
else:
kind = 'army' if m3.group(2).upper() == 'A' else 'fleet'
case['units'].append({'power': who, 'type': kind, 'at': where})
case['own'].setdefault(where.split('/')[0], who) if False else None
continue
# "Build A Kiel" / "Disband F Gulf of Lyon" / "Remove A Paris".
if m3 := re.match(r'^(Build|Disband|Remove|Automatic disband)\s+([AF])\s+(.*?)\s*$', s, re.I):
body, marks = m3.group(3), []
while True:
m4 = re.search(r',?\s*(' + '|'.join(ANNOTATIONS) + r')\s*$', body, re.I)
if not m4:
break
marks.insert(0, m4.group(1).lower())
body = body[:m4.start()].rstrip()
try:
where = place(body)
except KeyError as e:
unknown.add(str(e)); ok = False; break
kind = 'army' if m3.group(2).upper() == 'A' else 'fleet'
verb = m3.group(1).lower()
case['adjust'].append({
'type': 'build' if verb == 'build'
else 'auto' if verb == 'automatic disband' else 'disband',
'at': where, 'unit': kind, 'power': power,
})
if marks:
case['expectAdjust'][where.split('/')[0]] = marks
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
if phase == 'move':
case['units'].append({'power': power, 'type': unit_type, 'at': at})
case['orders'].append({**order, 'power': power})
if marks:
case['expect'][at.split('/')[0]] = marks
else:
case['retreats'].append({**order, 'power': power})
if marks:
case['expectRetreat'][at.split('/')[0]] = marks
if ok and (case['orders'] or case['adjust']):
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())