Files
great-powers/tools/datc.py
T
jcoffey-dev 8c31ecb35c Three of the six DATC deviations were bugs
The illegal/invalid line (6.A.5, 6.D.8). The document separates an order
that is impossible in this position from one this position allows that
merely did not come off. The first is ignored and its unit is holding like
any other; the second means the unit tried to leave and cannot be propped up
where it stands. An army sent across water with a fleet on the route and no
convoy ordered is the second; the identical order with nobody in the sea at
all is the first. Guessing at this once cost four passing cases to buy one.
Reading the document cost nothing -- it says so outright, in the note under
6.D.8 and again under 6.D.32.

A support that only looked like a hold (6.A.5). "Supports A Yorkshire" and
"Supports A Yorkshire - Yorkshire" are the same shape once parsed, and the
case turns on the difference: the first holds a unit down, the second
supports a move nobody can legally make. The extractor was throwing that
away; it now keeps it, and the fixture is regenerated.

The head of a cycle (6.F.22). Kruijswijk's resolver guesses that an order
fails, sees what follows, then guesses the other way. Only the outermost
order in a cycle may do that, because its answer is the one everything else
was computed against. This engine let any order that reached the dependency
list first declare the cycle its own -- and an inner one takes both guesses
with its callers' provisional answers held fixed, gets the same result twice
for that reason, and writes it down as settled. The cycle then becomes
invisible: the backup rule never runs and Szykman's rule never fires, and
the position resolves to whichever of its two consistent readings the search
happened to walk into first. It had been doing this on every paradox and
getting away with it, because on a first-order paradox both readings agree.

Also carried across from the reference algorithm: an order that resolved
itself while a nested call was away must not have the guess written over it.

Three remain. 6.F.28 and 6.F.29 are sixth-order paradoxes -- the same
twenty-six orders with one support between them, and the point of the pair
is that the answer swings on it, where this resolver gives both the same
answer. 6.G.19 the document itself gives three answers to, calls a little
bit pedantic, and advises adjudicators not to offer at all.
2026-09-09 09:41:41 -07:00

206 lines
8.5 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):
# A destination was written out. Worth keeping even when it
# is the mover's own province: "Supports A Yorkshire" and
# "Supports A Yorkshire - Yorkshire" come out identically
# otherwise, and 6.A.5 turns on the difference between
# them -- the first holds a unit down, the second supports
# a move nobody can legally make.
at = place(m2.group(1))
order = {'type': 'support', 'at': at, 'ofMove': True,
'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())