mantimon-tcg/backend/project_plans/PHASE_4_GAME_SERVICE.json
Cal Corum 154d466ff1 Implement /game namespace event handlers (WS-005, WS-006)
Add GameNamespaceHandler with full event handling for real-time gameplay:
- handle_join: Join/rejoin games with visibility-filtered state
- handle_action: Execute actions and broadcast state to participants
- handle_resign: Process resignation and end game
- handle_disconnect: Notify opponent of disconnection
- Broadcast helpers for state, game over, and opponent status

Includes 28 unit tests covering all handler methods.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 20:40:06 -06:00

694 lines
27 KiB
JSON

{
"meta": {
"version": "1.0.0",
"created": "2026-01-28",
"lastUpdated": "2026-01-29",
"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": 12,
"status": "in_progress",
"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": true,
"tested": true,
"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": true,
"tested": true,
"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": true,
"tested": true,
"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": true,
"tested": true,
"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": true,
"tested": true,
"dependencies": ["WS-002"],
"files": [
{"path": "app/services/game_service.py", "status": "created"}
],
"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": true,
"tested": true,
"dependencies": ["GS-001"],
"files": [
{"path": "app/services/game_service.py", "status": "modified"}
],
"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": true,
"tested": true,
"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": true,
"tested": true,
"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": true,
"tested": true,
"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": true,
"tested": true,
"dependencies": ["WS-004", "GS-003", "GS-004"],
"files": [
{"path": "app/socketio/game_namespace.py", "status": "created"},
{"path": "app/socketio/server.py", "status": "modified"}
],
"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": true,
"tested": true,
"dependencies": ["WS-005"],
"files": [
{"path": "app/socketio/game_namespace.py", "status": "modified", "note": "Integrated into GameNamespaceHandler"}
],
"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": "Integrated into GameNamespaceHandler rather than separate file. Spectator mode deferred to OPT-001."
},
{
"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"
},
{
"risk": "ConnectionManager race condition on rapid reconnects",
"mitigation": "Consider Redis Lua script for atomic old-connection cleanup in register_connection. Low probability in practice but could cause connection tracking issues during rapid connect/disconnect cycles.",
"priority": "low",
"status": "identified-in-review",
"notes": "Also consider: periodic cleanup of game_conns sets for long-running games, rate limiting in auth layer"
}
],
"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"
]
}
}