From 0a7c35c262ebbfeb29bce8323add1618bf12ae54 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Wed, 28 Jan 2026 15:50:57 -0600 Subject: [PATCH] Add detailed Phase 4 (Game Service + WebSocket) project plan 18 tasks covering: - WebSocket setup with python-socketio - GameService lifecycle (create, join, execute, resume, end) - Message protocol specification - Connection management with Redis - Turn timeout system - Reconnection handling - REST endpoints for game management - Unit and integration tests - Spectator mode (stretch goal) Includes architecture decisions, sequence diagrams, and acceptance criteria. Co-Authored-By: Claude Opus 4.5 --- .../project_plans/PHASE_4_GAME_SERVICE.json | 686 ++++++++++++++++++ 1 file changed, 686 insertions(+) create mode 100644 backend/project_plans/PHASE_4_GAME_SERVICE.json diff --git a/backend/project_plans/PHASE_4_GAME_SERVICE.json b/backend/project_plans/PHASE_4_GAME_SERVICE.json new file mode 100644 index 0000000..bda096c --- /dev/null +++ b/backend/project_plans/PHASE_4_GAME_SERVICE.json @@ -0,0 +1,686 @@ +{ + "meta": { + "version": "1.0.0", + "created": "2026-01-28", + "lastUpdated": "2026-01-28", + "planType": "phase", + "phaseId": "PHASE_4", + "phaseName": "Game Service + WebSocket", + "description": "Real-time gameplay infrastructure - WebSocket communication, game lifecycle management, reconnection handling, and turn timeout system", + "totalEstimatedHours": 45, + "totalTasks": 18, + "completedTasks": 0, + "status": "not_started", + "masterPlan": "../PROJECT_PLAN_MASTER.json" + }, + + "goals": [ + "Implement WebSocket server using python-socketio for real-time bidirectional communication", + "Create GameService to orchestrate game lifecycle (create, join, execute actions, resume, end)", + "Design message protocol specification for all client-server communication", + "Implement reconnection handling with session recovery", + "Build turn timeout system with configurable time limits", + "Integrate with existing GameEngine, GameStateManager, and DeckService", + "Capture replay data from game start for future replay feature", + "Implement spectator mode (stretch goal)" + ], + + "architectureNotes": { + "webSocketLibrary": { + "choice": "python-socketio", + "rationale": "Mature Socket.IO implementation, handles rooms/namespaces, auto-reconnection on client, ASGI integration with FastAPI", + "alternatives": ["starlette.websockets (raw WS)", "fastapi-websocket-rpc"], + "documentation": "https://python-socketio.readthedocs.io/" + }, + "socketIOIntegration": { + "pattern": "Mount Socket.IO as ASGI app alongside FastAPI", + "namespaces": { + "/game": "Active game communication (actions, state updates)", + "/lobby": "Pre-game lobby (matchmaking, invites) - Phase 6" + }, + "rooms": { + "game:{game_id}": "All participants of a specific game", + "spectators:{game_id}": "Spectators watching a game" + } + }, + "messageProtocol": { + "format": "JSON with type discriminator", + "clientToServer": [ + "game:action - Execute a game action (attack, play_pokemon, etc.)", + "game:join - Join/rejoin a game session", + "game:leave - Leave current game", + "game:resign - Resign from game", + "game:heartbeat - Keep connection alive" + ], + "serverToClient": [ + "game:state - Full/delta game state update", + "game:action_result - Result of player action", + "game:error - Error message", + "game:turn_start - New turn notification", + "game:turn_timeout - Turn timeout warning/expiration", + "game:game_over - Game ended notification", + "game:opponent_connected - Opponent connection status", + "game:opponent_action - Opponent performed action (for animations)" + ] + }, + "connectionState": { + "storage": "Redis hash per connection", + "keyPattern": "conn:{sid} -> {user_id, game_id, connected_at}", + "sessionRecovery": "On reconnect, client sends last_event_id to resume from" + }, + "turnTimeout": { + "implementation": "Redis EXPIRE + background task polling", + "defaultTimeout": 180, + "warningAt": 30, + "graceOnReconnect": 15, + "actions": ["timeout_warning", "auto_pass", "loss_by_timeout"] + }, + "persistenceStrategy": { + "everyAction": "Redis only (fast path) via GameStateManager.save_to_cache()", + "turnBoundary": "Redis + Postgres via GameStateManager.persist_to_db()", + "gameEnd": "Move to GameHistory, delete from ActiveGame" + }, + "existingComponents": { + "GameEngine": "app/core/engine.py - validate/execute actions, create games", + "GameStateManager": "app/services/game_state_manager.py - Redis/Postgres persistence", + "ActionModels": "app/core/models/actions.py - all action types", + "Visibility": "app/core/visibility.py - filter state per player", + "ActiveGame": "app/db/models/game.py - Postgres backup model", + "GameHistory": "app/db/models/game.py - completed game records", + "DeckService": "app/services/deck_service.py - load player decks" + } + }, + + "directoryStructure": { + "socketio": "backend/app/socketio/", + "services": "backend/app/services/", + "schemas": "backend/app/schemas/", + "tests": "backend/tests/socketio/, backend/tests/services/" + }, + + "tasks": [ + { + "id": "WS-001", + "name": "Set up python-socketio with FastAPI", + "description": "Install and configure python-socketio ASGI server, mount alongside FastAPI app", + "category": "infrastructure", + "priority": 1, + "completed": false, + "tested": false, + "dependencies": [], + "files": [ + {"path": "app/socketio/__init__.py", "status": "create"}, + {"path": "app/socketio/server.py", "status": "create"}, + {"path": "app/main.py", "status": "modify"} + ], + "details": [ + "Install python-socketio[asgi] via uv", + "Create AsyncServer with async_mode='asgi'", + "Create ASGI app combining FastAPI and Socket.IO", + "Configure CORS for Socket.IO to match FastAPI settings", + "Add /game namespace skeleton", + "Update app/main.py to use combined ASGI app" + ], + "estimatedHours": 2, + "notes": "Consider using engineio.async_drivers for uvicorn compatibility" + }, + { + "id": "WS-002", + "name": "Create WebSocket message schemas", + "description": "Define Pydantic models for all WebSocket message types", + "category": "schemas", + "priority": 2, + "completed": false, + "tested": false, + "dependencies": ["WS-001"], + "files": [ + {"path": "app/schemas/ws_messages.py", "status": "create"} + ], + "details": [ + "Base message model with 'type' discriminator", + "Client messages: JoinGameMessage, ActionMessage, ResignMessage, HeartbeatMessage", + "Server messages: GameStateMessage, ActionResultMessage, ErrorMessage, TurnStartMessage", + "Server messages: TurnTimeoutMessage, GameOverMessage, OpponentStatusMessage", + "Include message_id for idempotency and event ordering", + "Include timestamp on all server messages" + ], + "estimatedHours": 2, + "notes": "Use Pydantic discriminated unions for message parsing" + }, + { + "id": "WS-003", + "name": "Create ConnectionManager service", + "description": "Manage WebSocket connections with Redis-backed session tracking", + "category": "services", + "priority": 3, + "completed": false, + "tested": false, + "dependencies": ["WS-001"], + "files": [ + {"path": "app/services/connection_manager.py", "status": "create"} + ], + "details": [ + "Track connection state in Redis: conn:{sid} -> {user_id, game_id, connected_at}", + "Map user_id to active sid: user_conn:{user_id} -> sid", + "Track game participants: game_conns:{game_id} -> set of sids", + "Handle connect/disconnect lifecycle", + "Detect stale connections (heartbeat timeout)", + "Provide methods: get_user_connection, get_game_connections, is_user_online", + "Clean up on disconnect" + ], + "estimatedHours": 3, + "notes": "Use Redis HSET/HGET for connection data, SADD/SREM for sets" + }, + { + "id": "WS-004", + "name": "Implement Socket.IO authentication middleware", + "description": "Authenticate WebSocket connections using JWT tokens", + "category": "auth", + "priority": 4, + "completed": false, + "tested": false, + "dependencies": ["WS-001", "WS-003"], + "files": [ + {"path": "app/socketio/auth.py", "status": "create"}, + {"path": "app/socketio/server.py", "status": "modify"} + ], + "details": [ + "Extract JWT from connection handshake (auth header or query param)", + "Validate token using existing JWTService", + "Load user from database and attach to socket session", + "Reject connection if token invalid/expired", + "Handle token refresh during long sessions", + "Store user_id in socket session for subsequent events" + ], + "estimatedHours": 2, + "notes": "Socket.IO provides auth callback in connect event" + }, + { + "id": "GS-001", + "name": "Create GameService skeleton", + "description": "Service layer orchestrating game lifecycle between WebSocket and GameEngine", + "category": "services", + "priority": 5, + "completed": false, + "tested": false, + "dependencies": ["WS-002"], + "files": [ + {"path": "app/services/game_service.py", "status": "create"} + ], + "details": [ + "Constructor injection: GameEngine, GameStateManager, DeckService, CardService", + "Core methods: create_game, join_game, execute_action, resign_game, end_game", + "Helper methods: get_game_state, get_player_view, is_player_turn", + "Error handling with custom exceptions: GameNotFoundError, NotPlayerTurnError, InvalidActionError", + "All methods async for database/cache operations", + "No direct Socket.IO dependency - returns results for WS layer to emit" + ], + "estimatedHours": 2, + "notes": "Service is stateless - all state in Redis/Postgres via GameStateManager" + }, + { + "id": "GS-002", + "name": "Implement GameService.create_game", + "description": "Create new game from player decks, initialize in Redis and Postgres", + "category": "services", + "priority": 6, + "completed": false, + "tested": false, + "dependencies": ["GS-001"], + "files": [ + {"path": "app/services/game_service.py", "status": "modify"} + ], + "details": [ + "Accept player IDs, deck IDs, game type, optional rules config", + "Load decks via DeckService, convert to CardInstances", + "Load card registry from CardService", + "Call GameEngine.create_game() to initialize game state", + "Save to Redis via GameStateManager.save_to_cache()", + "Persist to Postgres via GameStateManager.persist_to_db()", + "Return game_id and initial player-visible states", + "Handle campaign mode (vs NPC) - defer to Phase 5" + ], + "estimatedHours": 3, + "notes": "CardInstances need unique IDs - use UUID per card" + }, + { + "id": "GS-003", + "name": "Implement GameService.execute_action", + "description": "Validate and execute player actions, persist state, return results", + "category": "services", + "priority": 7, + "completed": false, + "tested": false, + "dependencies": ["GS-002"], + "files": [ + {"path": "app/services/game_service.py", "status": "modify"} + ], + "details": [ + "Load game state from cache (GameStateManager.load_state())", + "Verify it's the player's turn", + "Parse action from message into Action model", + "Call GameEngine.execute_action()", + "If action ends turn, handle turn boundary (persist to DB, process status effects)", + "Save updated state to cache", + "If game over, call end_game()", + "Return ActionResult with state changes for animation" + ], + "estimatedHours": 4, + "notes": "Handle forced actions (select new active after KO, prize selection)" + }, + { + "id": "GS-004", + "name": "Implement GameService.join_game and resume_game", + "description": "Handle players joining/rejoining games", + "category": "services", + "priority": 8, + "completed": false, + "tested": false, + "dependencies": ["GS-002"], + "files": [ + {"path": "app/services/game_service.py", "status": "modify"} + ], + "details": [ + "join_game: For new games awaiting opponent (Phase 6 matchmaking)", + "resume_game: Reconnect to existing game after disconnect", + "Verify player is participant in the game", + "Return current game state filtered for player visibility", + "Include pending forced actions if any", + "Track last_event_id for event replay on reconnect" + ], + "estimatedHours": 2, + "notes": "Resume needs to handle mid-turn reconnection" + }, + { + "id": "GS-005", + "name": "Implement GameService.end_game", + "description": "Handle game completion - record history, cleanup active game", + "category": "services", + "priority": 9, + "completed": false, + "tested": false, + "dependencies": ["GS-003"], + "files": [ + {"path": "app/services/game_service.py", "status": "modify"} + ], + "details": [ + "Create GameHistory record with all game data", + "Include replay_data from game.action_log", + "Calculate duration_seconds from started_at", + "Delete from ActiveGame table", + "Delete from Redis cache", + "Return final result for clients", + "Handle campaign rewards - defer to Phase 5" + ], + "estimatedHours": 2, + "notes": "GameHistory model already exists in app/db/models/game.py" + }, + { + "id": "WS-005", + "name": "Implement /game namespace event handlers", + "description": "Socket.IO event handlers for game communication", + "category": "websocket", + "priority": 10, + "completed": false, + "tested": false, + "dependencies": ["WS-004", "GS-003", "GS-004"], + "files": [ + {"path": "app/socketio/game_namespace.py", "status": "create"}, + {"path": "app/socketio/server.py", "status": "modify"} + ], + "details": [ + "Event: connect - Auth, register connection, check for resumable games", + "Event: disconnect - Update connection state, notify opponent", + "Event: game:join - Call GameService.resume_game(), emit state to player", + "Event: game:action - Parse action, call GameService.execute_action(), emit results", + "Event: game:resign - Call GameService.resign_game(), emit game_over", + "Event: game:heartbeat - Update last_seen timestamp", + "Emit to room on state changes so all participants receive updates", + "Send visibility-filtered state to each player" + ], + "estimatedHours": 4, + "notes": "Each player sees their own visible state - use get_visible_state()" + }, + { + "id": "WS-006", + "name": "Implement state broadcast helpers", + "description": "Utilities to broadcast game state to all participants with proper filtering", + "category": "websocket", + "priority": 11, + "completed": false, + "tested": false, + "dependencies": ["WS-005"], + "files": [ + {"path": "app/socketio/broadcast.py", "status": "create"} + ], + "details": [ + "broadcast_game_state: Send filtered state to each player in game room", + "broadcast_action_result: Send action outcome with animation data", + "broadcast_turn_change: Notify all players of turn change", + "broadcast_game_over: Send final results to all participants", + "notify_opponent_status: Tell player if opponent connected/disconnected", + "Use Socket.IO rooms for efficient broadcasting", + "Handle spectators (spectator view uses get_spectator_state)" + ], + "estimatedHours": 2, + "notes": "Consider delta updates for bandwidth optimization (future)" + }, + { + "id": "TO-001", + "name": "Create TurnTimeoutService", + "description": "Manage turn time limits with warnings and automatic actions", + "category": "services", + "priority": 12, + "completed": false, + "tested": false, + "dependencies": ["GS-003", "WS-006"], + "files": [ + {"path": "app/services/turn_timeout_service.py", "status": "create"} + ], + "details": [ + "Store turn deadline in Redis: turn_timeout:{game_id} -> deadline_timestamp", + "Methods: start_turn_timer, cancel_timer, get_remaining_time, extend_timer", + "Background task checks for expired timers (polling or Redis keyspace notifications)", + "When timer expires: emit warning at 30s, auto-pass or loss at 0s", + "Configurable timeout per game type (campaign more lenient)", + "Grace period on reconnect (15s extension)" + ], + "estimatedHours": 4, + "notes": "Consider Redis EXPIRE with keyspace notification for efficiency" + }, + { + "id": "TO-002", + "name": "Integrate timeout with GameService", + "description": "Start/stop turn timers on turn boundaries", + "category": "integration", + "priority": 13, + "completed": false, + "tested": false, + "dependencies": ["TO-001", "GS-003"], + "files": [ + {"path": "app/services/game_service.py", "status": "modify"}, + {"path": "app/socketio/game_namespace.py", "status": "modify"} + ], + "details": [ + "Start timer after turn_start in execute_action", + "Cancel timer when action ends turn", + "Extend timer on reconnect", + "Handle timeout: auto-pass or declare loss based on config", + "Emit turn_timeout warning to current player", + "Update ActiveGame.turn_deadline for persistence" + ], + "estimatedHours": 2, + "notes": "Timeout should trigger auto-pass first, loss only after N timeouts" + }, + { + "id": "RC-001", + "name": "Implement reconnection flow", + "description": "Handle client reconnection to ongoing games", + "category": "reconnection", + "priority": 14, + "completed": false, + "tested": false, + "dependencies": ["WS-005", "GS-004"], + "files": [ + {"path": "app/socketio/game_namespace.py", "status": "modify"}, + {"path": "app/services/connection_manager.py", "status": "modify"} + ], + "details": [ + "On connect: Check for active game via ConnectionManager/GameStateManager", + "Auto-rejoin game room if active game exists", + "Send full game state to reconnecting player", + "Include pending actions (forced_actions queue)", + "Extend turn timer by grace period", + "Notify opponent of reconnection", + "Handle rapid disconnect/reconnect (debounce notifications)" + ], + "estimatedHours": 3, + "notes": "Client should store game_id locally for quick resume" + }, + { + "id": "API-001", + "name": "Create REST endpoints for game management", + "description": "HTTP endpoints for game creation and status checks", + "category": "api", + "priority": 15, + "completed": false, + "tested": false, + "dependencies": ["GS-002", "GS-004"], + "files": [ + {"path": "app/api/games.py", "status": "create"}, + {"path": "app/main.py", "status": "modify"} + ], + "details": [ + "POST /games - Create new game (returns game_id, connect via WebSocket)", + "GET /games/{game_id} - Get game info (for reconnection checks)", + "GET /games/me/active - List user's active games", + "POST /games/{game_id}/resign - Resign via HTTP (backup to WS)", + "Authentication required for all endpoints", + "Rate limiting on game creation" + ], + "estimatedHours": 3, + "notes": "WebSocket is primary for gameplay, REST for management" + }, + { + "id": "TEST-001", + "name": "Unit tests for GameService", + "description": "Test game lifecycle methods with mocked dependencies", + "category": "testing", + "priority": 16, + "completed": false, + "tested": false, + "dependencies": ["GS-005"], + "files": [ + {"path": "tests/unit/services/test_game_service.py", "status": "create"} + ], + "details": [ + "Test create_game with valid/invalid inputs", + "Test execute_action for all action types", + "Test turn boundary transitions", + "Test win condition detection", + "Test resume_game state recovery", + "Test end_game cleanup", + "Mock GameEngine, GameStateManager, DeckService" + ], + "estimatedHours": 4, + "notes": "Use pytest-asyncio for async tests" + }, + { + "id": "TEST-002", + "name": "Integration tests for WebSocket flow", + "description": "End-to-end tests for WebSocket game flow", + "category": "testing", + "priority": 17, + "completed": false, + "tested": false, + "dependencies": ["WS-006", "RC-001"], + "files": [ + {"path": "tests/socketio/test_game_namespace.py", "status": "create"}, + {"path": "tests/socketio/conftest.py", "status": "create"} + ], + "details": [ + "Test connection with valid/invalid JWT", + "Test game:join for valid/invalid games", + "Test action execution and state broadcast", + "Test turn timeout flow", + "Test reconnection and state recovery", + "Test opponent disconnect notification", + "Use python-socketio test client", + "Require testcontainers for Redis/Postgres" + ], + "estimatedHours": 5, + "notes": "Socket.IO test client simulates real connections" + }, + { + "id": "OPT-001", + "name": "Implement spectator mode", + "description": "Allow users to watch ongoing games (stretch goal)", + "category": "optional", + "priority": 18, + "completed": false, + "tested": false, + "dependencies": ["WS-006"], + "files": [ + {"path": "app/socketio/game_namespace.py", "status": "modify"}, + {"path": "app/services/game_service.py", "status": "modify"} + ], + "details": [ + "Event: game:spectate - Join as spectator", + "Add spectators to spectators:{game_id} room", + "Spectators receive get_spectator_state() view (no hands visible)", + "Spectator count visible to players", + "No action permissions for spectators", + "Optional: Public/private game setting" + ], + "estimatedHours": 3, + "notes": "Stretch goal - implement if time permits" + } + ], + + "acceptanceCriteria": [ + { + "id": "AC-001", + "description": "WebSocket server accepts connections with valid JWT authentication", + "tasks": ["WS-001", "WS-004"], + "met": false + }, + { + "id": "AC-002", + "description": "Game can be created via REST API and played via WebSocket", + "tasks": ["API-001", "GS-002", "WS-005"], + "met": false + }, + { + "id": "AC-003", + "description": "All game actions are validated and executed correctly through WebSocket", + "tasks": ["GS-003", "WS-005"], + "met": false + }, + { + "id": "AC-004", + "description": "Each player receives their own visibility-filtered game state", + "tasks": ["WS-006"], + "met": false + }, + { + "id": "AC-005", + "description": "Turn timeouts emit warnings and trigger auto-pass/loss appropriately", + "tasks": ["TO-001", "TO-002"], + "met": false + }, + { + "id": "AC-006", + "description": "Disconnected players can reconnect and resume their game", + "tasks": ["RC-001", "GS-004"], + "met": false + }, + { + "id": "AC-007", + "description": "Game state persists to Postgres at turn boundaries for durability", + "tasks": ["GS-003", "GS-005"], + "met": false + }, + { + "id": "AC-008", + "description": "Completed games are recorded in GameHistory with replay data", + "tasks": ["GS-005"], + "met": false + }, + { + "id": "AC-009", + "description": "Unit and integration tests cover critical game flow paths", + "tasks": ["TEST-001", "TEST-002"], + "met": false + } + ], + + "risksMitigations": [ + { + "risk": "Socket.IO scalability with many concurrent games", + "mitigation": "Redis adapter for Socket.IO enables horizontal scaling across multiple server instances", + "priority": "medium" + }, + { + "risk": "Race conditions in action execution", + "mitigation": "Use Redis locks (SETNX) for game state during action execution", + "priority": "high" + }, + { + "risk": "Stale connections consuming resources", + "mitigation": "Heartbeat mechanism with 30s interval, cleanup on missed heartbeats", + "priority": "medium" + }, + { + "risk": "Turn timeout drift due to server restart", + "mitigation": "Store absolute deadline in Redis/Postgres, recalculate on startup", + "priority": "medium" + } + ], + + "dependencies": { + "packages": { + "python-socketio": ">=5.10.0", + "aiohttp": "Required by python-socketio async mode" + }, + "existingModules": [ + "app.core.engine.GameEngine", + "app.core.visibility.get_visible_state", + "app.services.game_state_manager.GameStateManager", + "app.services.deck_service.DeckService", + "app.services.card_service.CardService", + "app.services.jwt_service.JWTService", + "app.db.models.game.ActiveGame", + "app.db.models.game.GameHistory" + ] + }, + + "testStrategy": { + "unit": { + "location": "tests/unit/services/", + "scope": "GameService, ConnectionManager, TurnTimeoutService", + "mocking": "Mock GameEngine, GameStateManager, Redis" + }, + "integration": { + "location": "tests/socketio/", + "scope": "Full WebSocket flow with real Redis/Postgres", + "tools": "testcontainers, python-socketio test client" + }, + "e2e": { + "location": "tests/e2e/", + "scope": "Complete game from creation to completion", + "deferred": "May defer to after Phase 5" + } + }, + + "sequenceDiagrams": { + "gameCreation": [ + "Client -> REST: POST /games {deck_id, rules_config}", + "REST -> GameService: create_game(user_id, deck_id, ...)", + "GameService -> DeckService: get_deck(deck_id)", + "GameService -> CardService: get_card_definitions()", + "GameService -> GameEngine: create_game(player_ids, decks, registry)", + "GameService -> GameStateManager: save_to_cache() + persist_to_db()", + "REST -> Client: {game_id, ws_url}", + "Client -> WebSocket: connect(jwt) + game:join(game_id)", + "WebSocket -> Client: game:state(visible_state)" + ], + "actionExecution": [ + "Client -> WebSocket: game:action({type: 'attack', attack_index: 0})", + "WebSocket -> GameService: execute_action(game_id, player_id, action)", + "GameService -> GameStateManager: load_state(game_id)", + "GameService -> GameEngine: execute_action(game, player_id, action)", + "GameService -> GameStateManager: save_to_cache()", + "GameService -> TurnTimeoutService: (re)start_timer if turn changed", + "WebSocket -> Room: game:action_result + game:state to each player" + ], + "reconnection": [ + "Client -> WebSocket: connect(jwt)", + "WebSocket -> ConnectionManager: get_user_active_game(user_id)", + "WebSocket -> GameService: resume_game(game_id, user_id)", + "GameService -> GameStateManager: load_state(game_id)", + "GameService -> TurnTimeoutService: extend_timer(grace_period)", + "WebSocket -> Client: game:state(visible_state, pending_actions)", + "WebSocket -> Opponent: game:opponent_connected" + ] + } +}