diff --git a/docs/v2/V2_08_GAME_LOGGING.md b/docs/v2/V2_08_GAME_LOGGING.md new file mode 100644 index 0000000..f4f5adb --- /dev/null +++ b/docs/v2/V2_08_GAME_LOGGING.md @@ -0,0 +1,317 @@ +# V2-08: Unified Game Logging + +## Overview + +This document covers the unified PostgreSQL game logging system that replaces +the legacy SQLite `game_log.py`. All game events and AI decisions are logged +to PostgreSQL for analysis, replay, and cloud deployment. + +**Dependencies:** V2-01 (Event Sourcing), V2-02 (Persistence) +**Dependents:** Game Analyzer, Stats Dashboard + +--- + +## Goals + +1. Consolidate all game data in PostgreSQL (drop SQLite dependency) +2. Preserve AI decision context for analysis +3. Maintain compatibility with existing services (Stats, Replay, Recovery) +4. Enable efficient queries for game analysis +5. Support cloud deployment without local file dependencies + +--- + +## Architecture + +``` + ┌─────────────────┐ + │ Game Server │ + │ (main.py) │ + └────────┬────────┘ + │ + ┌───────────────────┼───────────────────┐ + │ │ │ + ▼ ▼ ▼ +┌───────────────┐ ┌───────────────┐ ┌───────────────┐ +│ GameLogger │ │ EventStore │ │ StatsService │ +│ Service │ │ (events) │ │ ReplayService │ +└───────┬───────┘ └───────────────┘ └───────────────┘ + │ + ▼ +┌───────────────────────────────────────────────────┐ +│ PostgreSQL │ +│ ┌─────────┐ ┌───────────┐ ┌──────────────┐ │ +│ │ games_v2│ │ events │ │ moves │ │ +│ │ metadata│ │ (actions) │ │ (AI context) │ │ +│ └─────────┘ └───────────┘ └──────────────┘ │ +└───────────────────────────────────────────────────┘ +``` + +--- + +## Database Schema + +### moves Table (New) + +```sql +CREATE TABLE IF NOT EXISTS moves ( + id BIGSERIAL PRIMARY KEY, + game_id UUID NOT NULL, + sequence_num INT NOT NULL, + timestamp TIMESTAMPTZ DEFAULT NOW(), + player_id VARCHAR(50) NOT NULL, + player_name VARCHAR(100), + is_cpu BOOLEAN DEFAULT FALSE, + + -- Action details + action VARCHAR(30) NOT NULL, -- draw_deck, take_discard, swap, discard, flip, etc. + card_rank VARCHAR(5), + card_suit VARCHAR(10), + position INT, + + -- AI context (JSONB for flexibility) + hand_state JSONB, -- Player's hand at decision time + discard_top JSONB, -- Top of discard pile + visible_opponents JSONB, -- Face-up cards of opponents + decision_reason TEXT, -- AI reasoning + + UNIQUE(game_id, sequence_num) +); + +CREATE INDEX IF NOT EXISTS idx_moves_game ON moves(game_id); +CREATE INDEX IF NOT EXISTS idx_moves_action ON moves(action); +CREATE INDEX IF NOT EXISTS idx_moves_is_cpu ON moves(is_cpu); +CREATE INDEX IF NOT EXISTS idx_moves_player ON moves(player_id); +``` + +### Action Types + +| Action | Description | +|--------|-------------| +| `draw_deck` | Player drew from deck | +| `take_discard` | Player took top of discard pile | +| `swap` | Player swapped drawn card with hand card | +| `discard` | Player discarded drawn card | +| `flip` | Player flipped a card after discarding | +| `skip_flip` | Player skipped optional flip (endgame) | +| `flip_as_action` | Player used flip-as-action house rule | +| `knock_early` | Player knocked to end round early | + +--- + +## GameLogger Service + +**Location:** `/server/services/game_logger.py` + +### API + +```python +class GameLogger: + """Logs game events and moves to PostgreSQL.""" + + def __init__(self, event_store: EventStore): + """Initialize with event store instance.""" + + def log_game_start( + self, + room_code: str, + num_players: int, + options: GameOptions, + ) -> str: + """Log game start, returns game_id.""" + + def log_move( + self, + game_id: str, + player: Player, + is_cpu: bool, + action: str, + card: Optional[Card] = None, + position: Optional[int] = None, + game: Optional[Game] = None, + decision_reason: Optional[str] = None, + ) -> None: + """Log a move with AI context.""" + + def log_game_end(self, game_id: str) -> None: + """Mark game as ended.""" +``` + +### Usage + +```python +# In main.py lifespan +from services.game_logger import GameLogger, set_logger + +_event_store = await get_event_store(config.POSTGRES_URL) +_game_logger = GameLogger(_event_store) +set_logger(_game_logger) + +# In handlers +from services.game_logger import get_logger + +game_logger = get_logger() +if game_logger: + game_logger.log_move( + game_id=room.game_log_id, + player=player, + is_cpu=False, + action="swap", + card=drawn_card, + position=position, + game=room.game, + decision_reason="swapped 5 into position 2", + ) +``` + +--- + +## Query Patterns + +### Find Suspicious Discards + +```python +# Using EventStore +blunders = await event_store.find_suspicious_discards(limit=50) +``` + +```sql +-- Direct SQL +SELECT m.*, g.room_code +FROM moves m +JOIN games_v2 g ON m.game_id = g.id +WHERE m.action = 'discard' +AND m.card_rank IN ('A', '2', 'K') +AND m.is_cpu = TRUE +ORDER BY m.timestamp DESC +LIMIT 50; +``` + +### Get Player Decisions + +```python +moves = await event_store.get_player_decisions(game_id, player_name) +``` + +```sql +SELECT * FROM moves +WHERE game_id = $1 AND player_name = $2 +ORDER BY sequence_num; +``` + +### Recent Games with Stats + +```python +games = await event_store.get_recent_games_with_stats(limit=10) +``` + +```sql +SELECT g.*, COUNT(m.id) as total_moves +FROM games_v2 g +LEFT JOIN moves m ON g.id = m.game_id +GROUP BY g.id +ORDER BY g.created_at DESC +LIMIT 10; +``` + +--- + +## Migration from SQLite + +### Removed Files + +- `/server/game_log.py` - Replaced by `/server/services/game_logger.py` +- `/server/games.db` - Data now in PostgreSQL + +### Updated Files + +| File | Changes | +|------|---------| +| `main.py` | Import from `services.game_logger`, init in lifespan | +| `ai.py` | Import from `services.game_logger` | +| `simulate.py` | Removed logging, uses in-memory SimulationStats only | +| `game_analyzer.py` | CLI updated for PostgreSQL, class deprecated | +| `stores/event_store.py` | Added `moves` table and query methods | + +### Simulation Mode + +Simulations (`simulate.py`) no longer write to the database. They use in-memory +`SimulationStats` for analysis. This keeps simulations fast and avoids flooding +the database with bulk test runs. + +For simulation analysis: +```bash +python simulate.py 100 --preset baseline +# Stats printed to console +``` + +For production game analysis: +```bash +python game_analyzer.py blunders 20 +python game_analyzer.py recent 10 +``` + +--- + +## Acceptance Criteria + +1. **PostgreSQL Integration** + - [x] moves table created with proper indexes + - [x] All game actions logged to PostgreSQL via GameLogger + - [x] EventStore has append_move() and query methods + +2. **Service Compatibility** + - [x] StatsService still works (uses events table) + - [x] ReplayService still works (uses events table) + - [x] RecoveryService still works (uses events table) + +3. **Simulation Mode** + - [x] simulate.py works without PostgreSQL + - [x] In-memory SimulationStats provides analysis + +4. **SQLite Removal** + - [x] game_log.py can be deleted + - [x] games.db can be deleted + - [x] No sqlite3 imports in main game code + +--- + +## Implementation Notes + +### Async/Sync Bridging + +The GameLogger provides sync methods (`log_move`, `log_game_start`) that +internally fire async tasks. This allows existing sync code paths to call +the logger without blocking: + +```python +def log_move(self, game_id, ...): + if not game_id: + return + try: + loop = asyncio.get_running_loop() + asyncio.create_task(self.log_move_async(...)) + except RuntimeError: + # Not in async context - skip (simulations) + pass +``` + +### Fire-and-Forget Logging + +Move logging uses fire-and-forget async tasks to avoid blocking game logic. +This means: +- Logging failures don't crash the game +- Slight delay between action and database write is acceptable +- No acknowledgment that log succeeded + +For critical data, use the events table which is the source of truth. + +--- + +## Notes for Developers + +- The `moves` table is denormalized for efficient queries +- The `events` table remains the source of truth for game replay +- GameLogger is None when PostgreSQL is not configured (no logging) +- Always check `if game_logger:` before calling methods +- For quick development testing, use simulate.py without database diff --git a/server/game_analyzer.py b/server/game_analyzer.py index a375a60..2fddacf 100644 --- a/server/game_analyzer.py +++ b/server/game_analyzer.py @@ -3,10 +3,20 @@ Game Analyzer for 6-Card Golf AI decisions. Evaluates AI decisions against optimal play baselines and generates reports on decision quality, mistake rates, and areas for improvement. + +NOTE: This analyzer has been updated to use PostgreSQL. It requires +POSTGRES_URL to be configured. For quick analysis during simulations, +use the SimulationStats class in simulate.py instead. + +Usage: + python game_analyzer.py blunders [limit] + python game_analyzer.py recent [limit] """ +import asyncio import json -import sqlite3 +import os +import sqlite3 # For legacy GameAnalyzer class (deprecated) from dataclasses import dataclass from datetime import datetime from pathlib import Path @@ -339,7 +349,12 @@ class DecisionEvaluator: # ============================================================================= class GameAnalyzer: - """Analyzes logged games for decision quality.""" + """Analyzes logged games for decision quality. + + DEPRECATED: This class uses SQLite which has been replaced by PostgreSQL. + Use the CLI commands (blunders, recent) instead, or query the moves table + in PostgreSQL directly. + """ def __init__(self, db_path: str = "games.db"): self.db_path = Path(db_path) @@ -579,59 +594,76 @@ def print_blunder_report(blunders: list[dict]): # ============================================================================= -# CLI Interface +# CLI Interface (PostgreSQL version) # ============================================================================= -if __name__ == "__main__": +async def run_cli(): + """Async CLI entry point.""" import sys if len(sys.argv) < 2: print("Usage:") print(" python game_analyzer.py blunders [limit]") - print(" python game_analyzer.py game ") - print(" python game_analyzer.py summary") + print(" python game_analyzer.py recent [limit]") + print("") + print("Requires POSTGRES_URL environment variable.") + sys.exit(1) + + postgres_url = os.environ.get("POSTGRES_URL") + if not postgres_url: + print("Error: POSTGRES_URL environment variable not set.") + print("") + print("Set it like: export POSTGRES_URL=postgresql://golf:devpassword@localhost:5432/golf") + print("") + print("For simulation analysis without PostgreSQL, use:") + print(" python simulate.py 100 --preset baseline") + sys.exit(1) + + from stores.event_store import EventStore + + try: + event_store = await EventStore.create(postgres_url) + except Exception as e: + print(f"Error connecting to PostgreSQL: {e}") sys.exit(1) command = sys.argv[1] try: - analyzer = GameAnalyzer() - except FileNotFoundError: - print("No games.db found. Play some games first!") - sys.exit(1) + if command == "blunders": + limit = int(sys.argv[2]) if len(sys.argv) > 2 else 20 + blunders = await event_store.find_suspicious_discards(limit) - if command == "blunders": - limit = int(sys.argv[2]) if len(sys.argv) > 2 else 20 - blunders = analyzer.find_blunders(limit) - print_blunder_report(blunders) + print(f"\n=== Suspicious Discards ({len(blunders)} found) ===\n") + for b in blunders: + print(f"Player: {b.get('player_name', 'Unknown')}") + print(f"Action: discard {b.get('card_rank', '?')}") + print(f"Room: {b.get('room_code', 'N/A')}") + print(f"Reason: {b.get('decision_reason', 'N/A')}") + print("-" * 40) - elif command == "game" and len(sys.argv) >= 4: - game_id = sys.argv[2] - player_name = sys.argv[3] - summary = analyzer.analyze_player_game(game_id, player_name) - print(generate_player_report(summary)) - - elif command == "summary": - # Quick summary of recent games - with sqlite3.connect("games.db") as conn: - conn.row_factory = sqlite3.Row - cursor = conn.execute(""" - SELECT g.id, g.room_code, g.started_at, g.num_players, - COUNT(m.id) as move_count - FROM games g - LEFT JOIN moves m ON g.id = m.game_id - GROUP BY g.id - ORDER BY g.started_at DESC - LIMIT 10 - """) + elif command == "recent": + limit = int(sys.argv[2]) if len(sys.argv) > 2 else 10 + games = await event_store.get_recent_games_with_stats(limit) print("\n=== Recent Games ===\n") - for row in cursor: - print(f"Game: {row['id'][:8]}... Room: {row['room_code']}") - print(f" Players: {row['num_players']}, Moves: {row['move_count']}") - print(f" Started: {row['started_at']}") - print() + for game in games: + game_id = str(game.get('id', ''))[:8] + room_code = game.get('room_code', 'N/A') + status = game.get('status', 'unknown') + moves = game.get('total_moves', 0) + print(f"{game_id}... | Room: {room_code} | Status: {status} | Moves: {moves}") - else: - print(f"Unknown command: {command}") - sys.exit(1) + else: + print(f"Unknown command: {command}") + print("Available: blunders, recent") + + finally: + await event_store.close() + + +if __name__ == "__main__": + # Note: The detailed analysis (GameAnalyzer class) still uses the old SQLite + # schema format. For now, use the CLI commands above for PostgreSQL queries. + # Full migration of the analysis logic is TODO. + asyncio.run(run_cli()) diff --git a/server/main.py b/server/main.py index 8304477..22175c9 100644 --- a/server/main.py +++ b/server/main.py @@ -16,7 +16,7 @@ from config import config from room import RoomManager, Room from game import GamePhase, GameOptions from ai import GolfAI, process_cpu_turn, get_all_profiles, reset_all_profiles, cleanup_room_profiles -from game_log import get_logger +from services.game_logger import GameLogger, get_logger, set_logger # Import production components from logging_config import setup_logging @@ -142,6 +142,12 @@ async def lifespan(app: FastAPI): set_stats_auth_service(_auth_service) logger.info("Stats services initialized successfully") + # Initialize game logger (uses event_store for move logging) + logger.info("Initializing game logger...") + _game_logger = GameLogger(_event_store) + set_logger(_game_logger) + logger.info("Game logger initialized with PostgreSQL backend") + # Initialize replay service logger.info("Initializing replay services...") from services.replay_service import get_replay_service, set_replay_service @@ -343,9 +349,8 @@ app.add_middleware(LazyRateLimitMiddleware) room_manager = RoomManager() -# Initialize game logger database at startup -_game_logger = get_logger() -logger.info(f"Game analytics database initialized at: {_game_logger.db_path}") +# Game logger is initialized in lifespan after event_store is available +# The get_logger() function returns None until set_logger() is called # ============================================================================= @@ -692,11 +697,12 @@ async def websocket_endpoint(websocket: WebSocket): # Log game start for AI analysis game_logger = get_logger() - current_room.game_log_id = game_logger.log_game_start( - room_code=current_room.code, - num_players=len(current_room.players), - options=options, - ) + if game_logger: + current_room.game_log_id = game_logger.log_game_start( + room_code=current_room.code, + num_players=len(current_room.players), + options=options, + ) # CPU players do their initial flips immediately (if required) if options.initial_flips > 0: @@ -740,8 +746,8 @@ async def websocket_endpoint(websocket: WebSocket): if card: # Log draw decision for human player - if current_room.game_log_id: - game_logger = get_logger() + game_logger = get_logger() + if game_logger and current_room.game_log_id: player = current_room.game.get_player(player_id) if player: reason = f"took {discard_before_draw.rank.value} from discard" if source == "discard" else "drew from deck" @@ -779,8 +785,8 @@ async def websocket_endpoint(websocket: WebSocket): if discarded: # Log swap decision for human player - if current_room.game_log_id and drawn_card and player: - game_logger = get_logger() + game_logger = get_logger() + if game_logger and current_room.game_log_id and drawn_card and player: old_rank = old_card.rank.value if old_card else "?" game_logger.log_move( game_id=current_room.game_log_id, @@ -810,8 +816,8 @@ async def websocket_endpoint(websocket: WebSocket): if current_room.game.discard_drawn(player_id): # Log discard decision for human player - if current_room.game_log_id and drawn_card and player: - game_logger = get_logger() + game_logger = get_logger() + if game_logger and current_room.game_log_id and drawn_card and player: game_logger.log_move( game_id=current_room.game_log_id, player=player, @@ -864,8 +870,8 @@ async def websocket_endpoint(websocket: WebSocket): current_room.game.flip_and_end_turn(player_id, position) # Log flip decision for human player - if current_room.game_log_id and player and 0 <= position < len(player.cards): - game_logger = get_logger() + game_logger = get_logger() + if game_logger and current_room.game_log_id and player and 0 <= position < len(player.cards): flipped_card = player.cards[position] game_logger.log_move( game_id=current_room.game_log_id, @@ -889,8 +895,8 @@ async def websocket_endpoint(websocket: WebSocket): player = current_room.game.get_player(player_id) if current_room.game.skip_flip_and_end_turn(player_id): # Log skip flip decision for human player - if current_room.game_log_id and player: - game_logger = get_logger() + game_logger = get_logger() + if game_logger and current_room.game_log_id and player: game_logger.log_move( game_id=current_room.game_log_id, player=player, @@ -913,8 +919,8 @@ async def websocket_endpoint(websocket: WebSocket): player = current_room.game.get_player(player_id) if current_room.game.flip_card_as_action(player_id, position): # Log flip-as-action for human player - if current_room.game_log_id and player and 0 <= position < len(player.cards): - game_logger = get_logger() + game_logger = get_logger() + if game_logger and current_room.game_log_id and player and 0 <= position < len(player.cards): flipped_card = player.cards[position] game_logger.log_move( game_id=current_room.game_log_id, @@ -938,8 +944,8 @@ async def websocket_endpoint(websocket: WebSocket): player = current_room.game.get_player(player_id) if current_room.game.knock_early(player_id): # Log knock early for human player - if current_room.game_log_id and player: - game_logger = get_logger() + game_logger = get_logger() + if game_logger and current_room.game_log_id and player: face_down_count = sum(1 for c in player.cards if not c.face_up) game_logger.log_move( game_id=current_room.game_log_id, @@ -1098,8 +1104,8 @@ async def broadcast_game_state(room: Room): # Check for game over elif room.game.phase == GamePhase.GAME_OVER: # Log game end - if room.game_log_id: - game_logger = get_logger() + game_logger = get_logger() + if game_logger and room.game_log_id: game_logger.log_game_end(room.game_log_id) room.game_log_id = None # Clear to avoid duplicate logging diff --git a/server/services/game_logger.py b/server/services/game_logger.py new file mode 100644 index 0000000..6cc9439 --- /dev/null +++ b/server/services/game_logger.py @@ -0,0 +1,327 @@ +""" +PostgreSQL-backed game logging for AI decision analysis. + +Replaces SQLite game_log.py with unified event store integration. +Provides sync-compatible interface for existing callers (main.py, ai.py). + +Usage: + # Initialize in main.py lifespan + from services.game_logger import GameLogger, set_logger + game_logger = GameLogger(event_store) + set_logger(game_logger) + + # Use in handlers + from services.game_logger import get_logger + logger = get_logger() + if logger: + logger.log_move(game_id, player, is_cpu=False, action="swap", ...) +""" + +from typing import Optional, TYPE_CHECKING +import asyncio +import uuid +import logging + +if TYPE_CHECKING: + from stores.event_store import EventStore + from game import Card, Player, Game, GameOptions + +log = logging.getLogger(__name__) + + +class GameLogger: + """ + Logs game events and moves to PostgreSQL. + + Provides sync wrappers for compatibility with existing callers. + Uses fire-and-forget async tasks to avoid blocking game logic. + """ + + def __init__(self, event_store: "EventStore"): + """ + Initialize the game logger. + + Args: + event_store: PostgreSQL event store instance. + """ + self.event_store = event_store + + # ------------------------------------------------------------------------- + # Game Lifecycle + # ------------------------------------------------------------------------- + + async def log_game_start_async( + self, + room_code: str, + num_players: int, + options: "GameOptions", + ) -> str: + """ + Log game start, return game_id. + + Creates a game record in games_v2 table. + + Args: + room_code: Room code for the game. + num_players: Number of players. + options: Game options/house rules. + + Returns: + Generated game UUID. + """ + game_id = str(uuid.uuid4()) + + options_dict = { + "flip_mode": options.flip_mode, + "initial_flips": options.initial_flips, + "knock_penalty": options.knock_penalty, + "use_jokers": options.use_jokers, + "lucky_swing": options.lucky_swing, + "super_kings": options.super_kings, + "ten_penny": options.ten_penny, + "knock_bonus": options.knock_bonus, + "underdog_bonus": options.underdog_bonus, + "tied_shame": options.tied_shame, + "blackjack": options.blackjack, + "eagle_eye": options.eagle_eye, + "negative_pairs_keep_value": getattr(options, "negative_pairs_keep_value", False), + "four_of_a_kind": getattr(options, "four_of_a_kind", False), + "wolfpack": getattr(options, "wolfpack", False), + } + + try: + await self.event_store.create_game( + game_id=game_id, + room_code=room_code, + host_id="system", + options=options_dict, + ) + log.debug(f"Logged game start: {game_id} room={room_code}") + except Exception as e: + log.error(f"Failed to log game start: {e}") + + return game_id + + def log_game_start( + self, + room_code: str, + num_players: int, + options: "GameOptions", + ) -> str: + """ + Sync wrapper for log_game_start_async. + + In async context: fires task and returns generated ID immediately. + In sync context: runs synchronously. + """ + game_id = str(uuid.uuid4()) + + try: + loop = asyncio.get_running_loop() + # Already in async context - fire task, return ID immediately + asyncio.create_task(self._log_game_start_with_id(game_id, room_code, num_players, options)) + return game_id + except RuntimeError: + # Not in async context - run synchronously + return asyncio.run(self.log_game_start_async(room_code, num_players, options)) + + async def _log_game_start_with_id( + self, + game_id: str, + room_code: str, + num_players: int, + options: "GameOptions", + ) -> None: + """Helper to log game start with pre-generated ID.""" + options_dict = { + "flip_mode": options.flip_mode, + "initial_flips": options.initial_flips, + "knock_penalty": options.knock_penalty, + "use_jokers": options.use_jokers, + "lucky_swing": options.lucky_swing, + "super_kings": options.super_kings, + "ten_penny": options.ten_penny, + "knock_bonus": options.knock_bonus, + "underdog_bonus": options.underdog_bonus, + "tied_shame": options.tied_shame, + "blackjack": options.blackjack, + "eagle_eye": options.eagle_eye, + "negative_pairs_keep_value": getattr(options, "negative_pairs_keep_value", False), + "four_of_a_kind": getattr(options, "four_of_a_kind", False), + "wolfpack": getattr(options, "wolfpack", False), + } + + try: + await self.event_store.create_game( + game_id=game_id, + room_code=room_code, + host_id="system", + options=options_dict, + ) + log.debug(f"Logged game start: {game_id} room={room_code}") + except Exception as e: + log.error(f"Failed to log game start: {e}") + + async def log_game_end_async(self, game_id: str) -> None: + """ + Mark game as ended. + + Args: + game_id: Game UUID. + """ + try: + await self.event_store.update_game_completed(game_id) + log.debug(f"Logged game end: {game_id}") + except Exception as e: + log.error(f"Failed to log game end: {e}") + + def log_game_end(self, game_id: str) -> None: + """ + Sync wrapper for log_game_end_async. + + Fires async task in async context, skips in sync context. + """ + if not game_id: + return + + try: + loop = asyncio.get_running_loop() + asyncio.create_task(self.log_game_end_async(game_id)) + except RuntimeError: + # Not in async context - skip (simulations don't need this) + pass + + # ------------------------------------------------------------------------- + # Move Logging + # ------------------------------------------------------------------------- + + async def log_move_async( + self, + game_id: str, + player: "Player", + is_cpu: bool, + action: str, + card: Optional["Card"] = None, + position: Optional[int] = None, + game: Optional["Game"] = None, + decision_reason: Optional[str] = None, + ) -> None: + """ + Log a move with AI context to PostgreSQL. + + Args: + game_id: Game UUID. + player: Player who made the move. + is_cpu: Whether this is a CPU player. + action: Action type (draw_deck, take_discard, swap, discard, flip, etc.). + card: Card involved in the action. + position: Hand position (0-5) for swaps/flips. + game: Game instance for context capture. + decision_reason: AI reasoning for the decision. + """ + # Build AI context from game state + hand_state = None + discard_top = None + visible_opponents = None + + if game: + # Serialize player's hand + hand_state = [ + {"rank": c.rank.value, "suit": c.suit.value, "face_up": c.face_up} + for c in player.cards + ] + + # Serialize discard top + dt = game.discard_top() + if dt: + discard_top = {"rank": dt.rank.value, "suit": dt.suit.value} + + # Serialize visible opponent cards + visible_opponents = {} + for p in game.players: + if p.id != player.id: + visible = [ + {"rank": c.rank.value, "suit": c.suit.value} + for c in p.cards if c.face_up + ] + visible_opponents[p.name] = visible + + try: + await self.event_store.append_move( + game_id=game_id, + player_id=player.id, + player_name=player.name, + is_cpu=is_cpu, + action=action, + card_rank=card.rank.value if card else None, + card_suit=card.suit.value if card else None, + position=position, + hand_state=hand_state, + discard_top=discard_top, + visible_opponents=visible_opponents, + decision_reason=decision_reason, + ) + except Exception as e: + log.error(f"Failed to log move: {e}") + + def log_move( + self, + game_id: str, + player: "Player", + is_cpu: bool, + action: str, + card: Optional["Card"] = None, + position: Optional[int] = None, + game: Optional["Game"] = None, + decision_reason: Optional[str] = None, + ) -> None: + """ + Sync wrapper for log_move_async. + + Fires async task in async context. Does nothing if no game_id or not in async context. + """ + if not game_id: + return + + try: + loop = asyncio.get_running_loop() + asyncio.create_task( + self.log_move_async( + game_id, player, is_cpu, action, + card=card, position=position, game=game, decision_reason=decision_reason + ) + ) + except RuntimeError: + # Not in async context - skip logging (simulations) + pass + + +# ------------------------------------------------------------------------- +# Global Instance Management +# ------------------------------------------------------------------------- + +_game_logger: Optional[GameLogger] = None + + +def get_logger() -> Optional[GameLogger]: + """ + Get the global game logger instance. + + Returns: + GameLogger if initialized, None otherwise. + """ + return _game_logger + + +def set_logger(logger: GameLogger) -> None: + """ + Set the global game logger instance. + + Called during application startup in main.py lifespan. + + Args: + logger: GameLogger instance to use globally. + """ + global _game_logger + _game_logger = logger + log.info("Game logger initialized with PostgreSQL backend") diff --git a/server/stores/event_store.py b/server/stores/event_store.py index 780bf59..a013ae4 100644 --- a/server/stores/event_store.py +++ b/server/stores/event_store.py @@ -62,6 +62,32 @@ CREATE TABLE IF NOT EXISTS games_v2 ( player_ids VARCHAR(50)[] DEFAULT '{}' ); +-- Moves table (denormalized for AI decision analysis) +-- Replaces SQLite game_log.py - provides efficient queries for move-level analysis +CREATE TABLE IF NOT EXISTS moves ( + id BIGSERIAL PRIMARY KEY, + game_id UUID NOT NULL, + sequence_num INT NOT NULL, + timestamp TIMESTAMPTZ DEFAULT NOW(), + player_id VARCHAR(50) NOT NULL, + player_name VARCHAR(100), + is_cpu BOOLEAN DEFAULT FALSE, + + -- Action details + action VARCHAR(30) NOT NULL, -- draw_deck, take_discard, swap, discard, flip, etc. + card_rank VARCHAR(5), + card_suit VARCHAR(10), + position INT, + + -- AI context (JSONB for flexibility) + hand_state JSONB, -- Player's hand at decision time + discard_top JSONB, -- Top of discard pile + visible_opponents JSONB, -- Face-up cards of opponents + decision_reason TEXT, -- AI reasoning + + UNIQUE(game_id, sequence_num) +); + -- Indexes for common queries CREATE INDEX IF NOT EXISTS idx_events_game_seq ON events(game_id, sequence_num); CREATE INDEX IF NOT EXISTS idx_events_type ON events(event_type); @@ -72,6 +98,11 @@ CREATE INDEX IF NOT EXISTS idx_games_status ON games_v2(status); CREATE INDEX IF NOT EXISTS idx_games_room ON games_v2(room_code) WHERE status = 'active'; CREATE INDEX IF NOT EXISTS idx_games_players ON games_v2 USING GIN(player_ids); CREATE INDEX IF NOT EXISTS idx_games_completed ON games_v2(completed_at) WHERE status = 'completed'; + +CREATE INDEX IF NOT EXISTS idx_moves_game ON moves(game_id); +CREATE INDEX IF NOT EXISTS idx_moves_action ON moves(action); +CREATE INDEX IF NOT EXISTS idx_moves_is_cpu ON moves(is_cpu); +CREATE INDEX IF NOT EXISTS idx_moves_player ON moves(player_id); """ @@ -441,6 +472,230 @@ class EventStore: ) return dict(row) if row else None + # ------------------------------------------------------------------------- + # Move Logging (for AI decision analysis) + # ------------------------------------------------------------------------- + + async def append_move( + self, + game_id: str, + player_id: str, + player_name: str, + is_cpu: bool, + action: str, + card_rank: Optional[str] = None, + card_suit: Optional[str] = None, + position: Optional[int] = None, + hand_state: Optional[list] = None, + discard_top: Optional[dict] = None, + visible_opponents: Optional[dict] = None, + decision_reason: Optional[str] = None, + ) -> int: + """ + Append a move to the moves table for AI decision analysis. + + Args: + game_id: Game UUID. + player_id: Player who made the move. + player_name: Display name of the player. + is_cpu: Whether this is a CPU player. + action: Action type (draw_deck, take_discard, swap, discard, flip, etc.). + card_rank: Rank of the card involved. + card_suit: Suit of the card involved. + position: Hand position (0-5) for swaps/flips. + hand_state: Player's hand at decision time. + discard_top: Top of discard pile at decision time. + visible_opponents: Face-up cards of opponents. + decision_reason: AI reasoning for the decision. + + Returns: + The database ID of the inserted move. + """ + async with self.pool.acquire() as conn: + # Get next sequence number for this game + seq_row = await conn.fetchrow( + "SELECT COALESCE(MAX(sequence_num), 0) + 1 as seq FROM moves WHERE game_id = $1", + game_id, + ) + sequence_num = seq_row["seq"] + + row = await conn.fetchrow( + """ + INSERT INTO moves ( + game_id, sequence_num, player_id, player_name, is_cpu, + action, card_rank, card_suit, position, + hand_state, discard_top, visible_opponents, decision_reason + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + RETURNING id + """, + game_id, + sequence_num, + player_id, + player_name, + is_cpu, + action, + card_rank, + card_suit, + position, + json.dumps(hand_state) if hand_state else None, + json.dumps(discard_top) if discard_top else None, + json.dumps(visible_opponents) if visible_opponents else None, + decision_reason, + ) + return row["id"] + + async def get_moves( + self, + game_id: str, + player_id: Optional[str] = None, + is_cpu: Optional[bool] = None, + action: Optional[str] = None, + limit: int = 100, + ) -> list[dict]: + """ + Get moves for a game with optional filters. + + Args: + game_id: Game UUID. + player_id: Filter by player ID. + is_cpu: Filter by CPU status. + action: Filter by action type. + limit: Maximum number of moves to return. + + Returns: + List of move dicts. + """ + conditions = ["game_id = $1"] + params = [game_id] + param_idx = 2 + + if player_id is not None: + conditions.append(f"player_id = ${param_idx}") + params.append(player_id) + param_idx += 1 + + if is_cpu is not None: + conditions.append(f"is_cpu = ${param_idx}") + params.append(is_cpu) + param_idx += 1 + + if action is not None: + conditions.append(f"action = ${param_idx}") + params.append(action) + param_idx += 1 + + params.append(limit) + where_clause = " AND ".join(conditions) + + async with self.pool.acquire() as conn: + rows = await conn.fetch( + f""" + SELECT id, game_id, sequence_num, timestamp, player_id, player_name, is_cpu, + action, card_rank, card_suit, position, + hand_state, discard_top, visible_opponents, decision_reason + FROM moves + WHERE {where_clause} + ORDER BY sequence_num + LIMIT ${param_idx} + """, + *params, + ) + return [self._row_to_move(row) for row in rows] + + async def get_player_decisions( + self, + game_id: str, + player_name: str, + ) -> list[dict]: + """ + Get all decisions made by a specific player in a game. + + Args: + game_id: Game UUID. + player_name: Display name of the player. + + Returns: + List of move dicts. + """ + async with self.pool.acquire() as conn: + rows = await conn.fetch( + """ + SELECT id, game_id, sequence_num, timestamp, player_id, player_name, is_cpu, + action, card_rank, card_suit, position, + hand_state, discard_top, visible_opponents, decision_reason + FROM moves + WHERE game_id = $1 AND player_name = $2 + ORDER BY sequence_num + """, + game_id, + player_name, + ) + return [self._row_to_move(row) for row in rows] + + async def find_suspicious_discards(self, limit: int = 50) -> list[dict]: + """ + Find cases where CPU discarded good cards (Ace, 2, King). + + Used for AI decision quality analysis. + + Args: + limit: Maximum number of results. + + Returns: + List of suspicious move dicts with game room_code. + """ + async with self.pool.acquire() as conn: + rows = await conn.fetch( + """ + SELECT m.*, g.room_code + FROM moves m + JOIN games_v2 g ON m.game_id = g.id + WHERE m.action = 'discard' + AND m.card_rank IN ('A', '2', 'K') + AND m.is_cpu = TRUE + ORDER BY m.timestamp DESC + LIMIT $1 + """, + limit, + ) + return [self._row_to_move(row) for row in rows] + + async def get_recent_games_with_stats(self, limit: int = 10) -> list[dict]: + """ + Get recent games with move counts. + + Args: + limit: Maximum number of games. + + Returns: + List of game dicts with total_moves count. + """ + async with self.pool.acquire() as conn: + rows = await conn.fetch( + """ + SELECT g.*, COUNT(m.id) as total_moves + FROM games_v2 g + LEFT JOIN moves m ON g.id = m.game_id + GROUP BY g.id + ORDER BY g.created_at DESC + LIMIT $1 + """, + limit, + ) + return [dict(row) for row in rows] + + def _row_to_move(self, row: asyncpg.Record) -> dict: + """Convert a database row to a move dict.""" + result = dict(row) + # Parse JSON fields + if result.get("hand_state"): + result["hand_state"] = json.loads(result["hand_state"]) + if result.get("discard_top"): + result["discard_top"] = json.loads(result["discard_top"]) + if result.get("visible_opponents"): + result["visible_opponents"] = json.loads(result["visible_opponents"]) + return result + # ------------------------------------------------------------------------- # Helpers # -------------------------------------------------------------------------