Skip to content

Data Model Specification

Reflects the post-rewrite (M0–M7) engine and persistence layout. Two layers, kept deliberately separate — see architecture.md:

  1. Engine value types (src/engine/types.py) — pure, immutable, no persistence concerns. This is what adjudication actually operates on.
  2. Persistence (src/persistence/) — a game row stores the serialized engine GameState wholesale (state_json), not a normalized relational breakdown. A few peripheral tables (players, users, messages, channels, tournaments, ...) remain relational because they aren't engine-coupled.

For the algorithm that produces these values, see adjudication.md.

1. Engine value types (src/engine/types.py)

All frozen, hashable dataclasses; no dict-of-dicts, no mutation. Full definitions live in the source — this is the field-level reference.

Location

Location(province: str, coast: Optional[str] = None)

province is always uppercased on construction. coast is one of NC/SC/EC/WC (also uppercased) or None. Armies always have coast=None; fleets in split-coast provinces (BUL, SPA, STP) always name one. str(loc) renders the canonical text form: "PAR" or "SPA/SC" — this is also the JSON encoding (§4).

Unit

Unit(kind: UnitKind, power: str, location: Location)

kind is UnitKind.ARMY ("A") or UnitKind.FLEET ("F"). Raises if an army carries a coast. .province is a convenience property (location.province).

DislodgedUnit

DislodgedUnit(unit: Unit, attacker_origin: Optional[str] = None,
              retreats: tuple[Location, ...] = ())

Carries everything the retreat phase needs precomputed: the province the dislodging attack came from (None if that attack was convoyed — no shared-border block applies), and the full legal retreat set already computed against post-resolution occupancy (see adjudication.md §8). Empty retreats means the unit is trapped and must disband.

Orders

All orders share a power: str field and an order_type: OrderType property. One class per order kind (movement-phase: Hold, Move, SupportHold, SupportMove, Convoy; retreat-phase: Retreat, Disband; adjustment-phase: Build, Disband (shared), Waive):

Class Fields Example order text
Hold unit A PAR H
Move unit, dest, via_convoy A PAR - BUR, A LON - BEL VIA
SupportHold unit, target F BRE S A PAR
SupportMove unit, origin, dest F BRE S A PIC - BEL
Convoy unit, origin, dest F NTH C A LON - BEL
Retreat unit, dest A PAR R BUR
Disband unit D A PAR
Build location, kind BUILD A PAR, BUILD F STP/SC
Waive — WAIVE

All Location-typed fields, not strings — there is no separate "target province name" field to keep in sync with a Location. Grammar/parsing lives in orders/parser.py; legality (not just grammar) in orders/validation.py; adjudication semantics for each type in adjudication.md.

OrderResult / Resolution

OrderResult(order: Order, result: ResultCode, dislodged: bool = False,
            retreat_options: tuple[Location, ...] = ())
Resolution(results: tuple[OrderResult, ...] = ())

ResultCode: OK, BOUNCE, CUT, VOID, NO_CONVOY, DISLODGED, DISBAND, BUILD, WAIVE — see types.py's enum docstring for the precise meaning of each; the convoy-specific distinction between VOID/DISLODGED/NO_CONVOY/OK is covered in adjudication.md §6.

GameState

GameState(
    year: int, season: Season, phase_type: PhaseType,
    units: frozenset[Unit] = frozenset(),
    ownership: dict[str, str] = {},       # supply-center province -> owning power
    dislodged: tuple[DislodgedUnit, ...] = (),
    contested: frozenset[str] = frozenset(),  # standoff provinces (retreat phase only)
    status: GameStatus = GameStatus.ACTIVE,
)

An immutable snapshot of the whole game between phases — not a mutable "current state" object. phase_name derives the canonical code ("S1901M", "F1901R", "W1901A") from season/year/phase_type. Helper queries: units_of(power), unit_at(province), centers_of(power), dislodged_at(province).

Game (src/engine/game.py) wraps a GameState with its map: MapData and a history tuple of past snapshots; Game.adjudicate(orders) is the only way to advance it, and it returns a new Game plus the phase's Resolution — nothing mutates.

2. Serialization (src/engine/serialization.py)

