golfgame/docs/v2/V2_08_GAME_LOGGING.md
adlee-was-taken 49b2490c25 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>
2026-02-14 08:42:49 -05:00

318 lines
9.0 KiB
Markdown

# 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