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:
+73
-16
@@ -1,5 +1,5 @@
|
|||||||
import { ARMY, base } from './map'
|
import { ARMY, base } from './map'
|
||||||
import { canStep, convoyRoute, type Board, type Order, type Unit } from './orders'
|
import { canStep, convoyRoute, validate, type Board, type Order, type Unit } from './orders'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The adjudicator.
|
* The adjudicator.
|
||||||
@@ -47,11 +47,43 @@ export interface Outcome {
|
|||||||
|
|
||||||
type State = 'unresolved' | 'guessing' | 'resolved'
|
type State = 'unresolved' | 'guessing' | 'resolved'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when a convoy paradox turns up, to start the whole resolution again
|
||||||
|
* with that convoy's army held still. Restarting is not elegant and it is
|
||||||
|
* provably finite, which matters more: every restart forces one more army to
|
||||||
|
* stand, and there are only so many armies.
|
||||||
|
*/
|
||||||
|
class Paradox extends Error {
|
||||||
|
stalled: readonly string[]
|
||||||
|
constructor(stalled: readonly string[]) {
|
||||||
|
super('convoy paradox')
|
||||||
|
this.stalled = stalled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function adjudicate(board: Board, orderList: readonly Order[]): Outcome {
|
export function adjudicate(board: Board, orderList: readonly Order[]): Outcome {
|
||||||
const orders = new Map<string, Order>()
|
const forced = new Set<string>()
|
||||||
for (const o of orderList) orders.set(base(o.at), o)
|
for (;;) {
|
||||||
// A unit with no order holds; a unit with a nonsense order holds too.
|
try {
|
||||||
for (const p of board.keys()) if (!orders.has(p)) orders.set(p, { type: 'hold', at: p })
|
return resolveAll(board, orderList, forced)
|
||||||
|
} catch (e) {
|
||||||
|
if (!(e instanceof Paradox)) throw e
|
||||||
|
const before = forced.size
|
||||||
|
for (const p of e.stalled) forced.add(p)
|
||||||
|
// No progress would mean looping forever; there is a bug if it happens.
|
||||||
|
if (forced.size === before) throw new Error('paradox made no progress')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAll(
|
||||||
|
board: Board,
|
||||||
|
orderList: readonly Order[],
|
||||||
|
/** Convoyed armies a paradox has already forced to stand still. */
|
||||||
|
forced: ReadonlySet<string>,
|
||||||
|
): Outcome {
|
||||||
|
// A unit with no order holds, and so does a unit whose order was refused.
|
||||||
|
const { orders, illegal } = validate(board, orderList)
|
||||||
|
|
||||||
const state = new Map<string, State>()
|
const state = new Map<string, State>()
|
||||||
const result = new Map<string, boolean>()
|
const result = new Map<string, boolean>()
|
||||||
@@ -106,6 +138,10 @@ export function adjudicate(board: Board, orderList: readonly Order[]): Outcome {
|
|||||||
|
|
||||||
/** Can this move physically happen at all? Zero attack strength if not. */
|
/** Can this move physically happen at all? Zero attack strength if not. */
|
||||||
function hasPath(p: string): boolean {
|
function hasPath(p: string): boolean {
|
||||||
|
// An army Szykman's rule has told to stand has no route anywhere, and
|
||||||
|
// therefore no weight: it cuts nothing and prevents nothing. Leaving it
|
||||||
|
// with a path is what let the paradox re-form on the next pass.
|
||||||
|
if (forced.has(p)) return false
|
||||||
const o = orderAt(p)
|
const o = orderAt(p)
|
||||||
const unit = unitAt(p)
|
const unit = unitAt(p)
|
||||||
if (o?.type !== 'move' || !unit) return false
|
if (o?.type !== 'move' || !unit) return false
|
||||||
@@ -191,6 +227,10 @@ export function adjudicate(board: Board, orderList: readonly Order[]): Outcome {
|
|||||||
function adjudicateOne(p: string): boolean {
|
function adjudicateOne(p: string): boolean {
|
||||||
const o = orderAt(p)!
|
const o = orderAt(p)!
|
||||||
|
|
||||||
|
// Szykman's rule, already applied: this army was carried into a paradox
|
||||||
|
// on an earlier pass and has been told to stand.
|
||||||
|
if (forced.has(p)) return false
|
||||||
|
|
||||||
if (o.type === 'hold') return true
|
if (o.type === 'hold') return true
|
||||||
|
|
||||||
if (o.type === 'convoy') {
|
if (o.type === 'convoy') {
|
||||||
@@ -301,19 +341,34 @@ export function adjudicate(board: Board, orderList: readonly Order[]): Outcome {
|
|||||||
const cycle = dep.slice(mark)
|
const cycle = dep.slice(mark)
|
||||||
dep.length = mark
|
dep.length = mark
|
||||||
|
|
||||||
const paradox = cycle.some((p) => orderAt(p)?.type === 'convoy')
|
const convoys = cycle.filter((p) => orderAt(p)?.type === 'convoy')
|
||||||
|
|
||||||
for (const p of cycle) {
|
if (convoys.length > 0) {
|
||||||
const o = orderAt(p)
|
/*
|
||||||
if (paradox) {
|
* A convoy paradox: whether the convoy survives depends on the move the
|
||||||
if (o?.type === 'move' && isConvoyed(p)) {
|
* convoy is carrying. Szykman's rule settles it -- the convoyed move
|
||||||
state.set(p, 'resolved')
|
* fails -- and it is a convention rather than a deduction, which is why
|
||||||
result.set(p, false)
|
* it is written down here rather than buried in the arithmetic.
|
||||||
} else {
|
*
|
||||||
state.set(p, 'unresolved')
|
* The armies stalled are the ones those convoy orders name, not merely
|
||||||
|
* the ones that happen to be in the cycle, which in a real paradox is
|
||||||
|
* often none of them. Settling it by restarting rather than by patching
|
||||||
|
* the half-resolved state is what makes it terminate.
|
||||||
|
*/
|
||||||
|
const stalled: string[] = []
|
||||||
|
for (const c of convoys) {
|
||||||
|
const o = orderAt(c)
|
||||||
|
if (o?.type === 'convoy') stalled.push(base(o.from))
|
||||||
}
|
}
|
||||||
} else {
|
throw new Paradox(stalled)
|
||||||
// Everybody in the ring moves.
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
// A ring of units all moving into each other. Nobody dislodges
|
||||||
|
// anybody; they all shuffle round, so they all go.
|
||||||
|
// A ring of units all moving into each other. Nobody dislodges
|
||||||
|
// anybody; they all shuffle round, so they all go.
|
||||||
|
for (const p of cycle) {
|
||||||
state.set(p, 'resolved')
|
state.set(p, 'resolved')
|
||||||
result.set(p, true)
|
result.set(p, true)
|
||||||
}
|
}
|
||||||
@@ -324,6 +379,8 @@ export function adjudicate(board: Board, orderList: readonly Order[]): Outcome {
|
|||||||
|
|
||||||
const success = new Map<string, boolean>()
|
const success = new Map<string, boolean>()
|
||||||
for (const p of orders.keys()) success.set(p, resolve(p))
|
for (const p of orders.keys()) success.set(p, resolve(p))
|
||||||
|
// An order that was never a legal order did not succeed at anything.
|
||||||
|
for (const p of illegal) success.set(p, false)
|
||||||
|
|
||||||
const dislodged = new Map<string, Dislodgement>()
|
const dislodged = new Map<string, Dislodgement>()
|
||||||
for (const p of board.keys()) {
|
for (const p of board.keys()) {
|
||||||
|
|||||||
+13762
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
+113
-4
@@ -19,12 +19,18 @@ export interface Unit {
|
|||||||
at: string
|
at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `power` is who gave the order, and is optional only because most of this
|
||||||
|
* engine's callers are handing it orders for units it already knows they
|
||||||
|
* own. When it is present it is checked: ordering another country's unit is
|
||||||
|
* not a clever move, it is not a move at all.
|
||||||
|
*/
|
||||||
export type Order =
|
export type Order =
|
||||||
| { type: 'hold'; at: string }
|
| { type: 'hold'; at: string; power?: Power }
|
||||||
| { type: 'move'; at: string; to: string; viaConvoy?: boolean }
|
| { type: 'move'; at: string; to: string; viaConvoy?: boolean; power?: Power }
|
||||||
/** `from === to` is a support to hold. */
|
/** `from === to` is a support to hold. */
|
||||||
| { type: 'support'; at: string; from: string; to: string }
|
| { type: 'support'; at: string; from: string; to: string; power?: Power }
|
||||||
| { type: 'convoy'; at: string; from: string; to: string }
|
| { type: 'convoy'; at: string; from: string; to: string; power?: Power }
|
||||||
|
|
||||||
/** Units by province. One unit to a province, whatever its coast. */
|
/** Units by province. One unit to a province, whatever its coast. */
|
||||||
export type Board = Map<string, Unit>
|
export type Board = Map<string, Unit>
|
||||||
@@ -99,3 +105,106 @@ export function convoyRoute(
|
|||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which orders are orders at all.
|
||||||
|
*
|
||||||
|
* The rulebook's word is that an illegal order is not obeyed and the unit
|
||||||
|
* holds instead, and every published test case that says `illegal` is
|
||||||
|
* checking exactly that. It matters more than it sounds: a fleet ordered
|
||||||
|
* somewhere it cannot go must still be standing where it was, still
|
||||||
|
* occupying that province, still in everybody else's way.
|
||||||
|
*
|
||||||
|
* This is also where a fleet's coast is settled. An order that says "Spain"
|
||||||
|
* is fine when only one coast of Spain can be reached from where the fleet
|
||||||
|
* is, and is no order at all when both can -- the fleet has not been told
|
||||||
|
* which sea it is going to sit in.
|
||||||
|
*/
|
||||||
|
export interface Validated {
|
||||||
|
/** Province -> the order that will actually be carried out. */
|
||||||
|
orders: Map<string, Order>
|
||||||
|
/** Provinces whose order was refused; those units hold. */
|
||||||
|
illegal: Set<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validate(board: Board, given: readonly Order[]): Validated {
|
||||||
|
const orders = new Map<string, Order>()
|
||||||
|
const illegal = new Set<string>()
|
||||||
|
|
||||||
|
const refused = new Set<string>()
|
||||||
|
|
||||||
|
for (const order of given) {
|
||||||
|
const at = base(order.at)
|
||||||
|
const unit = board.get(at)
|
||||||
|
if (!unit) continue
|
||||||
|
// Ordering somebody else's unit is not an order. It is also not that
|
||||||
|
// unit's problem: whatever its owner told it to do, it still does.
|
||||||
|
if (order.power && order.power !== unit.power) {
|
||||||
|
refused.add(at)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = check(board, unit, order)
|
||||||
|
if (ok) orders.set(at, ok)
|
||||||
|
else refused.add(at)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const at of refused) if (!orders.has(at)) illegal.add(at)
|
||||||
|
for (const [p] of board) if (!orders.has(p)) orders.set(p, { type: 'hold', at: p })
|
||||||
|
return { orders, illegal }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The order as it will be obeyed, with the coast filled in, or null. */
|
||||||
|
function check(board: Board, unit: Unit, order: Order): Order | null {
|
||||||
|
switch (order.type) {
|
||||||
|
case 'hold':
|
||||||
|
return order
|
||||||
|
|
||||||
|
case 'move': {
|
||||||
|
if (base(order.to) === base(unit.at)) return null
|
||||||
|
const to = settleCoast(unit, order.to)
|
||||||
|
if (to === null) return null
|
||||||
|
if (unit.type === 'fleet') return canStep(unit, to) ? { ...order, to } : null
|
||||||
|
// An army may walk, or be carried; either is a legal thing to order.
|
||||||
|
if (canStep(unit, to)) return { ...order, to }
|
||||||
|
return convoyable(unit, to) ? { ...order, to, viaConvoy: true } : null
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'support': {
|
||||||
|
// You may only support into a province you could have gone to yourself.
|
||||||
|
if (base(order.from) === base(unit.at)) return null
|
||||||
|
return reaches(unit, order.to) ? order : null
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'convoy': {
|
||||||
|
if (unit.type !== 'fleet') return null
|
||||||
|
if (PROVINCES[base(unit.at)]!.terrain !== 'sea') return null
|
||||||
|
const army = board.get(base(order.from))
|
||||||
|
if (!army || army.type !== 'army') return null
|
||||||
|
return convoyable(army, order.to) ? order : null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A destination a fleet could sit on, or null when it was not told which. */
|
||||||
|
function settleCoast(unit: Unit, to: string): string | null {
|
||||||
|
if (unit.type === 'army') return base(to)
|
||||||
|
if (to.includes('/')) return to
|
||||||
|
const coasts = PROVINCES[base(to)]?.coasts
|
||||||
|
if (!coasts) return to
|
||||||
|
const reachable = fleetCoasts(unit.at, to)
|
||||||
|
return reachable.length === 1 ? reachable[0]! : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Could this unit ever step here, on any coast? Used for supports. */
|
||||||
|
function reaches(unit: Unit, to: string): boolean {
|
||||||
|
if (unit.type === 'army') return (ARMY[base(unit.at)] ?? []).includes(base(to))
|
||||||
|
if (to.includes('/')) return (FLEET[unit.at] ?? []).includes(to)
|
||||||
|
return fleetCoasts(unit.at, to).length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Both ends ashore, which is the most a convoy can be judged before it sails. */
|
||||||
|
const convoyable = (unit: Unit, to: string): boolean =>
|
||||||
|
unit.type === 'army' &&
|
||||||
|
PROVINCES[base(unit.at)]!.terrain === 'coast' &&
|
||||||
|
PROVINCES[base(to)]!.terrain === 'coast'
|
||||||
|
|||||||
+143
@@ -0,0 +1,143 @@
|
|||||||
|
#!/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())
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
"types": ["vite/client"],
|
"types": ["vite/client"],
|
||||||
"allowArbitraryExtensions": true,
|
"allowArbitraryExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|
||||||
/* Bundler mode */
|
/* Bundler mode */
|
||||||
|
|||||||
Reference in New Issue
Block a user