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.
144 lines
5.4 KiB
Python
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())
|