The one place GameState/Order/Resolution cross the pure-engine boundary into JSON. Round-trips exactly (state_from_dict(state_to_dict(s)) == s, Hypothesis-checked).

  • Location -> its canonical string (location_to_str/location_from_str): "PAR" or "SPA/SC".
  • Every enum -> its .value string.
  • frozenset/tuple fields (units, contested) -> JSON arrays (order doesn't matter for round-trip equality — they decode back into sets).
  • state_to_dict(state) -> {year, season, phase_type, units: [unit_dict...], ownership: {province: power}, dislodged: [dislodged_dict...], contested: [province...], status}.
  • order_to_dict(order) -> {type, power, ...order-specific Location strings...} (see the field table above — each order type serializes exactly its own fields).
  • resolution_to_dict(resolution) -> {results: [{order, result, dislodged, retreat_options}, ...]}.

to_json()/state_from_json()/order_from_json()/resolution_from_json() are thin json.dumps/json.loads wrappers around the dict functions.

3. Persistence (src/persistence/)

games table (GameModel, src/persistence/database.py)

The columns that matter for the new engine (M6 additions, all nullable so they layer onto the pre-existing row shape without a destructive migration for these columns — though the state_json/pending_orders migration a1b2c3d4e5f7 itself does wipe stored game rows; see fix_plan.md M6):

Column Type Written by Meaning
state_json JSON GameRepo.create / .save_state The serialized GameState — the authoritative source of truth for a game's board.
pending_orders JSON GameRepo.set_pending_orders {power: [order_str, ...]}, submitted but not yet adjudicated; cleared after process_turn.
last_resolution JSON GameRepo.save_state The most recent resolution_to_dict() output — kept only so /generate_map/resolution can draw arrows for the turn just processed; not otherwise authoritative (superseded on the next process_turn).
order_history JSON GameRepo.save_state {turn_number_str: {power: [order_str, ...]}}, appended (never overwritten) each process_turn, using the truthful A/F-lettered order text. Powers /orders/history.
dummy_powers JSON GameRepo.create / .set_dummy_powers W9: sorted list of powers played by civil disorder. Never joinable, never waited on (orders_status), excluded from draw quorum and deadline-proposal majorities (GameService.active_powers). Null/[] = none; at most six. In a game whose map_name is "demo" (the bot's solo demo), process_turn gives each dummy with no orders engine.simple_ai orders, seeded by game id and phase, and records them in order_history like anyone's.
auto_process Boolean GameRepo.create / .set_auto_process W10: process the turn as soon as orders_status has nothing missing and no wait flag is up. Null/false = manual or deadline only.
wait_flags JSON GameRepo.set_wait_flags W10: {power: true} for players who asked the table to wait. Cleared by finish_processed_turn on every processed turn; never stops a deadline or /processturn.
join_password_hash String(100) GameRepo.create / .set_join_password_hash W8: bcrypt hash of a private game's join password; null = open. Never serialized: views and GET /games carry only private, and the W5 export leaves it out (an imported game comes back open).
created_by_user_id Integer FK users.id, ON DELETE SET NULL GameRepo.create W9: who created the game (Bearer user, or the bot's telegram_id). Null for waiting-list, demo-seeder and pre-W9 games. Only the creator (or X-Admin-Token) may change dummy_powers.

Plus denormalized convenience columns kept in sync for code that doesn't want to parse state_json (deadline scheduler, game listings, channel posts): map_name, current_turn, current_year, current_season, current_phase, phase_code, status, deadline, channel_id, channel_settings, observer_mode, created_at, updated_at.

Legacy relational columns/tables — units, orders, supply_centers (as separate tables, still present in the schema) and the old per-game relational fields — predate the engine rewrite and are no longer written or read for game state; state_json fully supersedes them. They exist only because dropping them would be a separate, disruptive migration outside the rewrite's scope (fix_plan.md M6 explicitly deleted the code paths that wrote them — unit_to_dict/order_to_dict/dict_to_order — but left the tables themselves alone). Do not add new code that reads/writes them.

players table (PlayerModel)

Unaffected by the engine rewrite — player-to-power assignment is not an engine concern. Key columns: game_id (FK), power_name, user_id (FK to users), is_active, is_eliminated. GameRepo.players(game_id) reads this into {power: {user_id, is_active}} for the API view (§4).

Other tables

users, link_codes, password_reset_tokens, messages, turn_history, map_snapshots, tournament tables, channel-analytics tables — all unchanged by the engine rewrite; see database.py for the full model list. DatabaseService (database_service.py) remains the DAL for all of these; only game state itself was carved out into GameRepo + GameService.

Added by the split deployment (migration h6b2c3d4e5f6, Track J):

Table / column Purpose
bot_outbox Server → player notifications waiting for the bot: kind (dm), telegram_id, message, payload (spare JSON), created_at, delivered_at (NULL = pending), attempts, last_error. The bot pulls and acks; delivered rows are purged after 7 days.
idempotency_keys First response stored per bot-supplied Idempotency-Key: key, endpoint, status_code, response_json, created_at. Purged after 7 days.
games.phase_started_at When the current phase_code began; stamped by GameRepo on every phase change. Order submissions with a client_timestamp older than this are refused (409).
messages.timestamp Now the time the message was composed when the client sends client_timestamp; otherwise now.

4. The HTTP API view shape

GameService.view(game_id) (src/server/game_service.py) is the single place that builds the JSON a client sees for GET /games/{id}/state (and equivalent DAIDE/bot paths) — built directly from GameState, not from any legacy relational shape:

{
  "game_id": "1",
  "map_name": "standard",
  "phase": "S1901M",                    // GameState.phase_name
  "year": 1901,
  "season": "SPRING",
  "phase_type": "MOVEMENT",             // MOVEMENT | RETREAT | ADJUSTMENT
  "status": "ACTIVE",                   // ACTIVE | COMPLETED
  "units": [ {"kind": "F", "power": "FRANCE", "location": "BRE"}, ... ],
  "units_by_power": { "FRANCE": [ ... ], ... },
  "ownership": { "PAR": "FRANCE", ... },
  "supply_centers": { "PAR": "FRANCE", ... },  // == ownership; kept as an alias for callers
  "dislodged": [
    {"unit": {...}, "attacker_origin": "BUR", "retreats": ["PIC", "GAS"]}
  ],
  "contested": ["BUR"],
  "players": { "FRANCE": {"user_id": 42, "is_active": true}, ... },
  "dummy_powers": ["TURKEY"],           // W9: played by civil disorder; [] if none
  "auto_process": false,                // W10: turn runs by itself once all orders are in
  "wait_flags": ["ENGLAND"],            // W10: powers that asked the table to wait
  "private": false,                     // W8: joining needs a password (never the hash)
  "orders": { "FRANCE": ["F BRE H", "A PAR - BUR"], ... }  // pending, truthfully re-lettered
}

orders is re-derived every call via _humanize_orders: stored pending-order strings are reparsed and reformatted against the current board so the A/F unit letter is always correct (a fleet at a non-split-coast province displays F, not the coast-inferred guess format_order would otherwise produce — see orders/parser.py's format_order docstring). This is a display-only correction; adjudication always uses the actual board unit, never the letter in the order string.

Consumers of this exact shape: frontend/src (React SPA — GameView.tsx and friends), the Telegram bot's api_client.py, and src/server/daide/session.py. There is no legacy powers-keyed view left to support.

Resolution-result shapes: POST .../process_turn and GET .../last_resolution

Who may call it: the game's creator (games.created_by_user_id) — as a Bearer user, or as a Telegram player when the bot passes their telegram_id in the JSON body — plus the bare bot secret and the admin token. Anyone else, seated or not, gets 403; a game nobody created (a waiting-list game) can be ended early only by an admin. The game view carries created_by_user_id so the web shows "Process turn" only to the creator, and GET /users/{telegram_id}/games marks created games with is_creator: true for the bot.

POST /games/{id}/process_turn (api/routes/games.py) returns, additively (the pre-existing status: "ok" key is unchanged so existing clients keep working):

{
  "status": "ok",
  "phase": "F1901M",           // GameState.phase_name of the *new* (post-adjudication) phase
  "game_status": "ACTIVE",     // GameState.status of the new phase -- "status" was already
                                // taken by the pre-existing "ok" key above
  "resolution": { "results": [ ... ] }  // the turn just adjudicated -- see below
}

GET /games/{id}/last_resolution (added so a client can re-fetch this after a page reload, since the inline process_turn response above isn't persisted client-side) returns the same resolution shape directly, 404 when the game doesn't exist, {"results": []} when it exists but no turn has been processed yet:

{
  "results": [
    {
      "order": {"type": "MOVE", "power": "FRANCE", "unit": "PAR", "dest": "BUR", "via_convoy": false},
      "result": "BOUNCE",       // engine.types.ResultCode -- OK/BOUNCE/CUT/VOID/NO_CONVOY/DISLODGED/DISBAND/BUILD/WAIVE
      "dislodged": false,
      "retreat_options": [],
      "power": "FRANCE",        // convenience: same as order.power, flattened
      "order_str": "A PAR - BUR"  // convenience: format_order(order), best-effort --
                                   // without the pre-adjudication board's kind_by_province
                                   // (not retained), a fleet at a non-split-coast
                                   // province may print as "A"
    },
    ...
  ]
}

Both order and the bare result/dislodged/retreat_options fields are exactly engine.serialization.resolution_to_dict()'s canonical per-OrderResult shape (§2), passed through unchanged; power/order_str are the only fields added on top (GameService.last_resolution_view). This is what a client uses to answer "what happened to my orders?" without re-deriving adjudication itself.

Order/resolution overlay maps: GET .../map/orders, GET .../map/resolution

GET /games/{id}/map streams the current board as PNG bytes. Two more GET routes mirror it with overlay arrows drawn on top, so a browser can render them directly (the older POST .../generate_map/orders and .../generate_map/resolution return {"map_path": "/tmp/diplomacy_maps/..."} — a server-filesystem path, unreachable from a browser; both routes are kept for existing server-side callers):

  • GET /games/{id}/map/orders — the board plus arrows for the current pending orders (plain board if none submitted yet).
  • GET /games/{id}/map/resolution — the board plus arrows for the last processed turn's orders, coloured by ResultCode, plus standoff markers (plain board if no turn has been processed yet).

Both return image/png bytes with the same 404-on-missing-game behavior as GET .../map, and lean on Map.render_board_png*'s own disk-backed byte cache rather than a second caching layer (see get_map_preview_png's docstring in maps.py).

5. Validation

Order legality (not grammar — grammar is orders/parser.py's job) is centralized in orders/validation.py's validate(order, state, map) -> ValidationResult, the one path used by GameService.submit_orders (pre-check before an order is accepted into pending_orders) and by adjudicator/adjustments.py (build legality). There is no second, divergent validation path anywhere in the codebase.

The first check is the phase: an order whose kind has no meaning in state.phase_type is refused before any unit or topology check, with a reason that names the phase (a move order is not accepted during the retreat phase (S1901R); only retreat and disband (for dislodged units) orders are). Movement takes Hold / Move / SupportHold / SupportMove / Convoy; Retreat takes Retreat / Disband; Adjustment takes Build / Disband / Waive. The adjudicators already ignore orders from the wrong phase, so without this gate a build typed during a movement phase, or a move typed during a retreat or build phase, was accepted with ok=True, stored, shown as pending, and then dropped without a word — which for an adjustment phase meant a player's build was silently waived. Interactive menus never offered such orders (legal_orders.py is phase-aware); the gate covers free-text input from every client.

GameService.orders_status (and so GET /games/{id}/orders_status and process_turn?require_all=true) counts only the powers that have a decision to make this phase, via server.legal_orders.powers_with_orders_to_give: every power with a unit in a movement phase, only powers with a dislodged unit in a retreat phase, and in an adjustment phase only powers that must disband or that are owed a build and have a vacant owned home centre to put it on. A power legal_orders_for_power would offer nothing but WAIVE is not waited on.

6. Out of scope here

Full DB migration history: alembic/versions/. Route-by-route request/response models: the route modules themselves, or the generated OpenAPI schema at /docs. The adjudication algorithm that produces Resolution/next-GameState: adjudication.md.