Create 16 integration tests across 7 test classes covering: - JWT authentication (valid/invalid/expired tokens) - Game join flow with Redis connection tracking - Action execution and state broadcasting - Turn timeout Redis operations (start/get/cancel/extend) - Disconnection cleanup - Spectator filtered state - Reconnection tracking Uses testcontainers for real Redis/Postgres integration. Completes Phase 4 (Game Service + WebSocket) with all 18 tasks finished. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
712 lines
30 KiB
JSON
712 lines
30 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": 18,
|
|
"status": "completed",
|
|
"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": true,
|
|
"tested": true,
|
|
"dependencies": ["GS-003", "WS-006"],
|
|
"files": [
|
|
{"path": "app/services/turn_timeout_service.py", "status": "created"},
|
|
{"path": "app/core/config.py", "status": "modified", "note": "Added turn_timer_warning_thresholds and turn_timer_grace_seconds to WinConditionsConfig"},
|
|
{"path": "tests/unit/services/test_turn_timeout_service.py", "status": "created"}
|
|
],
|
|
"details": [
|
|
"Store turn deadline in Redis: turn_timeout:{game_id} -> hash with deadline, player_id, timeout_seconds, warnings_sent, warning_thresholds",
|
|
"Methods: start_turn_timer, cancel_timer, get_remaining_time, extend_timer, get_pending_warning, mark_warning_sent, check_expired_timers",
|
|
"Polling approach for timer checking (background task calls check_expired_timers)",
|
|
"Percentage-based warnings configurable via turn_timer_warning_thresholds (default [50, 25])",
|
|
"Grace period on reconnect via turn_timer_grace_seconds (default 15)",
|
|
"35 unit tests with full coverage"
|
|
],
|
|
"estimatedHours": 4,
|
|
"notes": "Used polling approach instead of keyspace notifications for simplicity. Warnings are percentage-based for better scaling across different timeout durations."
|
|
},
|
|
{
|
|
"id": "TO-002",
|
|
"name": "Integrate timeout with GameService",
|
|
"description": "Start/stop turn timers on turn boundaries",
|
|
"category": "integration",
|
|
"priority": 13,
|
|
"completed": true,
|
|
"tested": true,
|
|
"dependencies": ["TO-001", "GS-003"],
|
|
"files": [
|
|
{"path": "app/services/game_service.py", "status": "modified"},
|
|
{"path": "tests/unit/services/test_game_service.py", "status": "modified"}
|
|
],
|
|
"details": [
|
|
"Timer starts when SETUP phase ends (first real turn begins), not at game creation",
|
|
"Timer starts on turn change (player switches turns)",
|
|
"Timer canceled when game ends (win_result received)",
|
|
"Extend timer on reconnect (via join_game grace period)",
|
|
"handle_timeout method for timeout handling (declares loss)",
|
|
"GameActionResult and GameJoinResult include turn_timeout_seconds and turn_deadline",
|
|
"5 new integration tests in TestTurnTimerIntegration class",
|
|
"Mock timeout service added to test fixtures for DI pattern"
|
|
],
|
|
"estimatedHours": 2,
|
|
"notes": "Timer deliberately NOT started during SETUP phase - only starts when first real turn begins (SETUP -> DRAW/MAIN transition)"
|
|
},
|
|
{
|
|
"id": "RC-001",
|
|
"name": "Implement reconnection flow",
|
|
"description": "Handle client reconnection to ongoing games",
|
|
"category": "reconnection",
|
|
"priority": 14,
|
|
"completed": true,
|
|
"tested": true,
|
|
"dependencies": ["WS-005", "GS-004"],
|
|
"files": [
|
|
{"path": "app/socketio/game_namespace.py", "status": "modified", "note": "Added handle_reconnect method for auto-rejoin"},
|
|
{"path": "app/socketio/server.py", "status": "modified", "note": "Connect event now calls handle_reconnect and emits game:reconnected"},
|
|
{"path": "app/services/connection_manager.py", "status": "modified", "note": "Added get_user_active_game method"},
|
|
{"path": "tests/unit/socketio/test_game_namespace.py", "status": "modified", "note": "Added 9 tests for TestHandleReconnect"},
|
|
{"path": "tests/unit/services/test_connection_manager.py", "status": "modified", "note": "Added 4 tests for get_user_active_game"}
|
|
],
|
|
"details": [
|
|
"On connect: Check for active game via GameStateManager.get_player_active_games()",
|
|
"Auto-rejoin game room if active game exists (game:reconnected event)",
|
|
"Send full game state to reconnecting player",
|
|
"Include pending actions (forced_actions queue)",
|
|
"Extend turn timer by grace period (via GameService.join_game)",
|
|
"Notify opponent of reconnection",
|
|
"Handle rapid disconnect/reconnect (debounce notifications documented as TODO)"
|
|
],
|
|
"estimatedHours": 3,
|
|
"notes": "Debounce for rapid reconnect noted as future enhancement. 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": true,
|
|
"tested": true,
|
|
"dependencies": ["GS-002", "GS-004"],
|
|
"files": [
|
|
{"path": "app/api/games.py", "status": "created"},
|
|
{"path": "app/api/deps.py", "status": "modified", "note": "Added GameServiceDep and GameStateManagerDep"},
|
|
{"path": "app/schemas/game.py", "status": "created"},
|
|
{"path": "app/main.py", "status": "modified"},
|
|
{"path": "tests/api/test_games_api.py", "status": "created", "note": "21 unit tests"}
|
|
],
|
|
"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 - deferred to production hardening"
|
|
],
|
|
"estimatedHours": 3,
|
|
"notes": "WebSocket is primary for gameplay, REST for management. 21 unit tests with full coverage of happy paths and error cases."
|
|
},
|
|
{
|
|
"id": "TEST-001",
|
|
"name": "Unit tests for GameService",
|
|
"description": "Test game lifecycle methods with mocked dependencies",
|
|
"category": "testing",
|
|
"priority": 16,
|
|
"completed": true,
|
|
"tested": true,
|
|
"dependencies": ["GS-005"],
|
|
"files": [
|
|
{"path": "tests/unit/services/test_game_service.py", "status": "modified", "note": "83 tests total: create_game, execute_action (all types), join_game, end_game, handle_timeout, timer integration, spectator mode"}
|
|
],
|
|
"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": "83 unit tests with full coverage: TestGameStateAccess (7), TestJoinGame (8), TestExecuteAction (8), TestForcedActions (5), TestTurnBoundaryPersistence (3), TestPendingForcedActionInResult (2), TestResignGame (1), TestEndGame (5), TestCreateGame (4), TestDefaultEngineFactory (4), TestExceptionMessages (6), TestTurnTimerIntegration (5), TestSpectateGame (5), TestCannotSpectateOwnGameError (1), TestHandleTimeout (4), TestJoinGameTimerExtension (3), TestAdditionalActionTypes (7), TestEndReasonMapping (5)"
|
|
},
|
|
{
|
|
"id": "TEST-002",
|
|
"name": "Integration tests for WebSocket flow",
|
|
"description": "End-to-end tests for WebSocket game flow",
|
|
"category": "testing",
|
|
"priority": 17,
|
|
"completed": true,
|
|
"tested": true,
|
|
"dependencies": ["WS-006", "RC-001"],
|
|
"files": [
|
|
{"path": "tests/socketio/test_game_flow_integration.py", "status": "created"},
|
|
{"path": "tests/socketio/conftest.py", "status": "created"}
|
|
],
|
|
"details": [
|
|
"Test connection with valid/invalid/expired JWT (TestAuthenticationIntegration: 4 tests)",
|
|
"Test game:join room entry and Redis registration (TestGameJoinIntegration: 2 tests)",
|
|
"Test action execution and state broadcast (TestActionExecutionIntegration: 2 tests)",
|
|
"Test turn timeout Redis operations (TestTurnTimeoutIntegration: 4 tests)",
|
|
"Test disconnect connection cleanup (TestDisconnectionIntegration: 1 test)",
|
|
"Test spectator filtered state (TestSpectatorIntegration: 1 test)",
|
|
"Test reconnection tracking (TestReconnectionIntegration: 2 tests)",
|
|
"Uses real testcontainer Redis for connection/timer persistence",
|
|
"Uses real testcontainer Postgres for user fixtures"
|
|
],
|
|
"estimatedHours": 5,
|
|
"notes": "16 integration tests total. Uses testcontainers for Redis/Postgres. Tests real service layer interactions while mocking GameService for handler-level tests."
|
|
},
|
|
{
|
|
"id": "OPT-001",
|
|
"name": "Implement spectator mode",
|
|
"description": "Allow users to watch ongoing games (stretch goal)",
|
|
"category": "optional",
|
|
"priority": 18,
|
|
"completed": true,
|
|
"tested": true,
|
|
"dependencies": ["WS-006"],
|
|
"files": [
|
|
{"path": "app/socketio/game_namespace.py", "status": "modified"},
|
|
{"path": "app/socketio/server.py", "status": "modified"},
|
|
{"path": "app/services/game_service.py", "status": "modified"},
|
|
{"path": "app/services/connection_manager.py", "status": "modified"},
|
|
{"path": "app/schemas/ws_messages.py", "status": "modified"},
|
|
{"path": "tests/unit/services/test_game_service.py", "status": "modified"},
|
|
{"path": "tests/unit/services/test_connection_manager.py", "status": "modified"}
|
|
],
|
|
"details": [
|
|
"Event: game:spectate - Join as spectator",
|
|
"Event: game:leave_spectate - Leave spectator mode",
|
|
"Add spectators to spectators:{game_id} set in Redis",
|
|
"Spectators receive get_spectator_state() view (no hands visible)",
|
|
"Spectator count broadcast to players on join/leave",
|
|
"Spectator count included in GameStateMessage",
|
|
"No action permissions for spectators",
|
|
"17 new unit tests for spectator functionality"
|
|
],
|
|
"estimatedHours": 3,
|
|
"notes": "Public/private game setting deferred to future enhancement"
|
|
}
|
|
],
|
|
|
|
"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"
|
|
]
|
|
}
|
|
}
|