Architecture¶
Reflects the post-rewrite layout (
fix_plan.mdM0–M7). For the adjudication algorithm itself seeadjudication.md; for the wire/DB shapes seedata_spec.md.
Processes¶
Telegram Bot ──┐
React SPA ─────┼──► FastAPI (port 8000) ──► GameService ──► GameRepo ──► Postgres
DAIDE clients ─┘ │ │
│ └─ (state_json / pending_orders /
│ last_resolution / order_history)
└── engine.Game (pure logic, no I/O) ──► src/rendering (PNG maps)
Five things talk to one Postgres database: the FastAPI HTTP server, the Telegram bot (a
thin HTTP client, never touches the engine or DB directly), the React SPA, DAIDE TCP
clients (a real asyncio listener, src/server/daide/, started alongside the API
process — see "DAIDE protocol support" below; this is no longer aspirational, per
fix_plan.md Track D D1-D5), and the deadline scheduler background task inside the API
process.
Package boundaries (the M6 split)¶
src/engine/ # PURE rules core — stdlib only, no I/O, no DB, no rendering
types.py # frozen dataclasses: Location, Unit, Order variants, GameState,
# Resolution, DislodgedUnit; enums (UnitKind, Season, PhaseType,
# OrderType, ResultCode, GameStatus)
map_loader.py # .map file -> MapData; coasts first-class; topology only
orders/parser.py # order grammar: coasts, VIA convoy, aliases; parse + format
orders/validation.py # the one validation path: validate(order, state, map)
adjudicator/movement.py # Kruijswijk fixed-point resolver — see adjudication.md
adjudicator/retreats.py # retreat legality + phase
adjudicator/adjustments.py # builds/disbands/civil disorder
game.py # phase machine over immutable GameState snapshots
serialization.py # canonical GameState/Order/Resolution <-> JSON (one place)
simple_ai.py # dumb heuristic order generator for demo/AI-filled games
src/persistence/ # SQLAlchemy models + DAL (moved out of engine/ in M6)
database.py # ORM models (GameModel, UserModel, PlayerModel, ...)
database_service.py # DatabaseService — CRUD for players/users/messages/channels/
# tournaments/etc.; game *state* itself is delegated to...
game_repo.py # ...GameRepo: state_json/pending_orders/last_resolution/
# order_history persistence for the new engine
src/rendering/ # SVG -> PNG map rendering (moved out of engine/map.py in M6)
map.py # renderer: board state, order arrows, resolution arrows
order_overlay.py # adapts engine Order/Resolution into the renderer's arrow format
visualization_config.py # colors/sizes/layout config
src/server/ # FastAPI app, CLI Server, DAIDE, Telegram bot
game_service.py # THE single entry point from server code into the engine —
# wraps engine.Game + serialization + parser/validation over
# GameRepo. Routes/CLI/DAIDE never touch engine internals directly.
api/routes/ # games, orders, users, auth, messages, maps, channels, admin,
# dashboard, health, tournaments
telegram_bot/ # thin HTTP client over the API — see below
daide/ # the DAIDE TCP protocol (Track D D1-D5) — see below
server.py # text-command CLI surface (CREATE_GAME, ADD_PLAYER, ...),
# used by tests and DAIDE; independent of the HTTP API
Key discipline established in M6: src/engine/ imports nothing but stdlib. Nothing
in it knows about SQLAlchemy, FastAPI, Pillow, or JSON serialization frameworks — a
Hypothesis-checked property enforces this isn't just aspirational. Everything else
(persistence, rendering, the HTTP/bot/DAIDE surfaces) is an adapter around
GameService, which is the only thing that constructs engine.game.Game instances,
calls adjudicate(), or reaches into orders/parser.py and orders/validation.py.
Engine design decisions (see adjudication.md for the algorithm)¶
- Immutability. Every engine type is a frozen, hashable dataclass. Adjudication is a
pure function
(map, state, orders) -> (Resolution, new_state).Gamewraps the current state plus a tuple of past snapshots (history); nothing mutates in place. Location = (province, coast|None)everywhere. A fleet in Spain is atLocation("SPA", "SC"). Armies never carry a coast. There are no hardcoded adjacency/coast tables anywhere in the engine —map_loader.pyis the only reader ofmaps/standard.map, which is the sole topology source.- Kruijswijk fixed-point resolver. Per-order state UNRESOLVED/GUESSING/RESOLVED;
recursive resolve with dependency-cycle detection; circular movement succeeds,
convoy-entangled cycles apply the Szykman rule. Full writeup:
adjudication.md. - Phases:
S{y}M -> [S{y}R] -> F{y}M -> [F{y}R] -> [W{y}A] -> S{y+1}M -> ...; a retreat phase is inserted only when the preceding movement dislodged a unit; an adjustment phase only when some power's unit count differs from its supply-center count; SC ownership updates once, after the Fall turn settles; victory at 18 centers.
State persistence (the M6 clean break)¶
A game row (games table) stores the whole GameState as state_json
(engine.serialization.state_to_dict), not a normalized relational breakdown of units
and orders. Alongside it: pending_orders ({power: [order_str]}, submitted but not yet
adjudicated), last_resolution (the most recent Resolution, kept only so the
resolution-map renderer has something to draw arrows from), and order_history
({turn: {power: [order_str]}}, appended on every process_turn, powering the
Telegram bot's order-history view). Player-to-power assignments live in the separate
players table (unaffected — never engine-coupled). See data_spec.md for the exact
column list and the legacy relational tables that predate this and are no longer written.
GameService (src/server/game_service.py) is the funnel:
create_game / submit_orders / process_turn / view / last_resolution /
order_history. process_turn loads state_json, parses pending_orders, calls
Game.adjudicate(), and persists the next state_json + last_resolution +
appended order_history, then clears pending_orders. view builds the
GameState-native API response shape consumed by the frontend, the bot, and DAIDE (see
data_spec.md §API view shape).
Rendering¶
src/rendering/map.py renders PNGs from a GameState-derived unit/ownership view (not
from engine internals) plus, optionally, order or resolution arrows adapted by
order_overlay.py from Order/Resolution objects. Results are cached in-memory and on
disk at /tmp/diplomacy_map_cache. This package has no engine-internal coupling beyond
map_loader topology and the plain-dict view GameService.view already produces.
DAIDE protocol support¶
src/server/daide/ (Track D, D1-D5) is a real implementation of the DAIDE wire protocol
— interoperability with the external DAIDE bot ecosystem (DumbBot, Albert, and other
standalone Diplomacy AIs) — not the text-command stub that used to occupy this slot
(deleted in D4). It is started as an asyncio.start_server listener alongside the
deadline scheduler in _api_module.py's lifespan, on the same port (8432) the old stub
used.
src/server/daide/
tokens.py # Token: the DAIDE byte-level vocabulary (powers, provinces+coasts, unit
# types, order types, commands, THX/ORD/HLO tokens) as a bidirectional
# registry; province coverage is asserted against
# engine.map_loader.load_standard_map(), never a second hardcoded list
wire.py # DCSP framing: IM/RM/DM/FM/EM message types over asyncio Stream{Reader,
# Writer}; async read_message/write_message
clauses.py # the encode/decode bridge between DAIDE token clauses and engine.types
# (Location, Unit, Order variants); decode reuses
# engine.orders.parser.parse_order rather than a second grammar
session.py # DaideSession: per-connection protocol state machine — the IM/RM
# handshake, then NME/IAM/HLO/MAP/MDF/SCO/NOW/SUB/THX/MIS/TME/HST/DRW/
# ADM/SND dispatch, all routed through GameService (never engine
# internals directly)
server.py # DaideServer: owns the listening socket, the game a connection's NME
# resolves against (created lazily on first successful NME, not at
# listener startup — see its docstring), the power/passcode registry,
# and the notify_game_processed broadcast (NOW/ORD/OUT/SLO) that fires
# whenever GameService.process_turn runs for a game with live sessions
Known, permanent limitation: press content is relayed opaquely, not parsed. DAIDE's
press negotiation grammar (PRP/ALY/XDO/... nested inside SND/FRM) is the
deepest part of the spec and the least essential for interoperability. This codebase
syntax-checks press messages only (balanced parens, a valid recipient-power list) and
forwards the token payload opaquely between clients — negotiation content is the
bots' concern, not the server's. Full press-grammar parsing is out of scope by design
(see fix_plan.md Track D's "Ground rules" and "Out of scope"), not a temporary gap to
be closed later.
End-to-end proof this composes correctly over a real socket (not just each layer's own
unit tests) lives in tests/test_daide_server.py's
TestEndToEndOneFullTurnOverOneSocket — one continuous asyncio TCP connection drives
IM→RM→NME→HLO→MAP→MDF→SCO→NOW→SUB→THX against a real GameService/Postgres-backed game,
then GameService.process_turn + DaideServer.notify_game_processed (the same calls the
HTTP route/deadline scheduler make) push a real NOW/ORD notification back over that
same socket.
Notifications: who gets told what, when¶
There are three delivery surfaces, and they are not interchangeable:
- Telegram DM —
notify_user(telegram_id, message)andnotify_players(numeric_game_id, message, exclude_telegram_id=None)inapi/shared.py. These write a row to thebot_outboxtable and return; the bot pulls undelivered rows overGET /bot/outboxevery few seconds and acks them (POST /bot/outbox/ack) once Telegram has accepted the message. Server code never talks to Telegram and never pushes at the bot (the port-8081/notifyserver is gone — see Deployment and message reliability below). Players with a non-numerictelegram_id(test fixtures like"u1") are skipped, not errored. Tests observe notifications throughtests/reliability_helpers.OutboxProbe. A DM may carry inline buttons (buttons=→payload.buttons): turn processed, the 10-minute reminder, "you joined" and "game is full" carrygame_buttons(game_id)— 📝 Enter orders / 🗺 Map / 🎮 Game menu, asg|{game_id}|{action}|ncallbacks that the bot's game menu (telegram_bot/hub.py) answers in a new message. - Linked channel post —
telegram_bot/channels.py. Only fires for games that have a channel linked, gated by the per-gameshould_auto_post_*settings; a no-op otherwise. - Web client — pull-only. The SPA polls
GET /games/{id}/state; nothing is pushed. Any row below is therefore "visible on next poll" for the browser, and that is not a gap to close with websockets unless someone decides it is.
(DaideServer.notify_game_processed is a fourth, protocol-level path — NOW/ORD/OUT/SLO
to connected DAIDE bots. It is orthogonal to the table below and fires from both process_turn
call sites already.)
The matrix. This is the deliverable of G3, and it exists because the two process_turn
paths had silently drifted: the deadline path notified everyone and posted to the channel, while
the manual route notified nobody unless the game had just ended. The failure case was richly
instrumented and the success case was silent, because nobody owned the question.
| Event | Telegram DM | Channel post | Web client | Where |
|---|---|---|---|---|
| Turn processed (deadline) | all players | notification + rendered map | next poll | notify_turn_processed(trigger="deadline") |
| Turn processed (manual) | all players except the caller | notification + rendered map | next poll | notify_turn_processed(trigger="manual") |
| Game ended (18 centres, draw, last power) | all players except the caller | notification | next poll | notify_turn_processed(game_ended=True) |
| Deadline reminder (10 min out) | all players | — | — | check_and_send_reminders |
| Deadline set or cleared | all players except the setter | — | next poll | routes/games.py set_deadline |
| Player joined | all players | — | next poll | routes/games.py join |
| Game full / started | all players | — | next poll | routes/games.py join |
| Player quit / replaced | all players | — | next poll | routes/games.py quit, admin replace |
| Broadcast message | all players | the broadcast text | next poll | routes/messages.py |
| Private message | recipient only | — | next poll | routes/messages.py |
| Draw vote cast (not final) | all players except the voter | — | next poll | routes/games.py submit_draw_vote |
| Draw quorum reached → game ends | all players except the voter | notification | next poll | notify_turn_processed(game_ended=True) |
| Power conceded | all players except the conceder | — | next poll | routes/games.py concede_game |
| Waiting list filled | all seven placed players, each told their own power | — | — | api/routes/waiting_list.py |
Those three rows were nothing until v2.7.64 (G3a), and the reason is worth keeping:
submit_draw_vote finalizes the game inline the moment quorum is reached
(GameService.submit_draw_vote calls Game.draw() and save_state directly) and returns the
outcome to the voter only. Because the game is then COMPLETED, the deadline scheduler skips it
(get_games_with_deadlines_and_active_status), so no later turn-processed fan-out covered for
it — a game could end by agreement and six of seven players find out by refreshing. A
concession was likewise invisible until someone looked at the board.
A non-final draw vote is announced too, deliberately: a draw is the one outcome every power holds
a veto over, so discovering that one is being negotiated should not require running /status.
Deadlines are never imposed. A game has a deadline only when one was set explicitly via
POST /games/{id}/deadline — the bot's /deadline <game_id> <hours|clear> (F5, v2.7.73)
is the one client that does — and it is scoped to that phase: both processing paths clear it
once the phase is adjudicated and neither sets a new one (Track N, v2.7.72). Until then
the manual process_turn route re-armed a hard-coded +24h after every turn — a deadline
nobody had asked for, after which the scheduler processed the next phase with the missing
powers' units holding, then cleared it, so alternate phases had an auto-deadline and didn't.
Conceding removes the power's units and releases its supply centres (they become
neutral, like the unowned centres at game start), so Game.eliminated_powers() reports it at
once. D3 originally left ownership untouched, expecting the centres to sit "unclaimed"; in
fact ownership persists until a unit stands there, so at the next Winter the engine owed the
conceded power builds, orders_status waited on the player who had just left, and a BUILD
walked them back in. Changed in v2.7.71 (Track M). /quit is the other path: the seat is
vacated for a replacement and the board is untouched.
Seat writes go through DatabaseService.assign_player_seat (v2.7.75, Track P), never
through attribute assignment on a row returned by a DAL getter — those rows are detached the
moment the getter's session closes, and DatabaseService.commit() is a documented no-op, so
such writes are silently discarded. /quit and /replace both did exactly that for
user_id, which meant a quitter still held the power (orders, votes, concession all
authorized) and the seat could never be filled. A vacant seat is a row with user_id NULL
and is_active False; /join takes it over the same way /replace does, since the web
client already lists such seats as "Open"; _authorize_power treats it as held by nobody.
A COMPLETED game accepts no writes. GameService.submit_orders, process_turn,
submit_draw_vote and concede all raise GameOverError (a ValueError, deliberately not
an OrderError — routes map that to 404, and a finished game is found) once
state.status is COMPLETED; the routes answer 409 with a message naming the outcome
(game 12 is drawn between FRANCE, GERMANY; no further orders or votes are accepted), the bot
shows that detail verbatim, and the DAIDE session answers REJ. orders_status reports no
active or missing powers. Until v2.7.70 (Track L) every one of those writes went through:
orders were stored and shown as pending, process_turn "succeeded" with an empty resolution
and then DMed every player "turn processed" each time it was pressed, a draw vote was
"recorded", and a concession removed the power's units from the final board.
Rules for adding a notification.
- One fan-out per event, shared by every trigger.
notify_turn_processedexists so the deadline and manual paths cannot diverge again. If an event can be reached two ways, the second way calls the same function — do not bolt anotify_playerscall onto the new call site. - Never notify the caller of their own action twice. A player who presses "process turn"
gets the resolution in their HTTP response;
exclude_telegram_idskips their DM. - Every send is best-effort at the call site, durable after it. Wrap and log. A failure to queue (the database is down) must never fail a turn that is already committed to Postgres — the state change is the contract, the notification is not. But once queued, a notification is never dropped: only the bot's ack removes it.
- Update this table in the same commit. It is the only place the full picture exists.
notify_turn_processed is deliberately synchronous so the sync scheduler path
(process_due_deadlines) and the async HTTP route can share it with no bridge. Since
notifications became outbox inserts that costs one short database write per player, not the
two-second HTTP timeout the old push path risked.
Deployment and message reliability (Tracks J, V)¶
All four services — postgres, diplomacy_api, diplomacy_bot, diplomacy_web (nginx) —
run from one docker-compose.yml on one VPS; only nginx is public, and the bot and nginx
reach the API by its compose service name (docs/DEPLOYMENT.md). From v2.7.68 to
v2.7.84 the bot and nginx ran on the VPS and the API and Postgres on a home server across a
WireGuard tunnel; that layout was retired in Track V, but the reliability contract it
produced stays, because the API is still a separate process that is down during every deploy
and after any crash, and a deadline may pass while it is. The contract is that no player
message is ever lost in either direction — only delayed, and always with the original time
preserved.
Player → server (telegram_bot/outbox.py, api_client.api_post_reliable). Orders and
diplomatic messages are written to a SQLite queue (the bot_data volume) before the first
attempt.
Outcomes are exactly delivered / queued / rejected; the handler shows the matching
reply, and a background loop (notifications.outbox_replay_loop) retries queued entries
strictly in id order — stopping at the first that is still unreachable so nothing overtakes
an older write — and DMs each result. Each request carries:
client_timestamp— when the player composed it (the entry's creation time).api/client_timestamp.pynormalises it to naive UTC, clamps far-future values, refuses ones older than 30 days.MessageModel.timestampstores it; the recipient's notification gains "(sent HH:MM UTC)" when delivery was noticeably late. Order submissions composed beforegames.phase_started_atare refused with 409 (routes/orders.py::_refuse_if_stale) — the turn was adjudicated without them, and applying last phase's orders to this phase's board would be worse than telling the player.phase_started_atis stamped byGameRepoon every write that changesphase_code.Idempotency-Key— a UUID per entry.api/idempotency.pystores the first response (status + JSON) and replays it, withIdempotent-Replayed: true, to any later request with the same key. Honoured only withX-Bot-Secret, only for mutating methods, only for responses below 500 (a 5xx should be retried, a 4xx is a definitive answer to relay). Keys expire after 7 days (run_housekeeping).
game_context.fetch_user_games caches each user's games/powers in the same SQLite file and
falls back to the cache when the API is unreachable, which is what lets /order A PAR - BUR
resolve which power you hold and reach the queue while the server is down.
Server → player (bot_outbox table, routes/bot_outbox.py,
notifications.notification_loop). Described in the section above. Delivery is
at-least-once: a row is acked only after Telegram accepts the message, so a crash between
send and ack can repeat a DM. Permanent Telegram failures (user blocked the bot, chat not
found) are acked as failed with the error kept on the row. Delivered rows are purged after
7 days.
What the bot needs to run: python-telegram-bot, requests, and a writable
DIPLOMACY_BOT_DATA_DIR. Nothing else — no database URL, no engine, no FastAPI. The Docker
image installs requirements-bot.txt only; tests/test_execution_context.py and the image
build are what keep that boundary honest. The bot starts and stays up whether or not the API
is reachable; that is the whole point.
Frontend¶
React 18 + Vite + TypeScript SPA (frontend/), Tailwind + shadcn/ui. Consumes the
GameState-native GET /games/{id}/state view directly (units_by_power, ownership,
dislodged, contested, phase_type, players, ...) — there is no powers-shaped
legacy view to translate. Proxies API calls to http://localhost:8000 in dev.
Out of scope for this document¶
Route-by-route API reference, Telegram command list, and DB column-level schema live in
data_spec.md and the user-facing docs in docs/. Map-variant support beyond standard
and rendering-pipeline redesign are explicitly out of scope for the engine rewrite (see
fix_plan.md "Out of scope").