Add PostgreSQL game logging system

- Add GameLogger service for move logging to PostgreSQL
- Add moves table to event_store.py for AI decision analysis
- Update main.py to initialize GameLogger in lifespan
- Update game_analyzer.py to query PostgreSQL instead of SQLite
- Add VDD documentation V2_08_GAME_LOGGING.md

Replaces SQLite game_log.py with unified PostgreSQL backend.
See docs/v2/V2_08_GAME_LOGGING.md for architecture and API.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken 2026-02-14 08:42:49 -05:00
parent 7d28e83a49
commit 49b2490c25
5 changed files with 1003 additions and 66 deletions

View File

@ -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

View File

@ -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 <game_id> <player_name>")
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())

View File

@ -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

View File

@ -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")

View File

@ -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
# -------------------------------------------------------------------------