5.1 KiB
Game engine (game.py)
Key types
game.py defines the core runtime objects used by the server:
Game: contains the current game board state and implements actions/phasesPlayer: per-player scores and owned cardsLobbyMember/GameMember: lightweight records used by the server for lobby/game membership
It also provides:
load_game_data(...): builds an initialgame_statedict by pulling data from MariaDB and dealing stacksSummaryEncoderandGameObjectEncoder: JSON encoders used by the server to serialize game state
Game lifecycle
At runtime (via the server):
server.pycallsload_game_data(game_id, preset, game_gamers)to create a startinggame_state- The server wraps that dict in a
Gameobject:Game(game_state) - The server exposes the game via
/api/game/{game_id}/stateand/api/game/{game_id}/action
The Game object tracks:
player_listmonster_grid,citizen_grid,domain_grid- dice:
die_one,die_two,die_sum effectsaction_required(used to block on per-player, sequential “choose …” actions)concurrent_action(used to block on non-ordered, multi-player prompts; see “Concurrent actions” below)last_active_time(used by the server for cleanup)
DB-backed bootstrap (load_game_data)
load_game_data is responsible for:
- Fetching cards using stored procedures:
- citizens/monsters depend on the
preset(e.g."base1"/"base2") - domains and dukes are randomized via procedures
- starters are selected via a direct
SELECT * FROM starters
- citizens/monsters depend on the
- Creating
Playerinstances from the lobby/game membership list - Randomizing player order and dealing initial cards
- Dealing stacks onto the board:
- monsters grouped by area, then 5 areas selected
- citizens grouped by roll match, placed into 10 stacks (special-cased for roll 11)
- domains dealt into 5 stacks of 3, with the top visible/accessible
This function currently assumes local DB connectivity via 127.0.0.1:3306 (typically via SSH port forward).
See docs/database.md for DB setup and stored procedure installation.
Actions & phases
The server routes map action_type strings to methods on Game.
Common paths:
roll_phase()rolls two dice and computes a sumharvest_phase()pays out from owned starters/citizens for all players based on the rollhire_citizen(...),build_domain(...),slay_monster(...)mutate board stacks and player resources
“choose …” actions
Some special payouts set action_required and start a background thread that waits until act_on_required_action updates action_required with a choice.
This is a dev-oriented approach; it allows the REST API to supply a follow-up choice via act_on_required_action while the game engine waits.
Concurrent actions (non-ordered prompts)
Some prompts are not turn-based: every participating player should be able to respond at the same time, in any order, and the game must wait until all of them have submitted before progressing. The starting duke selection is the first example — every player simultaneously discards down to one of the dukes they were dealt.
These are modeled with Game.concurrent_action, which is independent from
action_required:
concurrent_action = {
"kind": "choose_duke", # routes to a handler in CONCURRENT_HANDLERS
"pending": ["pid1", "pid3"],
"completed": ["pid2"],
"responses": { "pid2": <opaque payload> },
"data": { ... } # handler-specific extras (often empty)
}
Engine semantics:
- While
concurrent_action.pendingis non-empty,advance_tick()returnsFalse. No phase transitions happen, no harvest progresses, and no per-player turn actions are accepted.is_blocked_on_concurrent_action()exposes the same predicate. - Players submit via
Game.submit_concurrent_action(player_id, response, kind=...). The handler'sapply()validates and applies that player's response immediately (so per-player effects don't have to wait for the others). - When the last pending player submits, the handler's
finalize()runs (for any cross-player resolution),concurrent_actionis cleared, and if the engine was sitting in setup it advances forward.
Adding a new concurrent action kind
- Implement a handler class with:
apply(self, game, player_id, response)— validate + apply per-player side effects. RaiseValueErrorto reject a submission (the player stays inpending).finalize(self, game)— optional; runs once after every participant has submitted.
- Register it in
CONCURRENT_HANDLERSkeyed bykind. - Build the prompt with
_new_concurrent_action(kind, participant_ids, data=...)and assign it togame.concurrent_actionat the point in the engine where the gate should appear. - On the client, register a renderer in the
CONCURRENT_RENDERERSmap in the dev client (server.py HTML) keyed on the samekind.
Because the engine itself only knows "block while pending is non-empty", the concurrent gate is fully reusable — no engine changes are required to add new kinds (mulligan, simultaneous discard, voting, etc.).