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:
317
docs/v2/V2_08_GAME_LOGGING.md
Normal file
317
docs/v2/V2_08_GAME_LOGGING.md
Normal 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
|
||||
Reference in New Issue
Block a user