Strategic mode – Developer documentation ======================================== Purpose ------- This document describes how the **Strategic mode** is implemented end-to-end in Earth Pulse: * backend room lifecycle and game logic, * WebSocket protocol, * frontend state/UI flow, * persistence and leaderboard, * local run and test strategy. It is based on the current implementation in ``apiserver/`` and ``frontend/``. ---- 1) High-level architecture -------------------------- Strategic mode is a real-time multiplayer mode built on: * **FastAPI REST** for room creation. * **FastAPI WebSocket** for real-time gameplay events. * **In-memory room state** (\ ``rooms`` dict) during active matches. * **PostgreSQL persistence** only for game/leaderboard records. * **Nuxt frontend** with Pinia + composable WebSocket client. Core backend modules: * ``apiserver/apiserver/routers/strategic_game.py`` * ``apiserver/apiserver/controllers/strategic_game_controller.py`` * ``apiserver/apiserver/services/strategic_game_service.py`` * ``apiserver/apiserver/models/strategic_room.py`` * ``apiserver/apiserver/models/strategic_player.py`` Core frontend modules: * ``frontend/components/create-game.vue`` * ``frontend/composables/useStrategicWebSocket.ts`` * ``frontend/class/StrategicGame.ts`` * ``frontend/class/StrategicPlayer.ts`` * ``frontend/pages/strategic-game-board.vue`` ---- 2) Backend entrypoints ---------------------- 2.1 Router mounting ^^^^^^^^^^^^^^^^^^^ The Strategic router is mounted with prefix ``/strategic`` in ``apiserver/main.py``. 2.2 REST endpoint ^^^^^^^^^^^^^^^^^ ``POST /strategic/create_room`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Creates a strategic room. Request body: .. code-block:: json { "max_players": 2, "deck_id": 1, "mode": "strategic" } Response: .. code-block:: json { "room_id": "123456", "deck_name": "Deck Name" } Behavior notes: * ``max_players`` must be in ``{2,3,4}`` depending on the selected ``mode``. * ``mode`` determines game behavior (e.g., ``"strategic"``\ , ``"tutorial"``\ , ``"solo"``\ ). * ``room_id`` is a **6-digit numeric string**. 2.3 WebSocket endpoints ^^^^^^^^^^^^^^^^^^^^^^^ * ``GET WS /strategic/ws/{room_id}/{pseudo}`` * ``GET WS /strategic/ws/{room_id}/{pseudo}/{token}`` Both delegate to ``handle_websocket(...)`` in the strategic controller. ---- 3) In-memory domain model ------------------------- 3.1 ``StrategicRoom`` ^^^^^^^^^^^^^^^^^^^^^^^^^ The room stores transient game state: * identity/config: ``room_id``\ , ``mode``\ , ``max_players``\ , ``deck_id``\ , ``deck_name`` * players: ``players`` * lifecycle: ``state`` in ``waiting -> playing -> finish`` * deck snapshot: ``deck_snapshot``\ , ``deck_card_ids`` * round state: ``round_index``\ , ``current_indicator``\ , ``round_indicators``\ , ``used_indicator_ids`` * timing/rules: ``round_duration_seconds``\ , ``hand_size``\ , ``max_rounds`` * metadata: ``deck_indicators_meta``\ , ``indicator_bounds`` * safety: ``lock`` (\ ``asyncio.Lock``\ ) for concurrent WS mutations 3.2 ``StrategicPlayer`` ^^^^^^^^^^^^^^^^^^^^^^^^^^^ Transient per-player data: * identity: ``id``\ , ``pseudo`` * socket: ``websocket`` * game state: ``hand_card_ids``\ , ``picked_card_id``\ , ``score`` * flags: ``is_bot``\ , ``is_eliminated``\ , ``is_deleted`` 3.3 Cache vs in-memory runtime state ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Strategic mode uses both a **process cache** and **in-memory match state**\ , with different roles. What is cached (\ ``CacheService``\ ) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Cached data is read-mostly deck metadata reused across rooms: * ``deck_bundle`` (cards + raw indicator values) via ``get_or_load_deck_bundle(deck_id)`` * deck indicators metadata via ``get_or_load_deck_indicators_with_types(deck_id)`` * indicator bounds via ``get_or_load_indicator_bounds(deck_id)`` Properties: * process-local cache (not shared across pods/instances) * reused by multiple Strategic rooms in the same backend process * can be refreshed/invalidation-managed by cache service logic What is in memory (live Strategic game state) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Runtime game state is stored in memory and is room-specific: * global strategic room registry: ``rooms: dict[str, StrategicRoom]`` * per-room mutable state: players, picks, scores, round index, deadlines, used indicators * per-room ``deck_snapshot`` built at room creation (copy derived from cached deck data) Properties: * ephemeral (lives only while process is alive) * removed by game flow/disconnect handling and ``cleanup_strategic_rooms(...)`` * not shared across instances Practical consequence ~~~~~~~~~~~~~~~~~~~~~ In multi-instance deployments, a player must reconnect to the **same backend instance** that owns the room in memory. Cache helps reduce DB/load overhead, but it does **not** replace shared room state. ---- 4) Room and game lifecycle -------------------------- 4.1 Create room ^^^^^^^^^^^^^^^ ``create_strategic_room(...)``\ : #. normalizes players count using ``tutorial/solo`` flags, #. validates deck via ``DeckService.validate_deck_usage("strategic", ...)``\ , #. loads cached deck bundle, #. loads indicator metadata, #. snapshots cards, #. allocates unique room id, #. stores room in global ``rooms`` dict. 4.2 Player join ^^^^^^^^^^^^^^^ ``handle_websocket(...)``\ : #. validates pseudo with toxicity checker, #. accepts socket, #. validates room joinability (\ ``exists``\ , ``not full``\ , ``state=waiting``\ ), #. creates player + appends to room, #. in solo mode, auto-injects ``LuckyBot``\ , #. sends ``game_context``\ , broadcasts ``player_join``. 4.3 Auto-start ^^^^^^^^^^^^^^ When ``len(room.players) == room.max_players`` and state is ``waiting``\ , server starts game: * state -> ``playing`` * round index initialized to ``1`` * indicator bounds computed * initial hands dealt * game DB record created * ``game_start`` broadcast * first ``round_start`` emitted per player (with own hand) 4.4 Round resolution loop ^^^^^^^^^^^^^^^^^^^^^^^^^ For each round: #. clients send ``selected_card`` (player/card ids), #. server registers picks, #. in solo mode bot auto-picks, #. when all active players picked -> ``resolve_round(...)``\ , #. server broadcasts ``round_result``\ , #. refills hands, increments round index, #. starts next round or finishes game. 4.5 Finish and cleanup ^^^^^^^^^^^^^^^^^^^^^^ On game end: * state -> ``finish`` * game record updated to ``finish`` * leaderboard scores persisted (except tutorial / bots) * ``game_finish`` broadcast Background cleanup task removes stale rooms: * empty room: immediately * finished room older than 5 min * any room older than 1h ---- 5) Strategic scoring system --------------------------- Scoring is hybrid: **risk points + logarithmic bonus**. 5.1 Risk points ^^^^^^^^^^^^^^^ From percentile of card value within indicator min/max bounds: * percentile >= 0.8 -> ``Low risk`` -> ``40`` * 0.5 <= percentile < 0.8 -> ``Medium risk`` -> ``100`` * percentile < 0.5 -> ``High risk`` -> ``200`` 5.2 Logarithmic bonus ^^^^^^^^^^^^^^^^^^^^^ Bonus is in ``[0..100]``\ , based on value position in range: $$ \text{bonus} = \frac{\log(value - min + \epsilon)}{\log(max - min + \epsilon)} \times 100 $$ Then clamped to ``[0,100]`` and rounded. 5.3 Winner/loser points ^^^^^^^^^^^^^^^^^^^^^^^ * Winner(s) (highest value): ``risk_points + bonus`` * Others: ``bonus`` only * Draw: ``winner = null``\ , ``is_draw = true`` All players accumulate cumulative score across rounds. 5.4 Round/game limits ^^^^^^^^^^^^^^^^^^^^^ * default strategic max rounds: ``4`` * tutorial max rounds: ``1`` ---- 6) WebSocket protocol (current implementation) ---------------------------------------------- 6.1 Server -> client events ^^^^^^^^^^^^^^^^^^^^^^^^^^^ ``game_context`` ~~~~~~~~~~~~~~~~~~~~ .. code-block:: json { "event": "game_context", "data": { "room_id": "1234", "mode": "strategic", "max_players": 2, "current_player_id": 5678, "deck_name": "...", "hand_size": 4, "round_duration": 20, "players": [{"id": 5678, "pseudo": "Alice"}] } } ``player_join`` ~~~~~~~~~~~~~~~~~~~ .. code-block:: json { "event": "player_join", "players": [{"id": 5678, "pseudo": "Alice"}, {"id": 9012, "pseudo": "Bob"}], "join": {"id": 9012, "pseudo": "Bob"} } ``game_start`` ~~~~~~~~~~~~~~~~~~ .. code-block:: json { "event": "game_start", "round": 1, "players": [{"id": 5678, "pseudo": "Alice"}, {"id": 9012, "pseudo": "Bob"}] } ``round_start`` (per player) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: json { "event": "round_start", "round": 1, "indicator": { "id": 11, "name": "Temperature", "icon": "...", "month": 7, "year": 2039 }, "deadline_ms": 1760000000000, "hand": [ { "card_id": 123, "city": "...", "country": "...", "photo": "https://...", "indicators": [ { "id": 11, "name": "Temperature", "icon": "...", "risk": "medium risk", "month": 7, "year": 2039 },... ] } ] } ``round_result`` ~~~~~~~~~~~~~~~~~~~~ .. code-block:: json { "event": "round_result", "round": 1, "indicator": {"id": 11, "name": "Temperature", "icon": "...", "month": 7, "year": 2039}, "plays": [ { "player_id": 5678, "card_id": 123, "card": {"card_id": 123, "city": "...", "country": "...", "photo": "...", "indicators": [...]}, "value": 18.2, "risk_level": "Medium risk", "risk_points": 100, "bonus": 63, "points": 163 } ], "winner": 5678, "is_draw": false, "scores": [{"player_id": 5678, "score": 163}, {"player_id": 9012, "score": 55}] } ``game_finish`` ~~~~~~~~~~~~~~~~~~~ .. code-block:: json { "event": "game_finish", "leaderboard": [ {"id": 5678, "pseudo": "Alice", "score": 600, "state": "player"}, {"id": 9012, "pseudo": "Bob", "score": 420, "state": "player"} ], "winner": {"id": 5678, "pseudo": "Alice"} } ``error`` ~~~~~~~~~~~~~ .. code-block:: json { "event": "error", "message": "Room not found" } 6.2 Client -> server event ^^^^^^^^^^^^^^^^^^^^^^^^^^ ``selected_card`` ~~~~~~~~~~~~~~~~~~~~~ .. code-block:: json { "event": "selected_card", "player_id": 5678, "card_id": 123 } ---- 7) Frontend implementation flow ------------------------------- 7.1 Create/join strategic room ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ``frontend/components/create-game.vue``\ : * choose mode ``strategic``\ , * create room (\ ``POST /strategic/create_room``\ ) or join existing room, * instantiate ``new StrategicGame(...)``\ , * open WebSocket with ``useStrategicWebSocket().createSocket(...)``\ , * route to waiting lobby. 7.2 WebSocket state handling ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ``frontend/composables/useStrategicWebSocket.ts`` maps server events into Pinia/game state: * ``game_context``\ : sets players count, current player id, deck name, * ``player_join``\ : replaces local players list, * ``game_start``\ : routes to ``/strategic-game-board``\ , * ``round_start``\ : sets round data + replaces current user hand, * ``round_result``\ : updates scores and modal state, * ``game_finish``\ : marks state ``finished``. 7.3 Board page ^^^^^^^^^^^^^^ ``frontend/pages/strategic-game-board.vue``\ : * controls board layout and card play, * shows round start/result modal, * sends played card via ``playStrategicCard(cardId)``. 7.4 Shared store ^^^^^^^^^^^^^^^^ ``frontend/store/main.ts`` keeps strategic mode state under: * ``strategicGame``\ , * error popup states, * leaderboard/general shared UI flags. ---- 8) Persistence and analytics ---------------------------- 8.1 Game records ^^^^^^^^^^^^^^^^ At game start/end: * ``create_game_record(...)`` calls ``add_game(room)``\ , * ``finish_game_record(...)`` calls ``update_game(room, "finish")``. 8.2 Leaderboard records ^^^^^^^^^^^^^^^^^^^^^^^ At finish: * non-tutorial and non-bot players are persisted with ``mode='strategic'``. 8.3 Read APIs using strategic filter ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ * ``GET /score/leaderboard?strategic=true`` * ``GET /score/leaderboard/now?strategic=true`` * ``GET /playedGame?strategic=true`` * ``GET /playedGame/now?strategic=true`` ---- 9) Local run and test --------------------- 9.1 Start services (workspace tasks) ------------------------------------ Recommended: * run task **EarthPulse - Start Frontend and Backend** Or separately: * **Database** task * **Backend** task * **Frontend** task * **Moderation API** task 9.2 Manual backend launch ^^^^^^^^^^^^^^^^^^^^^^^^^ .. code-block:: bash cd apiserver uvicorn main:app --host localhost --reload --port 8000 --log-config logging-loacl.yaml 9.3 Manual frontend launch ^^^^^^^^^^^^^^^^^^^^^^^^^^ .. code-block:: bash cd frontend npm run dev:fast 9.4 Manual moderation API launch ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ .. code-block:: bash cd api-moderation uvicorn main:app --host localhost --reload --port 8001 9.5 Strategic load test script ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ A dedicated stress script exists in ``tests/test.py``\ : * configurable mode: ``multiplayer | solo | tutorial`` * creates strategic rooms through HTTP, * connects WS clients, * auto-plays random cards, * collects latency/error metrics. Run example: .. code-block:: bash cd tests python test.py ---- 10) Current implementation notes (important) -------------------------------------------- #. **Room id size**\ : generated ids are now **6-digit numeric strings** (\ ``100000-999999``\ ). #. **Round duration mismatch**\ : * service constant defines ``50s``\ , * room default is ``20s``\ , * runtime currently uses room field (\ ``20s``\ ) unless overridden. #. **Token WS endpoint** exists (\ ``/ws/{room_id}/{pseudo}/{token}``\ ), but current strategic router delegates with ``current_user=None``. #. **Frontend type gap**\ : ``frontend/types/WebSocketEvent.ts`` is mostly classic-mode typing; strategic flow currently relies on runtime fields (\ ``any`` in several branches). #. **\ ``round_indicators`` field** is read in frontend, but not included in current ``round_start`` payload preparation. ---- 11) Suggested maintenance checklist ----------------------------------- When updating Strategic mode, validate all of these together: * REST create-room schema (\ ``StrategicRoomSettings``\ ) * WebSocket event schema in service + frontend composable * scoring constants and formulas * ``StrategicRoom`` defaults (round duration/rounds) * leaderboard mode persistence (\ ``mode='strategic'``\ ) * cleanup behavior for stale rooms * load test script expectations in ``tests/test.py`` ---- 12) File map ------------ Backend: * ``apiserver/main.py`` * ``apiserver/apiserver/routers/strategic_game.py`` * ``apiserver/apiserver/controllers/strategic_game_controller.py`` * ``apiserver/apiserver/services/strategic_game_service.py`` * ``apiserver/apiserver/models/strategic_room.py`` * ``apiserver/apiserver/models/strategic_player.py`` * ``apiserver/apiserver/tasks/strategic_room_cleaner.py`` * ``apiserver/apiserver/types/strategic_game_type.py`` Frontend: * ``frontend/components/create-game.vue`` * ``frontend/components/lobby-wait-players.vue`` * ``frontend/pages/strategic-game-board.vue`` * ``frontend/components/strategic-game-card.vue`` * ``frontend/components/strategic-game-board-card.vue`` * ``frontend/composables/useStrategicWebSocket.ts`` * ``frontend/class/StrategicGame.ts`` * ``frontend/class/StrategicPlayer.ts`` * ``frontend/store/main.ts`` Tests: * ``tests/test.py``