diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..dee5a9b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +# Production Dockerfile for Golf Card Game +FROM python:3.11-slim as base + +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Install Python dependencies +COPY server/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY server/ ./server/ +COPY client/ ./client/ + +# Create non-root user +RUN useradd --create-home --shell /bin/bash appuser \ + && chown -R appuser:appuser /app +USER appuser + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8000/health || exit 1 + +EXPOSE 8000 + +# Run with uvicorn +CMD ["python", "-m", "uvicorn", "server.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/V2_BUILD_PLAN.md b/V2_BUILD_PLAN.md new file mode 100644 index 0000000..c760349 --- /dev/null +++ b/V2_BUILD_PLAN.md @@ -0,0 +1,522 @@ +# Golf Card Game - V2 Build Plan + +## Vision + +Transform the current single-server Golf game into a production-ready, hostable platform with: +- **Event-sourced architecture** for full game replay and audit trails +- **Leaderboards** with player statistics +- **Scalable hosting** options (self-hosted or cloud) +- **Export/playback** for sharing memorable games + +--- + +## Current State (V1) + +``` +Client (Vanilla JS) ◄──WebSocket──► FastAPI Server ◄──► SQLite + │ + In-memory rooms + (lost on restart) +``` + +**What works well:** +- Game logic is solid and well-tested +- CPU AI with multiple personalities +- House rules system is flexible +- Real-time multiplayer via WebSockets + +**Limitations:** +- Single server, no horizontal scaling +- Game state lost on server restart +- Move logging exists but duplicates state +- No player accounts with persistent stats + +--- + +## V2 Architecture + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Clients │ +│ (Browser / Future: Mobile) │ +└───────────────────────────────┬─────────────────────────────────────┘ + │ WebSocket + REST API + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ FastAPI Application │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌────────────┐ │ +│ │ Command │ │ Event │ │ State │ │ Query │ │ +│ │ Handler │──► Store │──► Builder │ │ Service │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ └────────────┘ │ +└───────┬───────────────────┬───────────────────┬───────────────┬─────┘ + │ │ │ │ + ▼ ▼ ▼ ▼ +┌──────────────┐ ┌──────────────┐ ┌─────────────┐ ┌───────────┐ +│ Redis │ │ PostgreSQL │ │ PostgreSQL │ │ Postgres │ +│ (Live State) │ │ (Events) │ │ (Users) │ │ (Stats) │ +│ (Pub/Sub) │ │ │ │ │ │ │ +└──────────────┘ └──────────────┘ └─────────────┘ └───────────┘ +``` + +--- + +## Data Model + +### Event Store + +All game actions stored as immutable events: + +```sql +-- Core event log +CREATE TABLE events ( + id BIGSERIAL PRIMARY KEY, + game_id UUID NOT NULL, + sequence_num INT NOT NULL, + event_type VARCHAR(50) NOT NULL, + event_data JSONB NOT NULL, + player_id VARCHAR(50), + created_at TIMESTAMPTZ DEFAULT NOW(), + + UNIQUE(game_id, sequence_num) +); + +-- Game metadata (denormalized for queries) +CREATE TABLE games ( + id UUID PRIMARY KEY, + room_code VARCHAR(10), + status VARCHAR(20) DEFAULT 'active', -- active, completed, abandoned + created_at TIMESTAMPTZ DEFAULT NOW(), + completed_at TIMESTAMPTZ, + num_players INT, + num_rounds INT, + options JSONB, + winner_id VARCHAR(50), + + -- Denormalized for leaderboard queries + player_ids VARCHAR(50)[] +); + +CREATE INDEX idx_events_game ON events(game_id, sequence_num); +CREATE INDEX idx_games_status ON games(status, completed_at); +CREATE INDEX idx_games_players ON games USING GIN(player_ids); +``` + +### Event Types + +```python +@dataclass +class GameEvent: + game_id: str + sequence_num: int + event_type: str + player_id: Optional[str] + timestamp: datetime + data: dict + +# Lifecycle events +GameCreated(room_code, options, host_id) +PlayerJoined(player_id, player_name, is_cpu, profile_name?) +PlayerLeft(player_id, reason) +GameStarted(deck_seed, player_order) +RoundStarted(round_num) +RoundEnded(scores: dict, winner_id) +GameEnded(final_scores: dict, winner_id) + +# Gameplay events +InitialCardsFlipped(player_id, positions: list[int]) +CardDrawn(player_id, source: "deck"|"discard", card: Card) +CardSwapped(player_id, position: int, new_card: Card, old_card: Card) +CardDiscarded(player_id, card: Card) +CardFlipped(player_id, position: int, card: Card) +FlipSkipped(player_id) +FlipAsAction(player_id, position: int, card: Card) +``` + +### User & Stats Schema + +```sql +-- User accounts (expand existing auth) +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + username VARCHAR(50) UNIQUE NOT NULL, + email VARCHAR(255) UNIQUE, + password_hash VARCHAR(255), + role VARCHAR(20) DEFAULT 'player', + created_at TIMESTAMPTZ DEFAULT NOW(), + last_seen_at TIMESTAMPTZ, + is_active BOOLEAN DEFAULT true, + preferences JSONB DEFAULT '{}' +); + +-- Player statistics (materialized from events) +CREATE TABLE player_stats ( + user_id UUID PRIMARY KEY REFERENCES users(id), + games_played INT DEFAULT 0, + games_won INT DEFAULT 0, + rounds_played INT DEFAULT 0, + rounds_won INT DEFAULT 0, + total_points INT DEFAULT 0, -- Lower is better + best_round_score INT, + worst_round_score INT, + total_knockouts INT DEFAULT 0, -- Times going out first + total_blunders INT DEFAULT 0, -- From AI analyzer + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Leaderboard views +CREATE VIEW leaderboard_by_wins AS +SELECT + u.username, + s.games_played, + s.games_won, + ROUND(s.games_won::numeric / NULLIF(s.games_played, 0) * 100, 1) as win_rate, + s.rounds_won, + ROUND(s.total_points::numeric / NULLIF(s.rounds_played, 0), 1) as avg_score +FROM player_stats s +JOIN users u ON s.user_id = u.id +WHERE s.games_played >= 25 -- Minimum games for ranking +ORDER BY win_rate DESC, games_won DESC; + +CREATE VIEW leaderboard_by_games AS +SELECT + u.username, + s.games_played, + s.games_won, + s.rounds_won +FROM player_stats s +JOIN users u ON s.user_id = u.id +ORDER BY games_played DESC; +``` + +--- + +## Components to Build + +### Phase 1: Event Infrastructure (Foundation) + +| Component | Description | Effort | +|-----------|-------------|--------| +| Event classes | Python dataclasses for all event types | S | +| Event store | PostgreSQL table + write functions | S | +| State rebuilder | Fold events into GameState | M | +| Dual-write migration | Emit events alongside current mutations | M | +| Event validation | Ensure events can recreate identical state | M | + +### Phase 2: Persistence & Recovery + +| Component | Description | Effort | +|-----------|-------------|--------| +| Redis state cache | Store live game state in Redis | M | +| Pub/sub for multi-server | Redis pub/sub for WebSocket fan-out | M | +| Game recovery | Rebuild in-progress games from events on restart | S | +| Graceful shutdown | Save state before shutdown | S | + +### Phase 3: User System & Stats + +| Component | Description | Effort | +|-----------|-------------|--------| +| User registration flow | Proper signup/login UI | M | +| Guest-to-user conversion | Play as guest, register to save stats | S | +| Stats aggregation worker | Process events → update player_stats | M | +| Leaderboard API | REST endpoints for leaderboards | S | +| Leaderboard UI | Display in client | M | + +### Phase 4: Replay & Export + +| Component | Description | Effort | +|-----------|-------------|--------| +| Export API | `GET /api/games/{id}/export` returns event JSON | S | +| Import/load | Load exported game for replay | S | +| Replay UI | Playback controls, scrubbing, speed control | L | +| Share links | `/replay/{game_id}` public URLs | S | + +### Phase 5: Production Hardening + +| Component | Description | Effort | +|-----------|-------------|--------| +| Rate limiting | Prevent abuse | S | +| Health checks | `/health` with dependency checks | S | +| Metrics | Prometheus metrics for monitoring | M | +| Error tracking | Sentry or similar | S | +| Backup strategy | Automated PostgreSQL backups | S | + +--- + +## Tech Stack + +### Recommended Stack + +| Layer | Technology | Reasoning | +|-------|------------|-----------| +| **Web framework** | FastAPI (keep) | Already using, async, fast | +| **WebSockets** | Starlette (keep) | Built into FastAPI | +| **Live state cache** | Redis | Fast, pub/sub, TTL, battle-tested | +| **Event store** | PostgreSQL | JSONB, robust, great tooling | +| **User database** | PostgreSQL | Same instance, keep it simple | +| **Background jobs** | `arq` or `rq` | Stats aggregation, cleanup | +| **Containerization** | Docker | Consistent deployment | +| **Orchestration** | Docker Compose (small) / K8s (large) | Start simple | + +### Dependencies to Add + +```txt +# requirements.txt additions +redis>=5.0.0 +asyncpg>=0.29.0 # Async PostgreSQL +sqlalchemy>=2.0.0 # ORM (optional, can use raw SQL) +alembic>=1.13.0 # Migrations +arq>=0.26.0 # Background tasks +pydantic-settings>=2.0 # Config management +``` + +--- + +## Hosting Options + +### Option A: Single VPS (Simplest, $5-20/mo) + +``` +┌─────────────────────────────────────┐ +│ VPS (2-4GB RAM) │ +│ ┌─────────┐ ┌─────────┐ ┌───────┐ │ +│ │ FastAPI │ │ Redis │ │Postgres│ │ +│ │ :8000 │ │ :6379 │ │ :5432 │ │ +│ └─────────┘ └─────────┘ └───────┘ │ +│ Docker Compose │ +└─────────────────────────────────────┘ + +Providers: DigitalOcean, Linode, Hetzner, Vultr +Capacity: ~100-500 concurrent users +``` + +**docker-compose.yml:** +```yaml +version: '3.8' +services: + app: + build: . + ports: + - "8000:8000" + environment: + - DATABASE_URL=postgresql://golf:secret@db:5432/golf + - REDIS_URL=redis://redis:6379 + depends_on: + - db + - redis + + redis: + image: redis:7-alpine + volumes: + - redis_data:/data + + db: + image: postgres:16-alpine + environment: + POSTGRES_USER: golf + POSTGRES_PASSWORD: secret + POSTGRES_DB: golf + volumes: + - postgres_data:/var/lib/postgresql/data + + nginx: + image: nginx:alpine + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf + - ./certs:/etc/nginx/certs + +volumes: + redis_data: + postgres_data: +``` + +### Option B: Managed Services ($20-50/mo) + +``` +┌──────────────┐ ┌─────────────────┐ ┌──────────────┐ +│ Fly.io │ │ Upstash Redis │ │ Neon or │ +│ App │◄───►│ (Serverless) │ │ Supabase │ +│ $5-10/mo │ │ Free-$10/mo │ │ PostgreSQL │ +└──────────────┘ └─────────────────┘ │ Free-$25/mo │ + └──────────────┘ + +Alternative compute: Railway, Render, Google Cloud Run +``` + +**Pros:** Less ops, automatic SSL, easy scaling +**Cons:** Slightly higher latency, vendor lock-in + +### Option C: Self-Hosted (Home Server / NAS) + +``` +┌─────────────────────────────────────┐ +│ Home Server / Raspberry Pi 5 │ +│ Docker Compose (same as Option A) │ +└───────────────────┬─────────────────┘ + │ +┌───────────────────▼─────────────────┐ +│ Cloudflare Tunnel (free) │ +│ • No port forwarding needed │ +│ • Free SSL │ +│ • DDoS protection │ +└─────────────────────────────────────┘ + +Domain: golf.yourdomain.com +``` + +### Option D: Kubernetes (Overkill Unless Scaling Big) + +Only if you're expecting 5000+ concurrent users or need multi-region. + +--- + +## Migration Strategy + +### Step 1: Add Event Emission (Non-Breaking) + +Keep current code working, add event logging in parallel: + +```python +# In game.py or main.py +def draw_card(self, player_id: str, source: str) -> Optional[Card]: + # Existing logic + card = self._do_draw(player_id, source) + + if card: + # NEW: Emit event (doesn't affect gameplay) + self.emit_event(CardDrawn( + player_id=player_id, + source=source, + card=card.to_dict() + )) + + return card +``` + +### Step 2: Validate Event Replay + +Build a test that: +1. Plays a game normally +2. Captures all events +3. Replays events into fresh state +4. Asserts final state matches + +```python +def test_event_replay_matches(): + # Play a game, collect events + game, events = play_test_game() + final_state = game.get_state() + + # Rebuild from events + rebuilt = GameState() + for event in events: + rebuilt.apply(event) + + assert rebuilt == final_state +``` + +### Step 3: Switch to Event-Sourced + +Once validation passes: +1. Commands produce events +2. Events applied to state +3. State derived, not mutated directly + +### Step 4: Deploy New Infrastructure + +1. Set up PostgreSQL + Redis +2. Deploy with feature flag (old vs new storage) +3. Run both in parallel, compare +4. Cut over when confident + +--- + +## Milestones & Timeline + +| Phase | Milestone | Dependencies | +|-------|-----------|--------------| +| **1** | Events emitting alongside current code | None | +| **1** | Event replay test passing | Events emitting | +| **2** | Redis state cache working | None | +| **2** | Server survives restart (games recover) | Events + Redis | +| **3** | User accounts with persistent stats | PostgreSQL | +| **3** | Leaderboards displaying | Stats aggregation | +| **4** | Export API working | Events stored | +| **4** | Replay UI functional | Export API | +| **5** | Dockerized deployment | All above | +| **5** | Production deployment | Docker + hosting | + +--- + +## Open Questions + +1. **Guest play vs required accounts?** + - Recommendation: Allow guest play, prompt to register to save stats + +2. **Game history retention?** + - Keep all events forever? Or archive after 90 days? + - Events are small (~500 bytes each), storage is cheap + +3. **Replay visibility?** + - All games public? Only if shared? Privacy setting per game? + +4. **CPU games count for leaderboards?** + - Recommendation: Yes, but flag them. Separate "vs humans" stats later. + +5. **i18n approach?** + - Client-side translation files (JSON) + - Server messages are mostly game state, not text + +--- + +## Appendix: File Structure (Proposed) + +``` +golfgame/ +├── client/ # Frontend (keep as-is for now) +│ ├── index.html +│ ├── app.js +│ └── ... +├── server/ +│ ├── main.py # FastAPI app, WebSocket handlers +│ ├── config.py # Settings (env vars) +│ ├── models/ +│ │ ├── events.py # Event dataclasses +│ │ ├── game_state.py # State rebuilt from events +│ │ └── user.py # User model +│ ├── stores/ +│ │ ├── event_store.py # PostgreSQL event persistence +│ │ ├── state_cache.py # Redis live state +│ │ └── user_store.py # User/auth persistence +│ ├── services/ +│ │ ├── game_service.py # Command handling, event emission +│ │ ├── replay_service.py # Export, import, playback +│ │ ├── stats_service.py # Leaderboard queries +│ │ └── auth_service.py # Authentication +│ ├── workers/ +│ │ └── stats_worker.py # Background stats aggregation +│ ├── ai/ +│ │ ├── profiles.py # CPU personalities +│ │ └── decisions.py # AI logic +│ └── tests/ +│ ├── test_events.py +│ ├── test_replay.py +│ └── ... +├── migrations/ # Alembic migrations +├── docker-compose.yml +├── Dockerfile +└── V2_BUILD_PLAN.md # This file +``` + +--- + +## Next Steps + +1. **Review this plan** - Any adjustments to scope or priorities? +2. **Set up PostgreSQL locally** - For development +3. **Define event classes** - Start with Phase 1 +4. **Add event emission** - Non-breaking change to current code + +Ready to start building when you are. diff --git a/client/admin.css b/client/admin.css new file mode 100644 index 0000000..5833e77 --- /dev/null +++ b/client/admin.css @@ -0,0 +1,633 @@ +/* Golf Admin Dashboard Styles */ + +:root { + --color-primary: #2563eb; + --color-primary-dark: #1d4ed8; + --color-success: #059669; + --color-warning: #d97706; + --color-danger: #dc2626; + --color-bg: #f8fafc; + --color-surface: #ffffff; + --color-border: #e2e8f0; + --color-text: #1e293b; + --color-text-muted: #64748b; + --color-text-light: #94a3b8; + --radius: 8px; + --shadow: 0 1px 3px rgba(0,0,0,0.1), 0 1px 2px rgba(0,0,0,0.06); + --shadow-lg: 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -2px rgba(0,0,0,0.05); +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; + background: var(--color-bg); + color: var(--color-text); + line-height: 1.5; +} + +/* Screens */ +.screen { + min-height: 100vh; +} + +.hidden { + display: none !important; +} + +/* Login Screen */ +.login-container { + max-width: 400px; + margin: 100px auto; + padding: 2rem; + background: var(--color-surface); + border-radius: var(--radius); + box-shadow: var(--shadow-lg); +} + +.login-container h1 { + text-align: center; + margin-bottom: 1.5rem; + color: var(--color-primary); +} + +/* Forms */ +.form-group { + margin-bottom: 1rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; + color: var(--color-text); +} + +.form-group input, +.form-group textarea, +.form-group select { + width: 100%; + padding: 0.75rem; + border: 1px solid var(--color-border); + border-radius: var(--radius); + font-size: 1rem; + transition: border-color 0.2s; +} + +.form-group input:focus, +.form-group textarea:focus, +.form-group select:focus { + outline: none; + border-color: var(--color-primary); +} + +.form-group textarea { + min-height: 100px; + resize: vertical; +} + +.form-actions { + display: flex; + gap: 1rem; + justify-content: flex-end; + margin-top: 1.5rem; +} + +.error { + color: var(--color-danger); + margin-top: 1rem; + text-align: center; +} + +/* Buttons */ +.btn { + padding: 0.75rem 1.5rem; + border: none; + border-radius: var(--radius); + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s, transform 0.1s; + background: var(--color-border); + color: var(--color-text); +} + +.btn:hover { + filter: brightness(0.95); +} + +.btn:active { + transform: scale(0.98); +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-primary { + background: var(--color-primary); + color: white; +} + +.btn-primary:hover { + background: var(--color-primary-dark); +} + +.btn-success { + background: var(--color-success); + color: white; +} + +.btn-warning { + background: var(--color-warning); + color: white; +} + +.btn-danger { + background: var(--color-danger); + color: white; +} + +.btn-small { + padding: 0.5rem 1rem; + font-size: 0.75rem; +} + +/* Navigation */ +.admin-nav { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 2rem; + background: var(--color-surface); + border-bottom: 1px solid var(--color-border); + box-shadow: var(--shadow); +} + +.nav-brand h1 { + font-size: 1.5rem; + color: var(--color-primary); +} + +.nav-links { + display: flex; + gap: 0.5rem; +} + +.nav-link { + padding: 0.5rem 1rem; + text-decoration: none; + color: var(--color-text-muted); + border-radius: var(--radius); + transition: background-color 0.2s, color 0.2s; +} + +.nav-link:hover { + background: var(--color-bg); + color: var(--color-text); +} + +.nav-link.active { + background: var(--color-primary); + color: white; +} + +.nav-user { + display: flex; + align-items: center; + gap: 1rem; + color: var(--color-text-muted); +} + +/* Content */ +.admin-content { + max-width: 1400px; + margin: 0 auto; + padding: 2rem; +} + +/* Panels */ +.panel { + background: var(--color-surface); + border-radius: var(--radius); + padding: 1.5rem; + box-shadow: var(--shadow); +} + +.panel h2 { + margin-bottom: 1.5rem; + color: var(--color-text); +} + +.panel-section { + margin-top: 2rem; +} + +.panel-section h3 { + margin-bottom: 1rem; + color: var(--color-text); +} + +.panel-toolbar { + display: flex; + gap: 1rem; + align-items: center; + margin-bottom: 1rem; + flex-wrap: wrap; +} + +/* Stats Grid */ +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 1rem; + margin-bottom: 2rem; +} + +.stat-card { + background: var(--color-bg); + padding: 1.25rem; + border-radius: var(--radius); + text-align: center; +} + +.stat-value { + display: block; + font-size: 2rem; + font-weight: 700; + color: var(--color-primary); +} + +.stat-label { + display: block; + font-size: 0.875rem; + color: var(--color-text-muted); + margin-top: 0.25rem; +} + +/* Data Tables */ +.data-table { + width: 100%; + border-collapse: collapse; +} + +.data-table th, +.data-table td { + padding: 0.75rem; + text-align: left; + border-bottom: 1px solid var(--color-border); +} + +.data-table th { + background: var(--color-bg); + font-weight: 600; + color: var(--color-text-muted); + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.data-table tbody tr:hover { + background: var(--color-bg); +} + +.data-table.small { + font-size: 0.875rem; +} + +.data-table.small th, +.data-table.small td { + padding: 0.5rem; +} + +/* Search Bar */ +.search-bar { + display: flex; + gap: 0.5rem; +} + +.search-bar input { + padding: 0.5rem 1rem; + border: 1px solid var(--color-border); + border-radius: var(--radius); + min-width: 250px; +} + +.search-bar input:focus { + outline: none; + border-color: var(--color-primary); +} + +/* Filter Bar */ +.filter-bar { + display: flex; + gap: 0.5rem; +} + +.filter-bar select { + padding: 0.5rem 1rem; + border: 1px solid var(--color-border); + border-radius: var(--radius); + background: white; +} + +/* Checkbox */ +.checkbox-label { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + color: var(--color-text-muted); + font-size: 0.875rem; +} + +/* Create Invite Form */ +.create-invite-form { + display: flex; + gap: 1rem; + align-items: center; +} + +.create-invite-form label { + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--color-text-muted); + font-size: 0.875rem; +} + +.create-invite-form input { + width: 80px; + padding: 0.5rem; + border: 1px solid var(--color-border); + border-radius: var(--radius); +} + +/* Pagination */ +.pagination { + display: flex; + justify-content: center; + align-items: center; + gap: 1rem; + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--color-border); +} + +/* Status Badges */ +.badge { + display: inline-block; + padding: 0.25rem 0.5rem; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 500; +} + +.badge-success { + background: #dcfce7; + color: #166534; +} + +.badge-danger { + background: #fee2e2; + color: #991b1b; +} + +.badge-warning { + background: #fef3c7; + color: #92400e; +} + +.badge-info { + background: #dbeafe; + color: #1e40af; +} + +.badge-muted { + background: #f1f5f9; + color: #64748b; +} + +/* Modals */ +.modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.modal-content { + background: var(--color-surface); + border-radius: var(--radius); + max-width: 600px; + width: 90%; + max-height: 90vh; + overflow-y: auto; + box-shadow: var(--shadow-lg); +} + +.modal-content.modal-small { + max-width: 400px; +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 1.5rem; + border-bottom: 1px solid var(--color-border); +} + +.modal-header h3 { + margin: 0; +} + +.modal-close { + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: var(--color-text-muted); + padding: 0; + line-height: 1; +} + +.modal-close:hover { + color: var(--color-text); +} + +.modal-body { + padding: 1.5rem; +} + +/* User Detail Modal */ +.user-detail-grid { + display: grid; + gap: 0.75rem; +} + +.detail-row { + display: flex; + gap: 1rem; +} + +.detail-label { + font-weight: 500; + color: var(--color-text-muted); + min-width: 120px; +} + +.detail-value { + color: var(--color-text); +} + +.user-actions { + margin-top: 1.5rem; + padding-top: 1.5rem; + border-top: 1px solid var(--color-border); +} + +.user-actions h4 { + margin-bottom: 1rem; +} + +.action-buttons { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +#ban-history-section { + margin-top: 1.5rem; + padding-top: 1.5rem; + border-top: 1px solid var(--color-border); +} + +#ban-history-section h4 { + margin-bottom: 1rem; +} + +/* Toast Notifications */ +#toast-container { + position: fixed; + bottom: 2rem; + right: 2rem; + display: flex; + flex-direction: column; + gap: 0.5rem; + z-index: 2000; +} + +.toast { + padding: 1rem 1.5rem; + border-radius: var(--radius); + background: var(--color-text); + color: white; + box-shadow: var(--shadow-lg); + animation: slideIn 0.3s ease; +} + +.toast.success { + background: var(--color-success); +} + +.toast.error { + background: var(--color-danger); +} + +.toast.warning { + background: var(--color-warning); +} + +@keyframes slideIn { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +/* Responsive */ +@media (max-width: 768px) { + .admin-nav { + flex-direction: column; + gap: 1rem; + } + + .nav-links { + flex-wrap: wrap; + justify-content: center; + } + + .admin-content { + padding: 1rem; + } + + .panel-toolbar { + flex-direction: column; + align-items: stretch; + } + + .search-bar { + flex-direction: column; + } + + .search-bar input { + min-width: auto; + width: 100%; + } + + .create-invite-form { + flex-direction: column; + align-items: stretch; + } + + .data-table { + font-size: 0.875rem; + } + + .data-table th, + .data-table td { + padding: 0.5rem; + } +} + +/* Utility Classes */ +.text-muted { + color: var(--color-text-muted); +} + +.text-success { + color: var(--color-success); +} + +.text-danger { + color: var(--color-danger); +} + +.text-warning { + color: var(--color-warning); +} + +.text-small { + font-size: 0.875rem; +} + +.mt-1 { margin-top: 0.5rem; } +.mt-2 { margin-top: 1rem; } +.mb-1 { margin-bottom: 0.5rem; } +.mb-2 { margin-bottom: 1rem; } diff --git a/client/admin.html b/client/admin.html new file mode 100644 index 0000000..154a0ba --- /dev/null +++ b/client/admin.html @@ -0,0 +1,368 @@ + + + + + + Golf Admin Dashboard + + + + +
+
+

Golf Admin

+
+
+ + +
+
+ + +
+ +

+
+
+
+ + + + + + + + + + + + + + +
+ + + + diff --git a/client/admin.js b/client/admin.js new file mode 100644 index 0000000..766057d --- /dev/null +++ b/client/admin.js @@ -0,0 +1,809 @@ +/** + * Golf Admin Dashboard + * JavaScript for admin interface functionality + */ + +// State +let authToken = null; +let currentUser = null; +let currentPanel = 'dashboard'; +let selectedUserId = null; + +// Pagination state +let usersPage = 0; +let auditPage = 0; +const PAGE_SIZE = 20; + +// ============================================================================= +// API Functions +// ============================================================================= + +async function apiRequest(endpoint, options = {}) { + const headers = { + 'Content-Type': 'application/json', + ...options.headers, + }; + + if (authToken) { + headers['Authorization'] = `Bearer ${authToken}`; + } + + const response = await fetch(endpoint, { + ...options, + headers, + }); + + if (response.status === 401) { + // Unauthorized - clear auth and show login + logout(); + throw new Error('Session expired. Please login again.'); + } + + if (response.status === 403) { + throw new Error('Admin access required'); + } + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.detail || 'Request failed'); + } + + return data; +} + +// Auth API +async function login(username, password) { + const data = await apiRequest('/api/auth/login', { + method: 'POST', + body: JSON.stringify({ username, password }), + }); + return data; +} + +// Admin API +async function getStats() { + return apiRequest('/api/admin/stats'); +} + +async function getUsers(query = '', offset = 0, includeBanned = true) { + const params = new URLSearchParams({ + query, + offset, + limit: PAGE_SIZE, + include_banned: includeBanned, + }); + return apiRequest(`/api/admin/users?${params}`); +} + +async function getUser(userId) { + return apiRequest(`/api/admin/users/${userId}`); +} + +async function getUserBanHistory(userId) { + return apiRequest(`/api/admin/users/${userId}/ban-history`); +} + +async function banUser(userId, reason, durationDays) { + return apiRequest(`/api/admin/users/${userId}/ban`, { + method: 'POST', + body: JSON.stringify({ + reason, + duration_days: durationDays || null, + }), + }); +} + +async function unbanUser(userId) { + return apiRequest(`/api/admin/users/${userId}/unban`, { + method: 'POST', + }); +} + +async function forcePasswordReset(userId) { + return apiRequest(`/api/admin/users/${userId}/force-password-reset`, { + method: 'POST', + }); +} + +async function changeUserRole(userId, role) { + return apiRequest(`/api/admin/users/${userId}/role`, { + method: 'PUT', + body: JSON.stringify({ role }), + }); +} + +async function impersonateUser(userId) { + return apiRequest(`/api/admin/users/${userId}/impersonate`, { + method: 'POST', + }); +} + +async function getGames() { + return apiRequest('/api/admin/games'); +} + +async function getGameDetails(gameId) { + return apiRequest(`/api/admin/games/${gameId}`); +} + +async function endGame(gameId, reason) { + return apiRequest(`/api/admin/games/${gameId}/end`, { + method: 'POST', + body: JSON.stringify({ reason }), + }); +} + +async function getInvites(includeExpired = false) { + const params = new URLSearchParams({ include_expired: includeExpired }); + return apiRequest(`/api/admin/invites?${params}`); +} + +async function createInvite(maxUses, expiresDays) { + return apiRequest('/api/admin/invites', { + method: 'POST', + body: JSON.stringify({ + max_uses: maxUses, + expires_days: expiresDays, + }), + }); +} + +async function revokeInvite(code) { + return apiRequest(`/api/admin/invites/${code}`, { + method: 'DELETE', + }); +} + +async function getAuditLog(offset = 0, action = '', targetType = '') { + const params = new URLSearchParams({ + offset, + limit: PAGE_SIZE, + }); + if (action) params.append('action', action); + if (targetType) params.append('target_type', targetType); + return apiRequest(`/api/admin/audit?${params}`); +} + +// ============================================================================= +// UI Functions +// ============================================================================= + +function showScreen(screenId) { + document.querySelectorAll('.screen').forEach(s => s.classList.add('hidden')); + document.getElementById(screenId).classList.remove('hidden'); +} + +function showPanel(panelId) { + currentPanel = panelId; + document.querySelectorAll('.panel').forEach(p => p.classList.add('hidden')); + document.getElementById(`${panelId}-panel`).classList.remove('hidden'); + + document.querySelectorAll('.nav-link').forEach(link => { + link.classList.toggle('active', link.dataset.panel === panelId); + }); + + // Load panel data + switch (panelId) { + case 'dashboard': + loadDashboard(); + break; + case 'users': + loadUsers(); + break; + case 'games': + loadGames(); + break; + case 'invites': + loadInvites(); + break; + case 'audit': + loadAuditLog(); + break; + } +} + +function showModal(modalId) { + document.getElementById(modalId).classList.remove('hidden'); +} + +function hideModal(modalId) { + document.getElementById(modalId).classList.add('hidden'); +} + +function hideAllModals() { + document.querySelectorAll('.modal').forEach(m => m.classList.add('hidden')); +} + +function showToast(message, type = 'info') { + const container = document.getElementById('toast-container'); + const toast = document.createElement('div'); + toast.className = `toast ${type}`; + toast.textContent = message; + container.appendChild(toast); + + setTimeout(() => { + toast.remove(); + }, 4000); +} + +function formatDate(isoString) { + if (!isoString) return '-'; + const date = new Date(isoString); + return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); +} + +function formatDateShort(isoString) { + if (!isoString) return '-'; + const date = new Date(isoString); + return date.toLocaleDateString(); +} + +function getStatusBadge(user) { + if (user.is_banned) { + return 'Banned'; + } + if (!user.is_active) { + return 'Inactive'; + } + if (user.force_password_reset) { + return 'Reset Required'; + } + if (!user.email_verified && user.email) { + return 'Unverified'; + } + return 'Active'; +} + +// ============================================================================= +// Data Loading +// ============================================================================= + +async function loadDashboard() { + try { + const stats = await getStats(); + + document.getElementById('stat-active-users').textContent = stats.active_users_now; + document.getElementById('stat-active-games').textContent = stats.active_games_now; + document.getElementById('stat-total-users').textContent = stats.total_users; + document.getElementById('stat-games-today').textContent = stats.games_today; + document.getElementById('stat-reg-today').textContent = stats.registrations_today; + document.getElementById('stat-reg-week').textContent = stats.registrations_week; + document.getElementById('stat-total-games').textContent = stats.total_games_completed; + document.getElementById('stat-events-hour').textContent = stats.events_last_hour; + + // Top players table + const tbody = document.querySelector('#top-players-table tbody'); + tbody.innerHTML = ''; + stats.top_players.forEach((player, index) => { + const winRate = player.games_played > 0 + ? Math.round((player.games_won / player.games_played) * 100) + : 0; + tbody.innerHTML += ` + + ${index + 1} + ${escapeHtml(player.username)} + ${player.games_won} + ${player.games_played} + ${winRate}% + + `; + }); + + if (stats.top_players.length === 0) { + tbody.innerHTML = 'No players yet'; + } + } catch (error) { + showToast('Failed to load dashboard: ' + error.message, 'error'); + } +} + +async function loadUsers() { + try { + const query = document.getElementById('user-search').value; + const includeBanned = document.getElementById('include-banned').checked; + const data = await getUsers(query, usersPage * PAGE_SIZE, includeBanned); + + const tbody = document.querySelector('#users-table tbody'); + tbody.innerHTML = ''; + + data.users.forEach(user => { + tbody.innerHTML += ` + + ${escapeHtml(user.username)} + ${escapeHtml(user.email || '-')} + ${user.role} + ${getStatusBadge(user)} + ${user.games_played} (${user.games_won} wins) + ${formatDateShort(user.created_at)} + + + + + `; + }); + + if (data.users.length === 0) { + tbody.innerHTML = 'No users found'; + } + + // Update pagination + document.getElementById('users-page-info').textContent = `Page ${usersPage + 1}`; + document.getElementById('users-prev').disabled = usersPage === 0; + document.getElementById('users-next').disabled = data.users.length < PAGE_SIZE; + } catch (error) { + showToast('Failed to load users: ' + error.message, 'error'); + } +} + +async function viewUser(userId) { + try { + selectedUserId = userId; + const user = await getUser(userId); + const history = await getUserBanHistory(userId); + + // Populate details + document.getElementById('detail-username').textContent = user.username; + document.getElementById('detail-email').textContent = user.email || '-'; + document.getElementById('detail-role').textContent = user.role; + document.getElementById('detail-status').innerHTML = getStatusBadge(user); + document.getElementById('detail-games-played').textContent = user.games_played; + document.getElementById('detail-games-won').textContent = user.games_won; + document.getElementById('detail-joined').textContent = formatDate(user.created_at); + document.getElementById('detail-last-login').textContent = formatDate(user.last_login); + + // Update action buttons visibility + document.getElementById('action-ban').classList.toggle('hidden', user.is_banned); + document.getElementById('action-unban').classList.toggle('hidden', !user.is_banned); + document.getElementById('action-make-admin').classList.toggle('hidden', user.role === 'admin'); + document.getElementById('action-remove-admin').classList.toggle('hidden', user.role !== 'admin'); + + // Ban history + const historyBody = document.querySelector('#ban-history-table tbody'); + historyBody.innerHTML = ''; + history.history.forEach(ban => { + const status = ban.unbanned_at + ? `Unbanned` + : (ban.expires_at && new Date(ban.expires_at) < new Date() + ? `Expired` + : `Active`); + historyBody.innerHTML += ` + + ${formatDateShort(ban.banned_at)} + ${escapeHtml(ban.reason || '-')} + ${escapeHtml(ban.banned_by)} + ${status} + + `; + }); + + if (history.history.length === 0) { + historyBody.innerHTML = 'No ban history'; + } + + showModal('user-modal'); + } catch (error) { + showToast('Failed to load user: ' + error.message, 'error'); + } +} + +async function loadGames() { + try { + const data = await getGames(); + + const tbody = document.querySelector('#games-table tbody'); + tbody.innerHTML = ''; + + data.games.forEach(game => { + tbody.innerHTML += ` + + ${escapeHtml(game.room_code)} + ${game.player_count} + ${game.phase || game.status || '-'} + ${game.current_round || '-'} + ${game.status} + ${formatDate(game.created_at)} + + + + + `; + }); + + if (data.games.length === 0) { + tbody.innerHTML = 'No active games'; + } + } catch (error) { + showToast('Failed to load games: ' + error.message, 'error'); + } +} + +let selectedGameId = null; + +function promptEndGame(gameId) { + selectedGameId = gameId; + document.getElementById('end-game-reason').value = ''; + showModal('end-game-modal'); +} + +async function loadInvites() { + try { + const includeExpired = document.getElementById('include-expired').checked; + const data = await getInvites(includeExpired); + + const tbody = document.querySelector('#invites-table tbody'); + tbody.innerHTML = ''; + + data.codes.forEach(invite => { + const isExpired = new Date(invite.expires_at) < new Date(); + const status = !invite.is_active + ? 'Revoked' + : isExpired + ? 'Expired' + : invite.remaining_uses <= 0 + ? 'Used Up' + : 'Active'; + + tbody.innerHTML += ` + + ${escapeHtml(invite.code)} + ${invite.use_count} / ${invite.max_uses} + ${invite.remaining_uses} + ${escapeHtml(invite.created_by_username)} + ${formatDate(invite.expires_at)} + ${status} + + ${invite.is_active && !isExpired && invite.remaining_uses > 0 + ? `` + : '-' + } + + + `; + }); + + if (data.codes.length === 0) { + tbody.innerHTML = 'No invite codes'; + } + } catch (error) { + showToast('Failed to load invites: ' + error.message, 'error'); + } +} + +async function loadAuditLog() { + try { + const action = document.getElementById('audit-action-filter').value; + const targetType = document.getElementById('audit-target-filter').value; + const data = await getAuditLog(auditPage * PAGE_SIZE, action, targetType); + + const tbody = document.querySelector('#audit-table tbody'); + tbody.innerHTML = ''; + + data.entries.forEach(entry => { + const details = Object.keys(entry.details).length > 0 + ? `${escapeHtml(JSON.stringify(entry.details))}` + : '-'; + + tbody.innerHTML += ` + + ${formatDate(entry.created_at)} + ${escapeHtml(entry.admin_username)} + ${entry.action} + ${entry.target_type ? `${entry.target_type}: ${entry.target_id || '-'}` : '-'} + ${details} + ${entry.ip_address || '-'} + + `; + }); + + if (data.entries.length === 0) { + tbody.innerHTML = 'No audit entries'; + } + + // Update pagination + document.getElementById('audit-page-info').textContent = `Page ${auditPage + 1}`; + document.getElementById('audit-prev').disabled = auditPage === 0; + document.getElementById('audit-next').disabled = data.entries.length < PAGE_SIZE; + } catch (error) { + showToast('Failed to load audit log: ' + error.message, 'error'); + } +} + +// ============================================================================= +// Actions +// ============================================================================= + +async function handleBanUser(event) { + event.preventDefault(); + + const reason = document.getElementById('ban-reason').value; + const duration = document.getElementById('ban-duration').value; + + try { + await banUser(selectedUserId, reason, duration ? parseInt(duration) : null); + showToast('User banned successfully', 'success'); + hideAllModals(); + loadUsers(); + } catch (error) { + showToast('Failed to ban user: ' + error.message, 'error'); + } +} + +async function handleUnbanUser() { + if (!confirm('Are you sure you want to unban this user?')) return; + + try { + await unbanUser(selectedUserId); + showToast('User unbanned successfully', 'success'); + hideAllModals(); + loadUsers(); + } catch (error) { + showToast('Failed to unban user: ' + error.message, 'error'); + } +} + +async function handleForcePasswordReset() { + if (!confirm('Are you sure you want to force a password reset for this user? They will be logged out.')) return; + + try { + await forcePasswordReset(selectedUserId); + showToast('Password reset required for user', 'success'); + hideAllModals(); + loadUsers(); + } catch (error) { + showToast('Failed to force password reset: ' + error.message, 'error'); + } +} + +async function handleMakeAdmin() { + if (!confirm('Are you sure you want to make this user an admin?')) return; + + try { + await changeUserRole(selectedUserId, 'admin'); + showToast('User is now an admin', 'success'); + hideAllModals(); + loadUsers(); + } catch (error) { + showToast('Failed to change role: ' + error.message, 'error'); + } +} + +async function handleRemoveAdmin() { + if (!confirm('Are you sure you want to remove admin privileges from this user?')) return; + + try { + await changeUserRole(selectedUserId, 'user'); + showToast('Admin privileges removed', 'success'); + hideAllModals(); + loadUsers(); + } catch (error) { + showToast('Failed to change role: ' + error.message, 'error'); + } +} + +async function handleImpersonate() { + try { + const data = await impersonateUser(selectedUserId); + showToast(`Viewing as ${data.user.username} (read-only). Check console for details.`, 'success'); + console.log('Impersonation data:', data); + } catch (error) { + showToast('Failed to impersonate: ' + error.message, 'error'); + } +} + +async function handleEndGame(event) { + event.preventDefault(); + + const reason = document.getElementById('end-game-reason').value; + + try { + await endGame(selectedGameId, reason); + showToast('Game ended successfully', 'success'); + hideAllModals(); + loadGames(); + } catch (error) { + showToast('Failed to end game: ' + error.message, 'error'); + } +} + +async function handleCreateInvite() { + const maxUses = parseInt(document.getElementById('invite-max-uses').value) || 1; + const expiresDays = parseInt(document.getElementById('invite-expires-days').value) || 7; + + try { + const data = await createInvite(maxUses, expiresDays); + showToast(`Invite code created: ${data.code}`, 'success'); + loadInvites(); + } catch (error) { + showToast('Failed to create invite: ' + error.message, 'error'); + } +} + +async function promptRevokeInvite(code) { + if (!confirm(`Are you sure you want to revoke invite code ${code}?`)) return; + + try { + await revokeInvite(code); + showToast('Invite code revoked', 'success'); + loadInvites(); + } catch (error) { + showToast('Failed to revoke invite: ' + error.message, 'error'); + } +} + +// ============================================================================= +// Auth +// ============================================================================= + +async function handleLogin(event) { + event.preventDefault(); + + const username = document.getElementById('username').value; + const password = document.getElementById('password').value; + const errorEl = document.getElementById('login-error'); + + try { + const data = await login(username, password); + + // Check if user is admin + if (data.user.role !== 'admin') { + errorEl.textContent = 'Admin access required'; + return; + } + + // Store auth + authToken = data.token; + currentUser = data.user; + localStorage.setItem('adminToken', data.token); + localStorage.setItem('adminUser', JSON.stringify(data.user)); + + // Show dashboard + document.getElementById('admin-username').textContent = currentUser.username; + showScreen('dashboard-screen'); + showPanel('dashboard'); + } catch (error) { + errorEl.textContent = error.message; + } +} + +function logout() { + authToken = null; + currentUser = null; + localStorage.removeItem('adminToken'); + localStorage.removeItem('adminUser'); + showScreen('login-screen'); +} + +function checkAuth() { + const savedToken = localStorage.getItem('adminToken'); + const savedUser = localStorage.getItem('adminUser'); + + if (savedToken && savedUser) { + authToken = savedToken; + currentUser = JSON.parse(savedUser); + + if (currentUser.role === 'admin') { + document.getElementById('admin-username').textContent = currentUser.username; + showScreen('dashboard-screen'); + showPanel('dashboard'); + return; + } + } + + showScreen('login-screen'); +} + +// ============================================================================= +// Utilities +// ============================================================================= + +function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +// ============================================================================= +// Event Listeners +// ============================================================================= + +document.addEventListener('DOMContentLoaded', () => { + // Login form + document.getElementById('login-form').addEventListener('submit', handleLogin); + + // Logout button + document.getElementById('logout-btn').addEventListener('click', logout); + + // Navigation + document.querySelectorAll('.nav-link').forEach(link => { + link.addEventListener('click', (e) => { + e.preventDefault(); + showPanel(link.dataset.panel); + }); + }); + + // Users panel + document.getElementById('user-search-btn').addEventListener('click', () => { + usersPage = 0; + loadUsers(); + }); + document.getElementById('user-search').addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + usersPage = 0; + loadUsers(); + } + }); + document.getElementById('include-banned').addEventListener('change', () => { + usersPage = 0; + loadUsers(); + }); + document.getElementById('users-prev').addEventListener('click', () => { + if (usersPage > 0) { + usersPage--; + loadUsers(); + } + }); + document.getElementById('users-next').addEventListener('click', () => { + usersPage++; + loadUsers(); + }); + + // User modal actions + document.getElementById('action-ban').addEventListener('click', () => { + document.getElementById('ban-reason').value = ''; + document.getElementById('ban-duration').value = ''; + showModal('ban-modal'); + }); + document.getElementById('action-unban').addEventListener('click', handleUnbanUser); + document.getElementById('action-reset-pw').addEventListener('click', handleForcePasswordReset); + document.getElementById('action-make-admin').addEventListener('click', handleMakeAdmin); + document.getElementById('action-remove-admin').addEventListener('click', handleRemoveAdmin); + document.getElementById('action-impersonate').addEventListener('click', handleImpersonate); + + // Ban form + document.getElementById('ban-form').addEventListener('submit', handleBanUser); + + // Games panel + document.getElementById('refresh-games-btn').addEventListener('click', loadGames); + + // End game form + document.getElementById('end-game-form').addEventListener('submit', handleEndGame); + + // Invites panel + document.getElementById('create-invite-btn').addEventListener('click', handleCreateInvite); + document.getElementById('include-expired').addEventListener('change', loadInvites); + + // Audit panel + document.getElementById('audit-filter-btn').addEventListener('click', () => { + auditPage = 0; + loadAuditLog(); + }); + document.getElementById('audit-prev').addEventListener('click', () => { + if (auditPage > 0) { + auditPage--; + loadAuditLog(); + } + }); + document.getElementById('audit-next').addEventListener('click', () => { + auditPage++; + loadAuditLog(); + }); + + // Modal close buttons + document.querySelectorAll('.modal-close').forEach(btn => { + btn.addEventListener('click', hideAllModals); + }); + + // Close modal on overlay click + document.querySelectorAll('.modal').forEach(modal => { + modal.addEventListener('click', (e) => { + if (e.target === modal) { + hideAllModals(); + } + }); + }); + + // Check auth on load + checkAuth(); +}); diff --git a/client/app.js b/client/app.js index 1380840..3d91997 100644 --- a/client/app.js +++ b/client/app.js @@ -2257,4 +2257,197 @@ class GolfGame { // Initialize game when page loads document.addEventListener('DOMContentLoaded', () => { window.game = new GolfGame(); + window.auth = new AuthManager(window.game); }); + + +// =========================================== +// AUTH MANAGER +// =========================================== + +class AuthManager { + constructor(game) { + this.game = game; + this.token = localStorage.getItem('authToken'); + this.user = JSON.parse(localStorage.getItem('authUser') || 'null'); + + this.initElements(); + this.bindEvents(); + this.updateUI(); + } + + initElements() { + this.authBar = document.getElementById('auth-bar'); + this.authUsername = document.getElementById('auth-username'); + this.logoutBtn = document.getElementById('auth-logout-btn'); + this.authButtons = document.getElementById('auth-buttons'); + this.loginBtn = document.getElementById('login-btn'); + this.signupBtn = document.getElementById('signup-btn'); + this.modal = document.getElementById('auth-modal'); + this.modalClose = document.getElementById('auth-modal-close'); + this.loginFormContainer = document.getElementById('login-form-container'); + this.loginForm = document.getElementById('login-form'); + this.loginUsername = document.getElementById('login-username'); + this.loginPassword = document.getElementById('login-password'); + this.loginError = document.getElementById('login-error'); + this.signupFormContainer = document.getElementById('signup-form-container'); + this.signupForm = document.getElementById('signup-form'); + this.signupUsername = document.getElementById('signup-username'); + this.signupEmail = document.getElementById('signup-email'); + this.signupPassword = document.getElementById('signup-password'); + this.signupError = document.getElementById('signup-error'); + this.showSignupLink = document.getElementById('show-signup'); + this.showLoginLink = document.getElementById('show-login'); + } + + bindEvents() { + this.loginBtn?.addEventListener('click', () => this.showModal('login')); + this.signupBtn?.addEventListener('click', () => this.showModal('signup')); + this.modalClose?.addEventListener('click', () => this.hideModal()); + this.modal?.addEventListener('click', (e) => { + if (e.target === this.modal) this.hideModal(); + }); + this.showSignupLink?.addEventListener('click', (e) => { + e.preventDefault(); + this.showForm('signup'); + }); + this.showLoginLink?.addEventListener('click', (e) => { + e.preventDefault(); + this.showForm('login'); + }); + this.loginForm?.addEventListener('submit', (e) => this.handleLogin(e)); + this.signupForm?.addEventListener('submit', (e) => this.handleSignup(e)); + this.logoutBtn?.addEventListener('click', () => this.logout()); + } + + showModal(form = 'login') { + this.modal.classList.remove('hidden'); + this.showForm(form); + this.clearErrors(); + } + + hideModal() { + this.modal.classList.add('hidden'); + this.clearForms(); + } + + showForm(form) { + if (form === 'login') { + this.loginFormContainer.classList.remove('hidden'); + this.signupFormContainer.classList.add('hidden'); + this.loginUsername.focus(); + } else { + this.loginFormContainer.classList.add('hidden'); + this.signupFormContainer.classList.remove('hidden'); + this.signupUsername.focus(); + } + } + + clearForms() { + this.loginForm.reset(); + this.signupForm.reset(); + this.clearErrors(); + } + + clearErrors() { + this.loginError.textContent = ''; + this.signupError.textContent = ''; + } + + async handleLogin(e) { + e.preventDefault(); + this.clearErrors(); + + const username = this.loginUsername.value.trim(); + const password = this.loginPassword.value; + + try { + const response = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }); + + const data = await response.json(); + + if (!response.ok) { + this.loginError.textContent = data.detail || 'Login failed'; + return; + } + + this.setAuth(data.token, data.user); + this.hideModal(); + + if (data.user.username && this.game.playerNameInput) { + this.game.playerNameInput.value = data.user.username; + } + } catch (err) { + this.loginError.textContent = 'Connection error'; + } + } + + async handleSignup(e) { + e.preventDefault(); + this.clearErrors(); + + const username = this.signupUsername.value.trim(); + const email = this.signupEmail.value.trim() || null; + const password = this.signupPassword.value; + + try { + const response = await fetch('/api/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, email, password }), + }); + + const data = await response.json(); + + if (!response.ok) { + this.signupError.textContent = data.detail || 'Signup failed'; + return; + } + + this.setAuth(data.token, data.user); + this.hideModal(); + + if (data.user.username && this.game.playerNameInput) { + this.game.playerNameInput.value = data.user.username; + } + } catch (err) { + this.signupError.textContent = 'Connection error'; + } + } + + setAuth(token, user) { + this.token = token; + this.user = user; + localStorage.setItem('authToken', token); + localStorage.setItem('authUser', JSON.stringify(user)); + this.updateUI(); + } + + logout() { + this.token = null; + this.user = null; + localStorage.removeItem('authToken'); + localStorage.removeItem('authUser'); + this.updateUI(); + } + + updateUI() { + if (this.user) { + this.authBar?.classList.remove('hidden'); + this.authButtons?.classList.add('hidden'); + if (this.authUsername) { + this.authUsername.textContent = this.user.username; + } + if (this.game.playerNameInput && !this.game.playerNameInput.value) { + this.game.playerNameInput.value = this.user.username; + } + } else { + this.authBar?.classList.add('hidden'); + this.authButtons?.classList.remove('hidden'); + } + } +} diff --git a/client/index.html b/client/index.html index 8cfb1da..9b685c7 100644 --- a/client/index.html +++ b/client/index.html @@ -8,10 +8,22 @@
+ + +

🏌️ Golf

-

6-Card Golf Card Game

+

6-Card Golf Card Game

+ + +
+ + +
@@ -637,6 +649,83 @@ TOTAL: 0 + 8 + 16 = 24 points
+ + +
+
+ + +
+

Leaderboard

+

Top players ranked by performance

+
+ +
+ + + + + +
+ +
+
Loading...
+
+
+
+ + +
+
+

Game Replay

+
+
+ +
+ +
+ +
+ +
+ + + + + + +
+ + 0 / 0 +
+ +
+ + +
+
+ +
+ + + +
+
+
+ + + @@ -651,9 +740,53 @@ TOTAL: 0 + 8 + 16 = 24 points + + + + + diff --git a/client/leaderboard.js b/client/leaderboard.js new file mode 100644 index 0000000..1dd18bc --- /dev/null +++ b/client/leaderboard.js @@ -0,0 +1,314 @@ +/** + * Leaderboard component for Golf game. + * Handles leaderboard display, metric switching, and player stats modal. + */ + +class LeaderboardComponent { + constructor() { + this.currentMetric = 'wins'; + this.cache = new Map(); + this.cacheTimeout = 60000; // 1 minute cache + + this.elements = { + screen: document.getElementById('leaderboard-screen'), + backBtn: document.getElementById('leaderboard-back-btn'), + openBtn: document.getElementById('leaderboard-btn'), + tabs: document.getElementById('leaderboard-tabs'), + content: document.getElementById('leaderboard-content'), + statsModal: document.getElementById('player-stats-modal'), + statsContent: document.getElementById('player-stats-content'), + statsClose: document.getElementById('player-stats-close'), + }; + + this.metricLabels = { + wins: 'Total Wins', + win_rate: 'Win Rate', + avg_score: 'Avg Score', + knockouts: 'Knockouts', + streak: 'Best Streak', + }; + + this.metricFormats = { + wins: (v) => v.toLocaleString(), + win_rate: (v) => `${v.toFixed(1)}%`, + avg_score: (v) => v.toFixed(1), + knockouts: (v) => v.toLocaleString(), + streak: (v) => v.toLocaleString(), + }; + + this.init(); + } + + init() { + // Open leaderboard + this.elements.openBtn?.addEventListener('click', () => this.show()); + + // Back button + this.elements.backBtn?.addEventListener('click', () => this.hide()); + + // Tab switching + this.elements.tabs?.addEventListener('click', (e) => { + if (e.target.classList.contains('leaderboard-tab')) { + this.switchMetric(e.target.dataset.metric); + } + }); + + // Close player stats modal + this.elements.statsClose?.addEventListener('click', () => this.closePlayerStats()); + this.elements.statsModal?.addEventListener('click', (e) => { + if (e.target === this.elements.statsModal) { + this.closePlayerStats(); + } + }); + + // Handle clicks on player names + this.elements.content?.addEventListener('click', (e) => { + const playerLink = e.target.closest('.player-link'); + if (playerLink) { + const userId = playerLink.dataset.userId; + if (userId) { + this.showPlayerStats(userId); + } + } + }); + } + + show() { + // Hide other screens, show leaderboard + document.querySelectorAll('.screen').forEach(s => s.classList.remove('active')); + this.elements.screen.classList.add('active'); + this.loadLeaderboard(this.currentMetric); + } + + hide() { + this.elements.screen.classList.remove('active'); + document.getElementById('lobby-screen').classList.add('active'); + } + + switchMetric(metric) { + if (metric === this.currentMetric) return; + + this.currentMetric = metric; + + // Update tab styling + this.elements.tabs.querySelectorAll('.leaderboard-tab').forEach(tab => { + tab.classList.toggle('active', tab.dataset.metric === metric); + }); + + this.loadLeaderboard(metric); + } + + async loadLeaderboard(metric) { + // Check cache + const cacheKey = `leaderboard_${metric}`; + const cached = this.cache.get(cacheKey); + if (cached && Date.now() - cached.time < this.cacheTimeout) { + this.renderLeaderboard(cached.data, metric); + return; + } + + // Show loading + this.elements.content.innerHTML = '
Loading...
'; + + try { + const response = await fetch(`/api/stats/leaderboard?metric=${metric}&limit=50`); + if (!response.ok) throw new Error('Failed to load leaderboard'); + + const data = await response.json(); + + // Cache the result + this.cache.set(cacheKey, { data, time: Date.now() }); + + this.renderLeaderboard(data, metric); + } catch (error) { + console.error('Error loading leaderboard:', error); + this.elements.content.innerHTML = ` +
+

Failed to load leaderboard

+ +
+ `; + } + } + + renderLeaderboard(data, metric) { + const entries = data.entries || []; + + if (entries.length === 0) { + this.elements.content.innerHTML = ` +
+

No players on the leaderboard yet.

+

Play 5+ games to appear here!

+
+ `; + return; + } + + const formatValue = this.metricFormats[metric] || (v => v); + const currentUserId = this.getCurrentUserId(); + + let html = ` + + + + + + + + + + + `; + + entries.forEach(entry => { + const isMe = entry.user_id === currentUserId; + const medal = this.getMedal(entry.rank); + + html += ` + + + + + + + `; + }); + + html += '
#Player${this.metricLabels[metric]}Games
${medal || entry.rank} + + ${this.escapeHtml(entry.username)}${isMe ? ' (you)' : ''} + + ${formatValue(entry.value)}${entry.games_played}
'; + this.elements.content.innerHTML = html; + } + + getMedal(rank) { + switch (rank) { + case 1: return '🥇'; + case 2: return '🥈'; + case 3: return '🥉'; + default: return null; + } + } + + async showPlayerStats(userId) { + this.elements.statsModal.classList.remove('hidden'); + this.elements.statsContent.innerHTML = '
Loading...
'; + + try { + const [statsRes, achievementsRes] = await Promise.all([ + fetch(`/api/stats/players/${userId}`), + fetch(`/api/stats/players/${userId}/achievements`), + ]); + + if (!statsRes.ok) throw new Error('Failed to load player stats'); + + const stats = await statsRes.json(); + const achievements = achievementsRes.ok ? await achievementsRes.json() : { achievements: [] }; + + this.renderPlayerStats(stats, achievements.achievements || []); + } catch (error) { + console.error('Error loading player stats:', error); + this.elements.statsContent.innerHTML = ` +
+

Failed to load player stats

+
+ `; + } + } + + renderPlayerStats(stats, achievements) { + const currentUserId = this.getCurrentUserId(); + const isMe = stats.user_id === currentUserId; + + let html = ` +
+

${this.escapeHtml(stats.username)}${isMe ? ' (you)' : ''}

+ ${stats.games_played >= 5 ? '

Ranked Player

' : '

Unranked (needs 5+ games)

'} +
+ +
+
+
${stats.games_won}
+
Wins
+
+
+
${stats.win_rate.toFixed(1)}%
+
Win Rate
+
+
+
${stats.games_played}
+
Games
+
+
+
${stats.avg_score.toFixed(1)}
+
Avg Score
+
+
+
${stats.best_round_score ?? '-'}
+
Best Round
+
+
+
${stats.knockouts}
+
Knockouts
+
+
+
${stats.best_win_streak}
+
Best Streak
+
+
+
${stats.rounds_played}
+
Rounds
+
+
+ `; + + // Achievements section + if (achievements.length > 0) { + html += ` +
+

Achievements (${achievements.length})

+
+ `; + + achievements.forEach(a => { + html += ` +
+ ${a.icon} + ${this.escapeHtml(a.name)} +
+ `; + }); + + html += '
'; + } + + this.elements.statsContent.innerHTML = html; + } + + closePlayerStats() { + this.elements.statsModal.classList.add('hidden'); + } + + getCurrentUserId() { + // Get user ID from auth state if available + if (window.authState && window.authState.user) { + return window.authState.user.id; + } + return null; + } + + escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + // Public method to clear cache (e.g., after game ends) + clearCache() { + this.cache.clear(); + } +} + +// Initialize global leaderboard instance +const leaderboard = new LeaderboardComponent(); diff --git a/client/replay.js b/client/replay.js new file mode 100644 index 0000000..0f87daa --- /dev/null +++ b/client/replay.js @@ -0,0 +1,587 @@ +// Golf Card Game - Replay Viewer + +class ReplayViewer { + constructor() { + this.frames = []; + this.metadata = null; + this.currentFrame = 0; + this.isPlaying = false; + this.playbackSpeed = 1.0; + this.playInterval = null; + this.gameId = null; + this.shareCode = null; + + this.initElements(); + this.bindEvents(); + } + + initElements() { + this.replayScreen = document.getElementById('replay-screen'); + this.replayTitle = document.getElementById('replay-title'); + this.replayMeta = document.getElementById('replay-meta'); + this.replayBoard = document.getElementById('replay-board'); + this.eventDescription = document.getElementById('replay-event-description'); + this.controlsContainer = document.getElementById('replay-controls'); + this.frameCounter = document.getElementById('replay-frame-counter'); + this.timelineSlider = document.getElementById('replay-timeline'); + this.speedSelect = document.getElementById('replay-speed'); + + // Control buttons + this.btnStart = document.getElementById('replay-btn-start'); + this.btnPrev = document.getElementById('replay-btn-prev'); + this.btnPlay = document.getElementById('replay-btn-play'); + this.btnNext = document.getElementById('replay-btn-next'); + this.btnEnd = document.getElementById('replay-btn-end'); + + // Action buttons + this.btnShare = document.getElementById('replay-btn-share'); + this.btnExport = document.getElementById('replay-btn-export'); + this.btnBack = document.getElementById('replay-btn-back'); + } + + bindEvents() { + if (this.btnStart) this.btnStart.onclick = () => this.goToFrame(0); + if (this.btnEnd) this.btnEnd.onclick = () => this.goToFrame(this.frames.length - 1); + if (this.btnPrev) this.btnPrev.onclick = () => this.prevFrame(); + if (this.btnNext) this.btnNext.onclick = () => this.nextFrame(); + if (this.btnPlay) this.btnPlay.onclick = () => this.togglePlay(); + + if (this.timelineSlider) { + this.timelineSlider.oninput = (e) => { + this.goToFrame(parseInt(e.target.value)); + }; + } + + if (this.speedSelect) { + this.speedSelect.onchange = (e) => { + this.playbackSpeed = parseFloat(e.target.value); + if (this.isPlaying) { + this.stopPlayback(); + this.startPlayback(); + } + }; + } + + if (this.btnShare) { + this.btnShare.onclick = () => this.showShareDialog(); + } + + if (this.btnExport) { + this.btnExport.onclick = () => this.exportGame(); + } + + if (this.btnBack) { + this.btnBack.onclick = () => this.hide(); + } + + // Keyboard controls + document.addEventListener('keydown', (e) => { + if (!this.replayScreen || !this.replayScreen.classList.contains('active')) return; + + switch (e.key) { + case 'ArrowLeft': + e.preventDefault(); + this.prevFrame(); + break; + case 'ArrowRight': + e.preventDefault(); + this.nextFrame(); + break; + case ' ': + e.preventDefault(); + this.togglePlay(); + break; + case 'Home': + e.preventDefault(); + this.goToFrame(0); + break; + case 'End': + e.preventDefault(); + this.goToFrame(this.frames.length - 1); + break; + } + }); + } + + async loadReplay(gameId) { + this.gameId = gameId; + this.shareCode = null; + + try { + const token = localStorage.getItem('authToken'); + const headers = token ? { 'Authorization': `Bearer ${token}` } : {}; + + const response = await fetch(`/api/replay/game/${gameId}`, { headers }); + if (!response.ok) { + throw new Error('Failed to load replay'); + } + + const data = await response.json(); + this.frames = data.frames; + this.metadata = data.metadata; + this.currentFrame = 0; + + this.show(); + this.render(); + this.updateControls(); + } catch (error) { + console.error('Failed to load replay:', error); + this.showError('Failed to load replay. You may not have permission to view this game.'); + } + } + + async loadSharedReplay(shareCode) { + this.shareCode = shareCode; + this.gameId = null; + + try { + const response = await fetch(`/api/replay/shared/${shareCode}`); + if (!response.ok) { + throw new Error('Replay not found or expired'); + } + + const data = await response.json(); + this.frames = data.frames; + this.metadata = data.metadata; + this.gameId = data.game_id; + this.currentFrame = 0; + + // Update title with share info + if (data.title) { + this.replayTitle.textContent = data.title; + } + + this.show(); + this.render(); + this.updateControls(); + } catch (error) { + console.error('Failed to load shared replay:', error); + this.showError('Replay not found or has expired.'); + } + } + + show() { + // Hide other screens + document.querySelectorAll('.screen').forEach(s => s.classList.remove('active')); + this.replayScreen.classList.add('active'); + + // Update title + if (!this.shareCode && this.metadata) { + this.replayTitle.textContent = 'Game Replay'; + } + + // Update meta + if (this.metadata) { + const players = this.metadata.players.join(' vs '); + const duration = this.formatDuration(this.metadata.duration); + const rounds = `${this.metadata.total_rounds} hole${this.metadata.total_rounds > 1 ? 's' : ''}`; + this.replayMeta.innerHTML = `${players} | ${rounds} | ${duration}`; + } + } + + hide() { + this.stopPlayback(); + this.replayScreen.classList.remove('active'); + + // Return to lobby + document.getElementById('lobby-screen').classList.add('active'); + } + + render() { + if (!this.frames.length) return; + + const frame = this.frames[this.currentFrame]; + const state = frame.state; + + this.renderBoard(state); + this.renderEventInfo(frame); + this.updateTimeline(); + } + + renderBoard(state) { + const currentPlayerId = state.current_player_id; + + // Build HTML for all players + let html = '
'; + + state.players.forEach((player, idx) => { + const isCurrent = player.id === currentPlayerId; + html += ` +
+
+ ${this.escapeHtml(player.name)} + Score: ${player.score} | Total: ${player.total_score} +
+
+ ${this.renderPlayerCards(player.cards)} +
+
+ `; + }); + + html += '
'; + + // Center area (deck and discard) + html += ` +
+
+
+ ${state.deck_remaining} +
+
+
+ ${state.discard_top ? this.renderCard(state.discard_top, true) : '
'} +
+ ${state.drawn_card ? ` +
+ Drawn: + ${this.renderCard(state.drawn_card, true)} +
+ ` : ''} +
+ `; + + // Game info + html += ` +
+ Round ${state.current_round} / ${state.total_rounds} + Phase: ${this.formatPhase(state.phase)} +
+ `; + + this.replayBoard.innerHTML = html; + } + + renderPlayerCards(cards) { + let html = '
'; + + // Render as 2 rows x 3 columns + for (let row = 0; row < 2; row++) { + html += '
'; + for (let col = 0; col < 3; col++) { + const idx = row * 3 + col; + const card = cards[idx]; + if (card) { + html += this.renderCard(card, card.face_up); + } else { + html += '
'; + } + } + html += '
'; + } + + html += '
'; + return html; + } + + renderCard(card, revealed = false) { + if (!revealed || !card.face_up) { + return '
'; + } + + const suit = card.suit; + const rank = card.rank; + const isRed = suit === 'hearts' || suit === 'diamonds'; + const suitSymbol = { hearts: '♥', diamonds: '♦', clubs: '♣', spades: '♠' }[suit] || ''; + + return ` +
+ ${rank} + ${suitSymbol} +
+ `; + } + + renderEventInfo(frame) { + const descriptions = { + 'game_created': 'Game created', + 'player_joined': `${frame.event_data?.player_name || 'Player'} joined`, + 'player_left': `Player left the game`, + 'game_started': 'Game started', + 'round_started': `Round ${frame.event_data?.round || ''} started`, + 'initial_flip': `${this.getPlayerName(frame.player_id)} revealed initial cards`, + 'card_drawn': `${this.getPlayerName(frame.player_id)} drew from ${frame.event_data?.source || 'deck'}`, + 'card_swapped': `${this.getPlayerName(frame.player_id)} swapped a card`, + 'card_discarded': `${this.getPlayerName(frame.player_id)} discarded`, + 'card_flipped': `${this.getPlayerName(frame.player_id)} flipped a card`, + 'flip_skipped': `${this.getPlayerName(frame.player_id)} skipped flip`, + 'knock_early': `${this.getPlayerName(frame.player_id)} knocked early!`, + 'round_ended': `Round ended`, + 'game_ended': `Game over! ${this.metadata?.winner || 'Winner'} wins!`, + }; + + const desc = descriptions[frame.event_type] || frame.event_type; + const time = this.formatTimestamp(frame.timestamp); + + this.eventDescription.innerHTML = ` + ${time} + ${desc} + `; + } + + getPlayerName(playerId) { + if (!playerId || !this.frames.length) return 'Player'; + + const currentState = this.frames[this.currentFrame]?.state; + if (!currentState) return 'Player'; + + const player = currentState.players.find(p => p.id === playerId); + return player?.name || 'Player'; + } + + updateControls() { + if (this.timelineSlider) { + this.timelineSlider.max = Math.max(0, this.frames.length - 1); + this.timelineSlider.value = this.currentFrame; + } + + // Show/hide share button based on whether we own the game + if (this.btnShare) { + this.btnShare.style.display = this.gameId && localStorage.getItem('authToken') ? '' : 'none'; + } + } + + updateTimeline() { + if (this.timelineSlider) { + this.timelineSlider.value = this.currentFrame; + } + + if (this.frameCounter) { + this.frameCounter.textContent = `${this.currentFrame + 1} / ${this.frames.length}`; + } + } + + goToFrame(index) { + this.currentFrame = Math.max(0, Math.min(index, this.frames.length - 1)); + this.render(); + } + + nextFrame() { + if (this.currentFrame < this.frames.length - 1) { + this.currentFrame++; + this.render(); + } else if (this.isPlaying) { + this.togglePlay(); // Stop at end + } + } + + prevFrame() { + if (this.currentFrame > 0) { + this.currentFrame--; + this.render(); + } + } + + togglePlay() { + this.isPlaying = !this.isPlaying; + + if (this.btnPlay) { + this.btnPlay.textContent = this.isPlaying ? '⏸' : '▶'; + } + + if (this.isPlaying) { + this.startPlayback(); + } else { + this.stopPlayback(); + } + } + + startPlayback() { + const baseInterval = 1000; // 1 second between frames + this.playInterval = setInterval(() => { + this.nextFrame(); + }, baseInterval / this.playbackSpeed); + } + + stopPlayback() { + if (this.playInterval) { + clearInterval(this.playInterval); + this.playInterval = null; + } + } + + async showShareDialog() { + if (!this.gameId) return; + + const modal = document.createElement('div'); + modal.className = 'modal active'; + modal.id = 'share-modal'; + modal.innerHTML = ` + + `; + + document.body.appendChild(modal); + + const generateBtn = modal.querySelector('#share-generate-btn'); + const cancelBtn = modal.querySelector('#share-cancel-btn'); + const copyBtn = modal.querySelector('#share-copy-btn'); + + cancelBtn.onclick = () => modal.remove(); + + generateBtn.onclick = async () => { + const title = modal.querySelector('#share-title').value || null; + const expiry = modal.querySelector('#share-expiry').value || null; + + try { + const token = localStorage.getItem('authToken'); + const response = await fetch(`/api/replay/game/${this.gameId}/share`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({ + title, + expires_days: expiry ? parseInt(expiry) : null, + }), + }); + + if (!response.ok) { + throw new Error('Failed to create share link'); + } + + const data = await response.json(); + const fullUrl = `${window.location.origin}/replay/${data.share_code}`; + + modal.querySelector('#share-link').value = fullUrl; + modal.querySelector('#share-result').classList.remove('hidden'); + generateBtn.classList.add('hidden'); + } catch (error) { + console.error('Failed to create share link:', error); + alert('Failed to create share link'); + } + }; + + copyBtn.onclick = () => { + const input = modal.querySelector('#share-link'); + input.select(); + document.execCommand('copy'); + copyBtn.textContent = 'Copied!'; + setTimeout(() => copyBtn.textContent = 'Copy', 2000); + }; + } + + async exportGame() { + if (!this.gameId) return; + + try { + const token = localStorage.getItem('authToken'); + const response = await fetch(`/api/replay/game/${this.gameId}/export`, { + headers: { + 'Authorization': `Bearer ${token}`, + }, + }); + + if (!response.ok) { + throw new Error('Failed to export game'); + } + + const data = await response.json(); + + // Download as JSON file + const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `golf-game-${this.gameId.substring(0, 8)}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } catch (error) { + console.error('Failed to export game:', error); + alert('Failed to export game'); + } + } + + showError(message) { + this.show(); + this.replayBoard.innerHTML = ` +
+

${this.escapeHtml(message)}

+ +
+ `; + } + + formatDuration(seconds) { + if (!seconds) return '0:00'; + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins}:${secs.toString().padStart(2, '0')}`; + } + + formatTimestamp(seconds) { + return this.formatDuration(seconds); + } + + formatPhase(phase) { + const phases = { + 'waiting': 'Waiting', + 'initial_flip': 'Initial Flip', + 'playing': 'Playing', + 'final_turn': 'Final Turn', + 'round_over': 'Round Over', + 'game_over': 'Game Over', + }; + return phases[phase] || phase; + } + + escapeHtml(str) { + if (!str) return ''; + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; + } +} + +// Global instance +const replayViewer = new ReplayViewer(); + +// Check URL for replay links +document.addEventListener('DOMContentLoaded', () => { + const path = window.location.pathname; + + // Handle /replay/{share_code} URLs + if (path.startsWith('/replay/')) { + const shareCode = path.substring(8); + if (shareCode) { + replayViewer.loadSharedReplay(shareCode); + } + } +}); + +// Export for use in other modules +if (typeof module !== 'undefined' && module.exports) { + module.exports = { ReplayViewer, replayViewer }; +} diff --git a/client/style.css b/client/style.css index 38dd162..ac768ee 100644 --- a/client/style.css +++ b/client/style.css @@ -2830,3 +2830,925 @@ input::placeholder { font-size: 0.8rem; } } + +/* =========================================== + AUTH COMPONENTS + =========================================== */ + +/* Auth bar (top right when logged in) */ +.auth-bar { + position: fixed; + top: 10px; + right: 15px; + display: flex; + align-items: center; + gap: 10px; + background: rgba(0, 0, 0, 0.4); + padding: 6px 12px; + border-radius: 20px; + font-size: 0.85rem; + z-index: 100; +} + +.auth-bar.hidden { + display: none; +} + +#auth-username { + color: #f4a460; + font-weight: 500; +} + +/* Auth buttons in lobby */ +.auth-buttons { + display: flex; + justify-content: center; + gap: 10px; + margin-bottom: 20px; +} + +.auth-buttons.hidden { + display: none; +} + +/* Auth modal */ +.modal-auth { + max-width: 320px; + padding: 25px; +} + +.modal-auth h3 { + text-align: center; + margin-bottom: 20px; + color: #f4a460; + font-size: 1.3rem; +} + +.modal-auth .form-group { + margin-bottom: 15px; +} + +.modal-auth input { + width: 100%; + padding: 12px 15px; + background: rgba(0, 0, 0, 0.3); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 8px; + color: white; + font-size: 1rem; +} + +.modal-auth input::placeholder { + color: rgba(255, 255, 255, 0.4); +} + +.modal-auth input:focus { + outline: none; + border-color: #f4a460; +} + +.btn-full { + width: 100%; +} + +.auth-switch { + text-align: center; + margin-top: 15px; + font-size: 0.85rem; + color: rgba(255, 255, 255, 0.6); +} + +.auth-switch a { + color: #f4a460; + text-decoration: none; +} + +.auth-switch a:hover { + text-decoration: underline; +} + +.modal-close-btn { + position: absolute; + top: 10px; + right: 12px; + background: none; + border: none; + color: rgba(255, 255, 255, 0.5); + font-size: 1.5rem; + cursor: pointer; + line-height: 1; + padding: 0; +} + +.modal-close-btn:hover { + color: white; +} + +.modal-auth .error { + color: #f87171; + font-size: 0.85rem; + margin: 10px 0; + text-align: center; +} + +/* =========================================== + LEADERBOARD COMPONENTS + =========================================== */ + +/* Leaderboard button in lobby */ +.leaderboard-btn { + background: rgba(244, 164, 96, 0.2); + border: 1px solid #f4a460; + color: #ffb366; + cursor: pointer; + font-size: 0.65rem; + padding: 2px 8px; + margin-left: 8px; + vertical-align: middle; + border-radius: 3px; + font-weight: 600; + transition: background 0.2s, border-color 0.2s; +} + +.leaderboard-btn:hover { + background: rgba(244, 164, 96, 0.35); + border-color: #ffb366; + color: #ffc880; +} + +/* Leaderboard Screen */ +#leaderboard-screen { + max-width: 800px; + margin: 0 auto; + padding: 10px 20px; +} + +.leaderboard-container { + background: rgba(0, 0, 0, 0.3); + border-radius: 12px; + padding: 20px 25px; + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.leaderboard-header { + text-align: center; + margin-bottom: 20px; +} + +.leaderboard-header h1 { + color: #f4a460; + font-size: 1.8rem; + margin-bottom: 5px; +} + +.leaderboard-subtitle { + color: rgba(255, 255, 255, 0.6); + font-size: 0.9rem; + margin: 0; +} + +/* Leaderboard back button */ +.leaderboard-back-btn { + padding: 4px 12px; + font-size: 0.8rem; + background: transparent; + border: 1px solid rgba(255, 255, 255, 0.3); + color: rgba(255, 255, 255, 0.7); + margin-bottom: 15px; +} + +.leaderboard-back-btn:hover { + background: rgba(255, 255, 255, 0.1); + color: #fff; + border-color: rgba(255, 255, 255, 0.5); +} + +/* Metric tabs */ +.leaderboard-tabs { + display: flex; + gap: 8px; + margin-bottom: 20px; + flex-wrap: wrap; + justify-content: center; +} + +.leaderboard-tab { + padding: 10px 18px; + background: rgba(0, 0, 0, 0.3); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 8px; + color: rgba(255, 255, 255, 0.7); + cursor: pointer; + font-size: 0.9rem; + font-weight: 500; + transition: all 0.2s; +} + +.leaderboard-tab:hover { + background: rgba(244, 164, 96, 0.15); + border-color: rgba(244, 164, 96, 0.3); + color: #fff; +} + +.leaderboard-tab.active { + background: rgba(244, 164, 96, 0.25); + border-color: #f4a460; + color: #f4a460; + font-weight: 600; +} + +/* Leaderboard table */ +.leaderboard-table { + width: 100%; + border-collapse: collapse; +} + +.leaderboard-table th, +.leaderboard-table td { + padding: 12px 15px; + text-align: left; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.leaderboard-table th { + background: rgba(244, 164, 96, 0.15); + color: #f4a460; + font-weight: 600; + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.leaderboard-table tbody tr:hover { + background: rgba(255, 255, 255, 0.05); +} + +.leaderboard-table .rank-col { + width: 50px; + text-align: center; + font-weight: 700; + font-size: 1rem; +} + +.leaderboard-table .rank-col .medal { + font-size: 1.2rem; +} + +.leaderboard-table .username-col { + font-weight: 500; +} + +.leaderboard-table .value-col { + text-align: right; + font-weight: 600; + color: #f4a460; +} + +.leaderboard-table .games-col { + text-align: right; + color: rgba(255, 255, 255, 0.6); + font-size: 0.85rem; +} + +/* Player profile link */ +.player-link { + color: inherit; + text-decoration: none; + cursor: pointer; +} + +.player-link:hover { + color: #f4a460; + text-decoration: underline; +} + +/* Empty state */ +.leaderboard-empty { + text-align: center; + padding: 40px 20px; + color: rgba(255, 255, 255, 0.5); +} + +.leaderboard-empty p { + margin-bottom: 10px; +} + +/* Loading state */ +.leaderboard-loading { + text-align: center; + padding: 40px 20px; + color: rgba(255, 255, 255, 0.6); +} + +.leaderboard-loading::after { + content: ''; + display: inline-block; + width: 20px; + height: 20px; + border: 2px solid rgba(244, 164, 96, 0.3); + border-top-color: #f4a460; + border-radius: 50%; + animation: spin 1s linear infinite; + margin-left: 10px; + vertical-align: middle; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* Player Stats Modal */ +.player-stats-modal .modal-content { + max-width: 450px; +} + +.player-stats-header { + text-align: center; + margin-bottom: 20px; + padding-bottom: 15px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.player-stats-header h3 { + color: #f4a460; + margin: 0 0 5px 0; +} + +.player-stats-header .rank-badge { + font-size: 0.85rem; + color: rgba(255, 255, 255, 0.6); +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; + margin-bottom: 20px; +} + +.stat-item { + background: rgba(0, 0, 0, 0.2); + border-radius: 8px; + padding: 12px; + text-align: center; +} + +.stat-value { + font-size: 1.4rem; + font-weight: 700; + color: #f4a460; + margin-bottom: 4px; +} + +.stat-label { + font-size: 0.75rem; + color: rgba(255, 255, 255, 0.6); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +/* Achievements section in player stats */ +.achievements-section { + margin-top: 15px; + padding-top: 15px; + border-top: 1px solid rgba(255, 255, 255, 0.1); +} + +.achievements-section h4 { + font-size: 0.9rem; + color: rgba(255, 255, 255, 0.7); + margin-bottom: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.achievements-grid { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.achievement-badge { + display: flex; + align-items: center; + gap: 6px; + background: rgba(244, 164, 96, 0.15); + border: 1px solid rgba(244, 164, 96, 0.3); + border-radius: 20px; + padding: 6px 12px; + font-size: 0.85rem; +} + +.achievement-badge .icon { + font-size: 1rem; +} + +.achievement-badge .name { + color: #f4a460; + font-weight: 500; +} + +.achievement-badge.locked { + background: rgba(0, 0, 0, 0.2); + border-color: rgba(255, 255, 255, 0.1); + opacity: 0.5; +} + +.achievement-badge.locked .icon { + filter: grayscale(1); +} + +.achievement-badge.locked .name { + color: rgba(255, 255, 255, 0.5); +} + +/* My stats badge in leaderboard */ +.my-row { + background: rgba(244, 164, 96, 0.1) !important; + border-left: 3px solid #f4a460; +} + +/* Mobile adjustments */ +@media (max-width: 600px) { + #leaderboard-screen { + padding: 10px; + } + + .leaderboard-container { + padding: 15px; + } + + .leaderboard-tabs { + gap: 6px; + } + + .leaderboard-tab { + padding: 8px 14px; + font-size: 0.85rem; + } + + .leaderboard-table th, + .leaderboard-table td { + padding: 10px 8px; + font-size: 0.9rem; + } + + .stats-grid { + grid-template-columns: repeat(2, 1fr); + gap: 8px; + } + + .stat-item { + padding: 10px 8px; + } + + .stat-value { + font-size: 1.2rem; + } +} + +/* =========================================== + REPLAY VIEWER + =========================================== */ + +#replay-screen { + max-width: 900px; + margin: 0 auto; + padding: 15px 20px; +} + +.replay-header { + text-align: center; + margin-bottom: 20px; +} + +#replay-title { + color: #f4a460; + font-size: 1.5rem; + margin-bottom: 8px; +} + +.replay-meta { + color: rgba(255, 255, 255, 0.7); + font-size: 0.9rem; +} + +.replay-meta span { + display: inline-block; +} + +/* Replay Board */ +.replay-board-container { + background: rgba(0, 0, 0, 0.3); + border-radius: 12px; + padding: 20px; + margin-bottom: 15px; + min-height: 300px; +} + +.replay-players { + display: flex; + flex-wrap: wrap; + gap: 20px; + justify-content: center; + margin-bottom: 20px; +} + +.replay-player { + background: rgba(255, 255, 255, 0.05); + border-radius: 10px; + padding: 12px; + min-width: 180px; + border: 2px solid transparent; + transition: border-color 0.2s; +} + +.replay-player.is-current { + border-color: #f4a460; + box-shadow: 0 0 15px rgba(244, 164, 96, 0.3); +} + +.replay-player-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; + padding-bottom: 8px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.replay-player-name { + font-weight: 600; + color: #fff; +} + +.replay-player-score { + font-size: 0.8rem; + color: rgba(255, 255, 255, 0.6); +} + +.replay-cards-grid { + display: flex; + flex-direction: column; + gap: 4px; +} + +.replay-cards-row { + display: flex; + gap: 4px; + justify-content: center; +} + +/* Replay cards - smaller version */ +.replay-board-container .card { + width: 45px; + height: 63px; + font-size: 0.9rem; + border-radius: 4px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + font-weight: bold; +} + +.replay-board-container .card-back { + background-color: #c41e3a; + background-image: + linear-gradient(45deg, rgba(255,255,255,0.15) 25%, transparent 25%), + linear-gradient(-45deg, rgba(255,255,255,0.15) 25%, transparent 25%); + background-size: 6px 6px; + border: 2px solid #8b1528; +} + +.replay-board-container .card-red { + background: linear-gradient(145deg, #fff 0%, #f5f5f5 100%); + border: 1px solid #ddd; + color: #c0392b; +} + +.replay-board-container .card-black { + background: linear-gradient(145deg, #fff 0%, #f5f5f5 100%); + border: 1px solid #ddd; + color: #2c3e50; +} + +.replay-board-container .card-empty { + background: rgba(255, 255, 255, 0.1); + border: 1px dashed rgba(255, 255, 255, 0.2); +} + +/* Replay center area */ +.replay-center { + display: flex; + justify-content: center; + align-items: center; + gap: 30px; + padding: 20px; + background: rgba(0, 0, 0, 0.2); + border-radius: 8px; + margin-bottom: 15px; +} + +.replay-deck .card, +.replay-discard .card { + width: 55px; + height: 77px; +} + +.replay-deck .deck-count { + position: absolute; + bottom: -20px; + left: 50%; + transform: translateX(-50%); + font-size: 0.75rem; + color: rgba(255, 255, 255, 0.6); +} + +.replay-deck { + position: relative; +} + +.replay-drawn { + display: flex; + align-items: center; + gap: 8px; +} + +.drawn-label { + font-size: 0.8rem; + color: #f4a460; +} + +/* Replay info */ +.replay-info { + display: flex; + justify-content: center; + gap: 20px; + font-size: 0.85rem; + color: rgba(255, 255, 255, 0.6); +} + +/* Event description */ +.event-description { + text-align: center; + padding: 12px; + margin-bottom: 15px; + background: rgba(0, 0, 0, 0.2); + border-radius: 8px; + min-height: 50px; + display: flex; + align-items: center; + justify-content: center; + gap: 15px; +} + +.event-time { + font-family: monospace; + color: rgba(255, 255, 255, 0.5); + font-size: 0.85rem; +} + +.event-text { + font-size: 1rem; + color: #fff; +} + +/* Replay Controls */ +.replay-controls { + display: flex; + align-items: center; + gap: 12px; + padding: 15px; + background: rgba(0, 0, 0, 0.3); + border-radius: 10px; + flex-wrap: wrap; + justify-content: center; + margin-bottom: 15px; +} + +.replay-btn { + width: 40px; + height: 40px; + border-radius: 50%; + border: none; + background: rgba(244, 164, 96, 0.2); + color: #f4a460; + cursor: pointer; + font-size: 1rem; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; +} + +.replay-btn:hover { + background: rgba(244, 164, 96, 0.4); + transform: scale(1.05); +} + +.replay-btn-play { + width: 50px; + height: 50px; + font-size: 1.3rem; + background: #f4a460; + color: #1a472a; +} + +.replay-btn-play:hover { + background: #ffb366; +} + +/* Timeline */ +.timeline { + flex: 1; + min-width: 200px; + display: flex; + align-items: center; + gap: 10px; +} + +.timeline-slider { + flex: 1; + height: 8px; + -webkit-appearance: none; + appearance: none; + background: rgba(255, 255, 255, 0.2); + border-radius: 4px; + cursor: pointer; +} + +.timeline-slider::-webkit-slider-thumb { + -webkit-appearance: none; + width: 18px; + height: 18px; + background: #f4a460; + border-radius: 50%; + cursor: pointer; + transition: transform 0.1s; +} + +.timeline-slider::-webkit-slider-thumb:hover { + transform: scale(1.2); +} + +.timeline-slider::-moz-range-thumb { + width: 18px; + height: 18px; + background: #f4a460; + border-radius: 50%; + cursor: pointer; + border: none; +} + +.frame-counter { + font-family: monospace; + min-width: 70px; + text-align: right; + color: rgba(255, 255, 255, 0.7); + font-size: 0.85rem; +} + +/* Speed control */ +.speed-control { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.85rem; + color: rgba(255, 255, 255, 0.7); +} + +.speed-select { + padding: 6px 10px; + border-radius: 6px; + background: rgba(0, 0, 0, 0.3); + border: 1px solid rgba(255, 255, 255, 0.2); + color: #fff; + cursor: pointer; +} + +/* Replay Actions */ +.replay-actions { + display: flex; + justify-content: center; + gap: 12px; + flex-wrap: wrap; +} + +/* Replay Error */ +.replay-error { + text-align: center; + padding: 60px 20px; + color: rgba(255, 255, 255, 0.7); +} + +.replay-error p { + margin-bottom: 20px; + font-size: 1.1rem; +} + +/* Share link container */ +.share-link-container { + display: flex; + gap: 10px; + margin-top: 10px; +} + +.share-link-container input { + flex: 1; + padding: 10px 12px; + background: rgba(0, 0, 0, 0.3); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 6px; + color: #fff; + font-size: 0.9rem; +} + +/* Modal actions */ +.modal-actions { + display: flex; + gap: 12px; + justify-content: center; + margin-top: 20px; +} + +/* Spectator badge */ +.spectator-count { + position: fixed; + top: 10px; + right: 10px; + background: rgba(0, 0, 0, 0.7); + color: white; + padding: 8px 16px; + border-radius: 20px; + display: flex; + align-items: center; + gap: 8px; + font-size: 0.9rem; + z-index: 100; +} + +.spectator-count::before { + content: '👁'; +} + +/* Mobile adjustments for replay */ +@media (max-width: 600px) { + #replay-screen { + padding: 10px; + } + + .replay-board-container { + padding: 12px; + } + + .replay-players { + gap: 12px; + } + + .replay-player { + min-width: 150px; + padding: 10px; + } + + .replay-board-container .card { + width: 38px; + height: 53px; + font-size: 0.75rem; + } + + .replay-center { + gap: 15px; + padding: 12px; + } + + .replay-controls { + padding: 10px; + gap: 8px; + } + + .replay-btn { + width: 36px; + height: 36px; + font-size: 0.9rem; + } + + .replay-btn-play { + width: 44px; + height: 44px; + font-size: 1.1rem; + } + + .timeline { + min-width: 150px; + } + + .replay-actions { + flex-direction: column; + align-items: stretch; + } + + .replay-actions .btn { + width: 100%; + } +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..4193597 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,58 @@ +# Development Docker Compose for Golf Game V2 +# +# Provides PostgreSQL and Redis for local development. +# +# Usage: +# docker-compose -f docker-compose.dev.yml up -d +# +# Connect to PostgreSQL: +# docker exec -it golfgame-postgres-1 psql -U golf +# +# Connect to Redis CLI: +# docker exec -it golfgame-redis-1 redis-cli +# +# View logs: +# docker-compose -f docker-compose.dev.yml logs -f +# +# Stop services: +# docker-compose -f docker-compose.dev.yml down +# +# Stop and remove volumes (clean slate): +# docker-compose -f docker-compose.dev.yml down -v + +services: + redis: + image: redis:7-alpine + ports: + - "6379:6379" + volumes: + - redis_data:/data + # Enable AOF persistence for durability + command: redis-server --appendonly yes + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 5 + + postgres: + image: postgres:16-alpine + ports: + - "5432:5432" + environment: + POSTGRES_USER: golf + POSTGRES_PASSWORD: devpassword + POSTGRES_DB: golf + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U golf"] + interval: 5s + timeout: 3s + retries: 5 + +volumes: + redis_data: + driver: local + postgres_data: + driver: local diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..98fd9d9 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,139 @@ +# Production Docker Compose for Golf Card Game +# +# Usage: +# # Set required environment variables first +# export DB_PASSWORD=your-secure-password +# export SECRET_KEY=your-secret-key +# export ACME_EMAIL=your-email@example.com +# +# # Start services +# docker-compose -f docker-compose.prod.yml up -d +# +# # View logs +# docker-compose -f docker-compose.prod.yml logs -f app +# +# # Scale app instances +# docker-compose -f docker-compose.prod.yml up -d --scale app=2 + +services: + app: + build: + context: . + dockerfile: Dockerfile + environment: + - POSTGRES_URL=postgresql://golf:${DB_PASSWORD}@postgres:5432/golf + - REDIS_URL=redis://redis:6379 + - SECRET_KEY=${SECRET_KEY} + - RESEND_API_KEY=${RESEND_API_KEY:-} + - SENTRY_DSN=${SENTRY_DSN:-} + - ENVIRONMENT=production + - LOG_LEVEL=INFO + - BASE_URL=${BASE_URL:-https://golf.example.com} + - RATE_LIMIT_ENABLED=true + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + deploy: + replicas: 2 + restart_policy: + condition: on-failure + max_attempts: 3 + resources: + limits: + memory: 512M + reservations: + memory: 256M + networks: + - internal + - web + labels: + - "traefik.enable=true" + - "traefik.http.routers.golf.rule=Host(`${DOMAIN:-golf.example.com}`)" + - "traefik.http.routers.golf.entrypoints=websecure" + - "traefik.http.routers.golf.tls=true" + - "traefik.http.routers.golf.tls.certresolver=letsencrypt" + - "traefik.http.services.golf.loadbalancer.server.port=8000" + # WebSocket sticky sessions + - "traefik.http.services.golf.loadbalancer.sticky.cookie=true" + - "traefik.http.services.golf.loadbalancer.sticky.cookie.name=golf_server" + + postgres: + image: postgres:16-alpine + environment: + POSTGRES_DB: golf + POSTGRES_USER: golf + POSTGRES_PASSWORD: ${DB_PASSWORD} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U golf -d golf"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - internal + deploy: + resources: + limits: + memory: 512M + reservations: + memory: 256M + + redis: + image: redis:7-alpine + command: redis-server --appendonly yes --maxmemory 128mb --maxmemory-policy allkeys-lru + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - internal + deploy: + resources: + limits: + memory: 192M + reservations: + memory: 64M + + traefik: + image: traefik:v2.10 + command: + - "--api.dashboard=true" + - "--providers.docker=true" + - "--providers.docker.exposedbydefault=false" + - "--entrypoints.web.address=:80" + - "--entrypoints.web.http.redirections.entryPoint.to=websecure" + - "--entrypoints.web.http.redirections.entryPoint.scheme=https" + - "--entrypoints.websecure.address=:443" + - "--certificatesresolvers.letsencrypt.acme.httpchallenge=true" + - "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web" + - "--certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL}" + - "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json" + ports: + - "80:80" + - "443:443" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - letsencrypt:/letsencrypt + networks: + - web + deploy: + resources: + limits: + memory: 128M + +volumes: + postgres_data: + redis_data: + letsencrypt: + +networks: + internal: + driver: bridge + web: + driver: bridge diff --git a/docs/v2/V2_00_MASTER_PLAN.md b/docs/v2/V2_00_MASTER_PLAN.md new file mode 100644 index 0000000..5ca612c --- /dev/null +++ b/docs/v2/V2_00_MASTER_PLAN.md @@ -0,0 +1,327 @@ +# Golf Card Game - V2 Master Plan + +## Overview + +Transform the current single-server Golf game into a production-ready, hostable platform with: +- **Event-sourced architecture** for full game replay and audit trails +- **User accounts** with authentication, password reset, and profile management +- **Admin tools** for moderation and system management +- **Leaderboards** with player statistics +- **Scalable hosting** options (self-hosted or cloud) +- **Export/playback** for sharing memorable games + +--- + +## Document Structure (VDD) + +This plan is split into independent vertical slices. Each document is self-contained and can be worked on by a separate agent. + +| Document | Scope | Dependencies | +|----------|-------|--------------| +| `V2_01_EVENT_SOURCING.md` | Event classes, store, state rebuilding | None (foundation) | +| `V2_02_PERSISTENCE.md` | Redis cache, PostgreSQL, game recovery | 01 | +| `V2_03_USER_ACCOUNTS.md` | Registration, login, password reset, email | 02 | +| `V2_04_ADMIN_TOOLS.md` | Admin dashboard, moderation, system stats | 03 | +| `V2_05_STATS_LEADERBOARDS.md` | Stats aggregation, leaderboard API/UI | 03 | +| `V2_06_REPLAY_EXPORT.md` | Game replay, export, share links | 01, 02 | +| `V2_07_PRODUCTION.md` | Docker, deployment, monitoring, security | All | + +--- + +## Current State (V1) + +``` +Client (Vanilla JS) <──WebSocket──> FastAPI Server <──> SQLite + │ + In-memory rooms + (lost on restart) +``` + +**What works well:** +- Game logic is solid and well-tested +- CPU AI with 8 distinct personalities +- Flexible house rules system (15+ options) +- Real-time multiplayer via WebSockets +- Basic auth system with invite codes + +**Limitations:** +- Single server, no horizontal scaling +- Game state lost on server restart +- Move logging exists but duplicates state +- No persistent player stats or leaderboards +- Limited admin capabilities +- No password reset flow +- No email integration + +--- + +## V2 Target Architecture + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Clients │ +│ (Browser / Future: Mobile) │ +└───────────────────────────────┬─────────────────────────────────────┘ + │ WebSocket + REST API + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ FastAPI Application │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ Command │ │ Event │ │ State │ │ Query │ │ Auth │ │ +│ │ Handler │─► Store │─► Builder │ │ Service │ │ Service │ │ +│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ Admin │ │ Stats │ │ Email │ │ +│ │ Service │ │ Worker │ │ Service │ │ +│ └──────────┘ └──────────┘ └──────────┘ │ +└───────┬───────────────┬───────────────┬───────────────┬────────────┘ + │ │ │ │ + ▼ ▼ ▼ ▼ +┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ +│ Redis │ │ PostgreSQL │ │ PostgreSQL │ │ Email │ +│ (Live State) │ │ (Events) │ │ (Users/Stats)│ │ Provider │ +│ (Pub/Sub) │ │ │ │ │ │ (Resend) │ +└──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ +``` + +--- + +## Tech Stack + +| Layer | Technology | Reasoning | +|-------|------------|-----------| +| **Web framework** | FastAPI (keep) | Already using, async, fast | +| **WebSockets** | Starlette (keep) | Built into FastAPI | +| **Live state cache** | Redis | Fast, pub/sub, TTL, battle-tested | +| **Event store** | PostgreSQL | JSONB, robust, great tooling | +| **User database** | PostgreSQL | Same instance, keep it simple | +| **Background jobs** | `arq` | Async, Redis-backed, lightweight | +| **Email** | Resend | Simple API, good free tier, reliable | +| **Containerization** | Docker | Consistent deployment | +| **Orchestration** | Docker Compose | Start simple, K8s if needed | + +### New Dependencies + +```txt +# requirements.txt additions +redis>=5.0.0 +asyncpg>=0.29.0 # Async PostgreSQL +sqlalchemy>=2.0.0 # ORM for complex queries +alembic>=1.13.0 # Database migrations +arq>=0.26.0 # Background task queue +pydantic-settings>=2.0 # Config management +resend>=0.8.0 # Email service +python-jose[cryptography] # JWT tokens +passlib[bcrypt] # Password hashing +``` + +--- + +## Phases & Milestones + +### Phase 1: Event Infrastructure (Foundation) +**Goal:** Emit events alongside current code, validate replay works + +| Milestone | Description | Document | +|-----------|-------------|----------| +| Event classes defined | All gameplay events as dataclasses | 01 | +| Event store working | PostgreSQL persistence | 01 | +| Dual-write enabled | Events emitted without breaking current code | 01 | +| Replay validation | Test proves events recreate identical state | 01 | +| Rate limiting on auth | Brute force protection | 07 | + +### Phase 2: Persistence & Recovery +**Goal:** Games survive server restarts + +| Milestone | Description | Document | +|-----------|-------------|----------| +| Redis state cache | Live game state in Redis | 02 | +| Pub/sub ready | Multi-server WebSocket fan-out | 02 | +| Game recovery | Rebuild games from events on startup | 02 | +| Graceful shutdown | Save state before stopping | 02 | + +### Phase 3a: User Accounts +**Goal:** Full user lifecycle management + +| Milestone | Description | Document | +|-----------|-------------|----------| +| Email service integrated | Resend configured and tested | 03 | +| Registration with verification | Email confirmation flow | 03 | +| Password reset flow | Forgot password via email token | 03 | +| Session management | View/revoke sessions | 03 | +| Account settings | Profile, preferences, deletion | 03 | + +### Phase 3b: Admin Tools +**Goal:** Moderation and system management + +| Milestone | Description | Document | +|-----------|-------------|----------| +| Admin dashboard | User list, search, metrics | 04 | +| User management | Ban, unban, force password reset | 04 | +| Game moderation | View any game, end stuck games | 04 | +| System monitoring | Active games, users online, events/hour | 04 | +| Audit logging | Track admin actions | 04 | + +### Phase 4: Stats & Leaderboards +**Goal:** Persistent player statistics + +| Milestone | Description | Document | +|-----------|-------------|----------| +| Stats schema | PostgreSQL tables for aggregated stats | 05 | +| Stats worker | Background job processing events | 05 | +| Leaderboard API | REST endpoints | 05 | +| Leaderboard UI | Client display | 05 | +| Achievement system | Badges and milestones (stretch) | 05 | + +### Phase 5: Replay & Export +**Goal:** Share and replay games + +| Milestone | Description | Document | +|-----------|-------------|----------| +| Export API | Download game as JSON | 06 | +| Import/load | Upload and replay | 06 | +| Replay UI | Playback controls, scrubbing | 06 | +| Share links | Public `/replay/{id}` URLs | 06 | + +### Phase 6: Production +**Goal:** Deployable, monitored, secure + +| Milestone | Description | Document | +|-----------|-------------|----------| +| Dockerized | All services containerized | 07 | +| Health checks | `/health` endpoint with dependency checks | 07 | +| Metrics | Prometheus metrics | 07 | +| Error tracking | Sentry integration | 07 | +| Deployment guide | Step-by-step for VPS/cloud | 07 | + +--- + +## File Structure (Target) + +``` +golfgame/ +├── client/ # Frontend (enhance incrementally) +│ ├── index.html +│ ├── app.js +│ ├── components/ # New: modular UI components +│ │ ├── leaderboard.js +│ │ ├── replay-controls.js +│ │ └── admin-dashboard.js +│ └── ... +├── server/ +│ ├── main.py # FastAPI app entry point +│ ├── config.py # Settings from env vars +│ ├── dependencies.py # FastAPI dependency injection +│ ├── models/ +│ │ ├── events.py # Event dataclasses +│ │ ├── user.py # User model +│ │ └── game_state.py # State rebuilt from events +│ ├── stores/ +│ │ ├── event_store.py # PostgreSQL event persistence +│ │ ├── state_cache.py # Redis live state +│ │ └── user_store.py # User persistence +│ ├── services/ +│ │ ├── game_service.py # Command handling, event emission +│ │ ├── auth_service.py # Authentication, sessions +│ │ ├── email_service.py # Email sending +│ │ ├── admin_service.py # Admin operations +│ │ ├── stats_service.py # Leaderboard queries +│ │ └── replay_service.py # Export, import, playback +│ ├── routers/ +│ │ ├── auth.py # Auth endpoints +│ │ ├── admin.py # Admin endpoints +│ │ ├── games.py # Game/replay endpoints +│ │ └── stats.py # Leaderboard endpoints +│ ├── workers/ +│ │ └── stats_worker.py # Background stats aggregation +│ ├── middleware/ +│ │ ├── rate_limit.py # Rate limiting +│ │ └── auth.py # Auth middleware +│ ├── ai/ # Keep existing AI code +│ │ └── ... +│ └── tests/ +│ ├── test_events.py +│ ├── test_replay.py +│ ├── test_auth.py +│ └── ... +├── migrations/ # Alembic migrations +│ ├── versions/ +│ └── env.py +├── docker/ +│ ├── Dockerfile +│ ├── docker-compose.yml +│ └── docker-compose.prod.yml +├── docs/ +│ └── v2/ # These planning documents +│ ├── V2_00_MASTER_PLAN.md +│ ├── V2_01_EVENT_SOURCING.md +│ └── ... +└── scripts/ + ├── migrate.py # Run migrations + ├── create_admin.py # Bootstrap admin user + └── export_game.py # CLI game export +``` + +--- + +## Decision Log + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Event store DB | PostgreSQL | JSONB support, same DB as users, simpler ops | +| Email provider | Resend | Simple API, good free tier (3k/mo), reliable | +| Background jobs | arq | Async-native, Redis-backed, lightweight | +| Session storage | Redis | Fast, TTL support, already using for state | +| Password hashing | bcrypt | Industry standard, built-in work factor | +| JWT vs sessions | Both | JWT for API, sessions for WebSocket | + +--- + +## Open Questions + +1. **Guest play vs required accounts?** + - Decision: Allow guest play, prompt to register to save stats + - Guest games count for global stats but not personal leaderboards + +2. **Game history retention?** + - Decision: Keep events forever (they're small, ~500 bytes each) + - Implement archival to cold storage after 1 year if needed + +3. **Replay visibility?** + - Decision: Private by default, shareable via link + - Future: Public games opt-in + +4. **CPU games count for leaderboards?** + - Decision: Yes, but separate "vs humans only" leaderboard later + +5. **Multi-region?** + - Decision: Not for V2, single region is fine for card game latency + - Revisit if user base grows significantly + +--- + +## How to Use These Documents + +Each `V2_XX_*.md` document is designed to be: + +1. **Self-contained** - Has all context needed to implement that slice +2. **Agent-ready** - Can be given to a Claude agent as the primary context +3. **Testable** - Includes acceptance criteria and test requirements +4. **Incremental** - Can be implemented and shipped independently (respecting dependencies) + +**Workflow:** +1. Pick a document based on current phase +2. Start a new Claude session with that document as context +3. Implement the slice +4. Run tests specified in the document +5. PR and merge +6. Move to next slice + +--- + +## Next Steps + +1. Review all V2 documents +2. Set up PostgreSQL locally for development +3. Start with `V2_01_EVENT_SOURCING.md` +4. Implement rate limiting from `V2_07_PRODUCTION.md` early (security) diff --git a/docs/v2/V2_01_EVENT_SOURCING.md b/docs/v2/V2_01_EVENT_SOURCING.md new file mode 100644 index 0000000..493bcba --- /dev/null +++ b/docs/v2/V2_01_EVENT_SOURCING.md @@ -0,0 +1,867 @@ +# V2-01: Event Sourcing Infrastructure + +## Overview + +This document covers the foundational event sourcing system. All game actions will be stored as immutable events, enabling replay, audit trails, and stats aggregation. + +**Dependencies:** None (this is the foundation) +**Dependents:** All other V2 documents + +--- + +## Goals + +1. Define event classes for all game actions +2. Create PostgreSQL event store +3. Implement dual-write (events + current mutations) +4. Build state rebuilder from events +5. Validate that event replay produces identical state + +--- + +## Current State + +The game currently uses direct mutation: + +```python +# Current approach in game.py +def draw_card(self, player_id: str, source: str) -> Optional[Card]: + card = self.deck.pop() if source == "deck" else self.discard.pop() + self.drawn_card = card + self.phase = GamePhase.PLAY + return card +``` + +Move logging exists in `game_logger.py` but stores denormalized state snapshots, not replayable events. + +--- + +## Event Design + +### Base Event Class + +```python +# server/models/events.py +from dataclasses import dataclass, field +from datetime import datetime +from typing import Optional, Any +from enum import Enum +import uuid + +class EventType(str, Enum): + # Lifecycle + GAME_CREATED = "game_created" + PLAYER_JOINED = "player_joined" + PLAYER_LEFT = "player_left" + GAME_STARTED = "game_started" + ROUND_STARTED = "round_started" + ROUND_ENDED = "round_ended" + GAME_ENDED = "game_ended" + + # Gameplay + INITIAL_FLIP = "initial_flip" + CARD_DRAWN = "card_drawn" + CARD_SWAPPED = "card_swapped" + CARD_DISCARDED = "card_discarded" + CARD_FLIPPED = "card_flipped" + FLIP_SKIPPED = "flip_skipped" + FLIP_AS_ACTION = "flip_as_action" + KNOCK_EARLY = "knock_early" + + +@dataclass +class GameEvent: + """Base class for all game events.""" + event_type: EventType + game_id: str + sequence_num: int + timestamp: datetime = field(default_factory=datetime.utcnow) + player_id: Optional[str] = None + data: dict = field(default_factory=dict) + + def to_dict(self) -> dict: + return { + "event_type": self.event_type.value, + "game_id": self.game_id, + "sequence_num": self.sequence_num, + "timestamp": self.timestamp.isoformat(), + "player_id": self.player_id, + "data": self.data, + } + + @classmethod + def from_dict(cls, d: dict) -> "GameEvent": + return cls( + event_type=EventType(d["event_type"]), + game_id=d["game_id"], + sequence_num=d["sequence_num"], + timestamp=datetime.fromisoformat(d["timestamp"]), + player_id=d.get("player_id"), + data=d.get("data", {}), + ) +``` + +### Lifecycle Events + +```python +# Lifecycle event data structures + +@dataclass +class GameCreatedData: + room_code: str + host_id: str + options: dict # GameOptions as dict + +@dataclass +class PlayerJoinedData: + player_name: str + is_cpu: bool + cpu_profile: Optional[str] = None + +@dataclass +class GameStartedData: + deck_seed: int # For deterministic replay + player_order: list[str] # Player IDs in turn order + num_decks: int + num_rounds: int + dealt_cards: dict[str, list[dict]] # player_id -> cards dealt + +@dataclass +class RoundStartedData: + round_num: int + deck_seed: int + dealt_cards: dict[str, list[dict]] + +@dataclass +class RoundEndedData: + scores: dict[str, int] # player_id -> score + winner_id: Optional[str] + final_hands: dict[str, list[dict]] # For verification + +@dataclass +class GameEndedData: + final_scores: dict[str, int] # player_id -> total score + winner_id: str + rounds_won: dict[str, int] +``` + +### Gameplay Events + +```python +# Gameplay event data structures + +@dataclass +class InitialFlipData: + positions: list[int] + cards: list[dict] # The cards revealed + +@dataclass +class CardDrawnData: + source: str # "deck" or "discard" + card: dict # Card drawn + +@dataclass +class CardSwappedData: + position: int + new_card: dict # Card placed (was drawn) + old_card: dict # Card removed (goes to discard) + +@dataclass +class CardDiscardedData: + card: dict # Card discarded + +@dataclass +class CardFlippedData: + position: int + card: dict # Card revealed + +@dataclass +class FlipAsActionData: + position: int + card: dict # Card revealed + +@dataclass +class KnockEarlyData: + positions: list[int] # Positions flipped + cards: list[dict] # Cards revealed +``` + +--- + +## Event Store Schema + +```sql +-- migrations/versions/001_create_events.sql + +-- Events table (append-only log) +CREATE TABLE events ( + id BIGSERIAL PRIMARY KEY, + game_id UUID NOT NULL, + sequence_num INT NOT NULL, + event_type VARCHAR(50) NOT NULL, + player_id VARCHAR(50), + event_data JSONB NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + + -- Ensure events are ordered and unique per game + UNIQUE(game_id, sequence_num) +); + +-- Games metadata (for queries, not source of truth) +CREATE TABLE games_v2 ( + id UUID PRIMARY KEY, + room_code VARCHAR(10) NOT NULL, + status VARCHAR(20) DEFAULT 'active', -- active, completed, abandoned + created_at TIMESTAMPTZ DEFAULT NOW(), + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + num_players INT, + num_rounds INT, + options JSONB, + winner_id VARCHAR(50), + host_id VARCHAR(50), + + -- Denormalized for efficient queries + player_ids VARCHAR(50)[] DEFAULT '{}' +); + +-- Indexes +CREATE INDEX idx_events_game_seq ON events(game_id, sequence_num); +CREATE INDEX idx_events_type ON events(event_type); +CREATE INDEX idx_events_player ON events(player_id) WHERE player_id IS NOT NULL; +CREATE INDEX idx_events_created ON events(created_at); + +CREATE INDEX idx_games_status ON games_v2(status); +CREATE INDEX idx_games_room ON games_v2(room_code) WHERE status = 'active'; +CREATE INDEX idx_games_players ON games_v2 USING GIN(player_ids); +CREATE INDEX idx_games_completed ON games_v2(completed_at) WHERE status = 'completed'; +``` + +--- + +## Event Store Implementation + +```python +# server/stores/event_store.py +from typing import Optional, AsyncIterator +from datetime import datetime +import asyncpg +import json + +from models.events import GameEvent, EventType + + +class EventStore: + """PostgreSQL-backed event store.""" + + def __init__(self, pool: asyncpg.Pool): + self.pool = pool + + async def append(self, event: GameEvent) -> int: + """ + Append an event to the store. + Returns the event ID. + Raises if sequence_num already exists (optimistic concurrency). + """ + async with self.pool.acquire() as conn: + try: + row = await conn.fetchrow(""" + INSERT INTO events (game_id, sequence_num, event_type, player_id, event_data) + VALUES ($1, $2, $3, $4, $5) + RETURNING id + """, + event.game_id, + event.sequence_num, + event.event_type.value, + event.player_id, + json.dumps(event.data), + ) + return row["id"] + except asyncpg.UniqueViolationError: + raise ConcurrencyError( + f"Event {event.sequence_num} already exists for game {event.game_id}" + ) + + async def append_batch(self, events: list[GameEvent]) -> list[int]: + """Append multiple events atomically.""" + async with self.pool.acquire() as conn: + async with conn.transaction(): + ids = [] + for event in events: + row = await conn.fetchrow(""" + INSERT INTO events (game_id, sequence_num, event_type, player_id, event_data) + VALUES ($1, $2, $3, $4, $5) + RETURNING id + """, + event.game_id, + event.sequence_num, + event.event_type.value, + event.player_id, + json.dumps(event.data), + ) + ids.append(row["id"]) + return ids + + async def get_events( + self, + game_id: str, + from_sequence: int = 0, + to_sequence: Optional[int] = None, + ) -> list[GameEvent]: + """Get events for a game, optionally within a sequence range.""" + async with self.pool.acquire() as conn: + if to_sequence is not None: + rows = await conn.fetch(""" + SELECT event_type, game_id, sequence_num, player_id, event_data, created_at + FROM events + WHERE game_id = $1 AND sequence_num >= $2 AND sequence_num <= $3 + ORDER BY sequence_num + """, game_id, from_sequence, to_sequence) + else: + rows = await conn.fetch(""" + SELECT event_type, game_id, sequence_num, player_id, event_data, created_at + FROM events + WHERE game_id = $1 AND sequence_num >= $2 + ORDER BY sequence_num + """, game_id, from_sequence) + + return [ + GameEvent( + event_type=EventType(row["event_type"]), + game_id=row["game_id"], + sequence_num=row["sequence_num"], + player_id=row["player_id"], + data=json.loads(row["event_data"]), + timestamp=row["created_at"], + ) + for row in rows + ] + + async def get_latest_sequence(self, game_id: str) -> int: + """Get the latest sequence number for a game.""" + async with self.pool.acquire() as conn: + row = await conn.fetchrow(""" + SELECT COALESCE(MAX(sequence_num), -1) as seq + FROM events + WHERE game_id = $1 + """, game_id) + return row["seq"] + + async def stream_events( + self, + game_id: str, + from_sequence: int = 0, + ) -> AsyncIterator[GameEvent]: + """Stream events for memory-efficient processing.""" + async with self.pool.acquire() as conn: + async with conn.transaction(): + async for row in conn.cursor(""" + SELECT event_type, game_id, sequence_num, player_id, event_data, created_at + FROM events + WHERE game_id = $1 AND sequence_num >= $2 + ORDER BY sequence_num + """, game_id, from_sequence): + yield GameEvent( + event_type=EventType(row["event_type"]), + game_id=row["game_id"], + sequence_num=row["sequence_num"], + player_id=row["player_id"], + data=json.loads(row["event_data"]), + timestamp=row["created_at"], + ) + + +class ConcurrencyError(Exception): + """Raised when optimistic concurrency check fails.""" + pass +``` + +--- + +## State Rebuilder + +```python +# server/models/game_state.py +from dataclasses import dataclass, field +from typing import Optional +from enum import Enum + +from models.events import GameEvent, EventType + + +class GamePhase(str, Enum): + WAITING = "waiting" + INITIAL_FLIP = "initial_flip" + PLAYING = "playing" + FINAL_TURN = "final_turn" + ROUND_OVER = "round_over" + GAME_OVER = "game_over" + + +@dataclass +class Card: + rank: str + suit: str + face_up: bool = False + + def to_dict(self) -> dict: + return {"rank": self.rank, "suit": self.suit, "face_up": self.face_up} + + @classmethod + def from_dict(cls, d: dict) -> "Card": + return cls(rank=d["rank"], suit=d["suit"], face_up=d.get("face_up", False)) + + +@dataclass +class PlayerState: + id: str + name: str + cards: list[Card] = field(default_factory=list) + score: Optional[int] = None + total_score: int = 0 + rounds_won: int = 0 + is_cpu: bool = False + cpu_profile: Optional[str] = None + + +@dataclass +class RebuiltGameState: + """Game state rebuilt from events.""" + game_id: str + room_code: str = "" + phase: GamePhase = GamePhase.WAITING + players: dict[str, PlayerState] = field(default_factory=dict) + player_order: list[str] = field(default_factory=list) + current_player_idx: int = 0 + + deck: list[Card] = field(default_factory=list) + discard: list[Card] = field(default_factory=list) + drawn_card: Optional[Card] = None + + current_round: int = 0 + total_rounds: int = 9 + options: dict = field(default_factory=dict) + + sequence_num: int = 0 + finisher_id: Optional[str] = None + + def apply(self, event: GameEvent) -> "RebuiltGameState": + """ + Apply an event to produce new state. + Returns self for chaining. + """ + assert event.sequence_num == self.sequence_num + 1 or self.sequence_num == 0, \ + f"Expected sequence {self.sequence_num + 1}, got {event.sequence_num}" + + handler = getattr(self, f"_apply_{event.event_type.value}", None) + if handler: + handler(event) + else: + raise ValueError(f"Unknown event type: {event.event_type}") + + self.sequence_num = event.sequence_num + return self + + def _apply_game_created(self, event: GameEvent): + self.room_code = event.data["room_code"] + self.options = event.data.get("options", {}) + self.players[event.data["host_id"]] = PlayerState( + id=event.data["host_id"], + name="Host", # Will be updated by player_joined + ) + + def _apply_player_joined(self, event: GameEvent): + self.players[event.player_id] = PlayerState( + id=event.player_id, + name=event.data["player_name"], + is_cpu=event.data.get("is_cpu", False), + cpu_profile=event.data.get("cpu_profile"), + ) + + def _apply_player_left(self, event: GameEvent): + if event.player_id in self.players: + del self.players[event.player_id] + if event.player_id in self.player_order: + self.player_order.remove(event.player_id) + + def _apply_game_started(self, event: GameEvent): + self.player_order = event.data["player_order"] + self.total_rounds = event.data["num_rounds"] + self.current_round = 1 + self.phase = GamePhase.INITIAL_FLIP + + # Deal cards + for player_id, cards_data in event.data["dealt_cards"].items(): + if player_id in self.players: + self.players[player_id].cards = [ + Card.from_dict(c) for c in cards_data + ] + + # Rebuild deck from seed would go here for full determinism + # For now, we trust the dealt_cards data + + def _apply_round_started(self, event: GameEvent): + self.current_round = event.data["round_num"] + self.phase = GamePhase.INITIAL_FLIP + self.finisher_id = None + self.drawn_card = None + + for player_id, cards_data in event.data["dealt_cards"].items(): + if player_id in self.players: + self.players[player_id].cards = [ + Card.from_dict(c) for c in cards_data + ] + self.players[player_id].score = None + + def _apply_initial_flip(self, event: GameEvent): + player = self.players.get(event.player_id) + if player: + for pos, card_data in zip(event.data["positions"], event.data["cards"]): + if 0 <= pos < len(player.cards): + player.cards[pos] = Card.from_dict(card_data) + player.cards[pos].face_up = True + + # Check if all players have flipped + required = self.options.get("initial_flips", 2) + all_flipped = all( + sum(1 for c in p.cards if c.face_up) >= required + for p in self.players.values() + ) + if all_flipped and required > 0: + self.phase = GamePhase.PLAYING + + def _apply_card_drawn(self, event: GameEvent): + card = Card.from_dict(event.data["card"]) + card.face_up = True + self.drawn_card = card + + if event.data["source"] == "discard" and self.discard: + self.discard.pop() + + def _apply_card_swapped(self, event: GameEvent): + player = self.players.get(event.player_id) + if player and self.drawn_card: + pos = event.data["position"] + old_card = player.cards[pos] + + new_card = Card.from_dict(event.data["new_card"]) + new_card.face_up = True + player.cards[pos] = new_card + + old_card.face_up = True + self.discard.append(old_card) + self.drawn_card = None + + self._advance_turn(player) + + def _apply_card_discarded(self, event: GameEvent): + if self.drawn_card: + self.discard.append(self.drawn_card) + self.drawn_card = None + + player = self.players.get(event.player_id) + if player: + self._advance_turn(player) + + def _apply_card_flipped(self, event: GameEvent): + player = self.players.get(event.player_id) + if player: + pos = event.data["position"] + card = Card.from_dict(event.data["card"]) + card.face_up = True + player.cards[pos] = card + + self._advance_turn(player) + + def _apply_flip_skipped(self, event: GameEvent): + player = self.players.get(event.player_id) + if player: + self._advance_turn(player) + + def _apply_flip_as_action(self, event: GameEvent): + player = self.players.get(event.player_id) + if player: + pos = event.data["position"] + card = Card.from_dict(event.data["card"]) + card.face_up = True + player.cards[pos] = card + + self._advance_turn(player) + + def _apply_knock_early(self, event: GameEvent): + player = self.players.get(event.player_id) + if player: + for pos, card_data in zip(event.data["positions"], event.data["cards"]): + card = Card.from_dict(card_data) + card.face_up = True + player.cards[pos] = card + + self._check_all_face_up(player) + self._advance_turn(player) + + def _apply_round_ended(self, event: GameEvent): + self.phase = GamePhase.ROUND_OVER + for player_id, score in event.data["scores"].items(): + if player_id in self.players: + self.players[player_id].score = score + self.players[player_id].total_score += score + + winner_id = event.data.get("winner_id") + if winner_id and winner_id in self.players: + self.players[winner_id].rounds_won += 1 + + def _apply_game_ended(self, event: GameEvent): + self.phase = GamePhase.GAME_OVER + + def _advance_turn(self, player: PlayerState): + """Advance to next player's turn.""" + self._check_all_face_up(player) + + if self.phase == GamePhase.ROUND_OVER: + return + + self.current_player_idx = (self.current_player_idx + 1) % len(self.player_order) + + # Check if we've come back to finisher + if self.finisher_id: + current_id = self.player_order[self.current_player_idx] + if current_id == self.finisher_id: + self.phase = GamePhase.ROUND_OVER + + def _check_all_face_up(self, player: PlayerState): + """Check if player has all cards face up (triggers final turn).""" + if all(c.face_up for c in player.cards): + if self.phase == GamePhase.PLAYING and not self.finisher_id: + self.finisher_id = player.id + self.phase = GamePhase.FINAL_TURN + + @property + def current_player_id(self) -> Optional[str]: + if self.player_order and 0 <= self.current_player_idx < len(self.player_order): + return self.player_order[self.current_player_idx] + return None + + +def rebuild_state(events: list[GameEvent]) -> RebuiltGameState: + """Rebuild game state from a list of events.""" + if not events: + raise ValueError("Cannot rebuild state from empty event list") + + state = RebuiltGameState(game_id=events[0].game_id) + for event in events: + state.apply(event) + + return state +``` + +--- + +## Dual-Write Integration + +Modify existing game.py to emit events alongside mutations: + +```python +# server/game.py additions + +class Game: + def __init__(self): + # ... existing init ... + self._event_emitter: Optional[Callable[[GameEvent], None]] = None + self._sequence_num = 0 + + def set_event_emitter(self, emitter: Callable[[GameEvent], None]): + """Set callback for event emission.""" + self._event_emitter = emitter + + def _emit(self, event_type: EventType, player_id: Optional[str] = None, **data): + """Emit an event if emitter is configured.""" + if self._event_emitter: + self._sequence_num += 1 + event = GameEvent( + event_type=event_type, + game_id=self.game_id, + sequence_num=self._sequence_num, + player_id=player_id, + data=data, + ) + self._event_emitter(event) + + # Example: modify draw_card + def draw_card(self, player_id: str, source: str) -> Optional[Card]: + # ... existing validation ... + + if source == "deck": + card = self.deck.pop() + else: + card = self.discard_pile.pop() + + self.drawn_card = card + + # NEW: Emit event + self._emit( + EventType.CARD_DRAWN, + player_id=player_id, + source=source, + card=card.to_dict(), + ) + + return card +``` + +--- + +## Validation Test + +```python +# server/tests/test_event_replay.py +import pytest +from game import Game, GameOptions +from models.events import GameEvent, rebuild_state + + +class TestEventReplay: + """Verify that event replay produces identical state.""" + + def test_full_game_replay(self): + """Play a complete game and verify replay matches.""" + events = [] + + def collect_events(event: GameEvent): + events.append(event) + + # Play a real game + game = Game() + game.set_event_emitter(collect_events) + + game.add_player("p1", "Alice") + game.add_player("p2", "Bob") + game.start_game(num_decks=1, num_rounds=1, options=GameOptions()) + + # Play through initial flips + game.flip_initial_cards("p1", [0, 1]) + game.flip_initial_cards("p2", [0, 1]) + + # Play some turns + while game.phase not in (GamePhase.ROUND_OVER, GamePhase.GAME_OVER): + current = game.current_player() + if not current: + break + + # Simple bot: always draw from deck and discard + game.draw_card(current.id, "deck") + game.discard_drawn(current.id) + + if len(events) > 100: # Safety limit + break + + # Get final state + final_state = game.get_state("p1") + + # Rebuild from events + rebuilt = rebuild_state(events) + + # Verify key state matches + assert rebuilt.phase == game.phase + assert rebuilt.current_round == game.current_round + assert len(rebuilt.players) == len(game.players) + + for player_id, player in rebuilt.players.items(): + original = game.get_player(player_id) + assert player.score == original.score + assert player.total_score == original.total_score + assert len(player.cards) == len(original.cards) + + for i, card in enumerate(player.cards): + orig_card = original.cards[i] + assert card.rank == orig_card.rank + assert card.suit == orig_card.suit + assert card.face_up == orig_card.face_up + + def test_partial_replay(self): + """Verify we can replay to any point in the game.""" + events = [] + + def collect_events(event: GameEvent): + events.append(event) + + game = Game() + game.set_event_emitter(collect_events) + + # ... setup and play ... + + # Replay only first N events + for n in range(1, len(events) + 1): + partial = rebuild_state(events[:n]) + assert partial.sequence_num == n + + def test_event_order_enforced(self): + """Verify events must be applied in order.""" + events = [] + + # ... collect some events ... + + state = RebuiltGameState(game_id="test") + + # Skip an event - should fail + with pytest.raises(AssertionError): + state.apply(events[1]) # Skipping events[0] +``` + +--- + +## Acceptance Criteria + +1. **Event Classes Complete** + - [ ] All lifecycle events defined (created, joined, left, started, ended) + - [ ] All gameplay events defined (draw, swap, discard, flip, etc.) + - [ ] Events are serializable to/from JSON + - [ ] Events include all data needed for replay + +2. **Event Store Working** + - [ ] PostgreSQL schema created via migration + - [ ] Can append single events + - [ ] Can append batches atomically + - [ ] Can retrieve events by game_id + - [ ] Can retrieve events by sequence range + - [ ] Concurrent writes to same sequence fail cleanly + +3. **State Rebuilder Working** + - [ ] Can rebuild state from any event sequence + - [ ] Handles all event types + - [ ] Enforces event ordering + - [ ] Matches original game state exactly + +4. **Dual-Write Enabled** + - [ ] Game class has event emitter hook + - [ ] All state-changing methods emit events + - [ ] Events don't affect existing game behavior + - [ ] Can be enabled/disabled via config + +5. **Validation Tests Pass** + - [ ] Full game replay test + - [ ] Partial replay test + - [ ] Event order enforcement test + - [ ] At least 95% of games replay correctly + +--- + +## Implementation Order + +1. Create event dataclasses (`models/events.py`) +2. Create database migration for events table +3. Implement EventStore class +4. Implement RebuiltGameState class +5. Add event emitter to Game class +6. Add `_emit()` calls to all game methods +7. Write validation tests +8. Run tests until 100% pass + +--- + +## Notes for Agent + +- The existing `game.py` has good test coverage - don't break existing tests +- Start with lifecycle events, then gameplay events +- The deck seed is important for deterministic replay +- Consider edge cases: player disconnects, CPU players, house rules +- Events should be immutable - never modify after creation diff --git a/docs/v2/V2_02_PERSISTENCE.md b/docs/v2/V2_02_PERSISTENCE.md new file mode 100644 index 0000000..15daec5 --- /dev/null +++ b/docs/v2/V2_02_PERSISTENCE.md @@ -0,0 +1,870 @@ +# V2-02: Persistence & Recovery + +## Overview + +This document covers the live state caching and game recovery system. Games will survive server restarts by storing live state in Redis and rebuilding from events. + +**Dependencies:** V2-01 (Event Sourcing) +**Dependents:** V2-03 (User Accounts), V2-06 (Replay) + +--- + +## Goals + +1. Cache live game state in Redis +2. Implement Redis pub/sub for multi-server support +3. Enable game recovery from events on server restart +4. Implement graceful shutdown with state preservation + +--- + +## Current State + +Games are stored in-memory in `main.py`: + +```python +# Current approach +rooms: dict[str, Room] = {} # Lost on restart! +``` + +On server restart, all active games are lost. + +--- + +## Architecture + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ FastAPI #1 │ │ FastAPI #2 │ │ FastAPI #N │ +│ (WebSocket) │ │ (WebSocket) │ │ (WebSocket) │ +└────────┬────────┘ └────────┬────────┘ └────────┬────────┘ + │ │ │ + └───────────────────────┼───────────────────────┘ + │ + ┌────────────▼────────────┐ + │ Redis │ + │ ┌─────────────────┐ │ + │ │ State Cache │ │ <- Live game state + │ │ (Hash/JSON) │ │ + │ └─────────────────┘ │ + │ ┌─────────────────┐ │ + │ │ Pub/Sub │ │ <- Cross-server events + │ │ (Channels) │ │ + │ └─────────────────┘ │ + │ ┌─────────────────┐ │ + │ │ Room Index │ │ <- Active room codes + │ │ (Set) │ │ + │ └─────────────────┘ │ + └─────────────────────────┘ + │ + ┌────────────▼────────────┐ + │ PostgreSQL │ + │ (Event Store) │ <- Source of truth + └─────────────────────────┘ +``` + +--- + +## Redis Data Model + +### Key Patterns + +``` +golf:room:{room_code} -> Hash (room metadata) +golf:game:{game_id} -> JSON (full game state) +golf:room:{room_code}:players -> Set (connected player IDs) +golf:rooms:active -> Set (active room codes) +golf:player:{player_id}:room -> String (player's current room) +``` + +### Room Metadata Hash + +``` +golf:room:ABCD +├── game_id: "uuid-..." +├── host_id: "player-uuid" +├── created_at: "2024-01-15T10:30:00Z" +├── status: "waiting" | "playing" | "finished" +└── server_id: "server-1" # Which server owns this room +``` + +### Game State JSON + +```json +{ + "game_id": "uuid-...", + "room_code": "ABCD", + "phase": "playing", + "current_round": 3, + "total_rounds": 9, + "current_player_idx": 1, + "player_order": ["p1", "p2", "p3"], + "players": { + "p1": { + "id": "p1", + "name": "Alice", + "cards": [{"rank": "K", "suit": "hearts", "face_up": true}, ...], + "score": null, + "total_score": 15, + "rounds_won": 1, + "is_cpu": false + } + }, + "deck_count": 32, + "discard_top": {"rank": "7", "suit": "clubs"}, + "drawn_card": null, + "options": {...}, + "sequence_num": 47 +} +``` + +--- + +## State Cache Implementation + +```python +# server/stores/state_cache.py +import json +from typing import Optional +from datetime import timedelta +import redis.asyncio as redis + +from models.game_state import RebuiltGameState + + +class StateCache: + """Redis-backed live game state cache.""" + + # Key patterns + ROOM_KEY = "golf:room:{room_code}" + GAME_KEY = "golf:game:{game_id}" + ROOM_PLAYERS_KEY = "golf:room:{room_code}:players" + ACTIVE_ROOMS_KEY = "golf:rooms:active" + PLAYER_ROOM_KEY = "golf:player:{player_id}:room" + + # TTLs + ROOM_TTL = timedelta(hours=4) # Inactive rooms expire + GAME_TTL = timedelta(hours=4) + + def __init__(self, redis_client: redis.Redis): + self.redis = redis_client + + # --- Room Operations --- + + async def create_room( + self, + room_code: str, + game_id: str, + host_id: str, + server_id: str, + ) -> None: + """Create a new room.""" + pipe = self.redis.pipeline() + + # Room metadata + pipe.hset( + self.ROOM_KEY.format(room_code=room_code), + mapping={ + "game_id": game_id, + "host_id": host_id, + "status": "waiting", + "server_id": server_id, + "created_at": datetime.utcnow().isoformat(), + }, + ) + pipe.expire(self.ROOM_KEY.format(room_code=room_code), self.ROOM_TTL) + + # Add to active rooms + pipe.sadd(self.ACTIVE_ROOMS_KEY, room_code) + + # Track host's room + pipe.set( + self.PLAYER_ROOM_KEY.format(player_id=host_id), + room_code, + ex=self.ROOM_TTL, + ) + + await pipe.execute() + + async def get_room(self, room_code: str) -> Optional[dict]: + """Get room metadata.""" + data = await self.redis.hgetall(self.ROOM_KEY.format(room_code=room_code)) + if not data: + return None + return {k.decode(): v.decode() for k, v in data.items()} + + async def room_exists(self, room_code: str) -> bool: + """Check if room exists.""" + return await self.redis.exists(self.ROOM_KEY.format(room_code=room_code)) > 0 + + async def delete_room(self, room_code: str) -> None: + """Delete a room and all associated data.""" + room = await self.get_room(room_code) + if not room: + return + + pipe = self.redis.pipeline() + + # Get players to clean up their mappings + players = await self.redis.smembers( + self.ROOM_PLAYERS_KEY.format(room_code=room_code) + ) + for player_id in players: + pipe.delete(self.PLAYER_ROOM_KEY.format(player_id=player_id.decode())) + + # Delete room data + pipe.delete(self.ROOM_KEY.format(room_code=room_code)) + pipe.delete(self.ROOM_PLAYERS_KEY.format(room_code=room_code)) + pipe.srem(self.ACTIVE_ROOMS_KEY, room_code) + + # Delete game state if exists + if "game_id" in room: + pipe.delete(self.GAME_KEY.format(game_id=room["game_id"])) + + await pipe.execute() + + async def get_active_rooms(self) -> set[str]: + """Get all active room codes.""" + rooms = await self.redis.smembers(self.ACTIVE_ROOMS_KEY) + return {r.decode() for r in rooms} + + # --- Player Operations --- + + async def add_player_to_room(self, room_code: str, player_id: str) -> None: + """Add a player to a room.""" + pipe = self.redis.pipeline() + pipe.sadd(self.ROOM_PLAYERS_KEY.format(room_code=room_code), player_id) + pipe.set( + self.PLAYER_ROOM_KEY.format(player_id=player_id), + room_code, + ex=self.ROOM_TTL, + ) + # Refresh room TTL on activity + pipe.expire(self.ROOM_KEY.format(room_code=room_code), self.ROOM_TTL) + await pipe.execute() + + async def remove_player_from_room(self, room_code: str, player_id: str) -> None: + """Remove a player from a room.""" + pipe = self.redis.pipeline() + pipe.srem(self.ROOM_PLAYERS_KEY.format(room_code=room_code), player_id) + pipe.delete(self.PLAYER_ROOM_KEY.format(player_id=player_id)) + await pipe.execute() + + async def get_room_players(self, room_code: str) -> set[str]: + """Get player IDs in a room.""" + players = await self.redis.smembers( + self.ROOM_PLAYERS_KEY.format(room_code=room_code) + ) + return {p.decode() for p in players} + + async def get_player_room(self, player_id: str) -> Optional[str]: + """Get the room a player is in.""" + room = await self.redis.get(self.PLAYER_ROOM_KEY.format(player_id=player_id)) + return room.decode() if room else None + + # --- Game State Operations --- + + async def save_game_state(self, game_id: str, state: dict) -> None: + """Save full game state.""" + await self.redis.set( + self.GAME_KEY.format(game_id=game_id), + json.dumps(state), + ex=self.GAME_TTL, + ) + + async def get_game_state(self, game_id: str) -> Optional[dict]: + """Get full game state.""" + data = await self.redis.get(self.GAME_KEY.format(game_id=game_id)) + if not data: + return None + return json.loads(data) + + async def update_game_state(self, game_id: str, updates: dict) -> None: + """Partial update to game state (get, merge, set).""" + state = await self.get_game_state(game_id) + if state: + state.update(updates) + await self.save_game_state(game_id, state) + + async def delete_game_state(self, game_id: str) -> None: + """Delete game state.""" + await self.redis.delete(self.GAME_KEY.format(game_id=game_id)) + + # --- Room Status --- + + async def set_room_status(self, room_code: str, status: str) -> None: + """Update room status.""" + await self.redis.hset( + self.ROOM_KEY.format(room_code=room_code), + "status", + status, + ) + + async def refresh_room_ttl(self, room_code: str) -> None: + """Refresh room TTL on activity.""" + pipe = self.redis.pipeline() + pipe.expire(self.ROOM_KEY.format(room_code=room_code), self.ROOM_TTL) + + room = await self.get_room(room_code) + if room and "game_id" in room: + pipe.expire(self.GAME_KEY.format(game_id=room["game_id"]), self.GAME_TTL) + + await pipe.execute() +``` + +--- + +## Pub/Sub for Multi-Server + +```python +# server/stores/pubsub.py +import asyncio +import json +from typing import Callable, Awaitable +from dataclasses import dataclass +from enum import Enum +import redis.asyncio as redis + + +class MessageType(str, Enum): + GAME_STATE_UPDATE = "game_state_update" + PLAYER_JOINED = "player_joined" + PLAYER_LEFT = "player_left" + ROOM_CLOSED = "room_closed" + BROADCAST = "broadcast" + + +@dataclass +class PubSubMessage: + type: MessageType + room_code: str + data: dict + + def to_json(self) -> str: + return json.dumps({ + "type": self.type.value, + "room_code": self.room_code, + "data": self.data, + }) + + @classmethod + def from_json(cls, raw: str) -> "PubSubMessage": + d = json.loads(raw) + return cls( + type=MessageType(d["type"]), + room_code=d["room_code"], + data=d["data"], + ) + + +class GamePubSub: + """Redis pub/sub for cross-server game events.""" + + CHANNEL_PREFIX = "golf:room:" + + def __init__(self, redis_client: redis.Redis): + self.redis = redis_client + self.pubsub = redis_client.pubsub() + self._handlers: dict[str, list[Callable[[PubSubMessage], Awaitable[None]]]] = {} + self._running = False + self._task: Optional[asyncio.Task] = None + + def _channel(self, room_code: str) -> str: + return f"{self.CHANNEL_PREFIX}{room_code}" + + async def subscribe( + self, + room_code: str, + handler: Callable[[PubSubMessage], Awaitable[None]], + ) -> None: + """Subscribe to room events.""" + channel = self._channel(room_code) + if channel not in self._handlers: + self._handlers[channel] = [] + await self.pubsub.subscribe(channel) + self._handlers[channel].append(handler) + + async def unsubscribe(self, room_code: str) -> None: + """Unsubscribe from room events.""" + channel = self._channel(room_code) + if channel in self._handlers: + del self._handlers[channel] + await self.pubsub.unsubscribe(channel) + + async def publish(self, message: PubSubMessage) -> None: + """Publish a message to a room's channel.""" + channel = self._channel(message.room_code) + await self.redis.publish(channel, message.to_json()) + + async def start(self) -> None: + """Start listening for messages.""" + self._running = True + self._task = asyncio.create_task(self._listen()) + + async def stop(self) -> None: + """Stop listening.""" + self._running = False + if self._task: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + await self.pubsub.close() + + async def _listen(self) -> None: + """Main listener loop.""" + while self._running: + try: + message = await self.pubsub.get_message( + ignore_subscribe_messages=True, + timeout=1.0, + ) + if message and message["type"] == "message": + channel = message["channel"].decode() + handlers = self._handlers.get(channel, []) + + try: + msg = PubSubMessage.from_json(message["data"].decode()) + for handler in handlers: + await handler(msg) + except Exception as e: + print(f"Error handling pubsub message: {e}") + + except asyncio.CancelledError: + break + except Exception as e: + print(f"PubSub listener error: {e}") + await asyncio.sleep(1) +``` + +--- + +## Game Recovery + +```python +# server/services/recovery_service.py +from typing import Optional +import asyncio + +from stores.event_store import EventStore +from stores.state_cache import StateCache +from models.events import rebuild_state, EventType + + +class RecoveryService: + """Recovers games from event store on startup.""" + + def __init__(self, event_store: EventStore, state_cache: StateCache): + self.event_store = event_store + self.state_cache = state_cache + + async def recover_all_games(self) -> dict[str, any]: + """ + Recover all active games from event store. + Returns dict of recovered games. + """ + results = { + "recovered": 0, + "failed": 0, + "skipped": 0, + "games": [], + } + + # Get active rooms from Redis (may be stale) + active_rooms = await self.state_cache.get_active_rooms() + + for room_code in active_rooms: + room = await self.state_cache.get_room(room_code) + if not room: + results["skipped"] += 1 + continue + + game_id = room.get("game_id") + if not game_id: + results["skipped"] += 1 + continue + + try: + game = await self.recover_game(game_id) + if game: + results["recovered"] += 1 + results["games"].append({ + "game_id": game_id, + "room_code": room_code, + "phase": game.phase.value, + "sequence": game.sequence_num, + }) + else: + results["skipped"] += 1 + except Exception as e: + print(f"Failed to recover game {game_id}: {e}") + results["failed"] += 1 + + return results + + async def recover_game(self, game_id: str) -> Optional[any]: + """ + Recover a single game from event store. + Returns the rebuilt game state. + """ + # Get all events for this game + events = await self.event_store.get_events(game_id) + + if not events: + return None + + # Check if game is actually active (not ended) + last_event = events[-1] + if last_event.event_type == EventType.GAME_ENDED: + return None # Game is finished, don't recover + + # Rebuild state + state = rebuild_state(events) + + # Save to cache + await self.state_cache.save_game_state( + game_id, + self._state_to_dict(state), + ) + + return state + + async def recover_from_sequence( + self, + game_id: str, + cached_state: dict, + cached_sequence: int, + ) -> Optional[any]: + """ + Recover game by applying only new events to cached state. + More efficient than full rebuild. + """ + # Get events after cached sequence + new_events = await self.event_store.get_events( + game_id, + from_sequence=cached_sequence + 1, + ) + + if not new_events: + return None # No new events + + # Rebuild state from cache + new events + state = self._dict_to_state(cached_state) + for event in new_events: + state.apply(event) + + # Update cache + await self.state_cache.save_game_state( + game_id, + self._state_to_dict(state), + ) + + return state + + def _state_to_dict(self, state) -> dict: + """Convert RebuiltGameState to dict for caching.""" + return { + "game_id": state.game_id, + "room_code": state.room_code, + "phase": state.phase.value, + "current_round": state.current_round, + "total_rounds": state.total_rounds, + "current_player_idx": state.current_player_idx, + "player_order": state.player_order, + "players": { + pid: { + "id": p.id, + "name": p.name, + "cards": [c.to_dict() for c in p.cards], + "score": p.score, + "total_score": p.total_score, + "rounds_won": p.rounds_won, + "is_cpu": p.is_cpu, + "cpu_profile": p.cpu_profile, + } + for pid, p in state.players.items() + }, + "deck_count": len(state.deck), + "discard_top": state.discard[-1].to_dict() if state.discard else None, + "drawn_card": state.drawn_card.to_dict() if state.drawn_card else None, + "options": state.options, + "sequence_num": state.sequence_num, + "finisher_id": state.finisher_id, + } + + def _dict_to_state(self, d: dict): + """Convert dict back to RebuiltGameState.""" + # Implementation depends on RebuiltGameState structure + pass +``` + +--- + +## Graceful Shutdown + +```python +# server/main.py additions +import signal +import asyncio +from contextlib import asynccontextmanager + +from stores.state_cache import StateCache +from stores.event_store import EventStore +from services.recovery_service import RecoveryService + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Application lifespan handler.""" + # Startup + print("Starting up...") + + # Initialize connections + app.state.redis = await create_redis_pool() + app.state.pg_pool = await create_pg_pool() + + app.state.state_cache = StateCache(app.state.redis) + app.state.event_store = EventStore(app.state.pg_pool) + app.state.recovery_service = RecoveryService( + app.state.event_store, + app.state.state_cache, + ) + + # Recover games + print("Recovering games from event store...") + results = await app.state.recovery_service.recover_all_games() + print(f"Recovery complete: {results['recovered']} recovered, " + f"{results['failed']} failed, {results['skipped']} skipped") + + # Start pub/sub + app.state.pubsub = GamePubSub(app.state.redis) + await app.state.pubsub.start() + + yield + + # Shutdown + print("Shutting down...") + + # Stop accepting new connections + await app.state.pubsub.stop() + + # Flush any pending state to Redis + await flush_pending_states(app) + + # Close connections + await app.state.redis.close() + await app.state.pg_pool.close() + + print("Shutdown complete") + + +async def flush_pending_states(app: FastAPI): + """Flush any in-memory state to Redis before shutdown.""" + # If we have any rooms with unsaved state, save them now + for room_code, room in rooms.items(): + if room.game and room.game.game_id: + try: + state = room.game.get_full_state() + await app.state.state_cache.save_game_state( + room.game.game_id, + state, + ) + except Exception as e: + print(f"Error flushing state for room {room_code}: {e}") + + +app = FastAPI(lifespan=lifespan) + + +# Handle SIGTERM gracefully +def handle_sigterm(signum, frame): + """Handle SIGTERM by initiating graceful shutdown.""" + raise KeyboardInterrupt() + +signal.signal(signal.SIGTERM, handle_sigterm) +``` + +--- + +## Integration with Game Service + +```python +# server/services/game_service.py +from stores.state_cache import StateCache +from stores.event_store import EventStore +from stores.pubsub import GamePubSub, PubSubMessage, MessageType + + +class GameService: + """ + Handles game commands with event sourcing. + Coordinates between event store, state cache, and pub/sub. + """ + + def __init__( + self, + event_store: EventStore, + state_cache: StateCache, + pubsub: GamePubSub, + ): + self.event_store = event_store + self.state_cache = state_cache + self.pubsub = pubsub + + async def handle_draw( + self, + game_id: str, + player_id: str, + source: str, + ) -> dict: + """Handle draw card command.""" + # 1. Get current state from cache + state = await self.state_cache.get_game_state(game_id) + if not state: + raise GameNotFoundError(game_id) + + # 2. Validate command + if state["current_player_id"] != player_id: + raise NotYourTurnError() + + # 3. Execute command (get card from deck/discard) + # This uses the existing game logic + game = self._load_game_from_state(state) + card = game.draw_card(player_id, source) + + if not card: + raise InvalidMoveError("Cannot draw from that source") + + # 4. Create event + event = GameEvent( + event_type=EventType.CARD_DRAWN, + game_id=game_id, + sequence_num=state["sequence_num"] + 1, + player_id=player_id, + data={"source": source, "card": card.to_dict()}, + ) + + # 5. Persist event + await self.event_store.append(event) + + # 6. Update cache + new_state = game.get_full_state() + new_state["sequence_num"] = event.sequence_num + await self.state_cache.save_game_state(game_id, new_state) + + # 7. Publish to other servers + await self.pubsub.publish(PubSubMessage( + type=MessageType.GAME_STATE_UPDATE, + room_code=state["room_code"], + data={"game_state": new_state}, + )) + + return new_state +``` + +--- + +## Acceptance Criteria + +1. **Redis State Cache Working** + - [ ] Can create/get/delete rooms + - [ ] Can add/remove players from rooms + - [ ] Can save/get/delete game state + - [ ] TTL expiration works correctly + - [ ] Room code uniqueness enforced + +2. **Pub/Sub Working** + - [ ] Can subscribe to room channels + - [ ] Can publish messages + - [ ] Messages received by all subscribers + - [ ] Handles disconnections gracefully + - [ ] Multiple servers can communicate + +3. **Game Recovery Working** + - [ ] Games recovered on startup + - [ ] State matches what was saved + - [ ] Partial recovery (from sequence) works + - [ ] Ended games not recovered + - [ ] Failed recoveries logged and skipped + +4. **Graceful Shutdown Working** + - [ ] SIGTERM triggers clean shutdown + - [ ] In-flight requests complete + - [ ] State flushed to Redis + - [ ] Connections closed cleanly + - [ ] No data loss on restart + +5. **Integration Tests** + - [ ] Server restart doesn't lose games + - [ ] Multi-server state sync works + - [ ] State cache matches event store + - [ ] Performance acceptable (<100ms for state ops) + +--- + +## Implementation Order + +1. Set up Redis locally (docker) +2. Implement StateCache class +3. Write StateCache tests +4. Implement GamePubSub class +5. Implement RecoveryService +6. Add lifespan handler to main.py +7. Integrate with game commands +8. Test full recovery cycle +9. Test multi-server pub/sub + +--- + +## Docker Setup for Development + +```yaml +# docker-compose.dev.yml +version: '3.8' +services: + redis: + image: redis:7-alpine + ports: + - "6379:6379" + volumes: + - redis_data:/data + command: redis-server --appendonly yes + + postgres: + image: postgres:16-alpine + ports: + - "5432:5432" + environment: + POSTGRES_USER: golf + POSTGRES_PASSWORD: devpassword + POSTGRES_DB: golf + volumes: + - postgres_data:/var/lib/postgresql/data + +volumes: + redis_data: + postgres_data: +``` + +```bash +# Start services +docker-compose -f docker-compose.dev.yml up -d + +# Connect to Redis CLI +docker exec -it golfgame_redis_1 redis-cli + +# Connect to PostgreSQL +docker exec -it golfgame_postgres_1 psql -U golf +``` + +--- + +## Notes for Agent + +- Redis operations should use pipelines for atomicity +- Consider Redis Cluster for production (but not needed initially) +- The state cache is a cache, not source of truth (events are) +- Pub/sub is best-effort; state sync should handle missed messages +- Test with multiple server instances locally +- Use connection pooling for both Redis and PostgreSQL diff --git a/docs/v2/V2_03_USER_ACCOUNTS.md b/docs/v2/V2_03_USER_ACCOUNTS.md new file mode 100644 index 0000000..a92e747 --- /dev/null +++ b/docs/v2/V2_03_USER_ACCOUNTS.md @@ -0,0 +1,1255 @@ +# V2-03: User Accounts & Authentication + +## Overview + +This document covers the complete user account lifecycle: registration, email verification, login, password reset, session management, and account settings. + +**Dependencies:** V2-02 (Persistence - PostgreSQL setup) +**Dependents:** V2-04 (Admin Tools), V2-05 (Stats/Leaderboards) + +--- + +## Goals + +1. Email service integration (Resend) +2. User registration with email verification +3. Password reset via email +4. Session management (view/revoke) +5. Account settings and preferences +6. Guest-to-user conversion flow +7. Account deletion (GDPR-friendly) + +--- + +## Current State + +Basic auth exists in `auth.py`: +- Username/password authentication +- Session tokens stored in SQLite +- Admin role support +- Invite codes for registration + +**Missing:** +- Email integration +- Email verification +- Password reset flow +- Session management UI +- Account deletion +- Guest accounts + +--- + +## User Flow Diagrams + +### Registration Flow + +``` +┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ +│ Enter │ │ Create │ │ Send │ │ Click │ +│ Email + │────►│ Pending │────►│ Verify │────►│ Verify │ +│ Password│ │ Account │ │ Email │ │ Link │ +└──────────┘ └──────────┘ └──────────┘ └──────────┘ + │ + ▼ + ┌──────────┐ + │ Account │ + │ Active │ + └──────────┘ +``` + +### Password Reset Flow + +``` +┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ +│ Enter │ │ Generate│ │ Send │ │ Click │ +│ Email │────►│ Reset │────►│ Reset │────►│ Reset │ +│ │ │ Token │ │ Email │ │ Link │ +└──────────┘ └──────────┘ └──────────┘ └──────────┘ + │ + ▼ + ┌──────────┐ + │ Enter │ + │ New │ + │ Password│ + └──────────┘ +``` + +### Guest Conversion Flow + +``` +┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ +│ Play as │ │ Prompt │ │ Enter │ │ Link │ +│ Guest │────►│ "Save │────►│ Email + │────►│ Guest │ +│ │ │ Stats?" │ │ Password│ │ to User │ +└──────────┘ └──────────┘ └──────────┘ └──────────┘ +``` + +--- + +## Database Schema + +```sql +-- migrations/versions/002_user_accounts.sql + +-- Extend existing users table +ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified BOOLEAN DEFAULT false; +ALTER TABLE users ADD COLUMN IF NOT EXISTS verification_token VARCHAR(255); +ALTER TABLE users ADD COLUMN IF NOT EXISTS verification_expires TIMESTAMPTZ; +ALTER TABLE users ADD COLUMN IF NOT EXISTS reset_token VARCHAR(255); +ALTER TABLE users ADD COLUMN IF NOT EXISTS reset_expires TIMESTAMPTZ; +ALTER TABLE users ADD COLUMN IF NOT EXISTS guest_id VARCHAR(50); -- Links to guest session +ALTER TABLE users ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; -- Soft delete +ALTER TABLE users ADD COLUMN IF NOT EXISTS preferences JSONB DEFAULT '{}'; + +-- Sessions table (replace or extend existing) +CREATE TABLE IF NOT EXISTS user_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash VARCHAR(255) NOT NULL UNIQUE, + device_info JSONB DEFAULT '{}', + ip_address INET, + created_at TIMESTAMPTZ DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + last_used_at TIMESTAMPTZ DEFAULT NOW(), + revoked_at TIMESTAMPTZ, + + -- Index for token lookups + CONSTRAINT idx_sessions_token UNIQUE (token_hash) +); + +-- Guest sessions (for guest-to-user conversion) +CREATE TABLE IF NOT EXISTS guest_sessions ( + id VARCHAR(50) PRIMARY KEY, -- UUID stored as string + display_name VARCHAR(50) NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + last_seen_at TIMESTAMPTZ DEFAULT NOW(), + games_played INT DEFAULT 0, + converted_to_user_id UUID REFERENCES users(id), + + -- Expire after 30 days of inactivity + expires_at TIMESTAMPTZ DEFAULT NOW() + INTERVAL '30 days' +); + +-- Email log (for debugging/audit) +CREATE TABLE IF NOT EXISTS email_log ( + id BIGSERIAL PRIMARY KEY, + user_id UUID REFERENCES users(id), + email_type VARCHAR(50) NOT NULL, -- verification, password_reset, etc. + recipient VARCHAR(255) NOT NULL, + sent_at TIMESTAMPTZ DEFAULT NOW(), + resend_id VARCHAR(100), -- ID from email provider + status VARCHAR(20) DEFAULT 'sent' +); + +-- Indexes +CREATE INDEX idx_users_email ON users(email) WHERE email IS NOT NULL; +CREATE INDEX idx_users_guest ON users(guest_id) WHERE guest_id IS NOT NULL; +CREATE INDEX idx_sessions_user ON user_sessions(user_id); +CREATE INDEX idx_sessions_expires ON user_sessions(expires_at) WHERE revoked_at IS NULL; +CREATE INDEX idx_guests_expires ON guest_sessions(expires_at); +``` + +--- + +## Email Service + +```python +# server/services/email_service.py +import resend +from typing import Optional +from datetime import datetime +import os + +from config import config + + +class EmailService: + """Email sending via Resend.""" + + def __init__(self): + resend.api_key = config.RESEND_API_KEY + self.from_email = config.EMAIL_FROM # e.g., "Golf Game " + self.base_url = config.BASE_URL # e.g., "https://golf.yourdomain.com" + + async def send_verification_email( + self, + to_email: str, + username: str, + verification_token: str, + ) -> Optional[str]: + """Send email verification link.""" + verify_url = f"{self.base_url}/verify?token={verification_token}" + + try: + response = resend.Emails.send({ + "from": self.from_email, + "to": to_email, + "subject": "Verify your Golf Game account", + "html": f""" +

Welcome to Golf Game, {username}!

+

Please verify your email address by clicking the link below:

+

Verify Email

+

Or copy this link: {verify_url}

+

This link expires in 24 hours.

+

If you didn't create this account, you can ignore this email.

+ """, + "text": f""" + Welcome to Golf Game, {username}! + + Please verify your email address by visiting: + {verify_url} + + This link expires in 24 hours. + + If you didn't create this account, you can ignore this email. + """, + }) + return response.get("id") + except Exception as e: + print(f"Failed to send verification email: {e}") + return None + + async def send_password_reset_email( + self, + to_email: str, + username: str, + reset_token: str, + ) -> Optional[str]: + """Send password reset link.""" + reset_url = f"{self.base_url}/reset-password?token={reset_token}" + + try: + response = resend.Emails.send({ + "from": self.from_email, + "to": to_email, + "subject": "Reset your Golf Game password", + "html": f""" +

Password Reset Request

+

Hi {username},

+

We received a request to reset your password. Click the link below to choose a new password:

+

Reset Password

+

Or copy this link: {reset_url}

+

This link expires in 1 hour.

+

If you didn't request this, you can ignore this email. Your password won't be changed.

+ """, + "text": f""" + Password Reset Request + + Hi {username}, + + We received a request to reset your password. Visit this link to choose a new password: + {reset_url} + + This link expires in 1 hour. + + If you didn't request this, you can ignore this email. Your password won't be changed. + """, + }) + return response.get("id") + except Exception as e: + print(f"Failed to send password reset email: {e}") + return None + + async def send_password_changed_notification( + self, + to_email: str, + username: str, + ) -> Optional[str]: + """Notify user their password was changed.""" + try: + response = resend.Emails.send({ + "from": self.from_email, + "to": to_email, + "subject": "Your Golf Game password was changed", + "html": f""" +

Password Changed

+

Hi {username},

+

Your Golf Game password was recently changed.

+

If you made this change, you can ignore this email.

+

If you didn't change your password, please reset it immediately and contact support.

+ """, + "text": f""" + Password Changed + + Hi {username}, + + Your Golf Game password was recently changed. + + If you made this change, you can ignore this email. + + If you didn't change your password, please reset it immediately at: + {self.base_url}/reset-password + """, + }) + return response.get("id") + except Exception as e: + print(f"Failed to send password changed notification: {e}") + return None +``` + +--- + +## Auth Service + +```python +# server/services/auth_service.py +import secrets +import hashlib +from datetime import datetime, timedelta +from typing import Optional, Tuple +from dataclasses import dataclass +import asyncpg +from passlib.hash import bcrypt + +from services.email_service import EmailService + + +@dataclass +class User: + id: str + username: str + email: Optional[str] + role: str + email_verified: bool + created_at: datetime + preferences: dict + + +@dataclass +class Session: + id: str + user_id: str + device_info: dict + ip_address: str + created_at: datetime + expires_at: datetime + last_used_at: datetime + + +class AuthService: + """User authentication and account management.""" + + TOKEN_EXPIRY_HOURS = 24 * 7 # 1 week + VERIFICATION_EXPIRY_HOURS = 24 + RESET_EXPIRY_HOURS = 1 + + def __init__(self, db_pool: asyncpg.Pool, email_service: EmailService): + self.db = db_pool + self.email = email_service + + # --- Registration --- + + async def register( + self, + username: str, + email: str, + password: str, + guest_id: Optional[str] = None, + ) -> Tuple[Optional[User], Optional[str]]: + """ + Register a new user. + Returns (user, error_message). + """ + # Validate input + if len(username) < 3 or len(username) > 30: + return None, "Username must be 3-30 characters" + + if not self._is_valid_email(email): + return None, "Invalid email address" + + if len(password) < 8: + return None, "Password must be at least 8 characters" + + async with self.db.acquire() as conn: + # Check if username or email exists + existing = await conn.fetchrow(""" + SELECT id FROM users + WHERE username = $1 OR email = $2 + """, username, email) + + if existing: + return None, "Username or email already registered" + + # Generate verification token + verification_token = secrets.token_urlsafe(32) + verification_expires = datetime.utcnow() + timedelta( + hours=self.VERIFICATION_EXPIRY_HOURS + ) + + # Hash password + password_hash = bcrypt.hash(password) + + # Create user + user_id = secrets.token_urlsafe(16) + + await conn.execute(""" + INSERT INTO users ( + id, username, email, password_hash, role, + email_verified, verification_token, verification_expires, + guest_id, created_at + ) VALUES ($1, $2, $3, $4, 'user', false, $5, $6, $7, NOW()) + """, user_id, username, email, password_hash, + verification_token, verification_expires, guest_id) + + # If converting from guest, link stats + if guest_id: + await self._convert_guest_stats(conn, guest_id, user_id) + + # Send verification email + await self.email.send_verification_email( + email, username, verification_token + ) + + return User( + id=user_id, + username=username, + email=email, + role="user", + email_verified=False, + created_at=datetime.utcnow(), + preferences={}, + ), None + + async def verify_email(self, token: str) -> Tuple[bool, str]: + """ + Verify email with token. + Returns (success, message). + """ + async with self.db.acquire() as conn: + user = await conn.fetchrow(""" + SELECT id, username, verification_expires + FROM users + WHERE verification_token = $1 + AND deleted_at IS NULL + """, token) + + if not user: + return False, "Invalid verification link" + + if user["verification_expires"] < datetime.utcnow(): + return False, "Verification link has expired" + + await conn.execute(""" + UPDATE users + SET email_verified = true, + verification_token = NULL, + verification_expires = NULL + WHERE id = $1 + """, user["id"]) + + return True, "Email verified successfully" + + async def resend_verification(self, email: str) -> Tuple[bool, str]: + """Resend verification email.""" + async with self.db.acquire() as conn: + user = await conn.fetchrow(""" + SELECT id, username, email_verified + FROM users + WHERE email = $1 + AND deleted_at IS NULL + """, email) + + if not user: + # Don't reveal if email exists + return True, "If that email is registered, a verification link has been sent" + + if user["email_verified"]: + return False, "Email is already verified" + + # Generate new token + verification_token = secrets.token_urlsafe(32) + verification_expires = datetime.utcnow() + timedelta( + hours=self.VERIFICATION_EXPIRY_HOURS + ) + + await conn.execute(""" + UPDATE users + SET verification_token = $1, verification_expires = $2 + WHERE id = $3 + """, verification_token, verification_expires, user["id"]) + + await self.email.send_verification_email( + email, user["username"], verification_token + ) + + return True, "Verification email sent" + + # --- Login --- + + async def login( + self, + username_or_email: str, + password: str, + device_info: dict, + ip_address: str, + ) -> Tuple[Optional[str], Optional[User], Optional[str]]: + """ + Login user. + Returns (session_token, user, error_message). + """ + async with self.db.acquire() as conn: + user = await conn.fetchrow(""" + SELECT id, username, email, password_hash, role, + email_verified, preferences, created_at + FROM users + WHERE (username = $1 OR email = $1) + AND deleted_at IS NULL + """, username_or_email) + + if not user: + return None, None, "Invalid username or password" + + if not bcrypt.verify(password, user["password_hash"]): + return None, None, "Invalid username or password" + + # Check email verification (optional - can allow login without) + # if not user["email_verified"]: + # return None, None, "Please verify your email first" + + # Create session + session_token = secrets.token_urlsafe(32) + token_hash = hashlib.sha256(session_token.encode()).hexdigest() + expires_at = datetime.utcnow() + timedelta(hours=self.TOKEN_EXPIRY_HOURS) + + await conn.execute(""" + INSERT INTO user_sessions ( + user_id, token_hash, device_info, ip_address, expires_at + ) VALUES ($1, $2, $3, $4, $5) + """, user["id"], token_hash, device_info, ip_address, expires_at) + + # Update last login + await conn.execute(""" + UPDATE users SET last_seen_at = NOW() WHERE id = $1 + """, user["id"]) + + return session_token, User( + id=user["id"], + username=user["username"], + email=user["email"], + role=user["role"], + email_verified=user["email_verified"], + created_at=user["created_at"], + preferences=user["preferences"] or {}, + ), None + + async def logout(self, session_token: str) -> bool: + """Logout (revoke session).""" + token_hash = hashlib.sha256(session_token.encode()).hexdigest() + + async with self.db.acquire() as conn: + result = await conn.execute(""" + UPDATE user_sessions + SET revoked_at = NOW() + WHERE token_hash = $1 + AND revoked_at IS NULL + """, token_hash) + + return result != "UPDATE 0" + + async def logout_all(self, user_id: str, except_token: Optional[str] = None) -> int: + """Logout all sessions for a user.""" + async with self.db.acquire() as conn: + if except_token: + except_hash = hashlib.sha256(except_token.encode()).hexdigest() + result = await conn.execute(""" + UPDATE user_sessions + SET revoked_at = NOW() + WHERE user_id = $1 + AND revoked_at IS NULL + AND token_hash != $2 + """, user_id, except_hash) + else: + result = await conn.execute(""" + UPDATE user_sessions + SET revoked_at = NOW() + WHERE user_id = $1 + AND revoked_at IS NULL + """, user_id) + + # Parse "UPDATE N" to get count + return int(result.split()[1]) + + async def validate_session(self, session_token: str) -> Optional[User]: + """Validate session token, return user if valid.""" + token_hash = hashlib.sha256(session_token.encode()).hexdigest() + + async with self.db.acquire() as conn: + row = await conn.fetchrow(""" + SELECT u.id, u.username, u.email, u.role, + u.email_verified, u.preferences, u.created_at, + s.id as session_id + FROM user_sessions s + JOIN users u ON s.user_id = u.id + WHERE s.token_hash = $1 + AND s.revoked_at IS NULL + AND s.expires_at > NOW() + AND u.deleted_at IS NULL + """, token_hash) + + if not row: + return None + + # Update last used + await conn.execute(""" + UPDATE user_sessions SET last_used_at = NOW() WHERE id = $1 + """, row["session_id"]) + + return User( + id=row["id"], + username=row["username"], + email=row["email"], + role=row["role"], + email_verified=row["email_verified"], + created_at=row["created_at"], + preferences=row["preferences"] or {}, + ) + + # --- Password Reset --- + + async def request_password_reset(self, email: str) -> Tuple[bool, str]: + """Request password reset email.""" + async with self.db.acquire() as conn: + user = await conn.fetchrow(""" + SELECT id, username, email + FROM users + WHERE email = $1 + AND deleted_at IS NULL + """, email) + + if not user: + # Don't reveal if email exists + return True, "If that email is registered, a reset link has been sent" + + # Generate reset token + reset_token = secrets.token_urlsafe(32) + reset_expires = datetime.utcnow() + timedelta( + hours=self.RESET_EXPIRY_HOURS + ) + + await conn.execute(""" + UPDATE users + SET reset_token = $1, reset_expires = $2 + WHERE id = $3 + """, reset_token, reset_expires, user["id"]) + + await self.email.send_password_reset_email( + email, user["username"], reset_token + ) + + return True, "Reset link sent" + + async def reset_password( + self, + token: str, + new_password: str, + ) -> Tuple[bool, str]: + """Reset password with token.""" + if len(new_password) < 8: + return False, "Password must be at least 8 characters" + + async with self.db.acquire() as conn: + user = await conn.fetchrow(""" + SELECT id, username, email, reset_expires + FROM users + WHERE reset_token = $1 + AND deleted_at IS NULL + """, token) + + if not user: + return False, "Invalid reset link" + + if user["reset_expires"] < datetime.utcnow(): + return False, "Reset link has expired" + + # Update password + password_hash = bcrypt.hash(new_password) + + await conn.execute(""" + UPDATE users + SET password_hash = $1, + reset_token = NULL, + reset_expires = NULL + WHERE id = $2 + """, password_hash, user["id"]) + + # Revoke all sessions (security) + await conn.execute(""" + UPDATE user_sessions + SET revoked_at = NOW() + WHERE user_id = $1 + """, user["id"]) + + # Notify user + await self.email.send_password_changed_notification( + user["email"], user["username"] + ) + + return True, "Password updated successfully" + + async def change_password( + self, + user_id: str, + current_password: str, + new_password: str, + ) -> Tuple[bool, str]: + """Change password (when logged in).""" + if len(new_password) < 8: + return False, "Password must be at least 8 characters" + + async with self.db.acquire() as conn: + user = await conn.fetchrow(""" + SELECT id, username, email, password_hash + FROM users + WHERE id = $1 + AND deleted_at IS NULL + """, user_id) + + if not user: + return False, "User not found" + + if not bcrypt.verify(current_password, user["password_hash"]): + return False, "Current password is incorrect" + + # Update password + password_hash = bcrypt.hash(new_password) + + await conn.execute(""" + UPDATE users SET password_hash = $1 WHERE id = $2 + """, password_hash, user["id"]) + + # Notify user + if user["email"]: + await self.email.send_password_changed_notification( + user["email"], user["username"] + ) + + return True, "Password updated successfully" + + # --- Session Management --- + + async def get_sessions(self, user_id: str) -> list[Session]: + """Get all active sessions for a user.""" + async with self.db.acquire() as conn: + rows = await conn.fetch(""" + SELECT id, user_id, device_info, ip_address, + created_at, expires_at, last_used_at + FROM user_sessions + WHERE user_id = $1 + AND revoked_at IS NULL + AND expires_at > NOW() + ORDER BY last_used_at DESC + """, user_id) + + return [ + Session( + id=row["id"], + user_id=row["user_id"], + device_info=row["device_info"] or {}, + ip_address=str(row["ip_address"]) if row["ip_address"] else "", + created_at=row["created_at"], + expires_at=row["expires_at"], + last_used_at=row["last_used_at"], + ) + for row in rows + ] + + async def revoke_session(self, user_id: str, session_id: str) -> bool: + """Revoke a specific session.""" + async with self.db.acquire() as conn: + result = await conn.execute(""" + UPDATE user_sessions + SET revoked_at = NOW() + WHERE id = $1 + AND user_id = $2 + AND revoked_at IS NULL + """, session_id, user_id) + + return result != "UPDATE 0" + + # --- Account Management --- + + async def update_preferences( + self, + user_id: str, + preferences: dict, + ) -> bool: + """Update user preferences.""" + async with self.db.acquire() as conn: + await conn.execute(""" + UPDATE users + SET preferences = preferences || $1 + WHERE id = $2 + """, preferences, user_id) + + return True + + async def delete_account( + self, + user_id: str, + password: str, + ) -> Tuple[bool, str]: + """ + Soft-delete account. + Anonymizes data but preserves game history. + """ + async with self.db.acquire() as conn: + user = await conn.fetchrow(""" + SELECT id, password_hash + FROM users + WHERE id = $1 + AND deleted_at IS NULL + """, user_id) + + if not user: + return False, "User not found" + + if not bcrypt.verify(password, user["password_hash"]): + return False, "Incorrect password" + + # Soft delete - anonymize PII but keep ID for game history + deleted_username = f"deleted_{user_id[:8]}" + + await conn.execute(""" + UPDATE users + SET username = $1, + email = NULL, + password_hash = '', + deleted_at = NOW(), + preferences = '{}' + WHERE id = $2 + """, deleted_username, user_id) + + # Revoke all sessions + await conn.execute(""" + UPDATE user_sessions + SET revoked_at = NOW() + WHERE user_id = $1 + """, user_id) + + return True, "Account deleted" + + # --- Guest Conversion --- + + async def _convert_guest_stats( + self, + conn: asyncpg.Connection, + guest_id: str, + user_id: str, + ) -> None: + """Transfer guest stats to user account.""" + # Mark guest as converted + await conn.execute(""" + UPDATE guest_sessions + SET converted_to_user_id = $1 + WHERE id = $2 + """, user_id, guest_id) + + # Update game records to link to user + await conn.execute(""" + UPDATE games_v2 + SET player_ids = array_replace(player_ids, $1, $2) + WHERE $1 = ANY(player_ids) + """, guest_id, user_id) + + # --- Helpers --- + + def _is_valid_email(self, email: str) -> bool: + """Basic email validation.""" + import re + pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' + return bool(re.match(pattern, email)) +``` + +--- + +## API Endpoints + +```python +# server/routers/auth.py +from fastapi import APIRouter, Depends, HTTPException, Request +from pydantic import BaseModel, EmailStr + +router = APIRouter(prefix="/api/auth", tags=["auth"]) + + +class RegisterRequest(BaseModel): + username: str + email: EmailStr + password: str + guest_id: Optional[str] = None + + +class LoginRequest(BaseModel): + username_or_email: str + password: str + + +class PasswordResetRequest(BaseModel): + email: EmailStr + + +class PasswordResetConfirm(BaseModel): + token: str + new_password: str + + +class ChangePasswordRequest(BaseModel): + current_password: str + new_password: str + + +@router.post("/register") +async def register( + request: RegisterRequest, + auth: AuthService = Depends(get_auth_service), +): + user, error = await auth.register( + request.username, + request.email, + request.password, + request.guest_id, + ) + if error: + raise HTTPException(status_code=400, detail=error) + + return { + "message": "Registration successful. Please check your email to verify your account.", + "user_id": user.id, + } + + +@router.post("/verify-email") +async def verify_email( + token: str, + auth: AuthService = Depends(get_auth_service), +): + success, message = await auth.verify_email(token) + if not success: + raise HTTPException(status_code=400, detail=message) + return {"message": message} + + +@router.post("/resend-verification") +async def resend_verification( + email: EmailStr, + auth: AuthService = Depends(get_auth_service), +): + success, message = await auth.resend_verification(email) + return {"message": message} + + +@router.post("/login") +async def login( + request: LoginRequest, + req: Request, + auth: AuthService = Depends(get_auth_service), +): + device_info = { + "user_agent": req.headers.get("user-agent", ""), + } + ip_address = req.client.host + + token, user, error = await auth.login( + request.username_or_email, + request.password, + device_info, + ip_address, + ) + + if error: + raise HTTPException(status_code=401, detail=error) + + return { + "token": token, + "user": { + "id": user.id, + "username": user.username, + "email": user.email, + "role": user.role, + "email_verified": user.email_verified, + }, + } + + +@router.post("/logout") +async def logout( + user: User = Depends(get_current_user), + token: str = Depends(get_token), + auth: AuthService = Depends(get_auth_service), +): + await auth.logout(token) + return {"message": "Logged out"} + + +@router.post("/logout-all") +async def logout_all( + user: User = Depends(get_current_user), + token: str = Depends(get_token), + auth: AuthService = Depends(get_auth_service), +): + count = await auth.logout_all(user.id, except_token=token) + return {"message": f"Logged out {count} other sessions"} + + +@router.post("/forgot-password") +async def forgot_password( + request: PasswordResetRequest, + auth: AuthService = Depends(get_auth_service), +): + success, message = await auth.request_password_reset(request.email) + return {"message": message} + + +@router.post("/reset-password") +async def reset_password( + request: PasswordResetConfirm, + auth: AuthService = Depends(get_auth_service), +): + success, message = await auth.reset_password(request.token, request.new_password) + if not success: + raise HTTPException(status_code=400, detail=message) + return {"message": message} + + +@router.put("/password") +async def change_password( + request: ChangePasswordRequest, + user: User = Depends(get_current_user), + auth: AuthService = Depends(get_auth_service), +): + success, message = await auth.change_password( + user.id, + request.current_password, + request.new_password, + ) + if not success: + raise HTTPException(status_code=400, detail=message) + return {"message": message} + + +@router.get("/sessions") +async def get_sessions( + user: User = Depends(get_current_user), + auth: AuthService = Depends(get_auth_service), +): + sessions = await auth.get_sessions(user.id) + return { + "sessions": [ + { + "id": s.id, + "device_info": s.device_info, + "ip_address": s.ip_address, + "created_at": s.created_at.isoformat(), + "last_used_at": s.last_used_at.isoformat(), + } + for s in sessions + ] + } + + +@router.delete("/sessions/{session_id}") +async def revoke_session( + session_id: str, + user: User = Depends(get_current_user), + auth: AuthService = Depends(get_auth_service), +): + success = await auth.revoke_session(user.id, session_id) + if not success: + raise HTTPException(status_code=404, detail="Session not found") + return {"message": "Session revoked"} + + +@router.get("/me") +async def get_me(user: User = Depends(get_current_user)): + return { + "id": user.id, + "username": user.username, + "email": user.email, + "role": user.role, + "email_verified": user.email_verified, + "preferences": user.preferences, + } + + +@router.put("/preferences") +async def update_preferences( + preferences: dict, + user: User = Depends(get_current_user), + auth: AuthService = Depends(get_auth_service), +): + await auth.update_preferences(user.id, preferences) + return {"message": "Preferences updated"} + + +@router.delete("/account") +async def delete_account( + password: str, + user: User = Depends(get_current_user), + auth: AuthService = Depends(get_auth_service), +): + success, message = await auth.delete_account(user.id, password) + if not success: + raise HTTPException(status_code=400, detail=message) + return {"message": message} +``` + +--- + +## Frontend Integration + +### Login/Register UI + +Add to `client/index.html`: + +```html + + +``` + +--- + +## Config Additions + +```python +# server/config.py additions + +class Config: + # Email + RESEND_API_KEY: str = os.getenv("RESEND_API_KEY", "") + EMAIL_FROM: str = os.getenv("EMAIL_FROM", "Golf Game ") + BASE_URL: str = os.getenv("BASE_URL", "http://localhost:8000") + + # Auth + SESSION_EXPIRY_HOURS: int = int(os.getenv("SESSION_EXPIRY_HOURS", "168")) # 1 week + REQUIRE_EMAIL_VERIFICATION: bool = os.getenv("REQUIRE_EMAIL_VERIFICATION", "false").lower() == "true" +``` + +--- + +## Acceptance Criteria + +1. **Email Service** + - [ ] Resend integration working + - [ ] Verification emails send correctly + - [ ] Password reset emails send correctly + - [ ] Password changed notifications work + - [ ] Email delivery logged + +2. **Registration** + - [ ] Can register with username/email/password + - [ ] Validation enforced (username length, email format, password strength) + - [ ] Duplicate detection works + - [ ] Verification email sent + - [ ] Guest ID can be linked + +3. **Email Verification** + - [ ] Verification link works + - [ ] Expired links rejected + - [ ] Can resend verification + - [ ] Verified flag set correctly + +4. **Login/Logout** + - [ ] Can login with username or email + - [ ] Wrong credentials rejected + - [ ] Session token returned + - [ ] Logout revokes session + - [ ] Logout all works + +5. **Password Reset** + - [ ] Reset request sends email + - [ ] Reset link works + - [ ] Expired links rejected + - [ ] Password updated correctly + - [ ] All sessions revoked after reset + - [ ] Notification email sent + +6. **Session Management** + - [ ] Can list active sessions + - [ ] Can revoke individual sessions + - [ ] Session shows device info + - [ ] Expired sessions cleaned up + +7. **Account Management** + - [ ] Can update preferences + - [ ] Can change password (with current password) + - [ ] Can delete account (with password) + - [ ] Deleted accounts anonymized + - [ ] Game history preserved + +8. **Guest Conversion** + - [ ] Can play as guest + - [ ] Guest prompted to register + - [ ] Stats transfer on conversion + - [ ] Guest ID linked to user + +--- + +## Implementation Order + +1. Set up Resend account and get API key +2. Add email service config +3. Create database migrations +4. Implement EmailService +5. Implement AuthService (registration first) +6. Add API endpoints +7. Implement login/session management +8. Implement password reset flow +9. Add frontend UI +10. Test full flows + +--- + +## Security Notes + +- Store only token hashes, not tokens +- Use bcrypt for passwords (work factor 12+) +- Rate limit auth endpoints (see V2-07) +- Verification/reset tokens expire +- Notify on password change +- Soft-delete preserves audit trail +- Don't reveal if email exists (timing attacks) diff --git a/docs/v2/V2_04_ADMIN_TOOLS.md b/docs/v2/V2_04_ADMIN_TOOLS.md new file mode 100644 index 0000000..acf172f --- /dev/null +++ b/docs/v2/V2_04_ADMIN_TOOLS.md @@ -0,0 +1,1179 @@ +# V2-04: Admin Tools & Moderation + +## Overview + +This document covers admin capabilities: user management, game moderation, system monitoring, and audit logging. + +**Dependencies:** V2-03 (User Accounts) +**Dependents:** None (end feature) + +--- + +## Goals + +1. Admin dashboard with system overview +2. User management (search, view, ban, unban) +3. Force password reset capability +4. Game moderation (view any game, end stuck games) +5. System statistics and monitoring +6. Invite code management +7. Audit logging for admin actions + +--- + +## Current State + +Basic admin exists: +- Admin role in users table +- Some admin endpoints in `main.py` +- Invite code creation + +**Missing:** +- Admin dashboard UI +- User search/management +- Game moderation +- System stats +- Audit logging + +--- + +## Admin Capabilities Matrix + +| Capability | Description | Risk Level | +|------------|-------------|------------| +| View users | List, search, view user details | Low | +| Ban user | Prevent login, kick from games | Medium | +| Unban user | Restore access | Low | +| Force password reset | Invalidate password, require reset | Medium | +| Impersonate user | View as user (read-only) | High | +| View any game | See any game state | Low | +| End stuck game | Force-end a game | Medium | +| View system stats | Metrics and monitoring | Low | +| Manage invite codes | Create, revoke codes | Low | +| View audit log | See admin actions | Low | + +--- + +## Database Schema + +```sql +-- migrations/versions/003_admin_tools.sql + +-- Audit log for admin actions +CREATE TABLE admin_audit_log ( + id BIGSERIAL PRIMARY KEY, + admin_user_id UUID NOT NULL REFERENCES users(id), + action VARCHAR(50) NOT NULL, + target_type VARCHAR(50), -- 'user', 'game', 'invite_code', etc. + target_id VARCHAR(100), + details JSONB DEFAULT '{}', + ip_address INET, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- User bans +CREATE TABLE user_bans ( + id BIGSERIAL PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id), + banned_by UUID NOT NULL REFERENCES users(id), + reason TEXT, + banned_at TIMESTAMPTZ DEFAULT NOW(), + expires_at TIMESTAMPTZ, -- NULL = permanent + unbanned_at TIMESTAMPTZ, + unbanned_by UUID REFERENCES users(id) +); + +-- Extend users table +ALTER TABLE users ADD COLUMN IF NOT EXISTS is_banned BOOLEAN DEFAULT false; +ALTER TABLE users ADD COLUMN IF NOT EXISTS ban_reason TEXT; +ALTER TABLE users ADD COLUMN IF NOT EXISTS force_password_reset BOOLEAN DEFAULT false; + +-- System metrics snapshots (for historical data) +CREATE TABLE system_metrics ( + id BIGSERIAL PRIMARY KEY, + recorded_at TIMESTAMPTZ DEFAULT NOW(), + active_users INT, + active_games INT, + events_last_hour INT, + registrations_today INT, + games_completed_today INT, + metrics JSONB DEFAULT '{}' +); + +-- Indexes +CREATE INDEX idx_audit_admin ON admin_audit_log(admin_user_id); +CREATE INDEX idx_audit_target ON admin_audit_log(target_type, target_id); +CREATE INDEX idx_audit_created ON admin_audit_log(created_at); +CREATE INDEX idx_bans_user ON user_bans(user_id); +CREATE INDEX idx_bans_active ON user_bans(user_id) WHERE unbanned_at IS NULL; +CREATE INDEX idx_metrics_time ON system_metrics(recorded_at); +``` + +--- + +## Admin Service + +```python +# server/services/admin_service.py +from dataclasses import dataclass +from datetime import datetime, timedelta +from typing import Optional, List +import asyncpg + +from services.auth_service import User + + +@dataclass +class UserDetails: + id: str + username: str + email: Optional[str] + role: str + email_verified: bool + is_banned: bool + ban_reason: Optional[str] + force_password_reset: bool + created_at: datetime + last_seen_at: Optional[datetime] + games_played: int + games_won: int + + +@dataclass +class AuditEntry: + id: int + admin_username: str + action: str + target_type: Optional[str] + target_id: Optional[str] + details: dict + ip_address: str + created_at: datetime + + +@dataclass +class SystemStats: + active_users_now: int + active_games_now: int + total_users: int + total_games_completed: int + registrations_today: int + registrations_week: int + games_today: int + events_last_hour: int + top_players: List[dict] + + +class AdminService: + """Admin operations and moderation.""" + + def __init__(self, db_pool: asyncpg.Pool, state_cache): + self.db = db_pool + self.state_cache = state_cache + + # --- Audit Logging --- + + async def _audit( + self, + admin_id: str, + action: str, + target_type: Optional[str] = None, + target_id: Optional[str] = None, + details: dict = None, + ip_address: str = None, + ) -> None: + """Log an admin action.""" + async with self.db.acquire() as conn: + await conn.execute(""" + INSERT INTO admin_audit_log + (admin_user_id, action, target_type, target_id, details, ip_address) + VALUES ($1, $2, $3, $4, $5, $6) + """, admin_id, action, target_type, target_id, + details or {}, ip_address) + + async def get_audit_log( + self, + limit: int = 100, + offset: int = 0, + admin_id: Optional[str] = None, + action: Optional[str] = None, + target_type: Optional[str] = None, + ) -> List[AuditEntry]: + """Get audit log entries with filtering.""" + async with self.db.acquire() as conn: + query = """ + SELECT a.id, u.username as admin_username, a.action, + a.target_type, a.target_id, a.details, + a.ip_address, a.created_at + FROM admin_audit_log a + JOIN users u ON a.admin_user_id = u.id + WHERE 1=1 + """ + params = [] + param_num = 1 + + if admin_id: + query += f" AND a.admin_user_id = ${param_num}" + params.append(admin_id) + param_num += 1 + + if action: + query += f" AND a.action = ${param_num}" + params.append(action) + param_num += 1 + + if target_type: + query += f" AND a.target_type = ${param_num}" + params.append(target_type) + param_num += 1 + + query += f" ORDER BY a.created_at DESC LIMIT ${param_num} OFFSET ${param_num + 1}" + params.extend([limit, offset]) + + rows = await conn.fetch(query, *params) + + return [ + AuditEntry( + id=row["id"], + admin_username=row["admin_username"], + action=row["action"], + target_type=row["target_type"], + target_id=row["target_id"], + details=row["details"] or {}, + ip_address=str(row["ip_address"]) if row["ip_address"] else "", + created_at=row["created_at"], + ) + for row in rows + ] + + # --- User Management --- + + async def search_users( + self, + query: str = "", + limit: int = 50, + offset: int = 0, + include_banned: bool = True, + include_deleted: bool = False, + ) -> List[UserDetails]: + """Search users by username or email.""" + async with self.db.acquire() as conn: + sql = """ + SELECT u.id, u.username, u.email, u.role, + u.email_verified, u.is_banned, u.ban_reason, + u.force_password_reset, u.created_at, u.last_seen_at, + COALESCE(s.games_played, 0) as games_played, + COALESCE(s.games_won, 0) as games_won + FROM users u + LEFT JOIN player_stats s ON u.id = s.user_id + WHERE 1=1 + """ + params = [] + param_num = 1 + + if query: + sql += f" AND (u.username ILIKE ${param_num} OR u.email ILIKE ${param_num})" + params.append(f"%{query}%") + param_num += 1 + + if not include_banned: + sql += " AND u.is_banned = false" + + if not include_deleted: + sql += " AND u.deleted_at IS NULL" + + sql += f" ORDER BY u.created_at DESC LIMIT ${param_num} OFFSET ${param_num + 1}" + params.extend([limit, offset]) + + rows = await conn.fetch(sql, *params) + + return [ + UserDetails( + id=row["id"], + username=row["username"], + email=row["email"], + role=row["role"], + email_verified=row["email_verified"], + is_banned=row["is_banned"], + ban_reason=row["ban_reason"], + force_password_reset=row["force_password_reset"], + created_at=row["created_at"], + last_seen_at=row["last_seen_at"], + games_played=row["games_played"], + games_won=row["games_won"], + ) + for row in rows + ] + + async def get_user(self, user_id: str) -> Optional[UserDetails]: + """Get detailed user info.""" + users = await self.search_users() # Simplified; would filter by ID + async with self.db.acquire() as conn: + row = await conn.fetchrow(""" + SELECT u.id, u.username, u.email, u.role, + u.email_verified, u.is_banned, u.ban_reason, + u.force_password_reset, u.created_at, u.last_seen_at, + COALESCE(s.games_played, 0) as games_played, + COALESCE(s.games_won, 0) as games_won + FROM users u + LEFT JOIN player_stats s ON u.id = s.user_id + WHERE u.id = $1 + """, user_id) + + if not row: + return None + + return UserDetails( + id=row["id"], + username=row["username"], + email=row["email"], + role=row["role"], + email_verified=row["email_verified"], + is_banned=row["is_banned"], + ban_reason=row["ban_reason"], + force_password_reset=row["force_password_reset"], + created_at=row["created_at"], + last_seen_at=row["last_seen_at"], + games_played=row["games_played"], + games_won=row["games_won"], + ) + + async def ban_user( + self, + admin_id: str, + user_id: str, + reason: str, + duration_days: Optional[int] = None, + ip_address: str = None, + ) -> bool: + """Ban a user.""" + expires_at = None + if duration_days: + expires_at = datetime.utcnow() + timedelta(days=duration_days) + + async with self.db.acquire() as conn: + # Check user exists and isn't admin + user = await conn.fetchrow(""" + SELECT role FROM users WHERE id = $1 + """, user_id) + + if not user: + return False + + if user["role"] == "admin": + return False # Can't ban admins + + # Create ban record + await conn.execute(""" + INSERT INTO user_bans (user_id, banned_by, reason, expires_at) + VALUES ($1, $2, $3, $4) + """, user_id, admin_id, reason, expires_at) + + # Update user + await conn.execute(""" + UPDATE users + SET is_banned = true, ban_reason = $1 + WHERE id = $2 + """, reason, user_id) + + # Revoke all sessions + await conn.execute(""" + UPDATE user_sessions + SET revoked_at = NOW() + WHERE user_id = $1 + """, user_id) + + # Kick from any active games + await self._kick_from_games(user_id) + + # Audit + await self._audit( + admin_id, "ban_user", "user", user_id, + {"reason": reason, "duration_days": duration_days}, + ip_address, + ) + + return True + + async def unban_user( + self, + admin_id: str, + user_id: str, + ip_address: str = None, + ) -> bool: + """Unban a user.""" + async with self.db.acquire() as conn: + # Update ban record + await conn.execute(""" + UPDATE user_bans + SET unbanned_at = NOW(), unbanned_by = $1 + WHERE user_id = $2 + AND unbanned_at IS NULL + """, admin_id, user_id) + + # Update user + result = await conn.execute(""" + UPDATE users + SET is_banned = false, ban_reason = NULL + WHERE id = $1 + """, user_id) + + if result == "UPDATE 0": + return False + + await self._audit( + admin_id, "unban_user", "user", user_id, + ip_address=ip_address, + ) + + return True + + async def force_password_reset( + self, + admin_id: str, + user_id: str, + ip_address: str = None, + ) -> bool: + """Force user to reset password on next login.""" + async with self.db.acquire() as conn: + result = await conn.execute(""" + UPDATE users + SET force_password_reset = true, + password_hash = '' + WHERE id = $1 + """, user_id) + + if result == "UPDATE 0": + return False + + # Revoke all sessions + await conn.execute(""" + UPDATE user_sessions + SET revoked_at = NOW() + WHERE user_id = $1 + """, user_id) + + await self._audit( + admin_id, "force_password_reset", "user", user_id, + ip_address=ip_address, + ) + + return True + + async def change_user_role( + self, + admin_id: str, + user_id: str, + new_role: str, + ip_address: str = None, + ) -> bool: + """Change user role (user/admin).""" + if new_role not in ("user", "admin"): + return False + + async with self.db.acquire() as conn: + # Get old role for audit + old = await conn.fetchrow(""" + SELECT role FROM users WHERE id = $1 + """, user_id) + + if not old: + return False + + await conn.execute(""" + UPDATE users SET role = $1 WHERE id = $2 + """, new_role, user_id) + + await self._audit( + admin_id, "change_role", "user", user_id, + {"old_role": old["role"], "new_role": new_role}, + ip_address, + ) + + return True + + # --- Game Moderation --- + + async def get_active_games(self) -> List[dict]: + """Get all active games.""" + rooms = await self.state_cache.get_active_rooms() + games = [] + + for room_code in rooms: + room = await self.state_cache.get_room(room_code) + if room: + game_id = room.get("game_id") + state = None + if game_id: + state = await self.state_cache.get_game_state(game_id) + + games.append({ + "room_code": room_code, + "game_id": game_id, + "status": room.get("status"), + "created_at": room.get("created_at"), + "player_count": len(await self.state_cache.get_room_players(room_code)), + "phase": state.get("phase") if state else None, + "current_round": state.get("current_round") if state else None, + }) + + return games + + async def get_game_details( + self, + admin_id: str, + game_id: str, + ip_address: str = None, + ) -> Optional[dict]: + """Get full game state (admin view).""" + state = await self.state_cache.get_game_state(game_id) + + if state: + await self._audit( + admin_id, "view_game", "game", game_id, + ip_address=ip_address, + ) + + return state + + async def end_game( + self, + admin_id: str, + game_id: str, + reason: str, + ip_address: str = None, + ) -> bool: + """Force-end a stuck game.""" + state = await self.state_cache.get_game_state(game_id) + if not state: + return False + + room_code = state.get("room_code") + + # Mark game as ended + state["phase"] = "game_over" + state["admin_ended"] = True + state["admin_end_reason"] = reason + await self.state_cache.save_game_state(game_id, state) + + # Update games table + async with self.db.acquire() as conn: + await conn.execute(""" + UPDATE games_v2 + SET status = 'abandoned', + completed_at = NOW() + WHERE id = $1 + """, game_id) + + # Notify players via pub/sub + # (Implementation depends on pub/sub setup) + + await self._audit( + admin_id, "end_game", "game", game_id, + {"reason": reason, "room_code": room_code}, + ip_address, + ) + + return True + + async def _kick_from_games(self, user_id: str) -> None: + """Kick user from any active games.""" + player_room = await self.state_cache.get_player_room(user_id) + if player_room: + await self.state_cache.remove_player_from_room(player_room, user_id) + # Additional game-specific kick logic + + # --- System Stats --- + + async def get_system_stats(self) -> SystemStats: + """Get current system statistics.""" + # Active counts from Redis + active_rooms = await self.state_cache.get_active_rooms() + active_games = len([r for r in active_rooms]) # Could filter by status + + async with self.db.acquire() as conn: + # Total users + total_users = await conn.fetchval(""" + SELECT COUNT(*) FROM users WHERE deleted_at IS NULL + """) + + # Total completed games + total_games = await conn.fetchval(""" + SELECT COUNT(*) FROM games_v2 WHERE status = 'completed' + """) + + # Registrations today + reg_today = await conn.fetchval(""" + SELECT COUNT(*) FROM users + WHERE created_at >= CURRENT_DATE + AND deleted_at IS NULL + """) + + # Registrations this week + reg_week = await conn.fetchval(""" + SELECT COUNT(*) FROM users + WHERE created_at >= CURRENT_DATE - INTERVAL '7 days' + AND deleted_at IS NULL + """) + + # Games today + games_today = await conn.fetchval(""" + SELECT COUNT(*) FROM games_v2 + WHERE created_at >= CURRENT_DATE + """) + + # Events last hour + events_hour = await conn.fetchval(""" + SELECT COUNT(*) FROM events + WHERE created_at >= NOW() - INTERVAL '1 hour' + """) + + # Top players (by wins) + top_players = await conn.fetch(""" + SELECT u.username, s.games_won, s.games_played + FROM player_stats s + JOIN users u ON s.user_id = u.id + WHERE s.games_played >= 5 + ORDER BY s.games_won DESC + LIMIT 10 + """) + + # Active users (sessions used in last hour) + active_users = await conn.fetchval(""" + SELECT COUNT(DISTINCT user_id) + FROM user_sessions + WHERE last_used_at >= NOW() - INTERVAL '1 hour' + AND revoked_at IS NULL + """) + + return SystemStats( + active_users_now=active_users or 0, + active_games_now=active_games, + total_users=total_users or 0, + total_games_completed=total_games or 0, + registrations_today=reg_today or 0, + registrations_week=reg_week or 0, + games_today=games_today or 0, + events_last_hour=events_hour or 0, + top_players=[ + { + "username": p["username"], + "games_won": p["games_won"], + "games_played": p["games_played"], + } + for p in top_players + ], + ) + + # --- Invite Codes --- + + async def create_invite_code( + self, + admin_id: str, + max_uses: int = 1, + expires_days: int = 7, + ip_address: str = None, + ) -> str: + """Create a new invite code.""" + import secrets + code = secrets.token_urlsafe(8).upper()[:8] + expires_at = datetime.utcnow() + timedelta(days=expires_days) + + async with self.db.acquire() as conn: + await conn.execute(""" + INSERT INTO invite_codes + (code, created_by, expires_at, max_uses) + VALUES ($1, $2, $3, $4) + """, code, admin_id, expires_at, max_uses) + + await self._audit( + admin_id, "create_invite", "invite_code", code, + {"max_uses": max_uses, "expires_days": expires_days}, + ip_address, + ) + + return code + + async def get_invite_codes(self, include_expired: bool = False) -> List[dict]: + """Get all invite codes.""" + async with self.db.acquire() as conn: + query = """ + SELECT c.code, c.created_at, c.expires_at, + c.max_uses, c.use_count, c.is_active, + u.username as created_by + FROM invite_codes c + JOIN users u ON c.created_by = u.id + """ + if not include_expired: + query += " WHERE c.expires_at > NOW() AND c.is_active = true" + + query += " ORDER BY c.created_at DESC" + + rows = await conn.fetch(query) + + return [ + { + "code": row["code"], + "created_at": row["created_at"].isoformat(), + "expires_at": row["expires_at"].isoformat(), + "max_uses": row["max_uses"], + "use_count": row["use_count"], + "is_active": row["is_active"], + "created_by": row["created_by"], + } + for row in rows + ] + + async def revoke_invite_code( + self, + admin_id: str, + code: str, + ip_address: str = None, + ) -> bool: + """Revoke an invite code.""" + async with self.db.acquire() as conn: + result = await conn.execute(""" + UPDATE invite_codes + SET is_active = false + WHERE code = $1 + """, code) + + if result == "UPDATE 0": + return False + + await self._audit( + admin_id, "revoke_invite", "invite_code", code, + ip_address=ip_address, + ) + + return True +``` + +--- + +## API Endpoints + +```python +# server/routers/admin.py +from fastapi import APIRouter, Depends, HTTPException +from typing import Optional + +router = APIRouter(prefix="/api/admin", tags=["admin"]) + + +def require_admin(user: User = Depends(get_current_user)) -> User: + """Dependency that requires admin role.""" + if user.role != "admin": + raise HTTPException(status_code=403, detail="Admin access required") + return user + + +# --- User Management --- + +@router.get("/users") +async def list_users( + query: str = "", + limit: int = 50, + offset: int = 0, + include_banned: bool = True, + admin: User = Depends(require_admin), + service: AdminService = Depends(get_admin_service), +): + users = await service.search_users(query, limit, offset, include_banned) + return {"users": [u.__dict__ for u in users]} + + +@router.get("/users/{user_id}") +async def get_user( + user_id: str, + admin: User = Depends(require_admin), + service: AdminService = Depends(get_admin_service), +): + user = await service.get_user(user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + return user.__dict__ + + +@router.post("/users/{user_id}/ban") +async def ban_user( + user_id: str, + reason: str, + duration_days: Optional[int] = None, + admin: User = Depends(require_admin), + request: Request = None, + service: AdminService = Depends(get_admin_service), +): + success = await service.ban_user( + admin.id, user_id, reason, duration_days, request.client.host + ) + if not success: + raise HTTPException(status_code=400, detail="Cannot ban user") + return {"message": "User banned"} + + +@router.post("/users/{user_id}/unban") +async def unban_user( + user_id: str, + admin: User = Depends(require_admin), + request: Request = None, + service: AdminService = Depends(get_admin_service), +): + success = await service.unban_user(admin.id, user_id, request.client.host) + if not success: + raise HTTPException(status_code=400, detail="Cannot unban user") + return {"message": "User unbanned"} + + +@router.post("/users/{user_id}/force-password-reset") +async def force_password_reset( + user_id: str, + admin: User = Depends(require_admin), + request: Request = None, + service: AdminService = Depends(get_admin_service), +): + success = await service.force_password_reset( + admin.id, user_id, request.client.host + ) + if not success: + raise HTTPException(status_code=400, detail="Cannot force password reset") + return {"message": "Password reset required for user"} + + +@router.put("/users/{user_id}/role") +async def change_role( + user_id: str, + role: str, + admin: User = Depends(require_admin), + request: Request = None, + service: AdminService = Depends(get_admin_service), +): + success = await service.change_user_role( + admin.id, user_id, role, request.client.host + ) + if not success: + raise HTTPException(status_code=400, detail="Cannot change role") + return {"message": f"Role changed to {role}"} + + +# --- Game Moderation --- + +@router.get("/games") +async def list_games( + admin: User = Depends(require_admin), + service: AdminService = Depends(get_admin_service), +): + games = await service.get_active_games() + return {"games": games} + + +@router.get("/games/{game_id}") +async def get_game( + game_id: str, + admin: User = Depends(require_admin), + request: Request = None, + service: AdminService = Depends(get_admin_service), +): + game = await service.get_game_details( + admin.id, game_id, request.client.host + ) + if not game: + raise HTTPException(status_code=404, detail="Game not found") + return game + + +@router.post("/games/{game_id}/end") +async def end_game( + game_id: str, + reason: str, + admin: User = Depends(require_admin), + request: Request = None, + service: AdminService = Depends(get_admin_service), +): + success = await service.end_game( + admin.id, game_id, reason, request.client.host + ) + if not success: + raise HTTPException(status_code=400, detail="Cannot end game") + return {"message": "Game ended"} + + +# --- System Stats --- + +@router.get("/stats") +async def get_stats( + admin: User = Depends(require_admin), + service: AdminService = Depends(get_admin_service), +): + stats = await service.get_system_stats() + return stats.__dict__ + + +# --- Audit Log --- + +@router.get("/audit") +async def get_audit_log( + limit: int = 100, + offset: int = 0, + action: Optional[str] = None, + target_type: Optional[str] = None, + admin: User = Depends(require_admin), + service: AdminService = Depends(get_admin_service), +): + entries = await service.get_audit_log(limit, offset, action=action, target_type=target_type) + return {"entries": [e.__dict__ for e in entries]} + + +# --- Invite Codes --- + +@router.post("/invites") +async def create_invite( + max_uses: int = 1, + expires_days: int = 7, + admin: User = Depends(require_admin), + request: Request = None, + service: AdminService = Depends(get_admin_service), +): + code = await service.create_invite_code( + admin.id, max_uses, expires_days, request.client.host + ) + return {"code": code} + + +@router.get("/invites") +async def list_invites( + include_expired: bool = False, + admin: User = Depends(require_admin), + service: AdminService = Depends(get_admin_service), +): + codes = await service.get_invite_codes(include_expired) + return {"codes": codes} + + +@router.delete("/invites/{code}") +async def revoke_invite( + code: str, + admin: User = Depends(require_admin), + request: Request = None, + service: AdminService = Depends(get_admin_service), +): + success = await service.revoke_invite_code(admin.id, code, request.client.host) + if not success: + raise HTTPException(status_code=404, detail="Invite code not found") + return {"message": "Invite revoked"} +``` + +--- + +## Admin Dashboard UI + +```html + + + + + Golf Admin + + + + + +
+ +
+

System Overview

+
+
+ - + Active Users +
+
+ - + Active Games +
+
+ - + Total Users +
+
+ - + Games Today +
+
+ +

Top Players

+ + + + + + + + + +
UsernameWinsGames
+
+ + + + + + + + + + + + +
+ + + + +``` + +--- + +## Acceptance Criteria + +1. **User Management** + - [ ] Can search users by username/email + - [ ] Can view user details + - [ ] Can ban users (with reason, optional duration) + - [ ] Can unban users + - [ ] Can force password reset + - [ ] Can change user roles + - [ ] Cannot ban other admins + +2. **Game Moderation** + - [ ] Can list active games + - [ ] Can view any game state + - [ ] Can end stuck games + - [ ] Players notified when game ended + - [ ] Ended games marked as abandoned + +3. **System Stats** + - [ ] Shows active users count + - [ ] Shows active games count + - [ ] Shows total users + - [ ] Shows registrations today/week + - [ ] Shows games today + - [ ] Shows top players + +4. **Invite Codes** + - [ ] Can create invite codes + - [ ] Can set max uses and expiry + - [ ] Can list all codes + - [ ] Can revoke codes + +5. **Audit Logging** + - [ ] All admin actions logged + - [ ] Log shows admin, action, target, timestamp + - [ ] Can filter audit log + - [ ] IP address captured + +6. **Admin Dashboard UI** + - [ ] Dashboard shows overview stats + - [ ] Can navigate between sections + - [ ] Actions work correctly + - [ ] Responsive design + +--- + +## Implementation Order + +1. Create database migrations +2. Implement AdminService (audit logging first) +3. Add user management methods +4. Add game moderation methods +5. Add system stats +6. Create API endpoints +7. Build admin dashboard UI +8. Test all flows +9. Security review + +--- + +## Security Notes + +- All admin actions are audited +- Cannot ban other admins +- Cannot delete your own admin account +- IP addresses logged for forensics +- Admin dashboard requires separate auth check +- Consider 2FA for admin accounts (future) diff --git a/docs/v2/V2_05_STATS_LEADERBOARDS.md b/docs/v2/V2_05_STATS_LEADERBOARDS.md new file mode 100644 index 0000000..155a054 --- /dev/null +++ b/docs/v2/V2_05_STATS_LEADERBOARDS.md @@ -0,0 +1,871 @@ +# V2-05: Stats & Leaderboards + +## Overview + +This document covers player statistics aggregation and leaderboard systems. + +**Dependencies:** V2-03 (User Accounts), V2-01 (Events for aggregation) +**Dependents:** None (end feature) + +--- + +## Goals + +1. Aggregate player statistics from game events +2. Create leaderboard views (by wins, by average score, etc.) +3. Background worker for stats processing +4. Leaderboard API endpoints +5. Leaderboard UI in client +6. Achievement/badge system (stretch goal) + +--- + +## Database Schema + +```sql +-- migrations/versions/004_stats_leaderboards.sql + +-- Player statistics (aggregated from events) +CREATE TABLE player_stats ( + user_id UUID PRIMARY KEY REFERENCES users(id), + + -- Game counts + games_played INT DEFAULT 0, + games_won INT DEFAULT 0, + games_vs_humans INT DEFAULT 0, + games_won_vs_humans INT DEFAULT 0, + + -- Round stats + rounds_played INT DEFAULT 0, + rounds_won INT DEFAULT 0, + total_points INT DEFAULT 0, -- Sum of all round scores (lower is better) + + -- Best/worst + best_round_score INT, + worst_round_score INT, + best_game_score INT, -- Lowest total in a game + + -- Achievements + knockouts INT DEFAULT 0, -- Times going out first + perfect_rounds INT DEFAULT 0, -- Score of 0 or less + wolfpacks INT DEFAULT 0, -- Four jacks achieved + + -- Streaks + current_win_streak INT DEFAULT 0, + best_win_streak INT DEFAULT 0, + + -- Timestamps + first_game_at TIMESTAMPTZ, + last_game_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Stats processing queue (for background worker) +CREATE TABLE stats_queue ( + id BIGSERIAL PRIMARY KEY, + game_id UUID NOT NULL, + status VARCHAR(20) DEFAULT 'pending', -- pending, processing, completed, failed + created_at TIMESTAMPTZ DEFAULT NOW(), + processed_at TIMESTAMPTZ, + error_message TEXT +); + +-- Leaderboard cache (refreshed periodically) +CREATE MATERIALIZED VIEW leaderboard_overall AS +SELECT + u.id as user_id, + u.username, + s.games_played, + s.games_won, + ROUND(s.games_won::numeric / NULLIF(s.games_played, 0) * 100, 1) as win_rate, + s.rounds_won, + ROUND(s.total_points::numeric / NULLIF(s.rounds_played, 0), 1) as avg_score, + s.best_round_score, + s.knockouts, + s.best_win_streak, + s.last_game_at +FROM player_stats s +JOIN users u ON s.user_id = u.id +WHERE s.games_played >= 5 -- Minimum games for ranking +AND u.deleted_at IS NULL +AND u.is_banned = false; + +CREATE UNIQUE INDEX idx_leaderboard_overall_user ON leaderboard_overall(user_id); +CREATE INDEX idx_leaderboard_overall_wins ON leaderboard_overall(games_won DESC); +CREATE INDEX idx_leaderboard_overall_rate ON leaderboard_overall(win_rate DESC); +CREATE INDEX idx_leaderboard_overall_score ON leaderboard_overall(avg_score ASC); + +-- Achievements/badges +CREATE TABLE achievements ( + id VARCHAR(50) PRIMARY KEY, + name VARCHAR(100) NOT NULL, + description TEXT, + icon VARCHAR(50), + category VARCHAR(50), -- games, rounds, special + threshold INT, -- e.g., 10 for "Win 10 games" + sort_order INT DEFAULT 0 +); + +CREATE TABLE user_achievements ( + user_id UUID REFERENCES users(id), + achievement_id VARCHAR(50) REFERENCES achievements(id), + earned_at TIMESTAMPTZ DEFAULT NOW(), + game_id UUID, -- Game where it was earned (optional) + PRIMARY KEY (user_id, achievement_id) +); + +-- Seed achievements +INSERT INTO achievements (id, name, description, icon, category, threshold, sort_order) VALUES +('first_win', 'First Victory', 'Win your first game', '🏆', 'games', 1, 1), +('win_10', 'Rising Star', 'Win 10 games', '⭐', 'games', 10, 2), +('win_50', 'Veteran', 'Win 50 games', '🎖️', 'games', 50, 3), +('win_100', 'Champion', 'Win 100 games', '👑', 'games', 100, 4), +('perfect_round', 'Perfect', 'Score 0 or less in a round', '💎', 'rounds', 1, 10), +('negative_round', 'Below Zero', 'Score negative in a round', '❄️', 'rounds', 1, 11), +('knockout_10', 'Closer', 'Go out first 10 times', '🚪', 'special', 10, 20), +('wolfpack', 'Wolfpack', 'Get all 4 Jacks', '🐺', 'special', 1, 21), +('streak_5', 'Hot Streak', 'Win 5 games in a row', '🔥', 'special', 5, 30), +('streak_10', 'Unstoppable', 'Win 10 games in a row', '⚡', 'special', 10, 31); + +-- Indexes +CREATE INDEX idx_stats_queue_pending ON stats_queue(status, created_at) + WHERE status = 'pending'; +CREATE INDEX idx_user_achievements_user ON user_achievements(user_id); +``` + +--- + +## Stats Service + +```python +# server/services/stats_service.py +from dataclasses import dataclass +from typing import Optional, List +from datetime import datetime +import asyncpg + +from stores.event_store import EventStore +from models.events import EventType + + +@dataclass +class PlayerStats: + user_id: str + username: str + games_played: int + games_won: int + win_rate: float + rounds_played: int + rounds_won: int + avg_score: float + best_round_score: Optional[int] + knockouts: int + best_win_streak: int + achievements: List[str] + + +@dataclass +class LeaderboardEntry: + rank: int + user_id: str + username: str + value: float # The metric being ranked by + games_played: int + secondary_value: Optional[float] = None + + +class StatsService: + """Player statistics and leaderboards.""" + + def __init__(self, db_pool: asyncpg.Pool, event_store: EventStore): + self.db = db_pool + self.event_store = event_store + + # --- Stats Queries --- + + async def get_player_stats(self, user_id: str) -> Optional[PlayerStats]: + """Get stats for a specific player.""" + async with self.db.acquire() as conn: + row = await conn.fetchrow(""" + SELECT s.*, u.username, + ROUND(s.games_won::numeric / NULLIF(s.games_played, 0) * 100, 1) as win_rate, + ROUND(s.total_points::numeric / NULLIF(s.rounds_played, 0), 1) as avg_score + FROM player_stats s + JOIN users u ON s.user_id = u.id + WHERE s.user_id = $1 + """, user_id) + + if not row: + return None + + # Get achievements + achievements = await conn.fetch(""" + SELECT achievement_id FROM user_achievements + WHERE user_id = $1 + """, user_id) + + return PlayerStats( + user_id=row["user_id"], + username=row["username"], + games_played=row["games_played"], + games_won=row["games_won"], + win_rate=float(row["win_rate"] or 0), + rounds_played=row["rounds_played"], + rounds_won=row["rounds_won"], + avg_score=float(row["avg_score"] or 0), + best_round_score=row["best_round_score"], + knockouts=row["knockouts"], + best_win_streak=row["best_win_streak"], + achievements=[a["achievement_id"] for a in achievements], + ) + + async def get_leaderboard( + self, + metric: str = "wins", + limit: int = 50, + offset: int = 0, + ) -> List[LeaderboardEntry]: + """ + Get leaderboard by metric. + + Metrics: wins, win_rate, avg_score, knockouts, streak + """ + order_map = { + "wins": ("games_won", "DESC"), + "win_rate": ("win_rate", "DESC"), + "avg_score": ("avg_score", "ASC"), # Lower is better + "knockouts": ("knockouts", "DESC"), + "streak": ("best_win_streak", "DESC"), + } + + if metric not in order_map: + metric = "wins" + + column, direction = order_map[metric] + + async with self.db.acquire() as conn: + # Use materialized view for performance + rows = await conn.fetch(f""" + SELECT + user_id, username, games_played, games_won, + win_rate, avg_score, knockouts, best_win_streak, + ROW_NUMBER() OVER (ORDER BY {column} {direction}) as rank + FROM leaderboard_overall + ORDER BY {column} {direction} + LIMIT $1 OFFSET $2 + """, limit, offset) + + return [ + LeaderboardEntry( + rank=row["rank"], + user_id=row["user_id"], + username=row["username"], + value=float(row[column] or 0), + games_played=row["games_played"], + secondary_value=float(row["win_rate"] or 0) if metric != "win_rate" else None, + ) + for row in rows + ] + + async def get_player_rank(self, user_id: str, metric: str = "wins") -> Optional[int]: + """Get a player's rank on a leaderboard.""" + order_map = { + "wins": ("games_won", "DESC"), + "win_rate": ("win_rate", "DESC"), + "avg_score": ("avg_score", "ASC"), + } + + if metric not in order_map: + return None + + column, direction = order_map[metric] + + async with self.db.acquire() as conn: + row = await conn.fetchrow(f""" + SELECT rank FROM ( + SELECT user_id, ROW_NUMBER() OVER (ORDER BY {column} {direction}) as rank + FROM leaderboard_overall + ) ranked + WHERE user_id = $1 + """, user_id) + + return row["rank"] if row else None + + async def refresh_leaderboard(self) -> None: + """Refresh the materialized view.""" + async with self.db.acquire() as conn: + await conn.execute("REFRESH MATERIALIZED VIEW CONCURRENTLY leaderboard_overall") + + # --- Achievement Queries --- + + async def get_achievements(self) -> List[dict]: + """Get all available achievements.""" + async with self.db.acquire() as conn: + rows = await conn.fetch(""" + SELECT id, name, description, icon, category, threshold + FROM achievements + ORDER BY sort_order + """) + + return [dict(row) for row in rows] + + async def get_user_achievements(self, user_id: str) -> List[dict]: + """Get achievements earned by a user.""" + async with self.db.acquire() as conn: + rows = await conn.fetch(""" + SELECT a.id, a.name, a.description, a.icon, ua.earned_at + FROM user_achievements ua + JOIN achievements a ON ua.achievement_id = a.id + WHERE ua.user_id = $1 + ORDER BY ua.earned_at DESC + """, user_id) + + return [dict(row) for row in rows] + + # --- Stats Processing --- + + async def process_game_end(self, game_id: str) -> None: + """ + Process a completed game and update player stats. + Called by background worker or directly after game ends. + """ + # Get game events + events = await self.event_store.get_events(game_id) + + if not events: + return + + # Extract game data from events + game_data = self._extract_game_data(events) + + if not game_data: + return + + async with self.db.acquire() as conn: + async with conn.transaction(): + for player_id, player_data in game_data["players"].items(): + # Skip CPU players (they don't have user accounts) + if player_data.get("is_cpu"): + continue + + # Ensure stats row exists + await conn.execute(""" + INSERT INTO player_stats (user_id) + VALUES ($1) + ON CONFLICT (user_id) DO NOTHING + """, player_id) + + # Update stats + is_winner = player_id == game_data["winner_id"] + total_score = player_data["total_score"] + rounds_won = player_data["rounds_won"] + + await conn.execute(""" + UPDATE player_stats SET + games_played = games_played + 1, + games_won = games_won + $2, + rounds_played = rounds_played + $3, + rounds_won = rounds_won + $4, + total_points = total_points + $5, + knockouts = knockouts + $6, + best_round_score = LEAST(best_round_score, $7), + worst_round_score = GREATEST(worst_round_score, $8), + best_game_score = LEAST(best_game_score, $5), + current_win_streak = CASE WHEN $2 = 1 THEN current_win_streak + 1 ELSE 0 END, + best_win_streak = GREATEST(best_win_streak, + CASE WHEN $2 = 1 THEN current_win_streak + 1 ELSE best_win_streak END), + first_game_at = COALESCE(first_game_at, NOW()), + last_game_at = NOW(), + updated_at = NOW() + WHERE user_id = $1 + """, + player_id, + 1 if is_winner else 0, + game_data["num_rounds"], + rounds_won, + total_score, + player_data.get("knockouts", 0), + player_data.get("best_round", total_score), + player_data.get("worst_round", total_score), + ) + + # Check for new achievements + await self._check_achievements(conn, player_id, game_id, player_data, is_winner) + + def _extract_game_data(self, events) -> Optional[dict]: + """Extract game data from events.""" + data = { + "players": {}, + "num_rounds": 0, + "winner_id": None, + } + + for event in events: + if event.event_type == EventType.PLAYER_JOINED: + data["players"][event.player_id] = { + "is_cpu": event.data.get("is_cpu", False), + "total_score": 0, + "rounds_won": 0, + "knockouts": 0, + "best_round": None, + "worst_round": None, + } + + elif event.event_type == EventType.ROUND_ENDED: + data["num_rounds"] += 1 + scores = event.data.get("scores", {}) + winner_id = event.data.get("winner_id") + + for player_id, score in scores.items(): + if player_id in data["players"]: + p = data["players"][player_id] + p["total_score"] += score + + if p["best_round"] is None or score < p["best_round"]: + p["best_round"] = score + if p["worst_round"] is None or score > p["worst_round"]: + p["worst_round"] = score + + if player_id == winner_id: + p["rounds_won"] += 1 + + # Track who went out first (finisher) + # This would need to be tracked in events + + elif event.event_type == EventType.GAME_ENDED: + data["winner_id"] = event.data.get("winner_id") + + return data if data["num_rounds"] > 0 else None + + async def _check_achievements( + self, + conn: asyncpg.Connection, + user_id: str, + game_id: str, + player_data: dict, + is_winner: bool, + ) -> List[str]: + """Check and award new achievements.""" + new_achievements = [] + + # Get current stats + stats = await conn.fetchrow(""" + SELECT games_won, knockouts, best_win_streak, current_win_streak + FROM player_stats + WHERE user_id = $1 + """, user_id) + + if not stats: + return [] + + # Get already earned achievements + earned = await conn.fetch(""" + SELECT achievement_id FROM user_achievements WHERE user_id = $1 + """, user_id) + earned_ids = {e["achievement_id"] for e in earned} + + # Check win milestones + wins = stats["games_won"] + if wins >= 1 and "first_win" not in earned_ids: + new_achievements.append("first_win") + if wins >= 10 and "win_10" not in earned_ids: + new_achievements.append("win_10") + if wins >= 50 and "win_50" not in earned_ids: + new_achievements.append("win_50") + if wins >= 100 and "win_100" not in earned_ids: + new_achievements.append("win_100") + + # Check streak achievements + streak = stats["current_win_streak"] + if streak >= 5 and "streak_5" not in earned_ids: + new_achievements.append("streak_5") + if streak >= 10 and "streak_10" not in earned_ids: + new_achievements.append("streak_10") + + # Check knockout achievements + if stats["knockouts"] >= 10 and "knockout_10" not in earned_ids: + new_achievements.append("knockout_10") + + # Check round-specific achievements + if player_data.get("best_round") is not None: + if player_data["best_round"] <= 0 and "perfect_round" not in earned_ids: + new_achievements.append("perfect_round") + if player_data["best_round"] < 0 and "negative_round" not in earned_ids: + new_achievements.append("negative_round") + + # Award new achievements + for achievement_id in new_achievements: + await conn.execute(""" + INSERT INTO user_achievements (user_id, achievement_id, game_id) + VALUES ($1, $2, $3) + ON CONFLICT DO NOTHING + """, user_id, achievement_id, game_id) + + return new_achievements +``` + +--- + +## Background Worker + +```python +# server/workers/stats_worker.py +import asyncio +from datetime import datetime, timedelta +import asyncpg +from arq import create_pool +from arq.connections import RedisSettings + +from services.stats_service import StatsService +from stores.event_store import EventStore + + +async def process_stats_queue(ctx): + """Process pending games in the stats queue.""" + db: asyncpg.Pool = ctx["db_pool"] + stats_service: StatsService = ctx["stats_service"] + + async with db.acquire() as conn: + # Get pending games + games = await conn.fetch(""" + SELECT id, game_id FROM stats_queue + WHERE status = 'pending' + ORDER BY created_at + LIMIT 100 + """) + + for game in games: + try: + # Mark as processing + await conn.execute(""" + UPDATE stats_queue SET status = 'processing' WHERE id = $1 + """, game["id"]) + + # Process + await stats_service.process_game_end(game["game_id"]) + + # Mark complete + await conn.execute(""" + UPDATE stats_queue + SET status = 'completed', processed_at = NOW() + WHERE id = $1 + """, game["id"]) + + except Exception as e: + # Mark failed + await conn.execute(""" + UPDATE stats_queue + SET status = 'failed', error_message = $2 + WHERE id = $1 + """, game["id"], str(e)) + + +async def refresh_leaderboard(ctx): + """Refresh the materialized leaderboard view.""" + stats_service: StatsService = ctx["stats_service"] + await stats_service.refresh_leaderboard() + + +async def cleanup_old_queue_entries(ctx): + """Clean up old processed queue entries.""" + db: asyncpg.Pool = ctx["db_pool"] + + async with db.acquire() as conn: + await conn.execute(""" + DELETE FROM stats_queue + WHERE status IN ('completed', 'failed') + AND processed_at < NOW() - INTERVAL '7 days' + """) + + +class WorkerSettings: + """arq worker settings.""" + + functions = [ + process_stats_queue, + refresh_leaderboard, + cleanup_old_queue_entries, + ] + + cron_jobs = [ + # Process queue every minute + cron(process_stats_queue, minute={0, 15, 30, 45}), + # Refresh leaderboard every 5 minutes + cron(refresh_leaderboard, minute={0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55}), + # Cleanup daily + cron(cleanup_old_queue_entries, hour=3, minute=0), + ] + + redis_settings = RedisSettings() + + @staticmethod + async def on_startup(ctx): + """Initialize worker context.""" + ctx["db_pool"] = await asyncpg.create_pool(DATABASE_URL) + ctx["event_store"] = EventStore(ctx["db_pool"]) + ctx["stats_service"] = StatsService(ctx["db_pool"], ctx["event_store"]) + + @staticmethod + async def on_shutdown(ctx): + """Cleanup worker context.""" + await ctx["db_pool"].close() +``` + +--- + +## API Endpoints + +```python +# server/routers/stats.py +from fastapi import APIRouter, Depends, Query +from typing import Optional + +router = APIRouter(prefix="/api/stats", tags=["stats"]) + + +@router.get("/leaderboard") +async def get_leaderboard( + metric: str = Query("wins", regex="^(wins|win_rate|avg_score|knockouts|streak)$"), + limit: int = Query(50, ge=1, le=100), + offset: int = Query(0, ge=0), + service: StatsService = Depends(get_stats_service), +): + """Get leaderboard by metric.""" + entries = await service.get_leaderboard(metric, limit, offset) + return { + "metric": metric, + "entries": [ + { + "rank": e.rank, + "user_id": e.user_id, + "username": e.username, + "value": e.value, + "games_played": e.games_played, + } + for e in entries + ], + } + + +@router.get("/players/{user_id}") +async def get_player_stats( + user_id: str, + service: StatsService = Depends(get_stats_service), +): + """Get stats for a specific player.""" + stats = await service.get_player_stats(user_id) + if not stats: + raise HTTPException(status_code=404, detail="Player not found") + + return { + "user_id": stats.user_id, + "username": stats.username, + "games_played": stats.games_played, + "games_won": stats.games_won, + "win_rate": stats.win_rate, + "rounds_played": stats.rounds_played, + "rounds_won": stats.rounds_won, + "avg_score": stats.avg_score, + "best_round_score": stats.best_round_score, + "knockouts": stats.knockouts, + "best_win_streak": stats.best_win_streak, + "achievements": stats.achievements, + } + + +@router.get("/players/{user_id}/rank") +async def get_player_rank( + user_id: str, + metric: str = "wins", + service: StatsService = Depends(get_stats_service), +): + """Get player's rank on a leaderboard.""" + rank = await service.get_player_rank(user_id, metric) + return {"user_id": user_id, "metric": metric, "rank": rank} + + +@router.get("/me") +async def get_my_stats( + user: User = Depends(get_current_user), + service: StatsService = Depends(get_stats_service), +): + """Get current user's stats.""" + stats = await service.get_player_stats(user.id) + if not stats: + return { + "games_played": 0, + "games_won": 0, + "achievements": [], + } + return stats.__dict__ + + +@router.get("/achievements") +async def get_achievements( + service: StatsService = Depends(get_stats_service), +): + """Get all available achievements.""" + return {"achievements": await service.get_achievements()} + + +@router.get("/players/{user_id}/achievements") +async def get_user_achievements( + user_id: str, + service: StatsService = Depends(get_stats_service), +): + """Get achievements earned by a player.""" + return {"achievements": await service.get_user_achievements(user_id)} +``` + +--- + +## Frontend Integration + +```javascript +// client/components/leaderboard.js + +class LeaderboardComponent { + constructor(container) { + this.container = container; + this.metric = 'wins'; + this.render(); + } + + async fetchLeaderboard() { + const response = await fetch(`/api/stats/leaderboard?metric=${this.metric}&limit=50`); + return response.json(); + } + + async render() { + const data = await this.fetchLeaderboard(); + + this.container.innerHTML = ` +
+
+ + + +
+ + + + + + + + + + + ${data.entries.map(e => ` + + + + + + + `).join('')} + +
#Player${this.getMetricLabel()}Games
${this.getRankBadge(e.rank)}${e.username}${this.formatValue(e.value)}${e.games_played}
+
+ `; + + // Bind tab clicks + this.container.querySelectorAll('.tab').forEach(tab => { + tab.addEventListener('click', () => { + this.metric = tab.dataset.metric; + this.render(); + }); + }); + } + + getMetricLabel() { + const labels = { + wins: 'Wins', + win_rate: 'Win %', + avg_score: 'Avg Score', + }; + return labels[this.metric] || this.metric; + } + + formatValue(value) { + if (this.metric === 'win_rate') return `${value}%`; + if (this.metric === 'avg_score') return value.toFixed(1); + return value; + } + + getRankBadge(rank) { + if (rank === 1) return '🥇'; + if (rank === 2) return '🥈'; + if (rank === 3) return '🥉'; + return rank; + } +} +``` + +--- + +## Acceptance Criteria + +1. **Stats Aggregation** + - [ ] Stats calculated from game events + - [ ] Games played/won tracked + - [ ] Rounds played/won tracked + - [ ] Best/worst scores tracked + - [ ] Win streaks tracked + - [ ] Knockouts tracked + +2. **Leaderboards** + - [ ] Leaderboard by wins + - [ ] Leaderboard by win rate + - [ ] Leaderboard by average score + - [ ] Minimum games requirement + - [ ] Pagination working + - [ ] Materialized view refreshes + +3. **Background Worker** + - [ ] Queue processing works + - [ ] Failed jobs retried + - [ ] Leaderboard auto-refreshes + - [ ] Old entries cleaned up + +4. **Achievements** + - [ ] Achievement definitions in DB + - [ ] Achievements awarded correctly + - [ ] Achievement progress tracked + - [ ] Achievement UI displays + +5. **API** + - [ ] GET /leaderboard works + - [ ] GET /players/{id} works + - [ ] GET /me works + - [ ] GET /achievements works + +6. **UI** + - [ ] Leaderboard displays + - [ ] Tabs switch metrics + - [ ] Player profiles show stats + - [ ] Achievements display + +--- + +## Implementation Order + +1. Create database migrations +2. Implement stats processing logic +3. Add stats queue integration +4. Set up background worker +5. Implement leaderboard queries +6. Create API endpoints +7. Build leaderboard UI +8. Add achievements system +9. Test full flow + +--- + +## Notes + +- Materialized views are great for leaderboards but need periodic refresh +- Consider caching hot leaderboard data in Redis +- Achievement checking should be efficient (batch checks) +- Stats processing is async - don't block game completion +- Consider separate "vs humans only" stats in future diff --git a/docs/v2/V2_06_REPLAY_EXPORT.md b/docs/v2/V2_06_REPLAY_EXPORT.md new file mode 100644 index 0000000..5c34eff --- /dev/null +++ b/docs/v2/V2_06_REPLAY_EXPORT.md @@ -0,0 +1,976 @@ +# V2_06: Game Replay & Export System + +> **Scope**: Replay viewer, game export/import, share links, spectator mode +> **Dependencies**: V2_01 (Event Sourcing), V2_02 (Persistence), V2_03 (User Accounts) +> **Complexity**: Medium + +--- + +## Overview + +The replay system leverages our event-sourced architecture to provide: +- **Replay Viewer**: Step through any completed game move-by-move +- **Export/Import**: Download games as JSON, share with others +- **Share Links**: Generate public links to specific games +- **Spectator Mode**: Watch live games in progress + +--- + +## 1. Database Schema + +### Shared Games Table + +```sql +-- Public share links for completed games +CREATE TABLE shared_games ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + game_id UUID NOT NULL REFERENCES games(id), + share_code VARCHAR(12) UNIQUE NOT NULL, -- Short shareable code + created_by UUID REFERENCES users(id), + created_at TIMESTAMPTZ DEFAULT NOW(), + expires_at TIMESTAMPTZ, -- NULL = never expires + view_count INTEGER DEFAULT 0, + is_public BOOLEAN DEFAULT true, + title VARCHAR(100), -- Optional custom title + description TEXT -- Optional description +); + +CREATE INDEX idx_shared_games_code ON shared_games(share_code); +CREATE INDEX idx_shared_games_game ON shared_games(game_id); + +-- Track replay views for analytics +CREATE TABLE replay_views ( + id SERIAL PRIMARY KEY, + shared_game_id UUID REFERENCES shared_games(id), + viewer_id UUID REFERENCES users(id), -- NULL for anonymous + viewed_at TIMESTAMPTZ DEFAULT NOW(), + ip_hash VARCHAR(64), -- Hashed IP for rate limiting + watch_duration_seconds INTEGER +); +``` + +--- + +## 2. Replay Service + +### Core Implementation + +```python +# server/replay.py +from dataclasses import dataclass +from typing import Optional +import secrets +import json + +from server.events import EventStore, GameEvent +from server.game import Game, GameOptions + +@dataclass +class ReplayFrame: + """Single frame in a replay.""" + event_index: int + event: GameEvent + game_state: dict # Serialized game state after event + timestamp: float + +@dataclass +class GameReplay: + """Complete replay of a game.""" + game_id: str + frames: list[ReplayFrame] + total_duration_seconds: float + player_names: list[str] + final_scores: dict[str, int] + winner: Optional[str] + options: GameOptions + +class ReplayService: + def __init__(self, event_store: EventStore, db_pool): + self.event_store = event_store + self.db = db_pool + + async def build_replay(self, game_id: str) -> GameReplay: + """Build complete replay from event store.""" + events = await self.event_store.get_events(game_id) + if not events: + raise ValueError(f"No events found for game {game_id}") + + frames = [] + game = None + start_time = None + + for i, event in enumerate(events): + if start_time is None: + start_time = event.timestamp + + # Apply event to get state + if event.event_type == "game_started": + game = Game.from_event(event) + else: + game.apply_event(event) + + frames.append(ReplayFrame( + event_index=i, + event=event, + game_state=game.to_dict(reveal_all=True), + timestamp=(event.timestamp - start_time).total_seconds() + )) + + return GameReplay( + game_id=game_id, + frames=frames, + total_duration_seconds=frames[-1].timestamp if frames else 0, + player_names=[p.name for p in game.players], + final_scores={p.name: p.score for p in game.players}, + winner=game.winner.name if game.winner else None, + options=game.options + ) + + async def create_share_link( + self, + game_id: str, + user_id: Optional[str] = None, + title: Optional[str] = None, + expires_days: Optional[int] = None + ) -> str: + """Generate shareable link for a game.""" + share_code = secrets.token_urlsafe(8)[:12] # 12-char code + + expires_at = None + if expires_days: + expires_at = f"NOW() + INTERVAL '{expires_days} days'" + + async with self.db.acquire() as conn: + await conn.execute(""" + INSERT INTO shared_games + (game_id, share_code, created_by, title, expires_at) + VALUES ($1, $2, $3, $4, $5) + """, game_id, share_code, user_id, title, expires_at) + + return share_code + + async def get_shared_game(self, share_code: str) -> Optional[dict]: + """Retrieve shared game by code.""" + async with self.db.acquire() as conn: + row = await conn.fetchrow(""" + SELECT sg.*, g.room_code, g.completed_at + FROM shared_games sg + JOIN games g ON sg.game_id = g.id + WHERE sg.share_code = $1 + AND sg.is_public = true + AND (sg.expires_at IS NULL OR sg.expires_at > NOW()) + """, share_code) + + if row: + # Increment view count + await conn.execute(""" + UPDATE shared_games SET view_count = view_count + 1 + WHERE share_code = $1 + """, share_code) + + return dict(row) + return None + + async def export_game(self, game_id: str) -> dict: + """Export game as portable JSON format.""" + replay = await self.build_replay(game_id) + + return { + "version": "1.0", + "exported_at": datetime.utcnow().isoformat(), + "game": { + "id": replay.game_id, + "players": replay.player_names, + "winner": replay.winner, + "final_scores": replay.final_scores, + "duration_seconds": replay.total_duration_seconds, + "options": asdict(replay.options) + }, + "events": [ + { + "type": f.event.event_type, + "data": f.event.data, + "timestamp": f.timestamp + } + for f in replay.frames + ] + } + + async def import_game(self, export_data: dict, user_id: str) -> str: + """Import a game from exported JSON.""" + if export_data.get("version") != "1.0": + raise ValueError("Unsupported export version") + + # Generate new game ID for import + new_game_id = str(uuid.uuid4()) + + # Store events with new game ID + for event_data in export_data["events"]: + event = GameEvent( + game_id=new_game_id, + event_type=event_data["type"], + data=event_data["data"], + timestamp=datetime.fromisoformat(event_data["timestamp"]) + ) + await self.event_store.append(event) + + # Mark as imported game + async with self.db.acquire() as conn: + await conn.execute(""" + INSERT INTO games (id, imported_by, imported_at, is_imported) + VALUES ($1, $2, NOW(), true) + """, new_game_id, user_id) + + return new_game_id +``` + +--- + +## 3. Spectator Mode + +### Live Game Watching + +```python +# server/spectator.py +from typing import Set +from fastapi import WebSocket + +class SpectatorManager: + """Manage spectators watching live games.""" + + def __init__(self): + # game_id -> set of spectator websockets + self.spectators: dict[str, Set[WebSocket]] = {} + + async def add_spectator(self, game_id: str, ws: WebSocket): + """Add spectator to game.""" + if game_id not in self.spectators: + self.spectators[game_id] = set() + self.spectators[game_id].add(ws) + + # Send current game state + game = await self.get_game_state(game_id) + await ws.send_json({ + "type": "spectator_joined", + "game": game.to_dict(reveal_all=False), + "spectator_count": len(self.spectators[game_id]) + }) + + async def remove_spectator(self, game_id: str, ws: WebSocket): + """Remove spectator from game.""" + if game_id in self.spectators: + self.spectators[game_id].discard(ws) + if not self.spectators[game_id]: + del self.spectators[game_id] + + async def broadcast_to_spectators(self, game_id: str, message: dict): + """Send update to all spectators of a game.""" + if game_id not in self.spectators: + return + + dead_connections = set() + for ws in self.spectators[game_id]: + try: + await ws.send_json(message) + except: + dead_connections.add(ws) + + # Clean up dead connections + self.spectators[game_id] -= dead_connections + + def get_spectator_count(self, game_id: str) -> int: + return len(self.spectators.get(game_id, set())) + +# Integration with main game loop +async def handle_game_event(game_id: str, event: GameEvent): + """Called after each game event to notify spectators.""" + await spectator_manager.broadcast_to_spectators(game_id, { + "type": "game_update", + "event": event.to_dict(), + "timestamp": event.timestamp.isoformat() + }) +``` + +--- + +## 4. API Endpoints + +```python +# server/routes/replay.py +from fastapi import APIRouter, HTTPException, Query +from fastapi.responses import JSONResponse + +router = APIRouter(prefix="/api/replay", tags=["replay"]) + +@router.get("/game/{game_id}") +async def get_replay(game_id: str, user: Optional[User] = Depends(get_current_user)): + """Get full replay for a game.""" + # Check if user has permission (played in game or game is public) + if not await can_view_game(user, game_id): + raise HTTPException(403, "Cannot view this game") + + replay = await replay_service.build_replay(game_id) + return { + "game_id": replay.game_id, + "frames": [ + { + "index": f.event_index, + "event_type": f.event.event_type, + "timestamp": f.timestamp, + "state": f.game_state + } + for f in replay.frames + ], + "metadata": { + "players": replay.player_names, + "winner": replay.winner, + "final_scores": replay.final_scores, + "duration": replay.total_duration_seconds + } + } + +@router.post("/game/{game_id}/share") +async def create_share_link( + game_id: str, + title: Optional[str] = None, + expires_days: Optional[int] = Query(None, ge=1, le=365), + user: User = Depends(require_auth) +): + """Create shareable link for a game.""" + if not await user_played_in_game(user.id, game_id): + raise HTTPException(403, "Can only share games you played in") + + share_code = await replay_service.create_share_link( + game_id, user.id, title, expires_days + ) + + return { + "share_code": share_code, + "share_url": f"/replay/{share_code}", + "expires_days": expires_days + } + +@router.get("/shared/{share_code}") +async def get_shared_replay(share_code: str): + """Get replay via share code (public endpoint).""" + shared = await replay_service.get_shared_game(share_code) + if not shared: + raise HTTPException(404, "Shared game not found or expired") + + replay = await replay_service.build_replay(shared["game_id"]) + return { + "title": shared.get("title"), + "view_count": shared["view_count"], + "replay": replay + } + +@router.get("/game/{game_id}/export") +async def export_game(game_id: str, user: User = Depends(require_auth)): + """Export game as downloadable JSON.""" + if not await can_view_game(user, game_id): + raise HTTPException(403, "Cannot export this game") + + export_data = await replay_service.export_game(game_id) + + return JSONResponse( + content=export_data, + headers={ + "Content-Disposition": f'attachment; filename="golf-game-{game_id[:8]}.json"' + } + ) + +@router.post("/import") +async def import_game( + export_data: dict, + user: User = Depends(require_auth) +): + """Import a game from JSON export.""" + try: + new_game_id = await replay_service.import_game(export_data, user.id) + return {"game_id": new_game_id, "message": "Game imported successfully"} + except ValueError as e: + raise HTTPException(400, str(e)) + +# Spectator endpoints +@router.websocket("/spectate/{room_code}") +async def spectate_game(websocket: WebSocket, room_code: str): + """WebSocket endpoint for spectating live games.""" + await websocket.accept() + + game_id = await get_game_id_by_room(room_code) + if not game_id: + await websocket.close(code=4004, reason="Game not found") + return + + try: + await spectator_manager.add_spectator(game_id, websocket) + + while True: + # Keep connection alive, handle pings + data = await websocket.receive_text() + if data == "ping": + await websocket.send_text("pong") + except WebSocketDisconnect: + pass + finally: + await spectator_manager.remove_spectator(game_id, websocket) +``` + +--- + +## 5. Frontend: Replay Viewer + +### Replay Component + +```javascript +// client/replay.js +class ReplayViewer { + constructor(container) { + this.container = container; + this.frames = []; + this.currentFrame = 0; + this.isPlaying = false; + this.playbackSpeed = 1.0; + this.playInterval = null; + } + + async loadReplay(gameId) { + const response = await fetch(`/api/replay/game/${gameId}`); + const data = await response.json(); + + this.frames = data.frames; + this.metadata = data.metadata; + this.currentFrame = 0; + + this.render(); + this.renderControls(); + } + + async loadSharedReplay(shareCode) { + const response = await fetch(`/api/replay/shared/${shareCode}`); + if (!response.ok) { + this.showError("Replay not found or expired"); + return; + } + + const data = await response.json(); + this.frames = data.replay.frames; + this.metadata = data.replay; + this.title = data.title; + this.currentFrame = 0; + + this.render(); + } + + render() { + if (!this.frames.length) return; + + const frame = this.frames[this.currentFrame]; + const state = frame.state; + + // Render game board at this state + this.renderBoard(state); + + // Show event description + this.renderEventInfo(frame); + + // Update timeline + this.updateTimeline(); + } + + renderBoard(state) { + // Similar to main game rendering but read-only + const boardHtml = ` +
+ ${state.players.map(p => this.renderPlayerHand(p)).join('')} +
+
+
+ +
+ ${state.discard_top ? this.renderCard(state.discard_top) : ''} +
+
+
+ `; + this.container.querySelector('.replay-board-container').innerHTML = boardHtml; + } + + renderEventInfo(frame) { + const descriptions = { + 'game_started': 'Game started', + 'card_drawn': `${frame.event.data.player} drew a card`, + 'card_discarded': `${frame.event.data.player} discarded`, + 'card_swapped': `${frame.event.data.player} swapped a card`, + 'turn_ended': `${frame.event.data.player}'s turn ended`, + 'round_ended': 'Round ended', + 'game_ended': `Game over! ${this.metadata.winner} wins!` + }; + + const desc = descriptions[frame.event_type] || frame.event_type; + this.container.querySelector('.event-description').textContent = desc; + } + + renderControls() { + const controls = ` +
+ + + + + + +
+ + 1 / ${this.frames.length} +
+ +
+ + +
+
+ `; + this.container.querySelector('.controls-container').innerHTML = controls; + this.bindControlEvents(); + } + + bindControlEvents() { + this.container.querySelector('.btn-start').onclick = () => this.goToFrame(0); + this.container.querySelector('.btn-end').onclick = () => this.goToFrame(this.frames.length - 1); + this.container.querySelector('.btn-prev').onclick = () => this.prevFrame(); + this.container.querySelector('.btn-next').onclick = () => this.nextFrame(); + this.container.querySelector('.btn-play').onclick = () => this.togglePlay(); + + this.container.querySelector('.timeline-slider').oninput = (e) => { + this.goToFrame(parseInt(e.target.value)); + }; + + this.container.querySelector('.speed-select').onchange = (e) => { + this.playbackSpeed = parseFloat(e.target.value); + if (this.isPlaying) { + this.stopPlayback(); + this.startPlayback(); + } + }; + } + + goToFrame(index) { + this.currentFrame = Math.max(0, Math.min(index, this.frames.length - 1)); + this.render(); + } + + nextFrame() { + if (this.currentFrame < this.frames.length - 1) { + this.currentFrame++; + this.render(); + } else if (this.isPlaying) { + this.togglePlay(); // Stop at end + } + } + + prevFrame() { + if (this.currentFrame > 0) { + this.currentFrame--; + this.render(); + } + } + + togglePlay() { + this.isPlaying = !this.isPlaying; + const btn = this.container.querySelector('.btn-play'); + + if (this.isPlaying) { + btn.textContent = '⏸'; + this.startPlayback(); + } else { + btn.textContent = '▶'; + this.stopPlayback(); + } + } + + startPlayback() { + const baseInterval = 1000; // 1 second between frames + this.playInterval = setInterval(() => { + this.nextFrame(); + }, baseInterval / this.playbackSpeed); + } + + stopPlayback() { + if (this.playInterval) { + clearInterval(this.playInterval); + this.playInterval = null; + } + } + + updateTimeline() { + const slider = this.container.querySelector('.timeline-slider'); + const counter = this.container.querySelector('.frame-counter'); + + if (slider) slider.value = this.currentFrame; + if (counter) counter.textContent = `${this.currentFrame + 1} / ${this.frames.length}`; + } +} +``` + +### Replay Page HTML + +```html + + +``` + +### Replay Styles + +```css +/* client/style.css additions */ +.replay-controls { + display: flex; + align-items: center; + gap: 1rem; + padding: 1rem; + background: var(--surface-color); + border-radius: 8px; + flex-wrap: wrap; + justify-content: center; +} + +.replay-controls button { + width: 40px; + height: 40px; + border-radius: 50%; + border: none; + background: var(--primary-color); + color: white; + cursor: pointer; + font-size: 1.2rem; +} + +.replay-controls button:hover { + background: var(--primary-dark); +} + +.timeline { + flex: 1; + min-width: 200px; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.timeline-slider { + flex: 1; + height: 8px; + -webkit-appearance: none; + background: var(--border-color); + border-radius: 4px; +} + +.timeline-slider::-webkit-slider-thumb { + -webkit-appearance: none; + width: 16px; + height: 16px; + background: var(--primary-color); + border-radius: 50%; + cursor: pointer; +} + +.frame-counter { + font-family: monospace; + min-width: 80px; + text-align: right; +} + +.event-description { + text-align: center; + padding: 1rem; + font-size: 1.1rem; + color: var(--text-secondary); + min-height: 3rem; +} + +.speed-control { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.speed-select { + padding: 0.25rem 0.5rem; + border-radius: 4px; +} + +/* Spectator badge */ +.spectator-count { + position: absolute; + top: 10px; + right: 10px; + background: rgba(0,0,0,0.7); + color: white; + padding: 0.5rem 1rem; + border-radius: 20px; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.spectator-count::before { + content: '👁'; +} +``` + +--- + +## 6. Share Dialog + +```javascript +// Share modal component +class ShareDialog { + constructor(gameId) { + this.gameId = gameId; + } + + async show() { + const modal = document.createElement('div'); + modal.className = 'modal share-modal'; + modal.innerHTML = ` + + `; + + document.body.appendChild(modal); + this.bindEvents(modal); + } + + async generateLink(modal) { + const title = modal.querySelector('#share-title').value || null; + const expiry = modal.querySelector('#share-expiry').value || null; + + const response = await fetch(`/api/replay/game/${this.gameId}/share`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title, + expires_days: expiry ? parseInt(expiry) : null + }) + }); + + const data = await response.json(); + const fullUrl = `${window.location.origin}${data.share_url}`; + + modal.querySelector('#share-link').value = fullUrl; + modal.querySelector('.share-result').classList.remove('hidden'); + modal.querySelector('.btn-generate').classList.add('hidden'); + } +} +``` + +--- + +## 7. Integration Points + +### Game End Integration + +```python +# In main.py after game ends +async def on_game_end(game: Game): + # Store final game state + await event_store.append(GameEvent( + game_id=game.id, + event_type="game_ended", + data={ + "winner": game.winner.id, + "final_scores": {p.id: p.score for p in game.players}, + "duration": game.duration_seconds + } + )) + + # Notify spectators + await spectator_manager.broadcast_to_spectators(game.id, { + "type": "game_ended", + "winner": game.winner.name, + "final_scores": {p.name: p.score for p in game.players} + }) +``` + +### Navigation Links + +```javascript +// Add to game history/profile +function renderGameHistory(games) { + return games.map(game => ` +
+ ${formatDate(game.played_at)} + ${game.won ? 'Won' : 'Lost'} + ${game.score} pts + Watch Replay +
+ `).join(''); +} +``` + +--- + +## 8. Validation Tests + +```python +# tests/test_replay.py + +async def test_build_replay(): + """Verify replay correctly reconstructs game states.""" + # Create game with known moves + game_id = await create_test_game() + + replay = await replay_service.build_replay(game_id) + + assert len(replay.frames) > 0 + assert replay.game_id == game_id + assert replay.winner is not None + + # Verify each frame has valid state + for frame in replay.frames: + assert frame.game_state is not None + assert 'players' in frame.game_state + +async def test_share_link_creation(): + """Test creating and accessing share links.""" + game_id = await create_completed_game() + user_id = "test-user" + + share_code = await replay_service.create_share_link(game_id, user_id) + + assert len(share_code) == 12 + + # Retrieve via share code + shared = await replay_service.get_shared_game(share_code) + assert shared is not None + assert shared["game_id"] == game_id + +async def test_share_link_expiry(): + """Verify expired links return None.""" + game_id = await create_completed_game() + + # Create link that expires in -1 days (already expired) + share_code = await create_expired_share(game_id) + + shared = await replay_service.get_shared_game(share_code) + assert shared is None + +async def test_export_import_roundtrip(): + """Test game can be exported and reimported.""" + original_game_id = await create_completed_game() + + export_data = await replay_service.export_game(original_game_id) + + assert export_data["version"] == "1.0" + assert len(export_data["events"]) > 0 + + # Import as new game + new_game_id = await replay_service.import_game(export_data, "importer-user") + + # Verify imported game matches + original_replay = await replay_service.build_replay(original_game_id) + imported_replay = await replay_service.build_replay(new_game_id) + + assert len(original_replay.frames) == len(imported_replay.frames) + assert original_replay.final_scores == imported_replay.final_scores + +async def test_spectator_connection(): + """Test spectator can join and receive updates.""" + game_id = await create_active_game() + + async with websocket_client(f"/api/replay/spectate/{game_id}") as ws: + # Should receive initial state + msg = await ws.receive_json() + assert msg["type"] == "spectator_joined" + assert "game" in msg + + # Simulate game event + await trigger_game_event(game_id) + + # Should receive update + update = await ws.receive_json() + assert update["type"] == "game_update" +``` + +--- + +## 9. Security Considerations + +1. **Access Control**: Users can only view replays of games they played in, unless shared +2. **Rate Limiting**: Limit share link creation to prevent abuse +3. **Expired Links**: Clean up expired share links via background job +4. **Import Validation**: Validate imported JSON structure to prevent injection +5. **Spectator Limits**: Cap spectators per game to prevent resource exhaustion + +--- + +## Summary + +This document provides a complete replay and export system that: +- Leverages event sourcing for perfect game reconstruction +- Supports shareable links with optional expiry +- Enables live spectating of games in progress +- Allows game export/import for portability +- Includes frontend replay viewer with playback controls diff --git a/docs/v2/V2_07_PRODUCTION.md b/docs/v2/V2_07_PRODUCTION.md new file mode 100644 index 0000000..f21978e --- /dev/null +++ b/docs/v2/V2_07_PRODUCTION.md @@ -0,0 +1,999 @@ +# V2_07: Production Deployment & Operations + +> **Scope**: Docker, deployment, health checks, monitoring, security, rate limiting +> **Dependencies**: All other V2 documents +> **Complexity**: High (DevOps/Infrastructure) + +--- + +## Overview + +Production readiness requires: +- **Containerization**: Docker images for consistent deployment +- **Health Checks**: Liveness and readiness probes +- **Monitoring**: Metrics, logging, error tracking +- **Security**: HTTPS, headers, secrets management +- **Rate Limiting**: API protection from abuse (Phase 1 priority) +- **Graceful Operations**: Zero-downtime deploys, proper shutdown + +--- + +## 1. Docker Configuration + +### Application Dockerfile + +```dockerfile +# Dockerfile +FROM python:3.11-slim as base + +# Set environment +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Install Python dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY server/ ./server/ +COPY client/ ./client/ + +# Create non-root user +RUN useradd --create-home --shell /bin/bash appuser \ + && chown -R appuser:appuser /app +USER appuser + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8000/health || exit 1 + +EXPOSE 8000 + +CMD ["python", "-m", "uvicorn", "server.main:app", "--host", "0.0.0.0", "--port", "8000"] +``` + +### Production Docker Compose + +```yaml +# docker-compose.prod.yml +version: '3.8' + +services: + app: + build: + context: . + dockerfile: Dockerfile + environment: + - DATABASE_URL=postgresql://golf:${DB_PASSWORD}@postgres:5432/golfgame + - REDIS_URL=redis://redis:6379/0 + - SECRET_KEY=${SECRET_KEY} + - RESEND_API_KEY=${RESEND_API_KEY} + - SENTRY_DSN=${SENTRY_DSN} + - ENVIRONMENT=production + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + deploy: + replicas: 2 + restart_policy: + condition: on-failure + max_attempts: 3 + resources: + limits: + memory: 512M + reservations: + memory: 256M + networks: + - internal + - web + labels: + - "traefik.enable=true" + - "traefik.http.routers.golf.rule=Host(`golf.example.com`)" + - "traefik.http.routers.golf.tls=true" + - "traefik.http.routers.golf.tls.certresolver=letsencrypt" + + worker: + build: + context: . + dockerfile: Dockerfile + command: python -m arq server.worker.WorkerSettings + environment: + - DATABASE_URL=postgresql://golf:${DB_PASSWORD}@postgres:5432/golfgame + - REDIS_URL=redis://redis:6379/0 + depends_on: + - postgres + - redis + deploy: + replicas: 1 + resources: + limits: + memory: 256M + + postgres: + image: postgres:15-alpine + environment: + POSTGRES_DB: golfgame + POSTGRES_USER: golf + POSTGRES_PASSWORD: ${DB_PASSWORD} + volumes: + - postgres_data:/var/lib/postgresql/data + - ./init.sql:/docker-entrypoint-initdb.d/init.sql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U golf -d golfgame"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - internal + + redis: + image: redis:7-alpine + command: redis-server --appendonly yes --maxmemory 128mb --maxmemory-policy allkeys-lru + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - internal + + traefik: + image: traefik:v2.10 + command: + - "--api.dashboard=true" + - "--providers.docker=true" + - "--providers.docker.exposedbydefault=false" + - "--entrypoints.web.address=:80" + - "--entrypoints.websecure.address=:443" + - "--certificatesresolvers.letsencrypt.acme.httpchallenge=true" + - "--certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL}" + - "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json" + ports: + - "80:80" + - "443:443" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - letsencrypt:/letsencrypt + networks: + - web + +volumes: + postgres_data: + redis_data: + letsencrypt: + +networks: + internal: + web: + external: true +``` + +--- + +## 2. Health Checks & Readiness + +### Health Endpoint Implementation + +```python +# server/health.py +from fastapi import APIRouter, Response +from datetime import datetime +import asyncpg +import redis.asyncio as redis + +router = APIRouter(tags=["health"]) + +@router.get("/health") +async def health_check(): + """Basic liveness check - is the app running?""" + return {"status": "ok", "timestamp": datetime.utcnow().isoformat()} + +@router.get("/ready") +async def readiness_check( + db: asyncpg.Pool = Depends(get_db_pool), + redis_client: redis.Redis = Depends(get_redis) +): + """Readiness check - can the app handle requests?""" + checks = {} + overall_healthy = True + + # Check database + try: + async with db.acquire() as conn: + await conn.fetchval("SELECT 1") + checks["database"] = {"status": "ok"} + except Exception as e: + checks["database"] = {"status": "error", "message": str(e)} + overall_healthy = False + + # Check Redis + try: + await redis_client.ping() + checks["redis"] = {"status": "ok"} + except Exception as e: + checks["redis"] = {"status": "error", "message": str(e)} + overall_healthy = False + + status_code = 200 if overall_healthy else 503 + return Response( + content=json.dumps({ + "status": "ok" if overall_healthy else "degraded", + "checks": checks, + "timestamp": datetime.utcnow().isoformat() + }), + status_code=status_code, + media_type="application/json" + ) + +@router.get("/metrics") +async def metrics( + db: asyncpg.Pool = Depends(get_db_pool), + redis_client: redis.Redis = Depends(get_redis) +): + """Expose application metrics for monitoring.""" + async with db.acquire() as conn: + active_games = await conn.fetchval( + "SELECT COUNT(*) FROM games WHERE completed_at IS NULL" + ) + total_users = await conn.fetchval("SELECT COUNT(*) FROM users") + games_today = await conn.fetchval( + "SELECT COUNT(*) FROM games WHERE created_at > NOW() - INTERVAL '1 day'" + ) + + connected_players = await redis_client.scard("connected_players") + + return { + "active_games": active_games, + "total_users": total_users, + "games_today": games_today, + "connected_players": connected_players, + "timestamp": datetime.utcnow().isoformat() + } +``` + +--- + +## 3. Rate Limiting (Phase 1 Priority) + +Rate limiting is a Phase 1 priority for security. Implement early to prevent abuse. + +### Rate Limiter Implementation + +```python +# server/ratelimit.py +from fastapi import Request, HTTPException +from typing import Optional +import redis.asyncio as redis +import time +import hashlib + +class RateLimiter: + """Token bucket rate limiter using Redis.""" + + def __init__(self, redis_client: redis.Redis): + self.redis = redis_client + + async def is_allowed( + self, + key: str, + limit: int, + window_seconds: int + ) -> tuple[bool, dict]: + """Check if request is allowed under rate limit. + + Returns (allowed, info) where info contains: + - remaining: requests remaining in window + - reset: seconds until window resets + - limit: the limit that was applied + """ + now = int(time.time()) + window_key = f"ratelimit:{key}:{now // window_seconds}" + + async with self.redis.pipeline(transaction=True) as pipe: + pipe.incr(window_key) + pipe.expire(window_key, window_seconds) + results = await pipe.execute() + + current_count = results[0] + remaining = max(0, limit - current_count) + reset = window_seconds - (now % window_seconds) + + info = { + "remaining": remaining, + "reset": reset, + "limit": limit + } + + return current_count <= limit, info + + def get_client_key(self, request: Request, user_id: Optional[str] = None) -> str: + """Generate rate limit key for client.""" + if user_id: + return f"user:{user_id}" + + # For anonymous users, use IP hash + client_ip = request.client.host + forwarded = request.headers.get("X-Forwarded-For") + if forwarded: + client_ip = forwarded.split(",")[0].strip() + + # Hash IP for privacy + return f"ip:{hashlib.sha256(client_ip.encode()).hexdigest()[:16]}" + + +# Rate limit configurations per endpoint type +RATE_LIMITS = { + "api_general": (100, 60), # 100 requests per minute + "api_auth": (10, 60), # 10 auth attempts per minute + "api_create_room": (5, 60), # 5 room creations per minute + "websocket_connect": (10, 60), # 10 WS connections per minute + "email_send": (3, 300), # 3 emails per 5 minutes +} +``` + +### Rate Limit Middleware + +```python +# server/middleware.py +from fastapi import Request +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import JSONResponse + +class RateLimitMiddleware(BaseHTTPMiddleware): + def __init__(self, app, rate_limiter: RateLimiter): + super().__init__(app) + self.limiter = rate_limiter + + async def dispatch(self, request: Request, call_next): + # Determine rate limit tier based on path + path = request.url.path + + if path.startswith("/api/auth"): + limit, window = RATE_LIMITS["api_auth"] + elif path == "/api/rooms": + limit, window = RATE_LIMITS["api_create_room"] + elif path.startswith("/api"): + limit, window = RATE_LIMITS["api_general"] + else: + # No rate limiting for static files + return await call_next(request) + + # Get user ID if authenticated + user_id = getattr(request.state, "user_id", None) + client_key = self.limiter.get_client_key(request, user_id) + + allowed, info = await self.limiter.is_allowed( + f"{path}:{client_key}", limit, window + ) + + # Add rate limit headers to response + response = await call_next(request) if allowed else JSONResponse( + status_code=429, + content={ + "error": "Rate limit exceeded", + "retry_after": info["reset"] + } + ) + + response.headers["X-RateLimit-Limit"] = str(info["limit"]) + response.headers["X-RateLimit-Remaining"] = str(info["remaining"]) + response.headers["X-RateLimit-Reset"] = str(info["reset"]) + + if not allowed: + response.headers["Retry-After"] = str(info["reset"]) + + return response +``` + +### WebSocket Rate Limiting + +```python +# In server/main.py +async def websocket_endpoint(websocket: WebSocket): + client_key = rate_limiter.get_client_key(websocket) + + allowed, info = await rate_limiter.is_allowed( + f"ws_connect:{client_key}", + *RATE_LIMITS["websocket_connect"] + ) + + if not allowed: + await websocket.close(code=1008, reason="Rate limit exceeded") + return + + # Also rate limit messages within the connection + message_limiter = ConnectionMessageLimiter( + max_messages=30, + window_seconds=10 + ) + + await websocket.accept() + + try: + while True: + data = await websocket.receive_text() + + if not message_limiter.check(): + await websocket.send_json({ + "type": "error", + "message": "Slow down! Too many messages." + }) + continue + + await handle_message(websocket, data) + except WebSocketDisconnect: + pass +``` + +--- + +## 4. Security Headers & HTTPS + +### Security Middleware + +```python +# server/security.py +from starlette.middleware.base import BaseHTTPMiddleware + +class SecurityHeadersMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request, call_next): + response = await call_next(request) + + # Security headers + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + response.headers["X-XSS-Protection"] = "1; mode=block" + response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + response.headers["Permissions-Policy"] = "geolocation=(), microphone=(), camera=()" + + # Content Security Policy + csp = "; ".join([ + "default-src 'self'", + "script-src 'self'", + "style-src 'self' 'unsafe-inline'", # For inline styles + "img-src 'self' data:", + "font-src 'self'", + "connect-src 'self' wss://*.example.com", + "frame-ancestors 'none'", + "base-uri 'self'", + "form-action 'self'" + ]) + response.headers["Content-Security-Policy"] = csp + + # HSTS (only in production) + if request.url.scheme == "https": + response.headers["Strict-Transport-Security"] = ( + "max-age=31536000; includeSubDomains; preload" + ) + + return response +``` + +### CORS Configuration + +```python +# server/main.py +from fastapi.middleware.cors import CORSMiddleware + +app.add_middleware( + CORSMiddleware, + allow_origins=[ + "https://golf.example.com", + "https://www.golf.example.com", + ], + allow_credentials=True, + allow_methods=["GET", "POST", "PUT", "DELETE"], + allow_headers=["*"], +) +``` + +--- + +## 5. Error Tracking with Sentry + +### Sentry Integration + +```python +# server/main.py +import sentry_sdk +from sentry_sdk.integrations.fastapi import FastApiIntegration +from sentry_sdk.integrations.redis import RedisIntegration +from sentry_sdk.integrations.asyncpg import AsyncPGIntegration + +if os.getenv("SENTRY_DSN"): + sentry_sdk.init( + dsn=os.getenv("SENTRY_DSN"), + environment=os.getenv("ENVIRONMENT", "development"), + traces_sample_rate=0.1, # 10% of transactions for performance + profiles_sample_rate=0.1, + integrations=[ + FastApiIntegration(transaction_style="endpoint"), + RedisIntegration(), + AsyncPGIntegration(), + ], + # Filter out sensitive data + before_send=filter_sensitive_data, + ) + +def filter_sensitive_data(event, hint): + """Remove sensitive data before sending to Sentry.""" + if "request" in event: + headers = event["request"].get("headers", {}) + # Remove auth headers + headers.pop("authorization", None) + headers.pop("cookie", None) + + return event +``` + +### Custom Error Handler + +```python +# server/errors.py +from fastapi import Request +from fastapi.responses import JSONResponse +import sentry_sdk +import traceback + +async def global_exception_handler(request: Request, exc: Exception): + """Handle all unhandled exceptions.""" + + # Log to Sentry + sentry_sdk.capture_exception(exc) + + # Log locally + logger.error(f"Unhandled exception: {exc}", exc_info=True) + + # Return generic error to client + return JSONResponse( + status_code=500, + content={ + "error": "Internal server error", + "request_id": request.state.request_id + } + ) + +# Register handler +app.add_exception_handler(Exception, global_exception_handler) +``` + +--- + +## 6. Structured Logging + +### Logging Configuration + +```python +# server/logging_config.py +import logging +import json +from datetime import datetime + +class JSONFormatter(logging.Formatter): + """Format logs as JSON for aggregation.""" + + def format(self, record): + log_data = { + "timestamp": datetime.utcnow().isoformat(), + "level": record.levelname, + "logger": record.name, + "message": record.getMessage(), + } + + # Add extra fields + if hasattr(record, "request_id"): + log_data["request_id"] = record.request_id + if hasattr(record, "user_id"): + log_data["user_id"] = record.user_id + if hasattr(record, "game_id"): + log_data["game_id"] = record.game_id + + # Add exception info + if record.exc_info: + log_data["exception"] = self.formatException(record.exc_info) + + return json.dumps(log_data) + +def setup_logging(): + """Configure application logging.""" + handler = logging.StreamHandler() + + if os.getenv("ENVIRONMENT") == "production": + handler.setFormatter(JSONFormatter()) + else: + handler.setFormatter(logging.Formatter( + "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + )) + + logging.root.handlers = [handler] + logging.root.setLevel(logging.INFO) + + # Reduce noise from libraries + logging.getLogger("uvicorn.access").setLevel(logging.WARNING) + logging.getLogger("websockets").setLevel(logging.WARNING) +``` + +### Request ID Middleware + +```python +# server/middleware.py +import uuid + +class RequestIDMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request, call_next): + request_id = request.headers.get("X-Request-ID", str(uuid.uuid4())) + request.state.request_id = request_id + + response = await call_next(request) + response.headers["X-Request-ID"] = request_id + + return response +``` + +--- + +## 7. Graceful Shutdown + +### Shutdown Handler + +```python +# server/main.py +import signal +import asyncio + +shutdown_event = asyncio.Event() + +@app.on_event("startup") +async def startup(): + # Register signal handlers + loop = asyncio.get_running_loop() + for sig in (signal.SIGTERM, signal.SIGINT): + loop.add_signal_handler(sig, lambda: asyncio.create_task(shutdown())) + +@app.on_event("shutdown") +async def shutdown(): + logger.info("Shutdown initiated...") + + # Stop accepting new connections + shutdown_event.set() + + # Save all active games to Redis + await save_all_active_games() + + # Close WebSocket connections gracefully + for ws in list(active_connections): + try: + await ws.close(code=1001, reason="Server shutting down") + except: + pass + + # Wait for in-flight requests (max 30 seconds) + await asyncio.sleep(5) + + # Close database pool + await db_pool.close() + + # Close Redis connections + await redis_client.close() + + logger.info("Shutdown complete") + +async def save_all_active_games(): + """Persist all active games before shutdown.""" + for game_id, game in active_games.items(): + try: + await state_cache.save_game(game) + logger.info(f"Saved game {game_id}") + except Exception as e: + logger.error(f"Failed to save game {game_id}: {e}") +``` + +--- + +## 8. Secrets Management + +### Environment Configuration + +```python +# server/config.py +from pydantic import BaseSettings, PostgresDsn, RedisDsn + +class Settings(BaseSettings): + # Database + database_url: PostgresDsn + + # Redis + redis_url: RedisDsn + + # Security + secret_key: str + jwt_algorithm: str = "HS256" + jwt_expiry_hours: int = 24 + + # Email + resend_api_key: str + email_from: str = "Golf Game " + + # Monitoring + sentry_dsn: str = "" + environment: str = "development" + + # Rate limiting + rate_limit_enabled: bool = True + + class Config: + env_file = ".env" + case_sensitive = False + +settings = Settings() +``` + +### Production Secrets (Example for Docker Swarm) + +```yaml +# docker-compose.prod.yml +secrets: + db_password: + external: true + secret_key: + external: true + resend_api_key: + external: true + +services: + app: + secrets: + - db_password + - secret_key + - resend_api_key + environment: + - DATABASE_URL=postgresql://golf@postgres:5432/golfgame?password_file=/run/secrets/db_password +``` + +--- + +## 9. Database Migrations + +### Alembic Configuration + +```ini +# alembic.ini +[alembic] +script_location = migrations +sqlalchemy.url = env://DATABASE_URL + +[logging] +level = INFO +``` + +### Migration Script Template + +```python +# migrations/versions/001_initial.py +"""Initial schema + +Revision ID: 001 +Create Date: 2024-01-01 +""" +from alembic import op +import sqlalchemy as sa + +revision = '001' +down_revision = None + +def upgrade(): + # Users table + op.create_table( + 'users', + sa.Column('id', sa.UUID(), primary_key=True), + sa.Column('username', sa.String(50), unique=True, nullable=False), + sa.Column('email', sa.String(255), unique=True, nullable=False), + sa.Column('password_hash', sa.String(255), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column('is_admin', sa.Boolean(), default=False), + ) + + # Games table + op.create_table( + 'games', + sa.Column('id', sa.UUID(), primary_key=True), + sa.Column('room_code', sa.String(10), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column('completed_at', sa.DateTime(timezone=True)), + ) + + # Events table + op.create_table( + 'events', + sa.Column('id', sa.BigInteger(), primary_key=True, autoincrement=True), + sa.Column('game_id', sa.UUID(), sa.ForeignKey('games.id'), nullable=False), + sa.Column('event_type', sa.String(50), nullable=False), + sa.Column('data', sa.JSON(), nullable=False), + sa.Column('timestamp', sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + # Indexes + op.create_index('idx_events_game_id', 'events', ['game_id']) + op.create_index('idx_users_email', 'users', ['email']) + op.create_index('idx_users_username', 'users', ['username']) + +def downgrade(): + op.drop_table('events') + op.drop_table('games') + op.drop_table('users') +``` + +### Migration Commands + +```bash +# Create new migration +alembic revision --autogenerate -m "Add user sessions" + +# Run migrations +alembic upgrade head + +# Rollback one version +alembic downgrade -1 + +# Show current version +alembic current +``` + +--- + +## 10. Deployment Checklist + +### Pre-deployment + +- [ ] All environment variables set +- [ ] Database migrations applied +- [ ] Secrets configured in secret manager +- [ ] SSL certificates provisioned +- [ ] Rate limiting configured and tested +- [ ] Error tracking (Sentry) configured +- [ ] Logging aggregation set up +- [ ] Health check endpoints verified +- [ ] Backup strategy implemented + +### Deployment + +- [ ] Run database migrations +- [ ] Deploy new containers with rolling update +- [ ] Verify health checks pass +- [ ] Monitor error rates in Sentry +- [ ] Check application logs +- [ ] Verify WebSocket connections work +- [ ] Test critical user flows + +### Post-deployment + +- [ ] Monitor performance metrics +- [ ] Check database connection pool usage +- [ ] Verify Redis memory usage +- [ ] Review error logs +- [ ] Test graceful shutdown/restart + +--- + +## 11. Monitoring Dashboard (Grafana) + +### Key Metrics to Track + +```yaml +# Example Prometheus metrics +metrics: + # Application + - http_requests_total + - http_request_duration_seconds + - websocket_connections_active + - games_active + - games_completed_total + + # Infrastructure + - container_cpu_usage_seconds_total + - container_memory_usage_bytes + - pg_stat_activity_count + - redis_connected_clients + - redis_used_memory_bytes + + # Business + - users_registered_total + - games_played_today + - average_game_duration_seconds +``` + +### Alert Rules + +```yaml +# alertmanager rules +groups: + - name: golf-alerts + rules: + - alert: HighErrorRate + expr: rate(http_requests_total{status=~"5.."}[5m]) > 0.1 + for: 5m + labels: + severity: critical + annotations: + summary: "High error rate detected" + + - alert: DatabaseConnectionExhausted + expr: pg_stat_activity_count > 90 + for: 2m + labels: + severity: warning + annotations: + summary: "Database connections near limit" + + - alert: HighMemoryUsage + expr: container_memory_usage_bytes / container_spec_memory_limit_bytes > 0.9 + for: 5m + labels: + severity: warning + annotations: + summary: "Container memory usage above 90%" +``` + +--- + +## 12. Backup Strategy + +### Database Backups + +```bash +#!/bin/bash +# backup.sh - Daily database backup + +BACKUP_DIR=/backups +DATE=$(date +%Y%m%d_%H%M%S) +BACKUP_FILE="${BACKUP_DIR}/golfgame_${DATE}.sql.gz" + +# Backup with pg_dump +pg_dump -h postgres -U golf golfgame | gzip > "$BACKUP_FILE" + +# Upload to S3/B2/etc +aws s3 cp "$BACKUP_FILE" s3://golf-backups/ + +# Cleanup old local backups (keep 7 days) +find "$BACKUP_DIR" -name "*.sql.gz" -mtime +7 -delete + +# Cleanup old S3 backups (keep 30 days) via lifecycle policy +``` + +### Redis Persistence + +```conf +# redis.conf +appendonly yes +appendfsync everysec +auto-aof-rewrite-percentage 100 +auto-aof-rewrite-min-size 64mb +``` + +--- + +## Summary + +This document covers all production deployment concerns: + +1. **Docker**: Multi-stage builds, health checks, resource limits +2. **Rate Limiting**: Token bucket algorithm, per-endpoint limits (Phase 1 priority) +3. **Security**: Headers, CORS, CSP, HSTS +4. **Monitoring**: Sentry, structured logging, Prometheus metrics +5. **Operations**: Graceful shutdown, migrations, backups +6. **Deployment**: Checklist, rolling updates, health verification + +Rate limiting is implemented in Phase 1 as a security priority to protect against abuse before public launch. diff --git a/pyproject.toml b/pyproject.toml index 5e279de..d6d2b37 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,8 @@ dependencies = [ "uvicorn[standard]>=0.27.0", "websockets>=12.0", "python-dotenv>=1.0.0", + # V2: Event sourcing infrastructure + "asyncpg>=0.29.0", ] [project.optional-dependencies] diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 0000000..6cb1716 --- /dev/null +++ b/server/.env.example @@ -0,0 +1,55 @@ +# Golf Game Server Configuration +# Copy this file to .env and adjust values as needed + +# Server settings +HOST=0.0.0.0 +PORT=8000 +DEBUG=true +LOG_LEVEL=DEBUG + +# Environment (development, staging, production) +# Affects logging format, security headers (HSTS), etc. +ENVIRONMENT=development + +# Legacy SQLite database (for analytics/auth) +DATABASE_URL=sqlite:///games.db + +# V2: PostgreSQL for event store +# Used with: docker-compose -f docker-compose.dev.yml up -d +POSTGRES_URL=postgresql://golf:devpassword@localhost:5432/golf + +# V2: Redis for live state cache and pub/sub +# Used with: docker-compose -f docker-compose.dev.yml up -d +REDIS_URL=redis://localhost:6379 + +# Room settings +MAX_PLAYERS_PER_ROOM=6 +ROOM_TIMEOUT_MINUTES=60 + +# Security (optional) +# SECRET_KEY=your-secret-key-here +# INVITE_ONLY=false +# ADMIN_EMAILS=admin@example.com,another@example.com + +# V2: Email configuration (Resend) +# Get API key from https://resend.com +# RESEND_API_KEY=re_xxxxxxxx +# EMAIL_FROM=Golf Game + +# V2: Base URL for email links +# BASE_URL=http://localhost:8000 + +# V2: Session settings +# SESSION_EXPIRY_HOURS=168 + +# V2: Email verification +# Set to true to require email verification before login +# REQUIRE_EMAIL_VERIFICATION=false + +# V2: Rate limiting +# Set to false to disable API rate limiting +# RATE_LIMIT_ENABLED=true + +# V2: Error tracking (Sentry) +# Get DSN from https://sentry.io +# SENTRY_DSN=https://xxx@xxx.ingest.sentry.io/xxx diff --git a/server/ai.py b/server/ai.py index b3bfdc3..ed238d6 100644 --- a/server/ai.py +++ b/server/ai.py @@ -94,6 +94,34 @@ def get_end_game_pressure(player: Player, game: Game) -> float: return min(1.0, base_pressure + hidden_risk_bonus) +def get_standings_pressure(player: Player, game: Game) -> float: + """ + Calculate pressure based on player's position in standings. + Returns 0.0-1.0 where higher = more behind, needs aggressive play. + + Factors: + - How far behind the leader in total_score + - How late in the game (current_round / num_rounds) + """ + if len(game.players) < 2 or game.num_rounds <= 1: + return 0.0 + + # Calculate standings gap + scores = [p.total_score for p in game.players] + leader_score = min(scores) # Lower is better in golf + my_score = player.total_score + gap = my_score - leader_score # Positive = behind + + # Normalize gap (assume ~10 pts/round average, 20+ behind is dire) + gap_pressure = min(gap / 20.0, 1.0) if gap > 0 else 0.0 + + # Late-game multiplier (ramps up in final third of game) + round_progress = game.current_round / game.num_rounds + late_game_factor = max(0, (round_progress - 0.66) * 3) # 0 until 66%, then ramps to 1 + + return min(gap_pressure * (1 + late_game_factor), 1.0) + + def count_rank_in_hand(player: Player, rank: Rank) -> int: """Count how many cards of a given rank the player has visible.""" return sum(1 for c in player.cards if c.face_up and c.rank == rank) @@ -485,11 +513,26 @@ class GolfAI: ai_log(f" >> TAKE: One-eyed Jack (worth 0)") return True + # Wolfpack pursuit: Take Jacks when pursuing the bonus + if options.wolfpack and discard_card.rank == Rank.JACK: + jack_count = sum(1 for c in player.cards if c.face_up and c.rank == Rank.JACK) + if jack_count >= 2 and profile.aggression > 0.5: + ai_log(f" >> TAKE: Jack for wolfpack pursuit ({jack_count} Jacks visible)") + return True + # Auto-take 10s when ten_penny enabled (they're worth 1) if discard_card.rank == Rank.TEN and options.ten_penny: ai_log(f" >> TAKE: 10 (ten_penny rule)") return True + # Four-of-a-kind pursuit: Take cards when building toward bonus + if options.four_of_a_kind and profile.aggression > 0.5: + rank_count = sum(1 for c in player.cards if c.face_up and c.rank == discard_card.rank) + if rank_count >= 2: + # Already have 2+ of this rank, take to pursue four-of-a-kind! + ai_log(f" >> TAKE: {discard_card.rank.value} for four-of-a-kind ({rank_count} visible)") + return True + # Take card if it could make a column pair (but NOT for negative value cards) # Pairing negative cards is bad - you lose the negative benefit if discard_value > 0: @@ -612,7 +655,38 @@ class GolfAI: # 2. POINT GAIN - Direct value improvement if current_card.face_up: current_value = get_ai_card_value(current_card, options) - point_gain = current_value - drawn_value + + # CRITICAL: Check if current card is part of an existing column pair + # If so, breaking the pair is usually terrible - the paired column is worth 0, + # but after breaking it becomes (drawn_value + orphaned_partner_value) + if partner_card.face_up and partner_card.rank == current_card.rank: + partner_value = get_ai_card_value(partner_card, options) + + # Determine the current column value (what the pair contributes) + if options.eagle_eye and current_card.rank == Rank.JOKER: + # Eagle Eye: paired jokers contribute -4 total + old_column_value = -4 + # After swap: orphan joker becomes +2 (unpaired eagle_eye value) + new_column_value = drawn_value + 2 + point_gain = old_column_value - new_column_value + ai_log(f" Breaking Eagle Eye joker pair at pos {pos}: column {old_column_value} -> {new_column_value}, gain={point_gain}") + elif options.negative_pairs_keep_value and (current_value < 0 or partner_value < 0): + # Negative pairs keep value: column is worth sum of both values + old_column_value = current_value + partner_value + new_column_value = drawn_value + partner_value + point_gain = old_column_value - new_column_value + ai_log(f" Breaking negative-keep pair at pos {pos}: column {old_column_value} -> {new_column_value}, gain={point_gain}") + else: + # Standard pair - column is worth 0 + # After swap: column becomes drawn_value + partner_value + old_column_value = 0 + new_column_value = drawn_value + partner_value + point_gain = old_column_value - new_column_value + ai_log(f" Breaking standard pair at pos {pos}: column 0 -> {new_column_value}, gain={point_gain}") + else: + # No existing pair - normal calculation + point_gain = current_value - drawn_value + score += point_gain else: # Hidden card - expected value ~4.5 @@ -659,8 +733,52 @@ class GolfAI: if rank_count >= 2: # Already have 2+ of this rank, getting more is great for 4-of-a-kind four_kind_bonus = rank_count * 4 # 8 for 2 cards, 12 for 3 cards + # Boost when behind in standings + standings_pressure = get_standings_pressure(player, game) + if standings_pressure > 0.3: + four_kind_bonus *= (1 + standings_pressure * 0.5) # Up to 50% boost score += four_kind_bonus - ai_log(f" Four-of-a-kind pursuit bonus: +{four_kind_bonus}") + ai_log(f" Four-of-a-kind pursuit bonus: +{four_kind_bonus:.1f}") + + # 4c. WOLFPACK PURSUIT - Aggressive players chase Jack pairs for -20 bonus + if options.wolfpack and profile.aggression > 0.5: + # Count Jack pairs already formed + jack_pair_count = 0 + for col in range(3): + top, bot = player.cards[col], player.cards[col + 3] + if top.face_up and bot.face_up and top.rank == Rank.JACK and bot.rank == Rank.JACK: + jack_pair_count += 1 + + # Count visible Jacks that could form pairs + visible_jacks = sum(1 for c in player.cards if c.face_up and c.rank == Rank.JACK) + + if drawn_card.rank == Rank.JACK: + # Drawing a Jack - evaluate wolfpack potential + if jack_pair_count == 1: + # Already have one pair! Second pair gives -20 bonus + if partner_card.face_up and partner_card.rank == Rank.JACK: + # Completing second Jack pair! + wolfpack_bonus = 15 * profile.aggression + score += wolfpack_bonus + ai_log(f" Wolfpack pursuit: completing 2nd Jack pair! +{wolfpack_bonus:.1f}") + elif not partner_card.face_up: + # Partner unknown, Jack could pair + wolfpack_bonus = 6 * profile.aggression + score += wolfpack_bonus + ai_log(f" Wolfpack pursuit: Jack with unknown partner +{wolfpack_bonus:.1f}") + elif visible_jacks >= 1 and partner_card.face_up and partner_card.rank == Rank.JACK: + # Completing first Jack pair while having other Jacks + wolfpack_bonus = 8 * profile.aggression + score += wolfpack_bonus + ai_log(f" Wolfpack pursuit: first Jack pair +{wolfpack_bonus:.1f}") + + # 4d. COMEBACK AGGRESSION - Boost reveal bonus when behind in late game + standings_pressure = get_standings_pressure(player, game) + if standings_pressure > 0.3 and not current_card.face_up: + # Behind in standings - boost incentive to reveal and play faster + comeback_bonus = standings_pressure * 3 * profile.aggression + score += comeback_bonus + ai_log(f" Comeback aggression bonus: +{comeback_bonus:.1f} (pressure={standings_pressure:.2f})") # 5. GO-OUT SAFETY - Penalty for going out with bad score face_down_positions = [i for i, c in enumerate(player.cards) if not c.face_up] @@ -1019,6 +1137,13 @@ class GolfAI: # Base threshold based on aggression go_out_threshold = 8 if profile.aggression > 0.7 else (12 if profile.aggression > 0.4 else 16) + # COMEBACK MODE: Accept higher scores when significantly behind + standings_pressure = get_standings_pressure(player, game) + if standings_pressure > 0.5: + # Behind and late - swing for the fences + go_out_threshold += int(standings_pressure * 6) # Up to +6 points tolerance + ai_log(f" Comeback mode: raised go-out threshold to {go_out_threshold}") + # Knock Bonus (-5 for going out): Can afford to go out with higher score if options.knock_bonus: go_out_threshold += 5 @@ -1157,13 +1282,33 @@ async def process_cpu_turn( safe_positions = filter_bad_pair_positions(face_down, drawn, cpu_player, game.options) swap_pos = random.choice(safe_positions) else: - # All cards are face up - find worst card to replace (using house rules) + # All cards are face up - find worst card to replace + # IMPORTANT: Consider effective value (cards in pairs contribute 0, not face value) worst_pos = 0 - worst_val = -999 + worst_effective_val = -999 for i, c in enumerate(cpu_player.cards): - card_val = get_ai_card_value(c, game.options) # Apply house rules - if card_val > worst_val: - worst_val = card_val + card_val = get_ai_card_value(c, game.options) + partner_pos = get_column_partner_position(i) + partner = cpu_player.cards[partner_pos] + + # Check if this card is part of an existing pair + if partner.rank == c.rank: + # Card is paired - its effective value depends on house rules + if card_val >= 0 or not game.options.negative_pairs_keep_value: + # Standard pair: both contribute 0, so effective value is 0 + # BUT breaking it orphans partner, so true cost is partner's value + effective_val = -get_ai_card_value(partner, game.options) + elif game.options.eagle_eye and c.rank == Rank.JOKER: + # Eagle eye joker pair contributes -4 total, each contributes -2 effective + effective_val = -2 + else: + # Negative pairs keep value: each card contributes its value + effective_val = card_val + else: + effective_val = card_val + + if effective_val > worst_effective_val: + worst_effective_val = effective_val worst_pos = i swap_pos = worst_pos diff --git a/server/config.py b/server/config.py index 9feda9c..a2421d4 100644 --- a/server/config.py +++ b/server/config.py @@ -20,7 +20,10 @@ from typing import Optional # Load .env file if it exists try: from dotenv import load_dotenv - env_path = Path(__file__).parent.parent / ".env" + # Check server/.env first, then project root .env + env_path = Path(__file__).parent / ".env" + if not env_path.exists(): + env_path = Path(__file__).parent.parent / ".env" if env_path.exists(): load_dotenv(env_path) except ImportError: @@ -110,9 +113,31 @@ class ServerConfig: DEBUG: bool = False LOG_LEVEL: str = "INFO" - # Database + # Environment (development, staging, production) + ENVIRONMENT: str = "development" + + # Database (SQLite for legacy analytics/auth) DATABASE_URL: str = "sqlite:///games.db" + # PostgreSQL for V2 event store + # Format: postgresql://user:password@host:port/database + POSTGRES_URL: str = "" + + # Redis for V2 live state cache and pub/sub + # Format: redis://host:port or redis://:password@host:port + REDIS_URL: str = "" + + # Email settings (Resend integration) + RESEND_API_KEY: str = "" + EMAIL_FROM: str = "Golf Game " + BASE_URL: str = "http://localhost:8000" + + # Session settings + SESSION_EXPIRY_HOURS: int = 168 # 1 week + + # Email verification + REQUIRE_EMAIL_VERIFICATION: bool = False + # Room settings MAX_PLAYERS_PER_ROOM: int = 6 ROOM_TIMEOUT_MINUTES: int = 60 @@ -123,6 +148,12 @@ class ServerConfig: INVITE_ONLY: bool = False ADMIN_EMAILS: list[str] = field(default_factory=list) + # Rate limiting + RATE_LIMIT_ENABLED: bool = True + + # Error tracking (Sentry) + SENTRY_DSN: str = "" + # Card values card_values: CardValues = field(default_factory=CardValues) @@ -140,13 +171,23 @@ class ServerConfig: PORT=get_env_int("PORT", 8000), DEBUG=get_env_bool("DEBUG", False), LOG_LEVEL=get_env("LOG_LEVEL", "INFO"), + ENVIRONMENT=get_env("ENVIRONMENT", "development"), DATABASE_URL=get_env("DATABASE_URL", "sqlite:///games.db"), + POSTGRES_URL=get_env("POSTGRES_URL", ""), + REDIS_URL=get_env("REDIS_URL", ""), + RESEND_API_KEY=get_env("RESEND_API_KEY", ""), + EMAIL_FROM=get_env("EMAIL_FROM", "Golf Game "), + BASE_URL=get_env("BASE_URL", "http://localhost:8000"), + SESSION_EXPIRY_HOURS=get_env_int("SESSION_EXPIRY_HOURS", 168), + REQUIRE_EMAIL_VERIFICATION=get_env_bool("REQUIRE_EMAIL_VERIFICATION", False), MAX_PLAYERS_PER_ROOM=get_env_int("MAX_PLAYERS_PER_ROOM", 6), ROOM_TIMEOUT_MINUTES=get_env_int("ROOM_TIMEOUT_MINUTES", 60), ROOM_CODE_LENGTH=get_env_int("ROOM_CODE_LENGTH", 4), SECRET_KEY=get_env("SECRET_KEY", ""), INVITE_ONLY=get_env_bool("INVITE_ONLY", False), ADMIN_EMAILS=admin_emails, + RATE_LIMIT_ENABLED=get_env_bool("RATE_LIMIT_ENABLED", True), + SENTRY_DSN=get_env("SENTRY_DSN", ""), card_values=CardValues( ACE=get_env_int("CARD_ACE", 1), TWO=get_env_int("CARD_TWO", -2), diff --git a/server/game.py b/server/game.py index a1ff979..4ed8f96 100644 --- a/server/game.py +++ b/server/game.py @@ -19,10 +19,12 @@ Card Layout: """ import random +import uuid from collections import Counter from dataclasses import dataclass, field +from datetime import datetime, timezone from enum import Enum -from typing import Optional +from typing import Optional, Callable, Any from constants import ( DEFAULT_CARD_VALUES, @@ -163,6 +165,9 @@ class Deck: Supports multiple standard 52-card decks combined, with optional jokers in various configurations (standard 2-per-deck or lucky swing). + + For event sourcing, the deck can be initialized with a seed for + deterministic shuffling, enabling exact game replay. """ def __init__( @@ -170,6 +175,7 @@ class Deck: num_decks: int = 1, use_jokers: bool = False, lucky_swing: bool = False, + seed: Optional[int] = None, ) -> None: """ Initialize a new deck. @@ -178,8 +184,11 @@ class Deck: num_decks: Number of standard 52-card decks to combine. use_jokers: Whether to include joker cards. lucky_swing: If True, use single -5 joker instead of two -2 jokers. + seed: Optional random seed for deterministic shuffle. + If None, a random seed is generated and stored. """ self.cards: list[Card] = [] + self.seed: int = seed if seed is not None else random.randint(0, 2**31 - 1) # Build deck(s) with standard cards for _ in range(num_decks): @@ -199,9 +208,19 @@ class Deck: self.shuffle() - def shuffle(self) -> None: - """Randomize the order of cards in the deck.""" + def shuffle(self, seed: Optional[int] = None) -> None: + """ + Randomize the order of cards in the deck. + + Args: + seed: Optional seed to use. If None, uses the deck's stored seed. + """ + if seed is not None: + self.seed = seed + random.seed(self.seed) random.shuffle(self.cards) + # Reset random state to not affect other random calls + random.seed() def draw(self) -> Optional[Card]: """ @@ -486,6 +505,7 @@ class Game: players_with_final_turn: Set of player IDs who've had final turn. initial_flips_done: Set of player IDs who've done initial flips. options: Game configuration and house rules. + game_id: Unique identifier for event sourcing. """ players: list[Player] = field(default_factory=list) @@ -503,6 +523,74 @@ class Game: initial_flips_done: set = field(default_factory=set) options: GameOptions = field(default_factory=GameOptions) + # Event sourcing support + game_id: str = field(default_factory=lambda: str(uuid.uuid4())) + _event_emitter: Optional[Callable[["GameEvent"], None]] = field( + default=None, repr=False, compare=False + ) + _sequence_num: int = field(default=0, repr=False, compare=False) + + def set_event_emitter(self, emitter: Callable[["GameEvent"], None]) -> None: + """ + Set callback for event emission. + + The emitter will be called with each GameEvent as it occurs. + This enables event sourcing without changing game logic. + + Args: + emitter: Callback function that receives GameEvent objects. + """ + self._event_emitter = emitter + + def emit_game_created(self, room_code: str, host_id: str) -> None: + """ + Emit the game_created event. + + Should be called after setting up the event emitter and before + any players join. This establishes the game in the event store. + + Args: + room_code: 4-letter room code. + host_id: ID of the player who created the room. + """ + self._emit( + "game_created", + player_id=host_id, + room_code=room_code, + host_id=host_id, + options={}, # Options not set until game starts + ) + + def _emit( + self, + event_type: str, + player_id: Optional[str] = None, + **data: Any, + ) -> None: + """ + Emit an event if emitter is configured. + + Args: + event_type: Event type string (from EventType enum). + player_id: ID of player who triggered the event. + **data: Event-specific data fields. + """ + if self._event_emitter is None: + return + + # Import here to avoid circular dependency + from models.events import GameEvent, EventType + + self._sequence_num += 1 + event = GameEvent( + event_type=EventType(event_type), + game_id=self.game_id, + sequence_num=self._sequence_num, + player_id=player_id, + data=data, + ) + self._event_emitter(event) + @property def flip_on_discard(self) -> bool: """ @@ -556,12 +644,19 @@ class Game: # Player Management # ------------------------------------------------------------------------- - def add_player(self, player: Player) -> bool: + def add_player( + self, + player: Player, + is_cpu: bool = False, + cpu_profile: Optional[str] = None, + ) -> bool: """ Add a player to the game. Args: player: The player to add. + is_cpu: Whether this is a CPU player. + cpu_profile: CPU profile name (for AI replay analysis). Returns: True if added, False if game is full (max 6 players). @@ -569,21 +664,34 @@ class Game: if len(self.players) >= 6: return False self.players.append(player) + + # Emit player_joined event + self._emit( + "player_joined", + player_id=player.id, + player_name=player.name, + is_cpu=is_cpu, + cpu_profile=cpu_profile, + ) + return True - def remove_player(self, player_id: str) -> Optional[Player]: + def remove_player(self, player_id: str, reason: str = "left") -> Optional[Player]: """ Remove a player from the game by ID. Args: player_id: The unique ID of the player to remove. + reason: Why the player left (left, disconnected, kicked). Returns: The removed Player, or None if not found. """ for i, player in enumerate(self.players): if player.id == player_id: - return self.players.pop(i) + removed = self.players.pop(i) + self._emit("player_left", player_id=player_id, reason=reason) + return removed return None def get_player(self, player_id: str) -> Optional[Player]: @@ -629,8 +737,41 @@ class Game: self.num_rounds = num_rounds self.options = options or GameOptions() self.current_round = 1 + + # Emit game_started event + self._emit( + "game_started", + player_order=[p.id for p in self.players], + num_decks=num_decks, + num_rounds=num_rounds, + options=self._options_to_dict(), + ) + self.start_round() + def _options_to_dict(self) -> dict: + """Convert GameOptions to dictionary for event storage.""" + return { + "flip_mode": self.options.flip_mode, + "initial_flips": self.options.initial_flips, + "knock_penalty": self.options.knock_penalty, + "use_jokers": self.options.use_jokers, + "lucky_swing": self.options.lucky_swing, + "super_kings": self.options.super_kings, + "ten_penny": self.options.ten_penny, + "knock_bonus": self.options.knock_bonus, + "underdog_bonus": self.options.underdog_bonus, + "tied_shame": self.options.tied_shame, + "blackjack": self.options.blackjack, + "eagle_eye": self.options.eagle_eye, + "wolfpack": self.options.wolfpack, + "flip_as_action": self.options.flip_as_action, + "four_of_a_kind": self.options.four_of_a_kind, + "negative_pairs_keep_value": self.options.negative_pairs_keep_value, + "one_eyed_jacks": self.options.one_eyed_jacks, + "knock_early": self.options.knock_early, + } + def start_round(self) -> None: """ Initialize a new round. @@ -651,6 +792,7 @@ class Game: self.initial_flips_done = set() # Deal 6 cards to each player + dealt_cards: dict[str, list[dict]] = {} for player in self.players: player.cards = [] player.score = 0 @@ -658,15 +800,34 @@ class Game: card = self.deck.draw() if card: player.cards.append(card) + # Store dealt cards for event (include hidden card values server-side) + dealt_cards[player.id] = [ + {"rank": c.rank.value, "suit": c.suit.value} + for c in player.cards + ] # Start discard pile with one face-up card first_discard = self.deck.draw() + first_discard_dict = None if first_discard: first_discard.face_up = True self.discard_pile.append(first_discard) + first_discard_dict = { + "rank": first_discard.rank.value, + "suit": first_discard.suit.value, + } self.current_player_index = 0 + # Emit round_started event with deck seed and all dealt cards + self._emit( + "round_started", + round_num=self.current_round, + deck_seed=self.deck.seed, + dealt_cards=dealt_cards, + first_discard=first_discard_dict, + ) + # Skip initial flip phase if 0 flips required if self.options.initial_flips == 0: self.phase = GamePhase.PLAYING @@ -708,6 +869,18 @@ class Game: self.initial_flips_done.add(player_id) + # Emit initial_flip event with revealed cards + flipped_cards = [ + {"rank": player.cards[pos].rank.value, "suit": player.cards[pos].suit.value} + for pos in positions + ] + self._emit( + "initial_flip", + player_id=player_id, + positions=positions, + cards=flipped_cards, + ) + # Transition to PLAYING when all players have flipped if len(self.initial_flips_done) == len(self.players): self.phase = GamePhase.PLAYING @@ -751,6 +924,13 @@ class Game: if card: self.drawn_card = card self.drawn_from_discard = False + # Emit card_drawn event (with actual card value, server-side only) + self._emit( + "card_drawn", + player_id=player_id, + source=source, + card={"rank": card.rank.value, "suit": card.suit.value}, + ) return card # No cards available anywhere - end round gracefully self._end_round() @@ -760,6 +940,13 @@ class Game: card = self.discard_pile.pop() self.drawn_card = card self.drawn_from_discard = True + # Emit card_drawn event + self._emit( + "card_drawn", + player_id=player_id, + source=source, + card={"rank": card.rank.value, "suit": card.suit.value}, + ) return card return None @@ -812,11 +999,21 @@ class Game: if not (0 <= position < 6): return None + new_card = self.drawn_card old_card = player.swap_card(position, self.drawn_card) old_card.face_up = True self.discard_pile.append(old_card) self.drawn_card = None + # Emit card_swapped event + self._emit( + "card_swapped", + player_id=player_id, + position=position, + new_card={"rank": new_card.rank.value, "suit": new_card.suit.value}, + old_card={"rank": old_card.rank.value, "suit": old_card.suit.value}, + ) + self._check_end_turn(player) return old_card @@ -856,10 +1053,18 @@ class Game: if not self.can_discard_drawn(): return False + discarded_card = self.drawn_card self.drawn_card.face_up = True self.discard_pile.append(self.drawn_card) self.drawn_card = None + # Emit card_discarded event + self._emit( + "card_discarded", + player_id=player_id, + card={"rank": discarded_card.rank.value, "suit": discarded_card.suit.value}, + ) + if self.flip_on_discard: # Player must flip a card before turn ends has_face_down = any(not card.face_up for card in player.cards) @@ -895,6 +1100,16 @@ class Game: return False player.flip_card(position) + flipped_card = player.cards[position] + + # Emit card_flipped event + self._emit( + "card_flipped", + player_id=player_id, + position=position, + card={"rank": flipped_card.rank.value, "suit": flipped_card.suit.value}, + ) + self._check_end_turn(player) return True @@ -918,6 +1133,9 @@ class Game: if not player or player.id != player_id: return False + # Emit flip_skipped event + self._emit("flip_skipped", player_id=player_id) + self._check_end_turn(player) return True @@ -957,6 +1175,16 @@ class Game: return False # Already face-up, can't flip player.cards[card_index].face_up = True + flipped_card = player.cards[card_index] + + # Emit flip_as_action event + self._emit( + "flip_as_action", + player_id=player_id, + position=card_index, + card={"rank": flipped_card.rank.value, "suit": flipped_card.suit.value}, + ) + self._check_end_turn(player) return True @@ -996,8 +1224,21 @@ class Game: return False # Flip all remaining face-down cards + revealed_cards = [] for idx in face_down_indices: player.cards[idx].face_up = True + revealed_cards.append({ + "rank": player.cards[idx].rank.value, + "suit": player.cards[idx].suit.value, + }) + + # Emit knock_early event + self._emit( + "knock_early", + player_id=player_id, + positions=face_down_indices, + cards=revealed_cards, + ) self._check_end_turn(player) return True @@ -1122,6 +1363,20 @@ class Game: if player.score == min_score: player.rounds_won += 1 + # Emit round_ended event + scores = {p.id: p.score for p in self.players} + final_hands = { + p.id: [{"rank": c.rank.value, "suit": c.suit.value} for c in p.cards] + for p in self.players + } + self._emit( + "round_ended", + round_num=self.current_round, + scores=scores, + final_hands=final_hands, + finisher_id=self.finisher_id, + ) + def start_next_round(self) -> bool: """ Start the next round of the game. @@ -1134,6 +1389,25 @@ class Game: if self.current_round >= self.num_rounds: self.phase = GamePhase.GAME_OVER + + # Emit game_ended event + final_scores = {p.id: p.total_score for p in self.players} + rounds_won = {p.id: p.rounds_won for p in self.players} + + # Determine winner (lowest total score) + winner_id = None + if self.players: + min_score = min(p.total_score for p in self.players) + winners = [p for p in self.players if p.total_score == min_score] + if len(winners) == 1: + winner_id = winners[0].id + + self._emit( + "game_ended", + final_scores=final_scores, + rounds_won=rounds_won, + winner_id=winner_id, + ) return False self.current_round += 1 diff --git a/server/logging_config.py b/server/logging_config.py new file mode 100644 index 0000000..c8ce052 --- /dev/null +++ b/server/logging_config.py @@ -0,0 +1,251 @@ +""" +Structured logging configuration for Golf game server. + +Provides: +- JSONFormatter for production (machine-readable logs) +- Human-readable formatter for development +- Contextual logging (request_id, user_id, game_id) +""" + +import json +import logging +import os +import sys +from contextvars import ContextVar +from datetime import datetime, timezone +from typing import Optional + +# Context variables for request-scoped data +request_id_var: ContextVar[Optional[str]] = ContextVar("request_id", default=None) +user_id_var: ContextVar[Optional[str]] = ContextVar("user_id", default=None) +game_id_var: ContextVar[Optional[str]] = ContextVar("game_id", default=None) + + +class JSONFormatter(logging.Formatter): + """ + Format logs as JSON for production log aggregation. + + Output format is compatible with common log aggregation systems + (ELK, CloudWatch, Datadog, etc.). + """ + + def format(self, record: logging.LogRecord) -> str: + """ + Format log record as JSON. + + Args: + record: Log record to format. + + Returns: + JSON-formatted log string. + """ + log_data = { + "timestamp": datetime.now(timezone.utc).isoformat(), + "level": record.levelname, + "logger": record.name, + "message": record.getMessage(), + } + + # Add context from context variables + request_id = request_id_var.get() + if request_id: + log_data["request_id"] = request_id + + user_id = user_id_var.get() + if user_id: + log_data["user_id"] = user_id + + game_id = game_id_var.get() + if game_id: + log_data["game_id"] = game_id + + # Add extra fields from record + if hasattr(record, "request_id") and record.request_id: + log_data["request_id"] = record.request_id + if hasattr(record, "user_id") and record.user_id: + log_data["user_id"] = record.user_id + if hasattr(record, "game_id") and record.game_id: + log_data["game_id"] = record.game_id + if hasattr(record, "room_code") and record.room_code: + log_data["room_code"] = record.room_code + if hasattr(record, "player_id") and record.player_id: + log_data["player_id"] = record.player_id + + # Add source location for errors + if record.levelno >= logging.ERROR: + log_data["source"] = { + "file": record.pathname, + "line": record.lineno, + "function": record.funcName, + } + + # Add exception info + if record.exc_info: + log_data["exception"] = self.formatException(record.exc_info) + + return json.dumps(log_data, default=str) + + +class DevelopmentFormatter(logging.Formatter): + """ + Human-readable formatter for development. + + Includes colors and context for easy debugging. + """ + + COLORS = { + "DEBUG": "\033[36m", # Cyan + "INFO": "\033[32m", # Green + "WARNING": "\033[33m", # Yellow + "ERROR": "\033[31m", # Red + "CRITICAL": "\033[35m", # Magenta + } + RESET = "\033[0m" + + def format(self, record: logging.LogRecord) -> str: + """ + Format log record with colors and context. + + Args: + record: Log record to format. + + Returns: + Formatted log string. + """ + # Get color for level + color = self.COLORS.get(record.levelname, "") + reset = self.RESET if color else "" + + # Build timestamp + timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3] + + # Build context string + context_parts = [] + request_id = request_id_var.get() or getattr(record, "request_id", None) + if request_id: + context_parts.append(f"req={request_id[:8]}") + + user_id = user_id_var.get() or getattr(record, "user_id", None) + if user_id: + context_parts.append(f"user={user_id[:8]}") + + room_code = getattr(record, "room_code", None) + if room_code: + context_parts.append(f"room={room_code}") + + context = f" [{', '.join(context_parts)}]" if context_parts else "" + + # Format message + message = record.getMessage() + + # Build final output + output = f"{timestamp} {color}{record.levelname:8}{reset} {record.name}{context} - {message}" + + # Add exception if present + if record.exc_info: + output += "\n" + self.formatException(record.exc_info) + + return output + + +def setup_logging( + level: str = "INFO", + environment: str = "development", +) -> None: + """ + Configure application logging. + + Args: + level: Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL). + environment: Environment name (production uses JSON, else human-readable). + """ + # Get log level + log_level = getattr(logging, level.upper(), logging.INFO) + + # Create handler + handler = logging.StreamHandler(sys.stdout) + + # Choose formatter based on environment + if environment == "production": + handler.setFormatter(JSONFormatter()) + else: + handler.setFormatter(DevelopmentFormatter()) + + # Configure root logger + root_logger = logging.getLogger() + root_logger.handlers = [handler] + root_logger.setLevel(log_level) + + # Reduce noise from libraries + logging.getLogger("uvicorn.access").setLevel(logging.WARNING) + logging.getLogger("uvicorn.error").setLevel(logging.WARNING) + logging.getLogger("websockets").setLevel(logging.WARNING) + logging.getLogger("asyncio").setLevel(logging.WARNING) + + # Log startup + logger = logging.getLogger(__name__) + logger.info( + f"Logging configured: level={level}, environment={environment}", + extra={"level": level, "environment": environment}, + ) + + +class ContextLogger(logging.LoggerAdapter): + """ + Logger adapter that automatically includes context. + + Usage: + logger = ContextLogger(logging.getLogger(__name__)) + logger.with_context(room_code="ABCD", player_id="123").info("Player joined") + """ + + def __init__(self, logger: logging.Logger, extra: Optional[dict] = None): + """ + Initialize context logger. + + Args: + logger: Base logger instance. + extra: Extra context to include in all messages. + """ + super().__init__(logger, extra or {}) + + def with_context(self, **kwargs) -> "ContextLogger": + """ + Create a new logger with additional context. + + Args: + **kwargs: Context key-value pairs to add. + + Returns: + New ContextLogger with combined context. + """ + new_extra = {**self.extra, **kwargs} + return ContextLogger(self.logger, new_extra) + + def process(self, msg: str, kwargs: dict) -> tuple[str, dict]: + """ + Process log message to include context. + + Args: + msg: Log message. + kwargs: Keyword arguments. + + Returns: + Processed message and kwargs. + """ + # Merge extra into kwargs + kwargs["extra"] = {**self.extra, **kwargs.get("extra", {})} + return msg, kwargs + + +def get_logger(name: str) -> ContextLogger: + """ + Get a context-aware logger. + + Args: + name: Logger name (typically __name__). + + Returns: + ContextLogger instance. + """ + return ContextLogger(logging.getLogger(name)) diff --git a/server/main.py b/server/main.py index 57597ce..5efaf65 100644 --- a/server/main.py +++ b/server/main.py @@ -1,34 +1,261 @@ """FastAPI WebSocket server for Golf card game.""" +import asyncio import logging import os +import signal import uuid +from contextlib import asynccontextmanager from typing import Optional from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Depends, Header from fastapi.responses import FileResponse from pydantic import BaseModel +import redis.asyncio as redis from config import config from room import RoomManager, Room from game import GamePhase, GameOptions from ai import GolfAI, process_cpu_turn, get_all_profiles from game_log import get_logger -from auth import get_auth_manager, User, UserRole -# Configure logging -logging.basicConfig( - level=getattr(logging, config.LOG_LEVEL.upper(), logging.INFO), - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +# Import production components +from logging_config import setup_logging + +# Configure logging based on environment +setup_logging( + level=config.LOG_LEVEL, + environment=config.ENVIRONMENT, ) logger = logging.getLogger(__name__) + +# ============================================================================= +# Auth & Admin & Stats Services (initialized in lifespan) +# ============================================================================= + +_user_store = None +_auth_service = None +_admin_service = None +_stats_service = None +_replay_service = None +_spectator_manager = None +_leaderboard_refresh_task = None +_redis_client = None +_rate_limiter = None +_shutdown_event = asyncio.Event() + + +async def _periodic_leaderboard_refresh(): + """Periodic task to refresh the leaderboard materialized view.""" + import asyncio + while True: + try: + await asyncio.sleep(300) # 5 minutes + if _stats_service: + await _stats_service.refresh_leaderboard() + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"Leaderboard refresh failed: {e}") + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Application lifespan handler for async service initialization.""" + global _user_store, _auth_service, _admin_service, _stats_service, _replay_service + global _spectator_manager, _leaderboard_refresh_task, _redis_client, _rate_limiter + + # Register signal handlers for graceful shutdown + loop = asyncio.get_running_loop() + for sig in (signal.SIGTERM, signal.SIGINT): + loop.add_signal_handler(sig, lambda: asyncio.create_task(_initiate_shutdown())) + + # Initialize Redis client (for rate limiting, health checks, etc.) + if config.REDIS_URL: + try: + _redis_client = redis.from_url(config.REDIS_URL, decode_responses=False) + await _redis_client.ping() + logger.info("Redis client connected") + + # Initialize rate limiter + if config.RATE_LIMIT_ENABLED: + from services.ratelimit import get_rate_limiter + _rate_limiter = await get_rate_limiter(_redis_client) + logger.info("Rate limiter initialized") + except Exception as e: + logger.warning(f"Redis connection failed: {e} - rate limiting disabled") + _redis_client = None + _rate_limiter = None + + # Initialize auth, admin, and stats services (requires PostgreSQL) + if config.POSTGRES_URL: + try: + from stores.user_store import get_user_store + from stores.event_store import get_event_store + from services.auth_service import get_auth_service + from services.admin_service import get_admin_service + from services.stats_service import StatsService, set_stats_service + from routers.auth import set_auth_service + from routers.admin import set_admin_service + from routers.stats import set_stats_service as set_stats_router_service + from routers.stats import set_auth_service as set_stats_auth_service + + logger.info("Initializing auth services...") + _user_store = await get_user_store(config.POSTGRES_URL) + _auth_service = await get_auth_service(_user_store) + set_auth_service(_auth_service) + logger.info("Auth services initialized successfully") + + # Initialize admin service + logger.info("Initializing admin services...") + _admin_service = await get_admin_service( + pool=_user_store.pool, + user_store=_user_store, + state_cache=None, # Will add Redis state cache when available + ) + set_admin_service(_admin_service) + logger.info("Admin services initialized successfully") + + # Initialize stats service + logger.info("Initializing stats services...") + _event_store = await get_event_store(config.POSTGRES_URL) + _stats_service = StatsService(_user_store.pool, _event_store) + set_stats_service(_stats_service) + set_stats_router_service(_stats_service) + set_stats_auth_service(_auth_service) + logger.info("Stats services initialized successfully") + + # Initialize replay service + logger.info("Initializing replay services...") + from services.replay_service import get_replay_service, set_replay_service + from services.spectator import get_spectator_manager + from routers.replay import ( + set_replay_service as set_replay_router_service, + set_auth_service as set_replay_auth_service, + set_spectator_manager as set_replay_spectator, + set_room_manager as set_replay_room_manager, + ) + _replay_service = await get_replay_service(_user_store.pool, _event_store) + _spectator_manager = get_spectator_manager() + set_replay_service(_replay_service) + set_replay_router_service(_replay_service) + set_replay_auth_service(_auth_service) + set_replay_spectator(_spectator_manager) + set_replay_room_manager(room_manager) + logger.info("Replay services initialized successfully") + + # Start periodic leaderboard refresh task + _leaderboard_refresh_task = asyncio.create_task(_periodic_leaderboard_refresh()) + logger.info("Leaderboard refresh task started") + + except Exception as e: + logger.error(f"Failed to initialize services: {e}") + raise + else: + logger.warning("POSTGRES_URL not configured - auth/admin/stats endpoints will not work") + + # Set up health check dependencies + from routers.health import set_health_dependencies + db_pool = _user_store.pool if _user_store else None + set_health_dependencies( + db_pool=db_pool, + redis_client=_redis_client, + room_manager=room_manager, + ) + + logger.info(f"Golf server started (environment={config.ENVIRONMENT})") + + yield + + # Graceful shutdown + logger.info("Shutdown initiated...") + + # Signal shutdown to all components + _shutdown_event.set() + + # Close all WebSocket connections gracefully + await _close_all_websockets() + + # Cancel background tasks + if _leaderboard_refresh_task: + _leaderboard_refresh_task.cancel() + try: + await _leaderboard_refresh_task + except asyncio.CancelledError: + pass + logger.info("Leaderboard refresh task stopped") + + if _replay_service: + from services.replay_service import close_replay_service + close_replay_service() + + if _spectator_manager: + from services.spectator import close_spectator_manager + close_spectator_manager() + + if _stats_service: + from services.stats_service import close_stats_service + close_stats_service() + + if _user_store: + from stores.user_store import close_user_store + from services.admin_service import close_admin_service + close_admin_service() + await close_user_store() + + # Close Redis connection + if _redis_client: + await _redis_client.close() + logger.info("Redis connection closed") + + logger.info("Shutdown complete") + + +async def _initiate_shutdown(): + """Initiate graceful shutdown.""" + logger.info("Received shutdown signal") + _shutdown_event.set() + + +async def _close_all_websockets(): + """Close all active WebSocket connections gracefully.""" + for room in list(room_manager.rooms.values()): + for player in room.players.values(): + if player.websocket and not player.is_cpu: + try: + await player.websocket.close(code=1001, reason="Server shutting down") + except Exception: + pass + logger.info("All WebSocket connections closed") + + app = FastAPI( title="Golf Card Game", debug=config.DEBUG, version="0.1.0", + lifespan=lifespan, ) + +# ============================================================================= +# Middleware Setup (order matters: first added = outermost) +# ============================================================================= + +# Request ID middleware (outermost - generates/propagates request IDs) +from middleware.request_id import RequestIDMiddleware +app.add_middleware(RequestIDMiddleware) + +# Security headers middleware +from middleware.security import SecurityHeadersMiddleware +app.add_middleware( + SecurityHeadersMiddleware, + environment=config.ENVIRONMENT, +) + +# Note: Rate limiting middleware is added after app startup when Redis is available +# See _add_rate_limit_middleware() called from a startup event if needed + room_manager = RoomManager() # Initialize game logger database at startup @@ -36,65 +263,40 @@ _game_logger = get_logger() logger.info(f"Game analytics database initialized at: {_game_logger.db_path}") -@app.get("/health") -async def health_check(): - return {"status": "ok"} +# ============================================================================= +# Routers +# ============================================================================= + +from routers.auth import router as auth_router +from routers.admin import router as admin_router +from routers.stats import router as stats_router +from routers.replay import router as replay_router +from routers.health import router as health_router +app.include_router(auth_router) +app.include_router(admin_router) +app.include_router(stats_router) +app.include_router(replay_router) +app.include_router(health_router) # ============================================================================= -# Auth Models +# Auth Dependencies (for use in other routes) # ============================================================================= -class RegisterRequest(BaseModel): - username: str - password: str - email: Optional[str] = None - invite_code: str # Room code or explicit invite code +from models.user import User -class LoginRequest(BaseModel): - username: str - password: str - - -class SetupPasswordRequest(BaseModel): - username: str - new_password: str - - -class UpdateUserRequest(BaseModel): - username: Optional[str] = None - email: Optional[str] = None - role: Optional[str] = None - is_active: Optional[bool] = None - - -class ChangePasswordRequest(BaseModel): - new_password: str - - -class CreateInviteRequest(BaseModel): - max_uses: int = 1 - expires_in_days: Optional[int] = 7 - - -# ============================================================================= -# Auth Dependencies -# ============================================================================= - async def get_current_user(authorization: Optional[str] = Header(None)) -> Optional[User]: """Get current user from Authorization header.""" - if not authorization: + if not authorization or not _auth_service: return None - # Expect "Bearer " parts = authorization.split() if len(parts) != 2 or parts[0].lower() != "bearer": return None token = parts[1] - auth = get_auth_manager() - return auth.get_user_from_session(token) + return await _auth_service.get_user_from_token(token) async def require_user(user: Optional[User] = Depends(get_current_user)) -> User: @@ -113,302 +315,6 @@ async def require_admin(user: User = Depends(require_user)) -> User: return user -# ============================================================================= -# Auth Endpoints -# ============================================================================= - -@app.post("/api/auth/register") -async def register(request: RegisterRequest): - """Register a new user with an invite code.""" - auth = get_auth_manager() - - # Validate invite code - invite_valid = False - inviter_username = None - - # Check if it's an explicit invite code - invite = auth.get_invite_code(request.invite_code) - if invite and invite.is_valid(): - invite_valid = True - inviter = auth.get_user_by_id(invite.created_by) - inviter_username = inviter.username if inviter else None - - # Check if it's a valid room code - if not invite_valid: - room = room_manager.get_room(request.invite_code.upper()) - if room: - invite_valid = True - # Room codes are like open invites - - if not invite_valid: - raise HTTPException(status_code=400, detail="Invalid invite code") - - # Create user - user = auth.create_user( - username=request.username, - password=request.password, - email=request.email, - invited_by=inviter_username, - ) - - if not user: - raise HTTPException(status_code=400, detail="Username or email already taken") - - # Mark invite code as used (if it was an explicit invite) - if invite: - auth.use_invite_code(request.invite_code) - - # Create session - session = auth.create_session(user) - - return { - "user": user.to_dict(), - "token": session.token, - "expires_at": session.expires_at.isoformat(), - } - - -@app.post("/api/auth/login") -async def login(request: LoginRequest): - """Login with username and password.""" - auth = get_auth_manager() - - # Check if user needs password setup (first login) - if auth.needs_password_setup(request.username): - raise HTTPException( - status_code=428, # Precondition Required - detail="Password setup required. Use /api/auth/setup-password endpoint." - ) - - user = auth.authenticate(request.username, request.password) - if not user: - raise HTTPException(status_code=401, detail="Invalid credentials") - - session = auth.create_session(user) - - return { - "user": user.to_dict(), - "token": session.token, - "expires_at": session.expires_at.isoformat(), - } - - -@app.post("/api/auth/setup-password") -async def setup_password(request: SetupPasswordRequest): - """Set password for first-time login (admin accounts created without password).""" - auth = get_auth_manager() - - # Verify user exists and needs setup - if not auth.needs_password_setup(request.username): - raise HTTPException( - status_code=400, - detail="Password setup not available for this account" - ) - - # Set the password - user = auth.setup_password(request.username, request.new_password) - if not user: - raise HTTPException(status_code=400, detail="Setup failed") - - # Create session - session = auth.create_session(user) - - return { - "user": user.to_dict(), - "token": session.token, - "expires_at": session.expires_at.isoformat(), - } - - -@app.get("/api/auth/check-setup/{username}") -async def check_setup_needed(username: str): - """Check if a username needs password setup.""" - auth = get_auth_manager() - needs_setup = auth.needs_password_setup(username) - - return { - "username": username, - "needs_password_setup": needs_setup, - } - - -@app.post("/api/auth/logout") -async def logout(authorization: Optional[str] = Header(None)): - """Logout current session.""" - if authorization: - parts = authorization.split() - if len(parts) == 2 and parts[0].lower() == "bearer": - auth = get_auth_manager() - auth.invalidate_session(parts[1]) - - return {"status": "ok"} - - -@app.get("/api/auth/me") -async def get_me(user: User = Depends(require_user)): - """Get current user info.""" - return {"user": user.to_dict()} - - -@app.put("/api/auth/password") -async def change_own_password( - request: ChangePasswordRequest, - user: User = Depends(require_user) -): - """Change own password.""" - auth = get_auth_manager() - auth.change_password(user.id, request.new_password) - # Invalidate all other sessions - auth.invalidate_user_sessions(user.id) - # Create new session - session = auth.create_session(user) - - return { - "status": "ok", - "token": session.token, - "expires_at": session.expires_at.isoformat(), - } - - -# ============================================================================= -# Admin Endpoints -# ============================================================================= - -@app.get("/api/admin/users") -async def list_users( - include_inactive: bool = False, - admin: User = Depends(require_admin) -): - """List all users (admin only).""" - auth = get_auth_manager() - users = auth.list_users(include_inactive=include_inactive) - return {"users": [u.to_dict() for u in users]} - - -@app.get("/api/admin/users/{user_id}") -async def get_user(user_id: str, admin: User = Depends(require_admin)): - """Get user by ID (admin only).""" - auth = get_auth_manager() - user = auth.get_user_by_id(user_id) - if not user: - raise HTTPException(status_code=404, detail="User not found") - return {"user": user.to_dict()} - - -@app.put("/api/admin/users/{user_id}") -async def update_user( - user_id: str, - request: UpdateUserRequest, - admin: User = Depends(require_admin) -): - """Update user (admin only).""" - auth = get_auth_manager() - - # Convert role string to enum if provided - role = UserRole(request.role) if request.role else None - - user = auth.update_user( - user_id=user_id, - username=request.username, - email=request.email, - role=role, - is_active=request.is_active, - ) - - if not user: - raise HTTPException(status_code=400, detail="Update failed (duplicate username/email?)") - - return {"user": user.to_dict()} - - -@app.put("/api/admin/users/{user_id}/password") -async def admin_change_password( - user_id: str, - request: ChangePasswordRequest, - admin: User = Depends(require_admin) -): - """Change user password (admin only).""" - auth = get_auth_manager() - - if not auth.change_password(user_id, request.new_password): - raise HTTPException(status_code=404, detail="User not found") - - # Invalidate all user sessions - auth.invalidate_user_sessions(user_id) - - return {"status": "ok"} - - -@app.delete("/api/admin/users/{user_id}") -async def delete_user(user_id: str, admin: User = Depends(require_admin)): - """Deactivate user (admin only).""" - auth = get_auth_manager() - - # Don't allow deleting yourself - if user_id == admin.id: - raise HTTPException(status_code=400, detail="Cannot delete yourself") - - if not auth.delete_user(user_id): - raise HTTPException(status_code=404, detail="User not found") - - return {"status": "ok"} - - -@app.post("/api/admin/invites") -async def create_invite( - request: CreateInviteRequest, - admin: User = Depends(require_admin) -): - """Create an invite code (admin only).""" - auth = get_auth_manager() - - invite = auth.create_invite_code( - created_by=admin.id, - max_uses=request.max_uses, - expires_in_days=request.expires_in_days, - ) - - return { - "code": invite.code, - "max_uses": invite.max_uses, - "expires_at": invite.expires_at.isoformat() if invite.expires_at else None, - } - - -@app.get("/api/admin/invites") -async def list_invites(admin: User = Depends(require_admin)): - """List all invite codes (admin only).""" - auth = get_auth_manager() - invites = auth.list_invite_codes() - - return { - "invites": [ - { - "code": i.code, - "created_by": i.created_by, - "created_at": i.created_at.isoformat(), - "expires_at": i.expires_at.isoformat() if i.expires_at else None, - "max_uses": i.max_uses, - "use_count": i.use_count, - "is_active": i.is_active, - "is_valid": i.is_valid(), - } - for i in invites - ] - } - - -@app.delete("/api/admin/invites/{code}") -async def deactivate_invite(code: str, admin: User = Depends(require_admin)): - """Deactivate an invite code (admin only).""" - auth = get_auth_manager() - - if not auth.deactivate_invite_code(code): - raise HTTPException(status_code=404, detail="Invite code not found") - - return {"status": "ok"} - - @app.websocket("/ws") async def websocket_endpoint(websocket: WebSocket): await websocket.accept() @@ -902,6 +808,11 @@ async def websocket_endpoint(websocket: WebSocket): async def broadcast_game_state(room: Room): """Broadcast game state to all human players in a room.""" + # Notify spectators if spectator manager is available + if _spectator_manager: + spectator_state = room.game.get_state(None) # No player perspective + await _spectator_manager.send_game_state(room.code, spectator_state) + for pid, player in room.players.items(): # Skip CPU players if player.is_cpu or not player.websocket: @@ -937,10 +848,35 @@ async def broadcast_game_state(room: Room): elif room.game.phase == GamePhase.GAME_OVER: # Log game end if room.game_log_id: - logger = get_logger() - logger.log_game_end(room.game_log_id) + game_logger = get_logger() + game_logger.log_game_end(room.game_log_id) room.game_log_id = None # Clear to avoid duplicate logging + # Process stats for authenticated players + if _stats_service and room.game.players: + try: + # Build mapping - for non-CPU players, the player_id is their user_id + # (assigned during authentication or as a session UUID) + player_user_ids = {} + for player_id, room_player in room.players.items(): + if not room_player.is_cpu: + player_user_ids[player_id] = player_id + + # Find winner + winner_id = None + if room.game.players: + winner = min(room.game.players, key=lambda p: p.total_score) + winner_id = winner.id + + await _stats_service.process_game_from_state( + players=room.game.players, + winner_id=winner_id, + num_rounds=room.game.num_rounds, + player_user_ids=player_user_ids, + ) + except Exception as e: + logger.error(f"Failed to process game stats: {e}") + scores = [ {"name": p.name, "total": p.total_score, "rounds_won": p.rounds_won} for p in room.game.players @@ -1034,6 +970,28 @@ if os.path.exists(client_path): async def serve_animation_queue(): return FileResponse(os.path.join(client_path, "animation-queue.js"), media_type="application/javascript") + # Admin dashboard + @app.get("/admin") + async def serve_admin(): + return FileResponse(os.path.join(client_path, "admin.html")) + + @app.get("/admin.css") + async def serve_admin_css(): + return FileResponse(os.path.join(client_path, "admin.css"), media_type="text/css") + + @app.get("/admin.js") + async def serve_admin_js(): + return FileResponse(os.path.join(client_path, "admin.js"), media_type="application/javascript") + + @app.get("/replay.js") + async def serve_replay_js(): + return FileResponse(os.path.join(client_path, "replay.js"), media_type="application/javascript") + + # Serve replay page for share links + @app.get("/replay/{share_code}") + async def serve_replay_page(share_code: str): + return FileResponse(os.path.join(client_path, "index.html")) + def run(): """Run the server using uvicorn.""" diff --git a/server/middleware/__init__.py b/server/middleware/__init__.py new file mode 100644 index 0000000..48510e9 --- /dev/null +++ b/server/middleware/__init__.py @@ -0,0 +1,18 @@ +""" +Middleware components for Golf game server. + +Provides: +- RateLimitMiddleware: API rate limiting with Redis backend +- SecurityHeadersMiddleware: Security headers (CSP, HSTS, etc.) +- RequestIDMiddleware: Request tracing with X-Request-ID +""" + +from .ratelimit import RateLimitMiddleware +from .security import SecurityHeadersMiddleware +from .request_id import RequestIDMiddleware + +__all__ = [ + "RateLimitMiddleware", + "SecurityHeadersMiddleware", + "RequestIDMiddleware", +] diff --git a/server/middleware/ratelimit.py b/server/middleware/ratelimit.py new file mode 100644 index 0000000..3bf95eb --- /dev/null +++ b/server/middleware/ratelimit.py @@ -0,0 +1,173 @@ +""" +Rate limiting middleware for FastAPI. + +Applies per-endpoint rate limits and adds X-RateLimit-* headers to responses. +""" + +import logging +from typing import Callable, Optional + +from fastapi import Request +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import JSONResponse, Response + +from services.ratelimit import RateLimiter, RATE_LIMITS + +logger = logging.getLogger(__name__) + + +class RateLimitMiddleware(BaseHTTPMiddleware): + """ + HTTP middleware for rate limiting API requests. + + Applies rate limits based on request path and adds standard + rate limit headers to all responses. + """ + + def __init__( + self, + app, + rate_limiter: RateLimiter, + enabled: bool = True, + get_user_id: Optional[Callable[[Request], Optional[str]]] = None, + ): + """ + Initialize rate limit middleware. + + Args: + app: FastAPI application. + rate_limiter: RateLimiter service instance. + enabled: Whether rate limiting is enabled. + get_user_id: Optional callback to extract user ID from request. + """ + super().__init__(app) + self.limiter = rate_limiter + self.enabled = enabled + self.get_user_id = get_user_id + + async def dispatch(self, request: Request, call_next) -> Response: + """ + Process request through rate limiter. + + Args: + request: Incoming HTTP request. + call_next: Next middleware/handler in chain. + + Returns: + HTTP response with rate limit headers. + """ + # Skip if disabled + if not self.enabled: + return await call_next(request) + + # Determine rate limit tier based on path + path = request.url.path + limit_config = self._get_limit_config(path, request.method) + + # No rate limiting for this endpoint + if limit_config is None: + return await call_next(request) + + limit, window = limit_config + + # Get user ID if authenticated + user_id = None + if self.get_user_id: + try: + user_id = self.get_user_id(request) + except Exception: + pass + + # Generate client key + client_key = self.limiter.get_client_key(request, user_id) + + # Check rate limit + endpoint_key = self._get_endpoint_key(path) + full_key = f"{endpoint_key}:{client_key}" + + allowed, info = await self.limiter.is_allowed(full_key, limit, window) + + # Build response + if allowed: + response = await call_next(request) + else: + response = JSONResponse( + status_code=429, + content={ + "error": "Rate limit exceeded", + "message": f"Too many requests. Please wait {info['reset']} seconds.", + "retry_after": info["reset"], + }, + ) + + # Add rate limit headers + response.headers["X-RateLimit-Limit"] = str(info["limit"]) + response.headers["X-RateLimit-Remaining"] = str(info["remaining"]) + response.headers["X-RateLimit-Reset"] = str(info["reset"]) + + if not allowed: + response.headers["Retry-After"] = str(info["reset"]) + + return response + + def _get_limit_config( + self, + path: str, + method: str, + ) -> Optional[tuple[int, int]]: + """ + Get rate limit configuration for a path. + + Args: + path: Request URL path. + method: HTTP method. + + Returns: + Tuple of (limit, window_seconds) or None for no limiting. + """ + # No rate limiting for health checks + if path in ("/health", "/ready", "/metrics"): + return None + + # No rate limiting for static files + if path.endswith((".js", ".css", ".html", ".ico", ".png", ".jpg")): + return None + + # Authentication endpoints - stricter limits + if path.startswith("/api/auth"): + return RATE_LIMITS["api_auth"] + + # Room creation - moderate limits + if path == "/api/rooms" and method == "POST": + return RATE_LIMITS["api_create_room"] + + # Email endpoints - very strict + if "email" in path or "verify" in path: + return RATE_LIMITS["email_send"] + + # General API endpoints + if path.startswith("/api"): + return RATE_LIMITS["api_general"] + + # Default: no rate limiting for non-API paths + return None + + def _get_endpoint_key(self, path: str) -> str: + """ + Normalize path to endpoint key for rate limiting. + + Groups similar endpoints together (e.g., /api/users/123 -> /api/users/:id). + + Args: + path: Request URL path. + + Returns: + Normalized endpoint key. + """ + # Simple normalization - strip trailing slashes + key = path.rstrip("/") + + # Could add more sophisticated path parameter normalization here + # For example: /api/users/123 -> /api/users/:id + + return key or "/" diff --git a/server/middleware/request_id.py b/server/middleware/request_id.py new file mode 100644 index 0000000..5315d71 --- /dev/null +++ b/server/middleware/request_id.py @@ -0,0 +1,93 @@ +""" +Request ID middleware for request tracing. + +Generates or propagates X-Request-ID header for distributed tracing. +""" + +import logging +import uuid +from typing import Optional + +from fastapi import Request +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import Response + +from logging_config import request_id_var + +logger = logging.getLogger(__name__) + + +class RequestIDMiddleware(BaseHTTPMiddleware): + """ + HTTP middleware for request ID generation and propagation. + + - Extracts X-Request-ID from incoming request headers + - Generates a new UUID if not present + - Sets request_id in context var for logging + - Adds X-Request-ID to response headers + """ + + def __init__( + self, + app, + header_name: str = "X-Request-ID", + generator: Optional[callable] = None, + ): + """ + Initialize request ID middleware. + + Args: + app: FastAPI application. + header_name: Header name for request ID. + generator: Optional custom ID generator function. + """ + super().__init__(app) + self.header_name = header_name + self.generator = generator or (lambda: str(uuid.uuid4())) + + async def dispatch(self, request: Request, call_next) -> Response: + """ + Process request with request ID. + + Args: + request: Incoming HTTP request. + call_next: Next middleware/handler in chain. + + Returns: + HTTP response with X-Request-ID header. + """ + # Get or generate request ID + request_id = request.headers.get(self.header_name) + if not request_id: + request_id = self.generator() + + # Set in request state for access in handlers + request.state.request_id = request_id + + # Set in context var for logging + token = request_id_var.set(request_id) + + try: + # Process request + response = await call_next(request) + + # Add request ID to response + response.headers[self.header_name] = request_id + + return response + finally: + # Reset context var + request_id_var.reset(token) + + +def get_request_id(request: Request) -> Optional[str]: + """ + Get request ID from request state. + + Args: + request: FastAPI request object. + + Returns: + Request ID string or None. + """ + return getattr(request.state, "request_id", None) diff --git a/server/middleware/security.py b/server/middleware/security.py new file mode 100644 index 0000000..d1cb505 --- /dev/null +++ b/server/middleware/security.py @@ -0,0 +1,140 @@ +""" +Security headers middleware for FastAPI. + +Adds security headers to all responses: +- Content-Security-Policy (CSP) +- X-Content-Type-Options +- X-Frame-Options +- X-XSS-Protection +- Referrer-Policy +- Permissions-Policy +- Strict-Transport-Security (HSTS) +""" + +import logging +from typing import Optional + +from fastapi import Request +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import Response + +logger = logging.getLogger(__name__) + + +class SecurityHeadersMiddleware(BaseHTTPMiddleware): + """ + HTTP middleware for adding security headers. + + Configurable CSP and HSTS settings for different environments. + """ + + def __init__( + self, + app, + environment: str = "development", + csp_report_uri: Optional[str] = None, + allowed_hosts: Optional[list[str]] = None, + ): + """ + Initialize security headers middleware. + + Args: + app: FastAPI application. + environment: Environment name (production enables HSTS). + csp_report_uri: Optional URI for CSP violation reports. + allowed_hosts: List of allowed hosts for connect-src directive. + """ + super().__init__(app) + self.environment = environment + self.csp_report_uri = csp_report_uri + self.allowed_hosts = allowed_hosts or [] + + async def dispatch(self, request: Request, call_next) -> Response: + """ + Add security headers to response. + + Args: + request: Incoming HTTP request. + call_next: Next middleware/handler in chain. + + Returns: + HTTP response with security headers. + """ + response = await call_next(request) + + # Basic security headers + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + response.headers["X-XSS-Protection"] = "1; mode=block" + response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + + # Permissions Policy (formerly Feature-Policy) + response.headers["Permissions-Policy"] = ( + "geolocation=(), " + "microphone=(), " + "camera=(), " + "payment=(), " + "usb=()" + ) + + # Content Security Policy + csp = self._build_csp(request) + response.headers["Content-Security-Policy"] = csp + + # HSTS (only in production with HTTPS) + if self.environment == "production": + # Only add HSTS if request came via HTTPS + forwarded_proto = request.headers.get("X-Forwarded-Proto", "") + if forwarded_proto == "https" or request.url.scheme == "https": + response.headers["Strict-Transport-Security"] = ( + "max-age=31536000; includeSubDomains; preload" + ) + + return response + + def _build_csp(self, request: Request) -> str: + """ + Build Content-Security-Policy header. + + Args: + request: HTTP request (for host-specific directives). + + Returns: + CSP header value string. + """ + # Get the host for WebSocket connections + host = request.headers.get("host", "localhost") + + # Build connect-src directive + connect_sources = ["'self'"] + + # Add WebSocket URLs + if self.environment == "production": + connect_sources.append(f"wss://{host}") + for allowed_host in self.allowed_hosts: + connect_sources.append(f"wss://{allowed_host}") + else: + # Development - allow ws:// and wss:// + connect_sources.append(f"ws://{host}") + connect_sources.append(f"wss://{host}") + connect_sources.append("ws://localhost:*") + connect_sources.append("wss://localhost:*") + + directives = [ + "default-src 'self'", + "script-src 'self'", + # Allow inline styles for UI (cards, animations) + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data:", + "font-src 'self'", + f"connect-src {' '.join(connect_sources)}", + "frame-ancestors 'none'", + "base-uri 'self'", + "form-action 'self'", + ] + + # Add report-uri if configured + if self.csp_report_uri: + directives.append(f"report-uri {self.csp_report_uri}") + + return "; ".join(directives) diff --git a/server/models/__init__.py b/server/models/__init__.py new file mode 100644 index 0000000..e60ad63 --- /dev/null +++ b/server/models/__init__.py @@ -0,0 +1,19 @@ +"""Models package for Golf game V2.""" + +from .events import EventType, GameEvent +from .game_state import RebuiltGameState, rebuild_state, CardState, PlayerState, GamePhase +from .user import UserRole, User, UserSession, GuestSession + +__all__ = [ + "EventType", + "GameEvent", + "RebuiltGameState", + "rebuild_state", + "CardState", + "PlayerState", + "GamePhase", + "UserRole", + "User", + "UserSession", + "GuestSession", +] diff --git a/server/models/events.py b/server/models/events.py new file mode 100644 index 0000000..23c3c5d --- /dev/null +++ b/server/models/events.py @@ -0,0 +1,574 @@ +""" +Event definitions for Golf game event sourcing. + +All game actions are stored as immutable events, enabling: +- Full game replay from any point +- Audit trails for all player actions +- Stats aggregation from event streams +- Deterministic state reconstruction + +Events are the single source of truth for game state. +""" + +from dataclasses import dataclass, field +from datetime import datetime, timezone +from enum import Enum +from typing import Optional, Any +import json + + +class EventType(str, Enum): + """All possible event types in a Golf game.""" + + # Lifecycle events + GAME_CREATED = "game_created" + PLAYER_JOINED = "player_joined" + PLAYER_LEFT = "player_left" + GAME_STARTED = "game_started" + ROUND_STARTED = "round_started" + ROUND_ENDED = "round_ended" + GAME_ENDED = "game_ended" + + # Gameplay events + INITIAL_FLIP = "initial_flip" + CARD_DRAWN = "card_drawn" + CARD_SWAPPED = "card_swapped" + CARD_DISCARDED = "card_discarded" + CARD_FLIPPED = "card_flipped" + FLIP_SKIPPED = "flip_skipped" + FLIP_AS_ACTION = "flip_as_action" + KNOCK_EARLY = "knock_early" + + +@dataclass +class GameEvent: + """ + Base class for all game events. + + Events are immutable records of actions that occurred in a game. + They contain all information needed to reconstruct game state. + + Attributes: + event_type: The type of event (from EventType enum). + game_id: UUID of the game this event belongs to. + sequence_num: Monotonically increasing sequence number within game. + timestamp: When the event occurred (UTC). + player_id: ID of player who triggered the event (if applicable). + data: Event-specific payload data. + """ + + event_type: EventType + game_id: str + sequence_num: int + timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + player_id: Optional[str] = None + data: dict = field(default_factory=dict) + + def to_dict(self) -> dict: + """Serialize event to dictionary for JSON storage.""" + return { + "event_type": self.event_type.value, + "game_id": self.game_id, + "sequence_num": self.sequence_num, + "timestamp": self.timestamp.isoformat(), + "player_id": self.player_id, + "data": self.data, + } + + def to_json(self) -> str: + """Serialize event to JSON string.""" + return json.dumps(self.to_dict()) + + @classmethod + def from_dict(cls, d: dict) -> "GameEvent": + """Deserialize event from dictionary.""" + timestamp = d["timestamp"] + if isinstance(timestamp, str): + timestamp = datetime.fromisoformat(timestamp) + + return cls( + event_type=EventType(d["event_type"]), + game_id=d["game_id"], + sequence_num=d["sequence_num"], + timestamp=timestamp, + player_id=d.get("player_id"), + data=d.get("data", {}), + ) + + @classmethod + def from_json(cls, json_str: str) -> "GameEvent": + """Deserialize event from JSON string.""" + return cls.from_dict(json.loads(json_str)) + + +# ============================================================================= +# Event Factory Functions +# ============================================================================= +# These provide type-safe event construction with proper data structures. + + +def game_created( + game_id: str, + sequence_num: int, + room_code: str, + host_id: str, + options: dict, +) -> GameEvent: + """ + Create a GameCreated event. + + Emitted when a new game room is created. + + Args: + game_id: UUID for the new game. + sequence_num: Should be 1 (first event). + room_code: 4-letter room code. + host_id: Player ID of the host. + options: GameOptions as dict. + """ + return GameEvent( + event_type=EventType.GAME_CREATED, + game_id=game_id, + sequence_num=sequence_num, + player_id=host_id, + data={ + "room_code": room_code, + "host_id": host_id, + "options": options, + }, + ) + + +def player_joined( + game_id: str, + sequence_num: int, + player_id: str, + player_name: str, + is_cpu: bool = False, + cpu_profile: Optional[str] = None, +) -> GameEvent: + """ + Create a PlayerJoined event. + + Emitted when a player (human or CPU) joins the game. + + Args: + game_id: Game UUID. + sequence_num: Event sequence number. + player_id: Unique player identifier. + player_name: Display name. + is_cpu: Whether this is a CPU player. + cpu_profile: CPU profile name (for AI replay analysis). + """ + return GameEvent( + event_type=EventType.PLAYER_JOINED, + game_id=game_id, + sequence_num=sequence_num, + player_id=player_id, + data={ + "player_name": player_name, + "is_cpu": is_cpu, + "cpu_profile": cpu_profile, + }, + ) + + +def player_left( + game_id: str, + sequence_num: int, + player_id: str, + reason: str = "left", +) -> GameEvent: + """ + Create a PlayerLeft event. + + Emitted when a player leaves the game. + + Args: + game_id: Game UUID. + sequence_num: Event sequence number. + player_id: ID of player who left. + reason: Why they left (left, disconnected, kicked). + """ + return GameEvent( + event_type=EventType.PLAYER_LEFT, + game_id=game_id, + sequence_num=sequence_num, + player_id=player_id, + data={"reason": reason}, + ) + + +def game_started( + game_id: str, + sequence_num: int, + player_order: list[str], + num_decks: int, + num_rounds: int, + options: dict, +) -> GameEvent: + """ + Create a GameStarted event. + + Emitted when the host starts the game. This locks in settings + but doesn't deal cards (that's RoundStarted). + + Args: + game_id: Game UUID. + sequence_num: Event sequence number. + player_order: List of player IDs in turn order. + num_decks: Number of card decks being used. + num_rounds: Total rounds to play. + options: Final GameOptions as dict. + """ + return GameEvent( + event_type=EventType.GAME_STARTED, + game_id=game_id, + sequence_num=sequence_num, + data={ + "player_order": player_order, + "num_decks": num_decks, + "num_rounds": num_rounds, + "options": options, + }, + ) + + +def round_started( + game_id: str, + sequence_num: int, + round_num: int, + deck_seed: int, + dealt_cards: dict[str, list[dict]], + first_discard: dict, +) -> GameEvent: + """ + Create a RoundStarted event. + + Emitted at the start of each round. Contains all information + needed to recreate the initial state deterministically. + + Args: + game_id: Game UUID. + sequence_num: Event sequence number. + round_num: Round number (1-indexed). + deck_seed: Random seed used for deck shuffle. + dealt_cards: Map of player_id -> list of 6 card dicts. + Cards include {rank, suit} (face_up always False). + first_discard: The first card on the discard pile. + """ + return GameEvent( + event_type=EventType.ROUND_STARTED, + game_id=game_id, + sequence_num=sequence_num, + data={ + "round_num": round_num, + "deck_seed": deck_seed, + "dealt_cards": dealt_cards, + "first_discard": first_discard, + }, + ) + + +def round_ended( + game_id: str, + sequence_num: int, + round_num: int, + scores: dict[str, int], + final_hands: dict[str, list[dict]], + finisher_id: Optional[str] = None, +) -> GameEvent: + """ + Create a RoundEnded event. + + Emitted when a round completes and scores are calculated. + + Args: + game_id: Game UUID. + sequence_num: Event sequence number. + round_num: Round that just ended. + scores: Map of player_id -> round score. + final_hands: Map of player_id -> final 6 cards (all revealed). + finisher_id: ID of player who went out first (if any). + """ + return GameEvent( + event_type=EventType.ROUND_ENDED, + game_id=game_id, + sequence_num=sequence_num, + data={ + "round_num": round_num, + "scores": scores, + "final_hands": final_hands, + "finisher_id": finisher_id, + }, + ) + + +def game_ended( + game_id: str, + sequence_num: int, + final_scores: dict[str, int], + rounds_won: dict[str, int], + winner_id: Optional[str] = None, +) -> GameEvent: + """ + Create a GameEnded event. + + Emitted when all rounds are complete. + + Args: + game_id: Game UUID. + sequence_num: Event sequence number. + final_scores: Map of player_id -> total score. + rounds_won: Map of player_id -> rounds won count. + winner_id: ID of overall winner (lowest total score). + """ + return GameEvent( + event_type=EventType.GAME_ENDED, + game_id=game_id, + sequence_num=sequence_num, + data={ + "final_scores": final_scores, + "rounds_won": rounds_won, + "winner_id": winner_id, + }, + ) + + +def initial_flip( + game_id: str, + sequence_num: int, + player_id: str, + positions: list[int], + cards: list[dict], +) -> GameEvent: + """ + Create an InitialFlip event. + + Emitted when a player flips their initial cards at round start. + + Args: + game_id: Game UUID. + sequence_num: Event sequence number. + player_id: Player who flipped. + positions: Card positions that were flipped (0-5). + cards: The cards that were revealed [{rank, suit}, ...]. + """ + return GameEvent( + event_type=EventType.INITIAL_FLIP, + game_id=game_id, + sequence_num=sequence_num, + player_id=player_id, + data={ + "positions": positions, + "cards": cards, + }, + ) + + +def card_drawn( + game_id: str, + sequence_num: int, + player_id: str, + source: str, + card: dict, +) -> GameEvent: + """ + Create a CardDrawn event. + + Emitted when a player draws a card from deck or discard. + + Args: + game_id: Game UUID. + sequence_num: Event sequence number. + player_id: Player who drew. + source: "deck" or "discard". + card: The card drawn {rank, suit}. + """ + return GameEvent( + event_type=EventType.CARD_DRAWN, + game_id=game_id, + sequence_num=sequence_num, + player_id=player_id, + data={ + "source": source, + "card": card, + }, + ) + + +def card_swapped( + game_id: str, + sequence_num: int, + player_id: str, + position: int, + new_card: dict, + old_card: dict, +) -> GameEvent: + """ + Create a CardSwapped event. + + Emitted when a player swaps their drawn card with a hand card. + + Args: + game_id: Game UUID. + sequence_num: Event sequence number. + player_id: Player who swapped. + position: Hand position (0-5) where swap occurred. + new_card: Card placed into hand {rank, suit}. + old_card: Card removed from hand {rank, suit}. + """ + return GameEvent( + event_type=EventType.CARD_SWAPPED, + game_id=game_id, + sequence_num=sequence_num, + player_id=player_id, + data={ + "position": position, + "new_card": new_card, + "old_card": old_card, + }, + ) + + +def card_discarded( + game_id: str, + sequence_num: int, + player_id: str, + card: dict, +) -> GameEvent: + """ + Create a CardDiscarded event. + + Emitted when a player discards their drawn card without swapping. + + Args: + game_id: Game UUID. + sequence_num: Event sequence number. + player_id: Player who discarded. + card: The card discarded {rank, suit}. + """ + return GameEvent( + event_type=EventType.CARD_DISCARDED, + game_id=game_id, + sequence_num=sequence_num, + player_id=player_id, + data={"card": card}, + ) + + +def card_flipped( + game_id: str, + sequence_num: int, + player_id: str, + position: int, + card: dict, +) -> GameEvent: + """ + Create a CardFlipped event. + + Emitted when a player flips a card after discarding (flip_on_discard mode). + + Args: + game_id: Game UUID. + sequence_num: Event sequence number. + player_id: Player who flipped. + position: Position of flipped card (0-5). + card: The card revealed {rank, suit}. + """ + return GameEvent( + event_type=EventType.CARD_FLIPPED, + game_id=game_id, + sequence_num=sequence_num, + player_id=player_id, + data={ + "position": position, + "card": card, + }, + ) + + +def flip_skipped( + game_id: str, + sequence_num: int, + player_id: str, +) -> GameEvent: + """ + Create a FlipSkipped event. + + Emitted when a player skips the optional flip (endgame mode). + + Args: + game_id: Game UUID. + sequence_num: Event sequence number. + player_id: Player who skipped. + """ + return GameEvent( + event_type=EventType.FLIP_SKIPPED, + game_id=game_id, + sequence_num=sequence_num, + player_id=player_id, + data={}, + ) + + +def flip_as_action( + game_id: str, + sequence_num: int, + player_id: str, + position: int, + card: dict, +) -> GameEvent: + """ + Create a FlipAsAction event. + + Emitted when a player uses their turn to flip a card (house rule). + + Args: + game_id: Game UUID. + sequence_num: Event sequence number. + player_id: Player who used flip-as-action. + position: Position of flipped card (0-5). + card: The card revealed {rank, suit}. + """ + return GameEvent( + event_type=EventType.FLIP_AS_ACTION, + game_id=game_id, + sequence_num=sequence_num, + player_id=player_id, + data={ + "position": position, + "card": card, + }, + ) + + +def knock_early( + game_id: str, + sequence_num: int, + player_id: str, + positions: list[int], + cards: list[dict], +) -> GameEvent: + """ + Create a KnockEarly event. + + Emitted when a player knocks early to reveal remaining cards. + + Args: + game_id: Game UUID. + sequence_num: Event sequence number. + player_id: Player who knocked. + positions: Positions of cards that were face-down. + cards: The cards revealed [{rank, suit}, ...]. + """ + return GameEvent( + event_type=EventType.KNOCK_EARLY, + game_id=game_id, + sequence_num=sequence_num, + player_id=player_id, + data={ + "positions": positions, + "cards": cards, + }, + ) diff --git a/server/models/game_state.py b/server/models/game_state.py new file mode 100644 index 0000000..17a52d6 --- /dev/null +++ b/server/models/game_state.py @@ -0,0 +1,535 @@ +""" +Game state rebuilder for event sourcing. + +This module provides the ability to reconstruct game state from an event stream. +The RebuiltGameState class mirrors the Game class structure but is built +entirely from events rather than direct mutation. + +Usage: + events = await event_store.get_events(game_id) + state = rebuild_state(events) + print(state.phase, state.current_player_id) +""" + +from dataclasses import dataclass, field +from enum import Enum +from typing import Optional + +from models.events import GameEvent, EventType + + +class GamePhase(str, Enum): + """Game phases matching game.py GamePhase.""" + WAITING = "waiting" + INITIAL_FLIP = "initial_flip" + PLAYING = "playing" + FINAL_TURN = "final_turn" + ROUND_OVER = "round_over" + GAME_OVER = "game_over" + + +@dataclass +class CardState: + """ + A card's state during replay. + + Attributes: + rank: Card rank (A, 2-10, J, Q, K, or Joker). + suit: Card suit (hearts, diamonds, clubs, spades). + face_up: Whether the card is visible. + """ + rank: str + suit: str + face_up: bool = False + + def to_dict(self) -> dict: + """Convert to dictionary for comparison.""" + return { + "rank": self.rank, + "suit": self.suit, + "face_up": self.face_up, + } + + @classmethod + def from_dict(cls, d: dict) -> "CardState": + """Create from dictionary.""" + return cls( + rank=d["rank"], + suit=d["suit"], + face_up=d.get("face_up", False), + ) + + +@dataclass +class PlayerState: + """ + A player's state during replay. + + Attributes: + id: Unique player identifier. + name: Display name. + cards: The player's 6-card hand. + score: Current round score. + total_score: Cumulative score across rounds. + rounds_won: Number of rounds won. + is_cpu: Whether this is a CPU player. + cpu_profile: CPU profile name (for AI analysis). + """ + id: str + name: str + cards: list[CardState] = field(default_factory=list) + score: int = 0 + total_score: int = 0 + rounds_won: int = 0 + is_cpu: bool = False + cpu_profile: Optional[str] = None + + def all_face_up(self) -> bool: + """Check if all cards are revealed.""" + return all(card.face_up for card in self.cards) + + +@dataclass +class RebuiltGameState: + """ + Game state rebuilt from events. + + This class reconstructs the full game state by applying events in sequence. + It mirrors the structure of the Game class from game.py but is immutable + and derived entirely from events. + + Attributes: + game_id: UUID of the game. + room_code: 4-letter room code. + phase: Current game phase. + players: Map of player_id -> PlayerState. + player_order: List of player IDs in turn order. + current_player_idx: Index of current player in player_order. + deck_remaining: Cards left in deck (approximated). + discard_pile: Cards in discard pile (most recent at end). + drawn_card: Card currently held by active player. + current_round: Current round number (1-indexed). + total_rounds: Total rounds in game. + options: GameOptions as dict. + sequence_num: Last applied event sequence. + finisher_id: Player who went out first this round. + initial_flips_done: Set of player IDs who completed initial flips. + """ + game_id: str + room_code: str = "" + phase: GamePhase = GamePhase.WAITING + players: dict[str, PlayerState] = field(default_factory=dict) + player_order: list[str] = field(default_factory=list) + current_player_idx: int = 0 + deck_remaining: int = 0 + discard_pile: list[CardState] = field(default_factory=list) + drawn_card: Optional[CardState] = None + drawn_from_discard: bool = False + current_round: int = 0 + total_rounds: int = 1 + options: dict = field(default_factory=dict) + sequence_num: int = 0 + finisher_id: Optional[str] = None + players_with_final_turn: set = field(default_factory=set) + initial_flips_done: set = field(default_factory=set) + host_id: Optional[str] = None + + def apply(self, event: GameEvent) -> "RebuiltGameState": + """ + Apply an event to produce new state. + + Events must be applied in sequence order. + + Args: + event: The event to apply. + + Returns: + self for chaining. + + Raises: + ValueError: If event is out of sequence or unknown type. + """ + # Validate sequence (first event can be 1, then must be sequential) + expected_seq = self.sequence_num + 1 if self.sequence_num > 0 else 1 + if event.sequence_num != expected_seq: + raise ValueError( + f"Expected sequence {expected_seq}, got {event.sequence_num}" + ) + + # Dispatch to handler + handler = getattr(self, f"_apply_{event.event_type.value}", None) + if handler is None: + raise ValueError(f"Unknown event type: {event.event_type}") + + handler(event) + self.sequence_num = event.sequence_num + return self + + # ------------------------------------------------------------------------- + # Lifecycle Event Handlers + # ------------------------------------------------------------------------- + + def _apply_game_created(self, event: GameEvent) -> None: + """Handle game_created event.""" + self.room_code = event.data["room_code"] + self.host_id = event.data["host_id"] + self.options = event.data.get("options", {}) + + def _apply_player_joined(self, event: GameEvent) -> None: + """Handle player_joined event.""" + player_id = event.player_id + self.players[player_id] = PlayerState( + id=player_id, + name=event.data["player_name"], + is_cpu=event.data.get("is_cpu", False), + cpu_profile=event.data.get("cpu_profile"), + ) + + def _apply_player_left(self, event: GameEvent) -> None: + """Handle player_left event.""" + player_id = event.player_id + if player_id in self.players: + del self.players[player_id] + if player_id in self.player_order: + self.player_order.remove(player_id) + # Adjust current player index if needed + if self.current_player_idx >= len(self.player_order): + self.current_player_idx = 0 + + def _apply_game_started(self, event: GameEvent) -> None: + """Handle game_started event.""" + self.player_order = event.data["player_order"] + self.total_rounds = event.data["num_rounds"] + self.options = event.data.get("options", self.options) + # Note: round_started will set up the actual round + + def _apply_round_started(self, event: GameEvent) -> None: + """Handle round_started event.""" + self.current_round = event.data["round_num"] + self.finisher_id = None + self.players_with_final_turn = set() + self.initial_flips_done = set() + self.drawn_card = None + self.drawn_from_discard = False + self.current_player_idx = 0 + self.discard_pile = [] + + # Deal cards to players (all face-down) + dealt_cards = event.data["dealt_cards"] + for player_id, cards_data in dealt_cards.items(): + if player_id in self.players: + self.players[player_id].cards = [ + CardState.from_dict(c) for c in cards_data + ] + # Reset round score + self.players[player_id].score = 0 + + # Start discard pile + first_discard = event.data.get("first_discard") + if first_discard: + card = CardState.from_dict(first_discard) + card.face_up = True + self.discard_pile.append(card) + + # Set phase based on initial_flips setting + initial_flips = self.options.get("initial_flips", 2) + if initial_flips == 0: + self.phase = GamePhase.PLAYING + else: + self.phase = GamePhase.INITIAL_FLIP + + # Approximate deck size (we don't track exact cards) + num_decks = self.options.get("num_decks", 1) + cards_per_deck = 52 + if self.options.get("use_jokers"): + if self.options.get("lucky_swing"): + cards_per_deck += 1 # Single joker + else: + cards_per_deck += 2 # Two jokers + total_cards = num_decks * cards_per_deck + dealt_count = len(self.players) * 6 + 1 # 6 per player + 1 discard + self.deck_remaining = total_cards - dealt_count + + def _apply_round_ended(self, event: GameEvent) -> None: + """Handle round_ended event.""" + self.phase = GamePhase.ROUND_OVER + scores = event.data["scores"] + + # Update player scores + for player_id, score in scores.items(): + if player_id in self.players: + self.players[player_id].score = score + self.players[player_id].total_score += score + + # Determine round winner (lowest score) + if scores: + min_score = min(scores.values()) + for player_id, score in scores.items(): + if score == min_score and player_id in self.players: + self.players[player_id].rounds_won += 1 + + # Apply final hands if provided + final_hands = event.data.get("final_hands", {}) + for player_id, cards_data in final_hands.items(): + if player_id in self.players: + self.players[player_id].cards = [ + CardState.from_dict(c) for c in cards_data + ] + # Ensure all cards are face up + for card in self.players[player_id].cards: + card.face_up = True + + def _apply_game_ended(self, event: GameEvent) -> None: + """Handle game_ended event.""" + self.phase = GamePhase.GAME_OVER + # Final scores are already tracked in players + + # ------------------------------------------------------------------------- + # Gameplay Event Handlers + # ------------------------------------------------------------------------- + + def _apply_initial_flip(self, event: GameEvent) -> None: + """Handle initial_flip event.""" + player_id = event.player_id + player = self.players.get(player_id) + if not player: + return + + positions = event.data["positions"] + cards = event.data["cards"] + + for pos, card_data in zip(positions, cards): + if 0 <= pos < len(player.cards): + player.cards[pos] = CardState.from_dict(card_data) + player.cards[pos].face_up = True + + self.initial_flips_done.add(player_id) + + # Check if all players have flipped + if len(self.initial_flips_done) == len(self.players): + self.phase = GamePhase.PLAYING + + def _apply_card_drawn(self, event: GameEvent) -> None: + """Handle card_drawn event.""" + card = CardState.from_dict(event.data["card"]) + card.face_up = True + self.drawn_card = card + self.drawn_from_discard = event.data["source"] == "discard" + + if self.drawn_from_discard and self.discard_pile: + self.discard_pile.pop() + else: + self.deck_remaining = max(0, self.deck_remaining - 1) + + def _apply_card_swapped(self, event: GameEvent) -> None: + """Handle card_swapped event.""" + player_id = event.player_id + player = self.players.get(player_id) + if not player: + return + + position = event.data["position"] + new_card = CardState.from_dict(event.data["new_card"]) + old_card = CardState.from_dict(event.data["old_card"]) + + # Place new card in hand + new_card.face_up = True + if 0 <= position < len(player.cards): + player.cards[position] = new_card + + # Add old card to discard + old_card.face_up = True + self.discard_pile.append(old_card) + + # Clear drawn card + self.drawn_card = None + self.drawn_from_discard = False + + # Advance turn + self._end_turn(player) + + def _apply_card_discarded(self, event: GameEvent) -> None: + """Handle card_discarded event.""" + player_id = event.player_id + player = self.players.get(player_id) + + if self.drawn_card: + self.drawn_card.face_up = True + self.discard_pile.append(self.drawn_card) + self.drawn_card = None + self.drawn_from_discard = False + + # Check if flip_on_discard mode requires a flip + # If not, end turn now + flip_mode = self.options.get("flip_mode", "never") + if flip_mode == "never": + if player: + self._end_turn(player) + # For "always" or "endgame", wait for flip_card or flip_skipped event + + def _apply_card_flipped(self, event: GameEvent) -> None: + """Handle card_flipped event (after discard in flip mode).""" + player_id = event.player_id + player = self.players.get(player_id) + if not player: + return + + position = event.data["position"] + card = CardState.from_dict(event.data["card"]) + card.face_up = True + + if 0 <= position < len(player.cards): + player.cards[position] = card + + self._end_turn(player) + + def _apply_flip_skipped(self, event: GameEvent) -> None: + """Handle flip_skipped event (endgame mode optional flip).""" + player_id = event.player_id + player = self.players.get(player_id) + if player: + self._end_turn(player) + + def _apply_flip_as_action(self, event: GameEvent) -> None: + """Handle flip_as_action event (house rule).""" + player_id = event.player_id + player = self.players.get(player_id) + if not player: + return + + position = event.data["position"] + card = CardState.from_dict(event.data["card"]) + card.face_up = True + + if 0 <= position < len(player.cards): + player.cards[position] = card + + self._end_turn(player) + + def _apply_knock_early(self, event: GameEvent) -> None: + """Handle knock_early event (house rule).""" + player_id = event.player_id + player = self.players.get(player_id) + if not player: + return + + positions = event.data["positions"] + cards = event.data["cards"] + + for pos, card_data in zip(positions, cards): + if 0 <= pos < len(player.cards): + card = CardState.from_dict(card_data) + card.face_up = True + player.cards[pos] = card + + self._end_turn(player) + + # ------------------------------------------------------------------------- + # Turn Management + # ------------------------------------------------------------------------- + + def _end_turn(self, player: PlayerState) -> None: + """ + Handle end of player's turn. + + Checks for going out and advances to next player. + """ + # Check if player went out + if player.all_face_up() and self.finisher_id is None: + self.finisher_id = player.id + self.phase = GamePhase.FINAL_TURN + self.players_with_final_turn.add(player.id) + elif self.phase == GamePhase.FINAL_TURN: + # In final turn, reveal all cards after turn ends + for card in player.cards: + card.face_up = True + self.players_with_final_turn.add(player.id) + + # Advance to next player + self._next_turn() + + def _next_turn(self) -> None: + """Advance to the next player's turn.""" + if not self.player_order: + return + + if self.phase == GamePhase.FINAL_TURN: + # Check if all players have had their final turn + all_done = all( + pid in self.players_with_final_turn + for pid in self.player_order + ) + if all_done: + # Round will end (round_ended event will set phase) + return + + # Move to next player + self.current_player_idx = (self.current_player_idx + 1) % len(self.player_order) + + # ------------------------------------------------------------------------- + # Query Methods + # ------------------------------------------------------------------------- + + @property + def current_player_id(self) -> Optional[str]: + """Get the current player's ID.""" + if self.player_order and 0 <= self.current_player_idx < len(self.player_order): + return self.player_order[self.current_player_idx] + return None + + @property + def current_player(self) -> Optional[PlayerState]: + """Get the current player's state.""" + player_id = self.current_player_id + return self.players.get(player_id) if player_id else None + + def discard_top(self) -> Optional[CardState]: + """Get the top card of the discard pile.""" + return self.discard_pile[-1] if self.discard_pile else None + + def get_player(self, player_id: str) -> Optional[PlayerState]: + """Get a player's state by ID.""" + return self.players.get(player_id) + + +def rebuild_state(events: list[GameEvent]) -> RebuiltGameState: + """ + Rebuild game state from a list of events. + + Args: + events: List of events in sequence order. + + Returns: + Reconstructed game state. + + Raises: + ValueError: If events list is empty or has invalid sequence. + """ + if not events: + raise ValueError("Cannot rebuild state from empty event list") + + state = RebuiltGameState(game_id=events[0].game_id) + for event in events: + state.apply(event) + + return state + + +async def rebuild_state_from_store( + event_store, + game_id: str, + to_sequence: Optional[int] = None, +) -> RebuiltGameState: + """ + Rebuild game state by loading events from the store. + + Args: + event_store: EventStore instance. + game_id: Game UUID. + to_sequence: Optional sequence to rebuild up to. + + Returns: + Reconstructed game state. + """ + events = await event_store.get_events(game_id, to_sequence=to_sequence) + return rebuild_state(events) diff --git a/server/models/user.py b/server/models/user.py new file mode 100644 index 0000000..ce065b4 --- /dev/null +++ b/server/models/user.py @@ -0,0 +1,287 @@ +""" +User-related models for Golf game authentication. + +Defines user accounts, sessions, and guest tracking for the V2 auth system. +""" + +from dataclasses import dataclass, field +from datetime import datetime, timezone +from enum import Enum +from typing import Optional, Any +import json + + +class UserRole(str, Enum): + """User role levels.""" + GUEST = "guest" + USER = "user" + ADMIN = "admin" + + +@dataclass +class User: + """ + A registered user account. + + Attributes: + id: UUID primary key. + username: Unique display name. + email: Optional email address. + password_hash: bcrypt hash of password. + role: User role (guest, user, admin). + email_verified: Whether email has been verified. + verification_token: Token for email verification. + verification_expires: When verification token expires. + reset_token: Token for password reset. + reset_expires: When reset token expires. + guest_id: Guest session ID if converted from guest. + deleted_at: Soft delete timestamp. + preferences: User preferences as JSON. + created_at: When account was created. + last_login: Last login timestamp. + last_seen_at: Last activity timestamp. + is_active: Whether account is active. + is_banned: Whether user is banned. + ban_reason: Reason for ban (if banned). + force_password_reset: Whether user must reset password on next login. + """ + id: str + username: str + password_hash: str + email: Optional[str] = None + role: UserRole = UserRole.USER + email_verified: bool = False + verification_token: Optional[str] = None + verification_expires: Optional[datetime] = None + reset_token: Optional[str] = None + reset_expires: Optional[datetime] = None + guest_id: Optional[str] = None + deleted_at: Optional[datetime] = None + preferences: dict = field(default_factory=dict) + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + last_login: Optional[datetime] = None + last_seen_at: Optional[datetime] = None + is_active: bool = True + is_banned: bool = False + ban_reason: Optional[str] = None + force_password_reset: bool = False + + def is_admin(self) -> bool: + """Check if user has admin role.""" + return self.role == UserRole.ADMIN + + def is_guest(self) -> bool: + """Check if user has guest role.""" + return self.role == UserRole.GUEST + + def can_login(self) -> bool: + """Check if user can log in.""" + return self.is_active and self.deleted_at is None and not self.is_banned + + def to_dict(self, include_sensitive: bool = False) -> dict: + """ + Serialize user to dictionary. + + Args: + include_sensitive: Include password hash and tokens. + """ + d = { + "id": self.id, + "username": self.username, + "email": self.email, + "role": self.role.value, + "email_verified": self.email_verified, + "preferences": self.preferences, + "created_at": self.created_at.isoformat() if self.created_at else None, + "last_login": self.last_login.isoformat() if self.last_login else None, + "last_seen_at": self.last_seen_at.isoformat() if self.last_seen_at else None, + "is_active": self.is_active, + "is_banned": self.is_banned, + "ban_reason": self.ban_reason, + "force_password_reset": self.force_password_reset, + } + if include_sensitive: + d["password_hash"] = self.password_hash + d["verification_token"] = self.verification_token + d["verification_expires"] = ( + self.verification_expires.isoformat() if self.verification_expires else None + ) + d["reset_token"] = self.reset_token + d["reset_expires"] = ( + self.reset_expires.isoformat() if self.reset_expires else None + ) + d["guest_id"] = self.guest_id + d["deleted_at"] = self.deleted_at.isoformat() if self.deleted_at else None + return d + + @classmethod + def from_dict(cls, d: dict) -> "User": + """Deserialize user from dictionary.""" + def parse_dt(val: Any) -> Optional[datetime]: + if val is None: + return None + if isinstance(val, datetime): + return val + return datetime.fromisoformat(val) + + return cls( + id=d["id"], + username=d["username"], + password_hash=d.get("password_hash", ""), + email=d.get("email"), + role=UserRole(d.get("role", "user")), + email_verified=d.get("email_verified", False), + verification_token=d.get("verification_token"), + verification_expires=parse_dt(d.get("verification_expires")), + reset_token=d.get("reset_token"), + reset_expires=parse_dt(d.get("reset_expires")), + guest_id=d.get("guest_id"), + deleted_at=parse_dt(d.get("deleted_at")), + preferences=d.get("preferences", {}), + created_at=parse_dt(d.get("created_at")) or datetime.now(timezone.utc), + last_login=parse_dt(d.get("last_login")), + last_seen_at=parse_dt(d.get("last_seen_at")), + is_active=d.get("is_active", True), + is_banned=d.get("is_banned", False), + ban_reason=d.get("ban_reason"), + force_password_reset=d.get("force_password_reset", False), + ) + + +@dataclass +class UserSession: + """ + An active user session. + + Session tokens are hashed before storage for security. + + Attributes: + id: UUID primary key. + user_id: Reference to user. + token_hash: SHA256 hash of session token. + device_info: Device/browser information. + ip_address: Client IP address. + created_at: When session was created. + expires_at: When session expires. + last_used_at: Last activity timestamp. + revoked_at: When session was revoked (if any). + """ + id: str + user_id: str + token_hash: str + device_info: dict = field(default_factory=dict) + ip_address: Optional[str] = None + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + expires_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + last_used_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + revoked_at: Optional[datetime] = None + + def is_valid(self) -> bool: + """Check if session is still valid.""" + now = datetime.now(timezone.utc) + return ( + self.revoked_at is None + and self.expires_at > now + ) + + def to_dict(self) -> dict: + """Serialize session to dictionary.""" + return { + "id": self.id, + "user_id": self.user_id, + "token_hash": self.token_hash, + "device_info": self.device_info, + "ip_address": self.ip_address, + "created_at": self.created_at.isoformat() if self.created_at else None, + "expires_at": self.expires_at.isoformat() if self.expires_at else None, + "last_used_at": self.last_used_at.isoformat() if self.last_used_at else None, + "revoked_at": self.revoked_at.isoformat() if self.revoked_at else None, + } + + @classmethod + def from_dict(cls, d: dict) -> "UserSession": + """Deserialize session from dictionary.""" + def parse_dt(val: Any) -> Optional[datetime]: + if val is None: + return None + if isinstance(val, datetime): + return val + return datetime.fromisoformat(val) + + return cls( + id=d["id"], + user_id=d["user_id"], + token_hash=d["token_hash"], + device_info=d.get("device_info", {}), + ip_address=d.get("ip_address"), + created_at=parse_dt(d.get("created_at")) or datetime.now(timezone.utc), + expires_at=parse_dt(d.get("expires_at")) or datetime.now(timezone.utc), + last_used_at=parse_dt(d.get("last_used_at")) or datetime.now(timezone.utc), + revoked_at=parse_dt(d.get("revoked_at")), + ) + + +@dataclass +class GuestSession: + """ + A guest session for tracking anonymous users. + + Guests can play games without registering. Their session + can later be converted to a full user account. + + Attributes: + id: Guest session ID (stored in client). + display_name: Display name for the guest. + created_at: When session was created. + last_seen_at: Last activity timestamp. + games_played: Number of games played as guest. + converted_to_user_id: User ID if converted to account. + expires_at: When guest session expires. + """ + id: str + display_name: Optional[str] = None + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + last_seen_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + games_played: int = 0 + converted_to_user_id: Optional[str] = None + expires_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + + def is_converted(self) -> bool: + """Check if guest has been converted to user.""" + return self.converted_to_user_id is not None + + def is_expired(self) -> bool: + """Check if guest session has expired.""" + return datetime.now(timezone.utc) > self.expires_at + + def to_dict(self) -> dict: + """Serialize guest session to dictionary.""" + return { + "id": self.id, + "display_name": self.display_name, + "created_at": self.created_at.isoformat() if self.created_at else None, + "last_seen_at": self.last_seen_at.isoformat() if self.last_seen_at else None, + "games_played": self.games_played, + "converted_to_user_id": self.converted_to_user_id, + "expires_at": self.expires_at.isoformat() if self.expires_at else None, + } + + @classmethod + def from_dict(cls, d: dict) -> "GuestSession": + """Deserialize guest session from dictionary.""" + def parse_dt(val: Any) -> Optional[datetime]: + if val is None: + return None + if isinstance(val, datetime): + return val + return datetime.fromisoformat(val) + + return cls( + id=d["id"], + display_name=d.get("display_name"), + created_at=parse_dt(d.get("created_at")) or datetime.now(timezone.utc), + last_seen_at=parse_dt(d.get("last_seen_at")) or datetime.now(timezone.utc), + games_played=d.get("games_played", 0), + converted_to_user_id=d.get("converted_to_user_id"), + expires_at=parse_dt(d.get("expires_at")) or datetime.now(timezone.utc), + ) diff --git a/server/requirements.txt b/server/requirements.txt index 2aa4c44..ec5d2b5 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -2,3 +2,15 @@ fastapi>=0.109.0 uvicorn[standard]>=0.27.0 websockets>=12.0 python-dotenv>=1.0.0 +# V2: Event sourcing infrastructure +asyncpg>=0.29.0 +redis>=5.0.0 +# V2: Authentication +resend>=2.0.0 +bcrypt>=4.1.0 +# V2: Production monitoring (optional) +sentry-sdk[fastapi]>=1.40.0 + +# Testing +pytest>=8.0.0 +pytest-asyncio>=0.23.0 diff --git a/server/routers/__init__.py b/server/routers/__init__.py new file mode 100644 index 0000000..c8316fe --- /dev/null +++ b/server/routers/__init__.py @@ -0,0 +1,9 @@ +"""Routers package for Golf game API.""" + +from .auth import router as auth_router +from .admin import router as admin_router + +__all__ = [ + "auth_router", + "admin_router", +] diff --git a/server/routers/admin.py b/server/routers/admin.py new file mode 100644 index 0000000..7e65fad --- /dev/null +++ b/server/routers/admin.py @@ -0,0 +1,419 @@ +""" +Admin API router for Golf game V2. + +Provides endpoints for admin operations: user management, game moderation, +system statistics, invite codes, and audit logging. +""" + +import logging +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Request +from pydantic import BaseModel + +from models.user import User +from services.admin_service import AdminService + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/admin", tags=["admin"]) + + +# ============================================================================= +# Request/Response Models +# ============================================================================= + + +class BanUserRequest(BaseModel): + """Ban user request.""" + reason: str + duration_days: Optional[int] = None + + +class ChangeRoleRequest(BaseModel): + """Change user role request.""" + role: str + + +class CreateInviteRequest(BaseModel): + """Create invite code request.""" + max_uses: int = 1 + expires_days: int = 7 + + +class EndGameRequest(BaseModel): + """End game request.""" + reason: str + + +# ============================================================================= +# Dependencies +# ============================================================================= + +# These will be set by main.py during startup +_admin_service: Optional[AdminService] = None + + +def set_admin_service(service: AdminService) -> None: + """Set the admin service instance (called from main.py).""" + global _admin_service + _admin_service = service + + +def get_admin_service_dep() -> AdminService: + """Dependency to get admin service.""" + if _admin_service is None: + raise HTTPException(status_code=503, detail="Admin service not initialized") + return _admin_service + + +# Import the auth dependency from the auth router +from routers.auth import require_admin_v2, get_client_ip + + +# ============================================================================= +# User Management Endpoints +# ============================================================================= + + +@router.get("/users") +async def list_users( + query: str = "", + limit: int = 50, + offset: int = 0, + include_banned: bool = True, + include_deleted: bool = False, + admin: User = Depends(require_admin_v2), + service: AdminService = Depends(get_admin_service_dep), +): + """ + Search and list users. + + Args: + query: Search by username or email. + limit: Maximum results to return. + offset: Results to skip. + include_banned: Include banned users. + include_deleted: Include soft-deleted users. + """ + users = await service.search_users( + query=query, + limit=limit, + offset=offset, + include_banned=include_banned, + include_deleted=include_deleted, + ) + return {"users": [u.to_dict() for u in users]} + + +@router.get("/users/{user_id}") +async def get_user( + user_id: str, + admin: User = Depends(require_admin_v2), + service: AdminService = Depends(get_admin_service_dep), +): + """Get detailed user information.""" + user = await service.get_user(user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + return user.to_dict() + + +@router.get("/users/{user_id}/ban-history") +async def get_user_ban_history( + user_id: str, + admin: User = Depends(require_admin_v2), + service: AdminService = Depends(get_admin_service_dep), +): + """Get ban history for a user.""" + history = await service.get_user_ban_history(user_id) + return {"history": history} + + +@router.post("/users/{user_id}/ban") +async def ban_user( + user_id: str, + request_body: BanUserRequest, + request: Request, + admin: User = Depends(require_admin_v2), + service: AdminService = Depends(get_admin_service_dep), +): + """ + Ban a user. + + Banning revokes all sessions and optionally removes from active games. + Admins cannot be banned. + """ + if user_id == admin.id: + raise HTTPException(status_code=400, detail="Cannot ban yourself") + + success = await service.ban_user( + admin_id=admin.id, + user_id=user_id, + reason=request_body.reason, + duration_days=request_body.duration_days, + ip_address=get_client_ip(request), + ) + if not success: + raise HTTPException(status_code=400, detail="Cannot ban user (user not found or is admin)") + return {"message": "User banned successfully"} + + +@router.post("/users/{user_id}/unban") +async def unban_user( + user_id: str, + request: Request, + admin: User = Depends(require_admin_v2), + service: AdminService = Depends(get_admin_service_dep), +): + """Unban a user.""" + success = await service.unban_user( + admin_id=admin.id, + user_id=user_id, + ip_address=get_client_ip(request), + ) + if not success: + raise HTTPException(status_code=400, detail="Cannot unban user") + return {"message": "User unbanned successfully"} + + +@router.post("/users/{user_id}/force-password-reset") +async def force_password_reset( + user_id: str, + request: Request, + admin: User = Depends(require_admin_v2), + service: AdminService = Depends(get_admin_service_dep), +): + """ + Force user to reset password on next login. + + All existing sessions are revoked. + """ + success = await service.force_password_reset( + admin_id=admin.id, + user_id=user_id, + ip_address=get_client_ip(request), + ) + if not success: + raise HTTPException(status_code=400, detail="Cannot force password reset") + return {"message": "Password reset required for user"} + + +@router.put("/users/{user_id}/role") +async def change_user_role( + user_id: str, + request_body: ChangeRoleRequest, + request: Request, + admin: User = Depends(require_admin_v2), + service: AdminService = Depends(get_admin_service_dep), +): + """ + Change user role. + + Valid roles: "user", "admin" + """ + if user_id == admin.id: + raise HTTPException(status_code=400, detail="Cannot change your own role") + + if request_body.role not in ("user", "admin"): + raise HTTPException(status_code=400, detail="Invalid role. Must be 'user' or 'admin'") + + success = await service.change_user_role( + admin_id=admin.id, + user_id=user_id, + new_role=request_body.role, + ip_address=get_client_ip(request), + ) + if not success: + raise HTTPException(status_code=400, detail="Cannot change user role") + return {"message": f"Role changed to {request_body.role}"} + + +@router.post("/users/{user_id}/impersonate") +async def impersonate_user( + user_id: str, + request: Request, + admin: User = Depends(require_admin_v2), + service: AdminService = Depends(get_admin_service_dep), +): + """ + Start read-only impersonation of a user. + + Returns the user's data as they would see it. This is for + debugging and support purposes only. + """ + user = await service.impersonate_user( + admin_id=admin.id, + user_id=user_id, + ip_address=get_client_ip(request), + ) + if not user: + raise HTTPException(status_code=404, detail="User not found") + + return { + "message": "Impersonation started (read-only)", + "user": user.to_dict(), + } + + +# ============================================================================= +# Game Moderation Endpoints +# ============================================================================= + + +@router.get("/games") +async def list_active_games( + admin: User = Depends(require_admin_v2), + service: AdminService = Depends(get_admin_service_dep), +): + """List all active games.""" + games = await service.get_active_games() + return {"games": games} + + +@router.get("/games/{game_id}") +async def get_game_details( + game_id: str, + request: Request, + admin: User = Depends(require_admin_v2), + service: AdminService = Depends(get_admin_service_dep), +): + """ + Get full game state (admin view). + + This view shows all cards, including face-down cards. + """ + game = await service.get_game_details( + admin_id=admin.id, + game_id=game_id, + ip_address=get_client_ip(request), + ) + if not game: + raise HTTPException(status_code=404, detail="Game not found") + return game + + +@router.post("/games/{game_id}/end") +async def end_game( + game_id: str, + request_body: EndGameRequest, + request: Request, + admin: User = Depends(require_admin_v2), + service: AdminService = Depends(get_admin_service_dep), +): + """ + Force-end a stuck or problematic game. + + The game will be marked as abandoned. + """ + success = await service.end_game( + admin_id=admin.id, + game_id=game_id, + reason=request_body.reason, + ip_address=get_client_ip(request), + ) + if not success: + raise HTTPException(status_code=400, detail="Cannot end game") + return {"message": "Game ended successfully"} + + +# ============================================================================= +# System Stats Endpoints +# ============================================================================= + + +@router.get("/stats") +async def get_system_stats( + admin: User = Depends(require_admin_v2), + service: AdminService = Depends(get_admin_service_dep), +): + """Get current system statistics.""" + stats = await service.get_system_stats() + return stats.to_dict() + + +# ============================================================================= +# Audit Log Endpoints +# ============================================================================= + + +@router.get("/audit") +async def get_audit_log( + limit: int = 100, + offset: int = 0, + admin_id: Optional[str] = None, + action: Optional[str] = None, + target_type: Optional[str] = None, + admin: User = Depends(require_admin_v2), + service: AdminService = Depends(get_admin_service_dep), +): + """ + Get admin audit log. + + Can filter by admin_id, action type, or target type. + """ + entries = await service.get_audit_log( + limit=limit, + offset=offset, + admin_id=admin_id, + action=action, + target_type=target_type, + ) + return {"entries": [e.to_dict() for e in entries]} + + +# ============================================================================= +# Invite Code Endpoints +# ============================================================================= + + +@router.get("/invites") +async def list_invite_codes( + include_expired: bool = False, + admin: User = Depends(require_admin_v2), + service: AdminService = Depends(get_admin_service_dep), +): + """List all invite codes.""" + codes = await service.get_invite_codes(include_expired=include_expired) + return {"codes": [c.to_dict() for c in codes]} + + +@router.post("/invites") +async def create_invite_code( + request_body: CreateInviteRequest, + request: Request, + admin: User = Depends(require_admin_v2), + service: AdminService = Depends(get_admin_service_dep), +): + """ + Create a new invite code. + + Args: + max_uses: Maximum number of times the code can be used. + expires_days: Number of days until the code expires. + """ + code = await service.create_invite_code( + admin_id=admin.id, + max_uses=request_body.max_uses, + expires_days=request_body.expires_days, + ip_address=get_client_ip(request), + ) + return {"code": code, "message": "Invite code created successfully"} + + +@router.delete("/invites/{code}") +async def revoke_invite_code( + code: str, + request: Request, + admin: User = Depends(require_admin_v2), + service: AdminService = Depends(get_admin_service_dep), +): + """Revoke an invite code.""" + success = await service.revoke_invite_code( + admin_id=admin.id, + code=code, + ip_address=get_client_ip(request), + ) + if not success: + raise HTTPException(status_code=404, detail="Invite code not found") + return {"message": "Invite code revoked successfully"} diff --git a/server/routers/auth.py b/server/routers/auth.py new file mode 100644 index 0000000..018e0e5 --- /dev/null +++ b/server/routers/auth.py @@ -0,0 +1,506 @@ +""" +Authentication API router for Golf game V2. + +Provides endpoints for user registration, login, password management, +and session handling. +""" + +import logging +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Header, Request +from pydantic import BaseModel, EmailStr + +from models.user import User +from services.auth_service import AuthService + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/auth", tags=["auth"]) + + +# ============================================================================= +# Request/Response Models +# ============================================================================= + + +class RegisterRequest(BaseModel): + """Registration request.""" + username: str + password: str + email: Optional[str] = None + + +class LoginRequest(BaseModel): + """Login request.""" + username: str + password: str + + +class VerifyEmailRequest(BaseModel): + """Email verification request.""" + token: str + + +class ResendVerificationRequest(BaseModel): + """Resend verification email request.""" + email: str + + +class ForgotPasswordRequest(BaseModel): + """Forgot password request.""" + email: str + + +class ResetPasswordRequest(BaseModel): + """Password reset request.""" + token: str + new_password: str + + +class ChangePasswordRequest(BaseModel): + """Change password request.""" + current_password: str + new_password: str + + +class UpdatePreferencesRequest(BaseModel): + """Update preferences request.""" + preferences: dict + + +class ConvertGuestRequest(BaseModel): + """Convert guest to user request.""" + guest_id: str + username: str + password: str + email: Optional[str] = None + + +class UserResponse(BaseModel): + """User response (public fields only).""" + id: str + username: str + email: Optional[str] + role: str + email_verified: bool + preferences: dict + created_at: str + last_login: Optional[str] + + +class AuthResponse(BaseModel): + """Authentication response with token.""" + user: UserResponse + token: str + expires_at: str + + +class SessionResponse(BaseModel): + """Session response.""" + id: str + device_info: dict + ip_address: Optional[str] + created_at: str + last_used_at: str + + +# ============================================================================= +# Dependencies +# ============================================================================= + +# These will be set by main.py during startup +_auth_service: Optional[AuthService] = None + + +def set_auth_service(service: AuthService) -> None: + """Set the auth service instance (called from main.py).""" + global _auth_service + _auth_service = service + + +def get_auth_service_dep() -> AuthService: + """Dependency to get auth service.""" + if _auth_service is None: + raise HTTPException(status_code=503, detail="Auth service not initialized") + return _auth_service + + +async def get_current_user_v2( + authorization: Optional[str] = Header(None), + auth_service: AuthService = Depends(get_auth_service_dep), +) -> Optional[User]: + """Get current user from Authorization header (optional).""" + if not authorization: + return None + + parts = authorization.split() + if len(parts) != 2 or parts[0].lower() != "bearer": + return None + + token = parts[1] + return await auth_service.get_user_from_token(token) + + +async def require_user_v2( + user: Optional[User] = Depends(get_current_user_v2), +) -> User: + """Require authenticated user.""" + if not user: + raise HTTPException(status_code=401, detail="Not authenticated") + if not user.is_active: + raise HTTPException(status_code=403, detail="Account disabled") + return user + + +async def require_admin_v2( + user: User = Depends(require_user_v2), +) -> User: + """Require admin user.""" + if not user.is_admin(): + raise HTTPException(status_code=403, detail="Admin access required") + return user + + +def get_client_ip(request: Request) -> Optional[str]: + """Extract client IP from request.""" + forwarded = request.headers.get("x-forwarded-for") + if forwarded: + return forwarded.split(",")[0].strip() + if request.client: + return request.client.host + return None + + +def get_device_info(request: Request) -> dict: + """Extract device info from request headers.""" + return { + "user_agent": request.headers.get("user-agent", ""), + } + + +def get_token_from_header(authorization: Optional[str] = Header(None)) -> Optional[str]: + """Extract token from Authorization header.""" + if not authorization: + return None + parts = authorization.split() + if len(parts) != 2 or parts[0].lower() != "bearer": + return None + return parts[1] + + +# ============================================================================= +# Registration Endpoints +# ============================================================================= + + +@router.post("/register", response_model=AuthResponse) +async def register( + request_body: RegisterRequest, + request: Request, + auth_service: AuthService = Depends(get_auth_service_dep), +): + """Register a new user account.""" + result = await auth_service.register( + username=request_body.username, + password=request_body.password, + email=request_body.email, + ) + + if not result.success: + raise HTTPException(status_code=400, detail=result.error) + + if result.requires_verification: + # Return user info but note they need to verify + return { + "user": _user_to_response(result.user), + "token": "", + "expires_at": "", + "message": "Please check your email to verify your account", + } + + # Auto-login after registration + login_result = await auth_service.login( + username=request_body.username, + password=request_body.password, + device_info=get_device_info(request), + ip_address=get_client_ip(request), + ) + + if not login_result.success: + raise HTTPException(status_code=500, detail="Registration succeeded but login failed") + + return { + "user": _user_to_response(login_result.user), + "token": login_result.token, + "expires_at": login_result.expires_at.isoformat(), + } + + +@router.post("/verify-email") +async def verify_email( + request_body: VerifyEmailRequest, + auth_service: AuthService = Depends(get_auth_service_dep), +): + """Verify email address with token.""" + result = await auth_service.verify_email(request_body.token) + + if not result.success: + raise HTTPException(status_code=400, detail=result.error) + + return {"status": "ok", "message": "Email verified successfully"} + + +@router.post("/resend-verification") +async def resend_verification( + request_body: ResendVerificationRequest, + auth_service: AuthService = Depends(get_auth_service_dep), +): + """Resend verification email.""" + await auth_service.resend_verification(request_body.email) + # Always return success to prevent email enumeration + return {"status": "ok", "message": "If the email exists, a verification link has been sent"} + + +# ============================================================================= +# Login/Logout Endpoints +# ============================================================================= + + +@router.post("/login", response_model=AuthResponse) +async def login( + request_body: LoginRequest, + request: Request, + auth_service: AuthService = Depends(get_auth_service_dep), +): + """Login with username/email and password.""" + result = await auth_service.login( + username=request_body.username, + password=request_body.password, + device_info=get_device_info(request), + ip_address=get_client_ip(request), + ) + + if not result.success: + raise HTTPException(status_code=401, detail=result.error) + + return { + "user": _user_to_response(result.user), + "token": result.token, + "expires_at": result.expires_at.isoformat(), + } + + +@router.post("/logout") +async def logout( + token: Optional[str] = Depends(get_token_from_header), + auth_service: AuthService = Depends(get_auth_service_dep), +): + """Logout current session.""" + if token: + await auth_service.logout(token) + return {"status": "ok"} + + +@router.post("/logout-all") +async def logout_all( + user: User = Depends(require_user_v2), + token: Optional[str] = Depends(get_token_from_header), + auth_service: AuthService = Depends(get_auth_service_dep), +): + """Logout all sessions except current.""" + count = await auth_service.logout_all(user.id, except_token=token) + return {"status": "ok", "sessions_revoked": count} + + +# ============================================================================= +# Password Management Endpoints +# ============================================================================= + + +@router.post("/forgot-password") +async def forgot_password( + request_body: ForgotPasswordRequest, + auth_service: AuthService = Depends(get_auth_service_dep), +): + """Request password reset email.""" + await auth_service.forgot_password(request_body.email) + # Always return success to prevent email enumeration + return {"status": "ok", "message": "If the email exists, a reset link has been sent"} + + +@router.post("/reset-password") +async def reset_password( + request_body: ResetPasswordRequest, + auth_service: AuthService = Depends(get_auth_service_dep), +): + """Reset password with token.""" + result = await auth_service.reset_password( + token=request_body.token, + new_password=request_body.new_password, + ) + + if not result.success: + raise HTTPException(status_code=400, detail=result.error) + + return {"status": "ok", "message": "Password reset successfully"} + + +@router.put("/password") +async def change_password( + request_body: ChangePasswordRequest, + user: User = Depends(require_user_v2), + token: Optional[str] = Depends(get_token_from_header), + auth_service: AuthService = Depends(get_auth_service_dep), +): + """Change password for current user.""" + result = await auth_service.change_password( + user_id=user.id, + current_password=request_body.current_password, + new_password=request_body.new_password, + current_token=token, + ) + + if not result.success: + raise HTTPException(status_code=400, detail=result.error) + + return {"status": "ok", "message": "Password changed successfully"} + + +# ============================================================================= +# User Profile Endpoints +# ============================================================================= + + +@router.get("/me") +async def get_me(user: User = Depends(require_user_v2)): + """Get current user info.""" + return {"user": _user_to_response(user)} + + +@router.put("/me/preferences") +async def update_preferences( + request_body: UpdatePreferencesRequest, + user: User = Depends(require_user_v2), + auth_service: AuthService = Depends(get_auth_service_dep), +): + """Update user preferences.""" + updated = await auth_service.update_preferences(user.id, request_body.preferences) + if not updated: + raise HTTPException(status_code=500, detail="Failed to update preferences") + return {"user": _user_to_response(updated)} + + +# ============================================================================= +# Session Management Endpoints +# ============================================================================= + + +@router.get("/sessions") +async def get_sessions( + user: User = Depends(require_user_v2), + auth_service: AuthService = Depends(get_auth_service_dep), +): + """Get all active sessions for current user.""" + sessions = await auth_service.get_sessions(user.id) + return { + "sessions": [ + { + "id": s.id, + "device_info": s.device_info, + "ip_address": s.ip_address, + "created_at": s.created_at.isoformat() if s.created_at else None, + "last_used_at": s.last_used_at.isoformat() if s.last_used_at else None, + } + for s in sessions + ] + } + + +@router.delete("/sessions/{session_id}") +async def revoke_session( + session_id: str, + user: User = Depends(require_user_v2), + auth_service: AuthService = Depends(get_auth_service_dep), +): + """Revoke a specific session.""" + success = await auth_service.revoke_session(user.id, session_id) + if not success: + raise HTTPException(status_code=404, detail="Session not found") + return {"status": "ok"} + + +# ============================================================================= +# Guest Conversion Endpoint +# ============================================================================= + + +@router.post("/convert-guest", response_model=AuthResponse) +async def convert_guest( + request_body: ConvertGuestRequest, + request: Request, + auth_service: AuthService = Depends(get_auth_service_dep), +): + """Convert guest session to full user account.""" + result = await auth_service.convert_guest( + guest_id=request_body.guest_id, + username=request_body.username, + password=request_body.password, + email=request_body.email, + ) + + if not result.success: + raise HTTPException(status_code=400, detail=result.error) + + # Auto-login after conversion + login_result = await auth_service.login( + username=request_body.username, + password=request_body.password, + device_info=get_device_info(request), + ip_address=get_client_ip(request), + ) + + if not login_result.success: + raise HTTPException(status_code=500, detail="Conversion succeeded but login failed") + + return { + "user": _user_to_response(login_result.user), + "token": login_result.token, + "expires_at": login_result.expires_at.isoformat(), + } + + +# ============================================================================= +# Account Deletion Endpoint +# ============================================================================= + + +@router.delete("/me") +async def delete_account( + user: User = Depends(require_user_v2), + auth_service: AuthService = Depends(get_auth_service_dep), +): + """Delete (soft delete) current user account.""" + success = await auth_service.delete_account(user.id) + if not success: + raise HTTPException(status_code=500, detail="Failed to delete account") + return {"status": "ok", "message": "Account deleted"} + + +# ============================================================================= +# Helpers +# ============================================================================= + + +def _user_to_response(user: User) -> dict: + """Convert User to response dict (public fields only).""" + return { + "id": user.id, + "username": user.username, + "email": user.email, + "role": user.role.value, + "email_verified": user.email_verified, + "preferences": user.preferences, + "created_at": user.created_at.isoformat() if user.created_at else None, + "last_login": user.last_login.isoformat() if user.last_login else None, + } diff --git a/server/routers/health.py b/server/routers/health.py new file mode 100644 index 0000000..d97499b --- /dev/null +++ b/server/routers/health.py @@ -0,0 +1,171 @@ +""" +Health check endpoints for production deployment. + +Provides: +- /health - Basic liveness check (is the app running?) +- /ready - Readiness check (can the app handle requests?) +- /metrics - Application metrics for monitoring +""" + +import json +import logging +from datetime import datetime, timezone +from typing import Optional + +from fastapi import APIRouter, Response + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["health"]) + +# Service references (set during app initialization) +_db_pool = None +_redis_client = None +_room_manager = None + + +def set_health_dependencies( + db_pool=None, + redis_client=None, + room_manager=None, +): + """Set dependencies for health checks.""" + global _db_pool, _redis_client, _room_manager + _db_pool = db_pool + _redis_client = redis_client + _room_manager = room_manager + + +@router.get("/health") +async def health_check(): + """ + Basic liveness check - is the app running? + + This endpoint should always return 200 if the process is alive. + Used by container orchestration for restart decisions. + """ + return { + "status": "ok", + "timestamp": datetime.now(timezone.utc).isoformat(), + } + + +@router.get("/ready") +async def readiness_check(): + """ + Readiness check - can the app handle requests? + + Checks connectivity to required services (database, Redis). + Returns 503 if any critical service is unavailable. + """ + checks = {} + overall_healthy = True + + # Check PostgreSQL + if _db_pool is not None: + try: + async with _db_pool.acquire() as conn: + await conn.fetchval("SELECT 1") + checks["database"] = {"status": "ok"} + except Exception as e: + logger.warning(f"Database health check failed: {e}") + checks["database"] = {"status": "error", "message": str(e)} + overall_healthy = False + else: + checks["database"] = {"status": "not_configured"} + + # Check Redis + if _redis_client is not None: + try: + await _redis_client.ping() + checks["redis"] = {"status": "ok"} + except Exception as e: + logger.warning(f"Redis health check failed: {e}") + checks["redis"] = {"status": "error", "message": str(e)} + overall_healthy = False + else: + checks["redis"] = {"status": "not_configured"} + + status_code = 200 if overall_healthy else 503 + return Response( + content=json.dumps({ + "status": "ok" if overall_healthy else "degraded", + "checks": checks, + "timestamp": datetime.now(timezone.utc).isoformat(), + }), + status_code=status_code, + media_type="application/json", + ) + + +@router.get("/metrics") +async def metrics(): + """ + Expose application metrics for monitoring. + + Returns operational metrics useful for dashboards and alerting. + """ + metrics_data = { + "timestamp": datetime.now(timezone.utc).isoformat(), + } + + # Room/game metrics from room manager + if _room_manager is not None: + try: + rooms = _room_manager.rooms + active_rooms = len(rooms) + total_players = sum(len(r.players) for r in rooms.values()) + games_in_progress = sum( + 1 for r in rooms.values() + if hasattr(r.game, 'phase') and r.game.phase.name not in ('WAITING', 'GAME_OVER') + ) + metrics_data.update({ + "active_rooms": active_rooms, + "total_players": total_players, + "games_in_progress": games_in_progress, + }) + except Exception as e: + logger.warning(f"Failed to collect room metrics: {e}") + + # Database metrics + if _db_pool is not None: + try: + async with _db_pool.acquire() as conn: + # Count active games (if games table exists) + try: + games_today = await conn.fetchval( + "SELECT COUNT(*) FROM game_events WHERE timestamp > NOW() - INTERVAL '1 day'" + ) + metrics_data["events_today"] = games_today + except Exception: + pass # Table might not exist + + # Count users (if users table exists) + try: + total_users = await conn.fetchval("SELECT COUNT(*) FROM users") + metrics_data["total_users"] = total_users + except Exception: + pass # Table might not exist + except Exception as e: + logger.warning(f"Failed to collect database metrics: {e}") + + # Redis metrics + if _redis_client is not None: + try: + # Get connected players from Redis set if tracking + try: + connected = await _redis_client.scard("golf:connected_players") + metrics_data["connected_websockets"] = connected + except Exception: + pass + + # Get active rooms from Redis + try: + active_rooms_redis = await _redis_client.scard("golf:rooms:active") + metrics_data["active_rooms_redis"] = active_rooms_redis + except Exception: + pass + except Exception as e: + logger.warning(f"Failed to collect Redis metrics: {e}") + + return metrics_data diff --git a/server/routers/replay.py b/server/routers/replay.py new file mode 100644 index 0000000..aeaa8eb --- /dev/null +++ b/server/routers/replay.py @@ -0,0 +1,490 @@ +""" +Replay API router for Golf game. + +Provides endpoints for: +- Viewing game replays +- Creating and managing share links +- Exporting/importing games +- Spectating live games +""" + +import hashlib +import logging +from typing import Optional + +from fastapi import APIRouter, HTTPException, Query, Depends, Header, WebSocket, WebSocketDisconnect +from fastapi.responses import JSONResponse +from pydantic import BaseModel + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/replay", tags=["replay"]) + +# Service instances (set during app startup) +_replay_service = None +_auth_service = None +_spectator_manager = None +_room_manager = None + + +def set_replay_service(service) -> None: + """Set the replay service instance.""" + global _replay_service + _replay_service = service + + +def set_auth_service(service) -> None: + """Set the auth service instance.""" + global _auth_service + _auth_service = service + + +def set_spectator_manager(manager) -> None: + """Set the spectator manager instance.""" + global _spectator_manager + _spectator_manager = manager + + +def set_room_manager(manager) -> None: + """Set the room manager instance.""" + global _room_manager + _room_manager = manager + + +# ------------------------------------------------------------------------- +# Auth Dependencies +# ------------------------------------------------------------------------- + +async def get_current_user(authorization: Optional[str] = Header(None)): + """Get current user from Authorization header.""" + if not authorization or not _auth_service: + return None + + parts = authorization.split() + if len(parts) != 2 or parts[0].lower() != "bearer": + return None + + token = parts[1] + return await _auth_service.get_user_from_token(token) + + +async def require_auth(user=Depends(get_current_user)): + """Require authenticated user.""" + if not user: + raise HTTPException(status_code=401, detail="Authentication required") + return user + + +# ------------------------------------------------------------------------- +# Request/Response Models +# ------------------------------------------------------------------------- + +class ShareLinkRequest(BaseModel): + """Request to create a share link.""" + title: Optional[str] = None + description: Optional[str] = None + expires_days: Optional[int] = None + + +class ImportGameRequest(BaseModel): + """Request to import a game.""" + export_data: dict + + +# ------------------------------------------------------------------------- +# Replay Endpoints +# ------------------------------------------------------------------------- + +@router.get("/game/{game_id}") +async def get_replay(game_id: str, user=Depends(get_current_user)): + """ + Get full replay for a game. + + Returns all frames with game state at each step. + Requires authentication and permission to view the game. + """ + if not _replay_service: + raise HTTPException(status_code=503, detail="Replay service unavailable") + + # Check permission + user_id = user.id if user else None + if not await _replay_service.can_view_game(user_id, game_id): + raise HTTPException(status_code=403, detail="Cannot view this game") + + try: + replay = await _replay_service.build_replay(game_id) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + return { + "game_id": replay.game_id, + "room_code": replay.room_code, + "frames": [ + { + "index": f.event_index, + "event_type": f.event_type, + "event_data": f.event_data, + "timestamp": f.timestamp, + "state": f.game_state, + "player_id": f.player_id, + } + for f in replay.frames + ], + "metadata": { + "players": replay.player_names, + "winner": replay.winner, + "final_scores": replay.final_scores, + "duration": replay.total_duration_seconds, + "total_rounds": replay.total_rounds, + "options": replay.options, + }, + } + + +@router.get("/game/{game_id}/frame/{frame_index}") +async def get_replay_frame(game_id: str, frame_index: int, user=Depends(get_current_user)): + """ + Get a specific frame from a replay. + + Useful for seeking without loading the entire replay. + """ + if not _replay_service: + raise HTTPException(status_code=503, detail="Replay service unavailable") + + user_id = user.id if user else None + if not await _replay_service.can_view_game(user_id, game_id): + raise HTTPException(status_code=403, detail="Cannot view this game") + + frame = await _replay_service.get_replay_frame(game_id, frame_index) + if not frame: + raise HTTPException(status_code=404, detail="Frame not found") + + return { + "index": frame.event_index, + "event_type": frame.event_type, + "event_data": frame.event_data, + "timestamp": frame.timestamp, + "state": frame.game_state, + "player_id": frame.player_id, + } + + +# ------------------------------------------------------------------------- +# Share Link Endpoints +# ------------------------------------------------------------------------- + +@router.post("/game/{game_id}/share") +async def create_share_link( + game_id: str, + request: ShareLinkRequest, + user=Depends(require_auth), +): + """ + Create shareable link for a game. + + Only users who played in the game can create share links. + """ + if not _replay_service: + raise HTTPException(status_code=503, detail="Replay service unavailable") + + # Validate expires_days + if request.expires_days is not None and (request.expires_days < 1 or request.expires_days > 365): + raise HTTPException(status_code=400, detail="expires_days must be between 1 and 365") + + # Check if user played in the game + if not await _replay_service.can_view_game(user.id, game_id): + raise HTTPException(status_code=403, detail="Can only share games you played in") + + try: + share_code = await _replay_service.create_share_link( + game_id=game_id, + user_id=user.id, + title=request.title, + description=request.description, + expires_days=request.expires_days, + ) + except Exception as e: + logger.error(f"Failed to create share link: {e}") + raise HTTPException(status_code=500, detail="Failed to create share link") + + return { + "share_code": share_code, + "share_url": f"/replay/{share_code}", + "expires_days": request.expires_days, + } + + +@router.get("/shared/{share_code}") +async def get_shared_replay(share_code: str): + """ + Get replay via share code (public endpoint). + + No authentication required for public share links. + """ + if not _replay_service: + raise HTTPException(status_code=503, detail="Replay service unavailable") + + shared = await _replay_service.get_shared_game(share_code) + if not shared: + raise HTTPException(status_code=404, detail="Shared game not found or expired") + + try: + replay = await _replay_service.build_replay(str(shared["game_id"])) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + return { + "title": shared.get("title"), + "description": shared.get("description"), + "view_count": shared["view_count"], + "created_at": shared["created_at"].isoformat() if shared.get("created_at") else None, + "game_id": str(shared["game_id"]), + "room_code": replay.room_code, + "frames": [ + { + "index": f.event_index, + "event_type": f.event_type, + "event_data": f.event_data, + "timestamp": f.timestamp, + "state": f.game_state, + "player_id": f.player_id, + } + for f in replay.frames + ], + "metadata": { + "players": replay.player_names, + "winner": replay.winner, + "final_scores": replay.final_scores, + "duration": replay.total_duration_seconds, + "total_rounds": replay.total_rounds, + "options": replay.options, + }, + } + + +@router.get("/shared/{share_code}/info") +async def get_shared_info(share_code: str): + """ + Get info about a shared game without full replay data. + + Useful for preview/metadata display. + """ + if not _replay_service: + raise HTTPException(status_code=503, detail="Replay service unavailable") + + shared = await _replay_service.get_shared_game(share_code) + if not shared: + raise HTTPException(status_code=404, detail="Shared game not found or expired") + + return { + "title": shared.get("title"), + "description": shared.get("description"), + "view_count": shared["view_count"], + "created_at": shared["created_at"].isoformat() if shared.get("created_at") else None, + "room_code": shared.get("room_code"), + "num_players": shared.get("num_players"), + "num_rounds": shared.get("num_rounds"), + } + + +@router.delete("/shared/{share_code}") +async def delete_share_link(share_code: str, user=Depends(require_auth)): + """Delete a share link (creator only).""" + if not _replay_service: + raise HTTPException(status_code=503, detail="Replay service unavailable") + + deleted = await _replay_service.delete_share_link(share_code, user.id) + if not deleted: + raise HTTPException(status_code=404, detail="Share link not found or not authorized") + + return {"deleted": True} + + +@router.get("/my-shares") +async def get_my_shares(user=Depends(require_auth)): + """Get all share links created by the current user.""" + if not _replay_service: + raise HTTPException(status_code=503, detail="Replay service unavailable") + + shares = await _replay_service.get_user_shared_games(user.id) + return { + "shares": [ + { + "share_code": s["share_code"], + "game_id": str(s["game_id"]), + "title": s.get("title"), + "view_count": s["view_count"], + "created_at": s["created_at"].isoformat() if s.get("created_at") else None, + "expires_at": s["expires_at"].isoformat() if s.get("expires_at") else None, + } + for s in shares + ], + } + + +# ------------------------------------------------------------------------- +# Export/Import Endpoints +# ------------------------------------------------------------------------- + +@router.get("/game/{game_id}/export") +async def export_game(game_id: str, user=Depends(require_auth)): + """ + Export game as downloadable JSON. + + Returns the complete game data suitable for backup or sharing. + """ + if not _replay_service: + raise HTTPException(status_code=503, detail="Replay service unavailable") + + if not await _replay_service.can_view_game(user.id, game_id): + raise HTTPException(status_code=403, detail="Cannot export this game") + + try: + export_data = await _replay_service.export_game(game_id) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + # Return as downloadable JSON + return JSONResponse( + content=export_data, + headers={ + "Content-Disposition": f'attachment; filename="golf-game-{game_id[:8]}.json"' + }, + ) + + +@router.post("/import") +async def import_game(request: ImportGameRequest, user=Depends(require_auth)): + """ + Import a game from JSON export. + + Creates a new game record from the exported data. + """ + if not _replay_service: + raise HTTPException(status_code=503, detail="Replay service unavailable") + + try: + new_game_id = await _replay_service.import_game(request.export_data, user.id) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"Import failed: {e}") + raise HTTPException(status_code=500, detail="Failed to import game") + + return { + "game_id": new_game_id, + "message": "Game imported successfully", + } + + +# ------------------------------------------------------------------------- +# Game History +# ------------------------------------------------------------------------- + +@router.get("/history") +async def get_game_history( + limit: int = Query(default=20, ge=1, le=100), + offset: int = Query(default=0, ge=0), + user=Depends(require_auth), +): + """Get game history for the current user.""" + if not _replay_service: + raise HTTPException(status_code=503, detail="Replay service unavailable") + + games = await _replay_service.get_user_game_history(user.id, limit, offset) + return { + "games": [ + { + "game_id": str(g["id"]), + "room_code": g["room_code"], + "status": g["status"], + "completed_at": g["completed_at"].isoformat() if g.get("completed_at") else None, + "num_players": g["num_players"], + "num_rounds": g["num_rounds"], + "won": g.get("winner_id") == user.id, + } + for g in games + ], + "limit": limit, + "offset": offset, + } + + +# ------------------------------------------------------------------------- +# Spectator Endpoints +# ------------------------------------------------------------------------- + +@router.websocket("/spectate/{room_code}") +async def spectate_game(websocket: WebSocket, room_code: str): + """ + WebSocket endpoint for spectating live games. + + Spectators receive real-time game state updates but cannot interact. + """ + await websocket.accept() + + if not _spectator_manager or not _room_manager: + await websocket.close(code=4003, reason="Spectator service unavailable") + return + + # Find the game by room code + room = _room_manager.get_room(room_code.upper()) + if not room: + await websocket.close(code=4004, reason="Game not found") + return + + game_id = room_code.upper() # Use room code as identifier for spectators + + # Add spectator + added = await _spectator_manager.add_spectator(game_id, websocket) + if not added: + await websocket.close(code=4005, reason="Spectator limit reached") + return + + try: + # Send initial game state + game_state = room.game.get_state(None) # No player perspective + await websocket.send_json({ + "type": "spectator_joined", + "game_state": game_state, + "spectator_count": _spectator_manager.get_spectator_count(game_id), + "players": room.player_list(), + }) + + # Keep connection alive + while True: + data = await websocket.receive_text() + if data == "ping": + await websocket.send_text("pong") + + except WebSocketDisconnect: + pass + except Exception as e: + logger.debug(f"Spectator connection error: {e}") + finally: + await _spectator_manager.remove_spectator(game_id, websocket) + + +@router.get("/spectate/{room_code}/count") +async def get_spectator_count(room_code: str): + """Get the number of spectators for a game.""" + if not _spectator_manager: + return {"count": 0} + + count = _spectator_manager.get_spectator_count(room_code.upper()) + return {"count": count} + + +@router.get("/spectate/active") +async def get_active_spectated_games(): + """Get list of games with active spectators.""" + if not _spectator_manager: + return {"games": []} + + games = _spectator_manager.get_games_with_spectators() + return { + "games": [ + {"room_code": game_id, "spectator_count": count} + for game_id, count in games.items() + ], + } diff --git a/server/routers/stats.py b/server/routers/stats.py new file mode 100644 index 0000000..caf49d6 --- /dev/null +++ b/server/routers/stats.py @@ -0,0 +1,385 @@ +""" +Stats and Leaderboards API router for Golf game. + +Provides public endpoints for viewing leaderboards and player stats, +and authenticated endpoints for viewing personal stats and achievements. +""" + +import logging +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Header, Query +from pydantic import BaseModel + +from models.user import User +from services.stats_service import StatsService + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/stats", tags=["stats"]) + + +# ============================================================================= +# Request/Response Models +# ============================================================================= + + +class LeaderboardEntryResponse(BaseModel): + """Single leaderboard entry.""" + rank: int + user_id: str + username: str + value: float + games_played: int + secondary_value: Optional[float] = None + + +class LeaderboardResponse(BaseModel): + """Leaderboard response.""" + metric: str + entries: list[LeaderboardEntryResponse] + total_players: Optional[int] = None + + +class PlayerStatsResponse(BaseModel): + """Player statistics response.""" + user_id: str + username: str + games_played: int + games_won: int + win_rate: float + rounds_played: int + rounds_won: int + avg_score: float + best_round_score: Optional[int] + worst_round_score: Optional[int] + knockouts: int + perfect_rounds: int + wolfpacks: int + current_win_streak: int + best_win_streak: int + first_game_at: Optional[str] + last_game_at: Optional[str] + achievements: list[str] + + +class PlayerRankResponse(BaseModel): + """Player rank response.""" + user_id: str + metric: str + rank: Optional[int] + qualified: bool # Whether player has enough games + + +class AchievementResponse(BaseModel): + """Achievement definition response.""" + id: str + name: str + description: str + icon: str + category: str + threshold: int + + +class UserAchievementResponse(BaseModel): + """User achievement response.""" + id: str + name: str + description: str + icon: str + earned_at: str + game_id: Optional[str] + + +# ============================================================================= +# Dependencies +# ============================================================================= + +# Set by main.py during startup +_stats_service: Optional[StatsService] = None + + +def set_stats_service(service: StatsService) -> None: + """Set the stats service instance (called from main.py).""" + global _stats_service + _stats_service = service + + +def get_stats_service_dep() -> StatsService: + """Dependency to get stats service.""" + if _stats_service is None: + raise HTTPException(status_code=503, detail="Stats service not initialized") + return _stats_service + + +# Auth dependencies - imported from auth router +_auth_service = None + + +def set_auth_service(service) -> None: + """Set auth service for user lookup.""" + global _auth_service + _auth_service = service + + +async def get_current_user_optional( + authorization: Optional[str] = Header(None), +) -> Optional[User]: + """Get current user from Authorization header (optional).""" + if not authorization or not _auth_service: + return None + + parts = authorization.split() + if len(parts) != 2 or parts[0].lower() != "bearer": + return None + + token = parts[1] + return await _auth_service.get_user_from_token(token) + + +async def require_user( + user: Optional[User] = Depends(get_current_user_optional), +) -> User: + """Require authenticated user.""" + if not user: + raise HTTPException(status_code=401, detail="Not authenticated") + if not user.is_active: + raise HTTPException(status_code=403, detail="Account disabled") + return user + + +# ============================================================================= +# Public Endpoints (No Auth Required) +# ============================================================================= + + +@router.get("/leaderboard", response_model=LeaderboardResponse) +async def get_leaderboard( + metric: str = Query("wins", pattern="^(wins|win_rate|avg_score|knockouts|streak)$"), + limit: int = Query(50, ge=1, le=100), + offset: int = Query(0, ge=0), + service: StatsService = Depends(get_stats_service_dep), +): + """ + Get leaderboard by metric. + + Metrics: + - wins: Total games won + - win_rate: Win percentage (requires 5+ games) + - avg_score: Average points per round (lower is better) + - knockouts: Times going out first + - streak: Best win streak + + Players must have 5+ games to appear on leaderboards. + """ + entries = await service.get_leaderboard(metric, limit, offset) + + return { + "metric": metric, + "entries": [ + { + "rank": e.rank, + "user_id": e.user_id, + "username": e.username, + "value": e.value, + "games_played": e.games_played, + "secondary_value": e.secondary_value, + } + for e in entries + ], + } + + +@router.get("/players/{user_id}", response_model=PlayerStatsResponse) +async def get_player_stats( + user_id: str, + service: StatsService = Depends(get_stats_service_dep), +): + """Get stats for a specific player (public profile).""" + stats = await service.get_player_stats(user_id) + + if not stats: + raise HTTPException(status_code=404, detail="Player not found") + + return { + "user_id": stats.user_id, + "username": stats.username, + "games_played": stats.games_played, + "games_won": stats.games_won, + "win_rate": stats.win_rate, + "rounds_played": stats.rounds_played, + "rounds_won": stats.rounds_won, + "avg_score": stats.avg_score, + "best_round_score": stats.best_round_score, + "worst_round_score": stats.worst_round_score, + "knockouts": stats.knockouts, + "perfect_rounds": stats.perfect_rounds, + "wolfpacks": stats.wolfpacks, + "current_win_streak": stats.current_win_streak, + "best_win_streak": stats.best_win_streak, + "first_game_at": stats.first_game_at.isoformat() if stats.first_game_at else None, + "last_game_at": stats.last_game_at.isoformat() if stats.last_game_at else None, + "achievements": stats.achievements, + } + + +@router.get("/players/{user_id}/rank", response_model=PlayerRankResponse) +async def get_player_rank( + user_id: str, + metric: str = Query("wins", pattern="^(wins|win_rate|avg_score|knockouts|streak)$"), + service: StatsService = Depends(get_stats_service_dep), +): + """Get player's rank on a leaderboard.""" + rank = await service.get_player_rank(user_id, metric) + + return { + "user_id": user_id, + "metric": metric, + "rank": rank, + "qualified": rank is not None, + } + + +@router.get("/achievements", response_model=dict) +async def get_achievements( + service: StatsService = Depends(get_stats_service_dep), +): + """Get all available achievements.""" + achievements = await service.get_achievements() + + return { + "achievements": [ + { + "id": a.id, + "name": a.name, + "description": a.description, + "icon": a.icon, + "category": a.category, + "threshold": a.threshold, + } + for a in achievements + ] + } + + +@router.get("/players/{user_id}/achievements", response_model=dict) +async def get_user_achievements( + user_id: str, + service: StatsService = Depends(get_stats_service_dep), +): + """Get achievements earned by a player.""" + achievements = await service.get_user_achievements(user_id) + + return { + "user_id": user_id, + "achievements": [ + { + "id": a.id, + "name": a.name, + "description": a.description, + "icon": a.icon, + "earned_at": a.earned_at.isoformat(), + "game_id": a.game_id, + } + for a in achievements + ], + } + + +# ============================================================================= +# Authenticated Endpoints +# ============================================================================= + + +@router.get("/me", response_model=PlayerStatsResponse) +async def get_my_stats( + user: User = Depends(require_user), + service: StatsService = Depends(get_stats_service_dep), +): + """Get current user's stats.""" + stats = await service.get_player_stats(user.id) + + if not stats: + # Return empty stats for new user + return { + "user_id": user.id, + "username": user.username, + "games_played": 0, + "games_won": 0, + "win_rate": 0.0, + "rounds_played": 0, + "rounds_won": 0, + "avg_score": 0.0, + "best_round_score": None, + "worst_round_score": None, + "knockouts": 0, + "perfect_rounds": 0, + "wolfpacks": 0, + "current_win_streak": 0, + "best_win_streak": 0, + "first_game_at": None, + "last_game_at": None, + "achievements": [], + } + + return { + "user_id": stats.user_id, + "username": stats.username, + "games_played": stats.games_played, + "games_won": stats.games_won, + "win_rate": stats.win_rate, + "rounds_played": stats.rounds_played, + "rounds_won": stats.rounds_won, + "avg_score": stats.avg_score, + "best_round_score": stats.best_round_score, + "worst_round_score": stats.worst_round_score, + "knockouts": stats.knockouts, + "perfect_rounds": stats.perfect_rounds, + "wolfpacks": stats.wolfpacks, + "current_win_streak": stats.current_win_streak, + "best_win_streak": stats.best_win_streak, + "first_game_at": stats.first_game_at.isoformat() if stats.first_game_at else None, + "last_game_at": stats.last_game_at.isoformat() if stats.last_game_at else None, + "achievements": stats.achievements, + } + + +@router.get("/me/rank", response_model=PlayerRankResponse) +async def get_my_rank( + metric: str = Query("wins", pattern="^(wins|win_rate|avg_score|knockouts|streak)$"), + user: User = Depends(require_user), + service: StatsService = Depends(get_stats_service_dep), +): + """Get current user's rank on a leaderboard.""" + rank = await service.get_player_rank(user.id, metric) + + return { + "user_id": user.id, + "metric": metric, + "rank": rank, + "qualified": rank is not None, + } + + +@router.get("/me/achievements", response_model=dict) +async def get_my_achievements( + user: User = Depends(require_user), + service: StatsService = Depends(get_stats_service_dep), +): + """Get current user's achievements.""" + achievements = await service.get_user_achievements(user.id) + + return { + "user_id": user.id, + "achievements": [ + { + "id": a.id, + "name": a.name, + "description": a.description, + "icon": a.icon, + "earned_at": a.earned_at.isoformat(), + "game_id": a.game_id, + } + for a in achievements + ], + } diff --git a/server/scripts/create_admin.py b/server/scripts/create_admin.py new file mode 100644 index 0000000..0dbc982 --- /dev/null +++ b/server/scripts/create_admin.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +""" +Create an admin user for the Golf game. + +Usage: + python scripts/create_admin.py [email] + +Example: + python scripts/create_admin.py admin secretpassword admin@example.com +""" + +import asyncio +import sys +import os + +# Add parent directory to path for imports +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from config import config +from stores.user_store import UserStore +from models.user import UserRole +import bcrypt + + +def hash_password(password: str) -> str: + """Hash a password using bcrypt.""" + salt = bcrypt.gensalt() + hashed = bcrypt.hashpw(password.encode(), salt) + return hashed.decode() + + +async def create_admin(username: str, password: str, email: str = None): + """Create an admin user.""" + if not config.POSTGRES_URL: + print("Error: POSTGRES_URL not configured in environment or .env file") + print("Make sure docker-compose is running and .env is set up") + sys.exit(1) + + print(f"Connecting to database...") + store = await UserStore.create(config.POSTGRES_URL) + + # Check if user already exists + existing = await store.get_user_by_username(username) + if existing: + print(f"User '{username}' already exists.") + if existing.role != UserRole.ADMIN: + # Upgrade to admin + print(f"Upgrading '{username}' to admin role...") + await store.update_user(existing.id, role=UserRole.ADMIN) + print(f"Done! User '{username}' is now an admin.") + else: + print(f"User '{username}' is already an admin.") + await store.close() + return + + # Create new admin user + print(f"Creating admin user '{username}'...") + password_hash = hash_password(password) + + user = await store.create_user( + username=username, + password_hash=password_hash, + email=email, + role=UserRole.ADMIN, + ) + + if user: + print(f"Admin user created successfully!") + print(f" Username: {user.username}") + print(f" Email: {user.email or '(none)'}") + print(f" Role: {user.role.value}") + print(f"\nYou can now login at /admin") + else: + print("Failed to create user (username or email may already exist)") + + await store.close() + + +def main(): + if len(sys.argv) < 3: + print(__doc__) + sys.exit(1) + + username = sys.argv[1] + password = sys.argv[2] + email = sys.argv[3] if len(sys.argv) > 3 else None + + if len(password) < 8: + print("Error: Password must be at least 8 characters") + sys.exit(1) + + asyncio.run(create_admin(username, password, email)) + + +if __name__ == "__main__": + main() diff --git a/server/services/__init__.py b/server/services/__init__.py new file mode 100644 index 0000000..c14daf2 --- /dev/null +++ b/server/services/__init__.py @@ -0,0 +1,33 @@ +"""Services package for Golf game V2 business logic.""" + +from .recovery_service import RecoveryService, RecoveryResult +from .email_service import EmailService, get_email_service +from .auth_service import AuthService, AuthResult, RegistrationResult, get_auth_service, close_auth_service +from .admin_service import ( + AdminService, + UserDetails, + AuditEntry, + SystemStats, + InviteCode, + get_admin_service, + close_admin_service, +) + +__all__ = [ + "RecoveryService", + "RecoveryResult", + "EmailService", + "get_email_service", + "AuthService", + "AuthResult", + "RegistrationResult", + "get_auth_service", + "close_auth_service", + "AdminService", + "UserDetails", + "AuditEntry", + "SystemStats", + "InviteCode", + "get_admin_service", + "close_admin_service", +] diff --git a/server/services/admin_service.py b/server/services/admin_service.py new file mode 100644 index 0000000..4cb3d54 --- /dev/null +++ b/server/services/admin_service.py @@ -0,0 +1,1243 @@ +""" +Admin service for Golf game. + +Provides admin capabilities: user management, game moderation, +system monitoring, audit logging, and invite code management. +""" + +import json +import logging +import secrets +from dataclasses import dataclass +from datetime import datetime, timezone, timedelta +from typing import Optional, List + +import asyncpg + +from models.user import User, UserRole +from stores.user_store import UserStore + +logger = logging.getLogger(__name__) + + +@dataclass +class UserDetails: + """Extended user info for admin view.""" + id: str + username: str + email: Optional[str] + role: str + email_verified: bool + is_banned: bool + ban_reason: Optional[str] + force_password_reset: bool + created_at: datetime + last_login: Optional[datetime] + last_seen_at: Optional[datetime] + is_active: bool + games_played: int + games_won: int + + def to_dict(self) -> dict: + return { + "id": self.id, + "username": self.username, + "email": self.email, + "role": self.role, + "email_verified": self.email_verified, + "is_banned": self.is_banned, + "ban_reason": self.ban_reason, + "force_password_reset": self.force_password_reset, + "created_at": self.created_at.isoformat() if self.created_at else None, + "last_login": self.last_login.isoformat() if self.last_login else None, + "last_seen_at": self.last_seen_at.isoformat() if self.last_seen_at else None, + "is_active": self.is_active, + "games_played": self.games_played, + "games_won": self.games_won, + } + + +@dataclass +class AuditEntry: + """Admin audit log entry.""" + id: int + admin_username: str + admin_user_id: str + action: str + target_type: Optional[str] + target_id: Optional[str] + details: dict + ip_address: Optional[str] + created_at: datetime + + def to_dict(self) -> dict: + return { + "id": self.id, + "admin_username": self.admin_username, + "admin_user_id": self.admin_user_id, + "action": self.action, + "target_type": self.target_type, + "target_id": self.target_id, + "details": self.details, + "ip_address": self.ip_address, + "created_at": self.created_at.isoformat() if self.created_at else None, + } + + +@dataclass +class SystemStats: + """System statistics snapshot.""" + active_users_now: int + active_games_now: int + total_users: int + total_games_completed: int + registrations_today: int + registrations_week: int + games_today: int + events_last_hour: int + top_players: List[dict] + + def to_dict(self) -> dict: + return { + "active_users_now": self.active_users_now, + "active_games_now": self.active_games_now, + "total_users": self.total_users, + "total_games_completed": self.total_games_completed, + "registrations_today": self.registrations_today, + "registrations_week": self.registrations_week, + "games_today": self.games_today, + "events_last_hour": self.events_last_hour, + "top_players": self.top_players, + } + + +@dataclass +class InviteCode: + """Invite code details.""" + code: str + created_by: str + created_by_username: str + created_at: datetime + expires_at: datetime + max_uses: int + use_count: int + is_active: bool + + def to_dict(self) -> dict: + return { + "code": self.code, + "created_by": self.created_by, + "created_by_username": self.created_by_username, + "created_at": self.created_at.isoformat() if self.created_at else None, + "expires_at": self.expires_at.isoformat() if self.expires_at else None, + "max_uses": self.max_uses, + "use_count": self.use_count, + "is_active": self.is_active, + "remaining_uses": max(0, self.max_uses - self.use_count), + } + + +class AdminService: + """ + Admin operations and moderation service. + + Provides methods for: + - Audit logging + - User management (search, ban, unban, force password reset) + - Game moderation (view active games, end stuck games) + - System statistics + - Invite code management + - User impersonation (read-only) + """ + + def __init__(self, pool: asyncpg.Pool, user_store: UserStore, state_cache=None): + """ + Initialize admin service. + + Args: + pool: asyncpg connection pool. + user_store: User persistence store. + state_cache: Optional Redis state cache for game operations. + """ + self.pool = pool + self.user_store = user_store + self.state_cache = state_cache + + # ------------------------------------------------------------------------- + # Audit Logging + # ------------------------------------------------------------------------- + + async def audit( + self, + admin_id: str, + action: str, + target_type: Optional[str] = None, + target_id: Optional[str] = None, + details: Optional[dict] = None, + ip_address: Optional[str] = None, + ) -> int: + """ + Log an admin action. + + Args: + admin_id: Admin user ID. + action: Action name (e.g., "ban_user", "end_game"). + target_type: Type of target (e.g., "user", "game", "invite_code"). + target_id: ID of the target. + details: Additional details as JSON. + ip_address: Admin's IP address. + + Returns: + Audit log entry ID. + """ + async with self.pool.acquire() as conn: + row = await conn.fetchrow( + """ + INSERT INTO admin_audit_log + (admin_user_id, action, target_type, target_id, details, ip_address) + VALUES ($1, $2, $3, $4, $5, $6::inet) + RETURNING id + """, + admin_id, + action, + target_type, + target_id, + json.dumps(details or {}), + ip_address, + ) + return row["id"] + + async def get_audit_log( + self, + limit: int = 100, + offset: int = 0, + admin_id: Optional[str] = None, + action: Optional[str] = None, + target_type: Optional[str] = None, + ) -> List[AuditEntry]: + """ + Get audit log entries with optional filtering. + + Args: + limit: Maximum number of entries to return. + offset: Number of entries to skip. + admin_id: Filter by admin user ID. + action: Filter by action name. + target_type: Filter by target type. + + Returns: + List of audit entries. + """ + async with self.pool.acquire() as conn: + query = """ + SELECT a.id, u.username as admin_username, a.admin_user_id, + a.action, a.target_type, a.target_id, a.details, + a.ip_address, a.created_at + FROM admin_audit_log a + JOIN users_v2 u ON a.admin_user_id = u.id + WHERE 1=1 + """ + params = [] + param_num = 1 + + if admin_id: + query += f" AND a.admin_user_id = ${param_num}" + params.append(admin_id) + param_num += 1 + + if action: + query += f" AND a.action = ${param_num}" + params.append(action) + param_num += 1 + + if target_type: + query += f" AND a.target_type = ${param_num}" + params.append(target_type) + param_num += 1 + + query += f" ORDER BY a.created_at DESC LIMIT ${param_num} OFFSET ${param_num + 1}" + params.extend([limit, offset]) + + rows = await conn.fetch(query, *params) + + return [ + AuditEntry( + id=row["id"], + admin_username=row["admin_username"], + admin_user_id=str(row["admin_user_id"]), + action=row["action"], + target_type=row["target_type"], + target_id=row["target_id"], + details=json.loads(row["details"]) if row["details"] else {}, + ip_address=str(row["ip_address"]) if row["ip_address"] else None, + created_at=row["created_at"].replace(tzinfo=timezone.utc) if row["created_at"] else None, + ) + for row in rows + ] + + # ------------------------------------------------------------------------- + # User Management + # ------------------------------------------------------------------------- + + async def search_users( + self, + query: str = "", + limit: int = 50, + offset: int = 0, + include_banned: bool = True, + include_deleted: bool = False, + ) -> List[UserDetails]: + """ + Search users by username or email. + + Args: + query: Search query (matches username or email). + limit: Maximum number of results. + offset: Number of results to skip. + include_banned: Include banned users. + include_deleted: Include soft-deleted users. + + Returns: + List of user details. + """ + async with self.pool.acquire() as conn: + sql = """ + SELECT u.id, u.username, u.email, u.role, + u.email_verified, u.is_banned, u.ban_reason, + u.force_password_reset, u.created_at, u.last_login, + u.last_seen_at, u.is_active, + COALESCE(s.games_played, 0) as games_played, + COALESCE(s.games_won, 0) as games_won + FROM users_v2 u + LEFT JOIN player_stats s ON u.id = s.user_id + WHERE 1=1 + """ + params = [] + param_num = 1 + + if query: + sql += f" AND (u.username ILIKE ${param_num} OR u.email ILIKE ${param_num})" + params.append(f"%{query}%") + param_num += 1 + + if not include_banned: + sql += " AND (u.is_banned = false OR u.is_banned IS NULL)" + + if not include_deleted: + sql += " AND u.deleted_at IS NULL" + + sql += f" ORDER BY u.created_at DESC LIMIT ${param_num} OFFSET ${param_num + 1}" + params.extend([limit, offset]) + + rows = await conn.fetch(sql, *params) + + return [ + UserDetails( + id=str(row["id"]), + username=row["username"], + email=row["email"], + role=row["role"], + email_verified=row["email_verified"], + is_banned=row["is_banned"] or False, + ban_reason=row["ban_reason"], + force_password_reset=row["force_password_reset"] or False, + created_at=row["created_at"].replace(tzinfo=timezone.utc) if row["created_at"] else None, + last_login=row["last_login"].replace(tzinfo=timezone.utc) if row["last_login"] else None, + last_seen_at=row["last_seen_at"].replace(tzinfo=timezone.utc) if row["last_seen_at"] else None, + is_active=row["is_active"], + games_played=row["games_played"] or 0, + games_won=row["games_won"] or 0, + ) + for row in rows + ] + + async def get_user(self, user_id: str) -> Optional[UserDetails]: + """ + Get detailed user info by ID. + + Args: + user_id: User UUID. + + Returns: + User details, or None if not found. + """ + async with self.pool.acquire() as conn: + row = await conn.fetchrow( + """ + SELECT u.id, u.username, u.email, u.role, + u.email_verified, u.is_banned, u.ban_reason, + u.force_password_reset, u.created_at, u.last_login, + u.last_seen_at, u.is_active, + COALESCE(s.games_played, 0) as games_played, + COALESCE(s.games_won, 0) as games_won + FROM users_v2 u + LEFT JOIN player_stats s ON u.id = s.user_id + WHERE u.id = $1 + """, + user_id, + ) + + if not row: + return None + + return UserDetails( + id=str(row["id"]), + username=row["username"], + email=row["email"], + role=row["role"], + email_verified=row["email_verified"], + is_banned=row["is_banned"] or False, + ban_reason=row["ban_reason"], + force_password_reset=row["force_password_reset"] or False, + created_at=row["created_at"].replace(tzinfo=timezone.utc) if row["created_at"] else None, + last_login=row["last_login"].replace(tzinfo=timezone.utc) if row["last_login"] else None, + last_seen_at=row["last_seen_at"].replace(tzinfo=timezone.utc) if row["last_seen_at"] else None, + is_active=row["is_active"], + games_played=row["games_played"] or 0, + games_won=row["games_won"] or 0, + ) + + async def ban_user( + self, + admin_id: str, + user_id: str, + reason: str, + duration_days: Optional[int] = None, + ip_address: Optional[str] = None, + ) -> bool: + """ + Ban a user. + + Args: + admin_id: Admin performing the ban. + user_id: User to ban. + reason: Reason for ban. + duration_days: Optional ban duration (None = permanent). + ip_address: Admin's IP address for audit. + + Returns: + True if ban was successful. + """ + expires_at = None + if duration_days: + expires_at = datetime.now(timezone.utc) + timedelta(days=duration_days) + + async with self.pool.acquire() as conn: + # Check user exists and isn't admin + user = await conn.fetchrow( + "SELECT role FROM users_v2 WHERE id = $1", + user_id, + ) + + if not user: + return False + + if user["role"] == "admin": + logger.warning(f"Admin {admin_id} attempted to ban admin {user_id}") + return False # Can't ban admins + + # Create ban record + await conn.execute( + """ + INSERT INTO user_bans (user_id, banned_by, reason, expires_at) + VALUES ($1, $2, $3, $4) + """, + user_id, + admin_id, + reason, + expires_at, + ) + + # Update user + await conn.execute( + """ + UPDATE users_v2 + SET is_banned = true, ban_reason = $1 + WHERE id = $2 + """, + reason, + user_id, + ) + + # Revoke all sessions + await conn.execute( + """ + UPDATE user_sessions + SET revoked_at = NOW() + WHERE user_id = $1 AND revoked_at IS NULL + """, + user_id, + ) + + # Kick from any active games (if state cache available) + if self.state_cache: + await self._kick_from_games(user_id) + + # Audit log + await self.audit( + admin_id, + "ban_user", + "user", + user_id, + {"reason": reason, "duration_days": duration_days}, + ip_address, + ) + + logger.info(f"Admin {admin_id} banned user {user_id}: {reason}") + return True + + async def unban_user( + self, + admin_id: str, + user_id: str, + ip_address: Optional[str] = None, + ) -> bool: + """ + Unban a user. + + Args: + admin_id: Admin performing the unban. + user_id: User to unban. + ip_address: Admin's IP address for audit. + + Returns: + True if unban was successful. + """ + async with self.pool.acquire() as conn: + # Update ban record + await conn.execute( + """ + UPDATE user_bans + SET unbanned_at = NOW(), unbanned_by = $1 + WHERE user_id = $2 AND unbanned_at IS NULL + """, + admin_id, + user_id, + ) + + # Update user + result = await conn.execute( + """ + UPDATE users_v2 + SET is_banned = false, ban_reason = NULL + WHERE id = $1 + """, + user_id, + ) + + if result == "UPDATE 0": + return False + + await self.audit( + admin_id, + "unban_user", + "user", + user_id, + ip_address=ip_address, + ) + + logger.info(f"Admin {admin_id} unbanned user {user_id}") + return True + + async def force_password_reset( + self, + admin_id: str, + user_id: str, + ip_address: Optional[str] = None, + ) -> bool: + """ + Force user to reset password on next login. + + Args: + admin_id: Admin performing the action. + user_id: User to force reset. + ip_address: Admin's IP address for audit. + + Returns: + True if successful. + """ + async with self.pool.acquire() as conn: + result = await conn.execute( + """ + UPDATE users_v2 + SET force_password_reset = true + WHERE id = $1 + """, + user_id, + ) + + if result == "UPDATE 0": + return False + + # Revoke all sessions to force re-login + await conn.execute( + """ + UPDATE user_sessions + SET revoked_at = NOW() + WHERE user_id = $1 AND revoked_at IS NULL + """, + user_id, + ) + + await self.audit( + admin_id, + "force_password_reset", + "user", + user_id, + ip_address=ip_address, + ) + + logger.info(f"Admin {admin_id} forced password reset for user {user_id}") + return True + + async def change_user_role( + self, + admin_id: str, + user_id: str, + new_role: str, + ip_address: Optional[str] = None, + ) -> bool: + """ + Change user role. + + Args: + admin_id: Admin performing the action. + user_id: User to modify. + new_role: New role ("user" or "admin"). + ip_address: Admin's IP address for audit. + + Returns: + True if successful. + """ + if new_role not in ("user", "admin"): + return False + + async with self.pool.acquire() as conn: + # Get old role for audit + old = await conn.fetchrow( + "SELECT role FROM users_v2 WHERE id = $1", + user_id, + ) + + if not old: + return False + + await conn.execute( + "UPDATE users_v2 SET role = $1 WHERE id = $2", + new_role, + user_id, + ) + + await self.audit( + admin_id, + "change_role", + "user", + user_id, + {"old_role": old["role"], "new_role": new_role}, + ip_address, + ) + + logger.info(f"Admin {admin_id} changed role for user {user_id}: {old['role']} -> {new_role}") + return True + + async def get_user_ban_history(self, user_id: str) -> List[dict]: + """ + Get ban history for a user. + + Args: + user_id: User UUID. + + Returns: + List of ban records. + """ + async with self.pool.acquire() as conn: + rows = await conn.fetch( + """ + SELECT b.id, b.reason, b.banned_at, b.expires_at, + b.unbanned_at, u1.username as banned_by_username, + u2.username as unbanned_by_username + FROM user_bans b + JOIN users_v2 u1 ON b.banned_by = u1.id + LEFT JOIN users_v2 u2 ON b.unbanned_by = u2.id + WHERE b.user_id = $1 + ORDER BY b.banned_at DESC + """, + user_id, + ) + + return [ + { + "id": row["id"], + "reason": row["reason"], + "banned_at": row["banned_at"].isoformat() if row["banned_at"] else None, + "expires_at": row["expires_at"].isoformat() if row["expires_at"] else None, + "unbanned_at": row["unbanned_at"].isoformat() if row["unbanned_at"] else None, + "banned_by": row["banned_by_username"], + "unbanned_by": row["unbanned_by_username"], + } + for row in rows + ] + + # ------------------------------------------------------------------------- + # User Impersonation (Read-Only) + # ------------------------------------------------------------------------- + + async def impersonate_user( + self, + admin_id: str, + user_id: str, + ip_address: Optional[str] = None, + ) -> Optional[User]: + """ + Get user object for read-only impersonation. + + This allows an admin to view the app as another user would see it, + without being able to make changes. The returned User object should + only be used for read operations. + + Args: + admin_id: Admin performing impersonation. + user_id: User to impersonate. + ip_address: Admin's IP address for audit. + + Returns: + User object for impersonation, or None if user not found. + """ + user = await self.user_store.get_user_by_id(user_id) + + if user: + await self.audit( + admin_id, + "impersonate_user", + "user", + user_id, + ip_address=ip_address, + ) + logger.info(f"Admin {admin_id} started impersonating user {user_id}") + + return user + + # ------------------------------------------------------------------------- + # Game Moderation + # ------------------------------------------------------------------------- + + async def get_active_games(self) -> List[dict]: + """ + Get all active games. + + Returns: + List of active game info dicts. + """ + if not self.state_cache: + # Fall back to database + async with self.pool.acquire() as conn: + rows = await conn.fetch( + """ + SELECT id, room_code, status, created_at, started_at, + num_players, num_rounds, host_id + FROM games_v2 + WHERE status = 'active' + ORDER BY created_at DESC + """ + ) + return [ + { + "game_id": str(row["id"]), + "room_code": row["room_code"], + "status": row["status"], + "created_at": row["created_at"].isoformat() if row["created_at"] else None, + "started_at": row["started_at"].isoformat() if row["started_at"] else None, + "player_count": row["num_players"] or 0, + "num_rounds": row["num_rounds"] or 0, + "host_id": row["host_id"], + } + for row in rows + ] + + # Use Redis state cache for live data + rooms = await self.state_cache.get_active_rooms() + games = [] + + for room_code in rooms: + room = await self.state_cache.get_room(room_code) + if room: + game_id = room.get("game_id") + state = None + if game_id: + state = await self.state_cache.get_game_state(game_id) + + players = await self.state_cache.get_room_players(room_code) + + games.append({ + "room_code": room_code, + "game_id": game_id, + "status": room.get("status"), + "created_at": room.get("created_at"), + "player_count": len(players), + "phase": state.get("phase") if state else None, + "current_round": state.get("current_round") if state else None, + }) + + return games + + async def get_game_details( + self, + admin_id: str, + game_id: str, + ip_address: Optional[str] = None, + ) -> Optional[dict]: + """ + Get full game state (admin view). + + Args: + admin_id: Admin requesting the view. + game_id: Game UUID. + ip_address: Admin's IP address for audit. + + Returns: + Full game state dict, or None if not found. + """ + state = None + + if self.state_cache: + state = await self.state_cache.get_game_state(game_id) + + if not state: + # Try database + async with self.pool.acquire() as conn: + row = await conn.fetchrow( + """ + SELECT id, room_code, status, created_at, started_at, + completed_at, num_players, num_rounds, options, + winner_id, host_id, player_ids + FROM games_v2 + WHERE id = $1 + """, + game_id, + ) + if row: + state = { + "game_id": str(row["id"]), + "room_code": row["room_code"], + "status": row["status"], + "created_at": row["created_at"].isoformat() if row["created_at"] else None, + "started_at": row["started_at"].isoformat() if row["started_at"] else None, + "completed_at": row["completed_at"].isoformat() if row["completed_at"] else None, + "num_players": row["num_players"], + "num_rounds": row["num_rounds"], + "options": json.loads(row["options"]) if row["options"] else {}, + "winner_id": row["winner_id"], + "host_id": row["host_id"], + "player_ids": row["player_ids"] or [], + } + + if state: + await self.audit( + admin_id, + "view_game", + "game", + game_id, + ip_address=ip_address, + ) + + return state + + async def end_game( + self, + admin_id: str, + game_id: str, + reason: str, + ip_address: Optional[str] = None, + ) -> bool: + """ + Force-end a stuck game. + + Args: + admin_id: Admin ending the game. + game_id: Game UUID. + reason: Reason for ending. + ip_address: Admin's IP address for audit. + + Returns: + True if game was ended. + """ + room_code = None + + if self.state_cache: + state = await self.state_cache.get_game_state(game_id) + if state: + room_code = state.get("room_code") + # Mark game as ended in cache + state["phase"] = "game_over" + state["admin_ended"] = True + state["admin_end_reason"] = reason + await self.state_cache.save_game_state(game_id, state) + + # Update database + async with self.pool.acquire() as conn: + result = await conn.execute( + """ + UPDATE games_v2 + SET status = 'abandoned', completed_at = NOW() + WHERE id = $1 AND status = 'active' + """, + game_id, + ) + + if result == "UPDATE 0" and not room_code: + return False + + # Get room code if we didn't have it + if not room_code: + row = await conn.fetchrow( + "SELECT room_code FROM games_v2 WHERE id = $1", + game_id, + ) + if row: + room_code = row["room_code"] + + await self.audit( + admin_id, + "end_game", + "game", + game_id, + {"reason": reason, "room_code": room_code}, + ip_address, + ) + + logger.info(f"Admin {admin_id} ended game {game_id}: {reason}") + return True + + async def _kick_from_games(self, user_id: str) -> None: + """ + Kick user from any active games. + + Args: + user_id: User to kick. + """ + if not self.state_cache: + return + + player_room = await self.state_cache.get_player_room(user_id) + if player_room: + await self.state_cache.remove_player_from_room(player_room, user_id) + logger.info(f"Kicked user {user_id} from room {player_room}") + + # ------------------------------------------------------------------------- + # System Stats + # ------------------------------------------------------------------------- + + async def get_system_stats(self) -> SystemStats: + """ + Get current system statistics. + + Returns: + SystemStats snapshot. + """ + # Active games from Redis + active_games = 0 + if self.state_cache: + active_rooms = await self.state_cache.get_active_rooms() + active_games = len(active_rooms) + + async with self.pool.acquire() as conn: + # Total users + total_users = await conn.fetchval( + "SELECT COUNT(*) FROM users_v2 WHERE deleted_at IS NULL" + ) + + # Total completed games + total_games = await conn.fetchval( + "SELECT COUNT(*) FROM games_v2 WHERE status = 'completed'" + ) + + # Registrations today + reg_today = await conn.fetchval( + """ + SELECT COUNT(*) FROM users_v2 + WHERE created_at >= CURRENT_DATE + AND deleted_at IS NULL + """ + ) + + # Registrations this week + reg_week = await conn.fetchval( + """ + SELECT COUNT(*) FROM users_v2 + WHERE created_at >= CURRENT_DATE - INTERVAL '7 days' + AND deleted_at IS NULL + """ + ) + + # Games today + games_today = await conn.fetchval( + "SELECT COUNT(*) FROM games_v2 WHERE created_at >= CURRENT_DATE" + ) + + # Events last hour + events_hour = await conn.fetchval( + """ + SELECT COUNT(*) FROM events + WHERE created_at >= NOW() - INTERVAL '1 hour' + """ + ) or 0 + + # Top players (by wins) + top_players = await conn.fetch( + """ + SELECT u.username, s.games_won, s.games_played + FROM player_stats s + JOIN users_v2 u ON s.user_id = u.id + WHERE s.games_played >= 3 + ORDER BY s.games_won DESC + LIMIT 10 + """ + ) + + # Active users (sessions used in last hour) + active_users = await conn.fetchval( + """ + SELECT COUNT(DISTINCT user_id) + FROM user_sessions + WHERE last_used_at >= NOW() - INTERVAL '1 hour' + AND revoked_at IS NULL + """ + ) + + return SystemStats( + active_users_now=active_users or 0, + active_games_now=active_games, + total_users=total_users or 0, + total_games_completed=total_games or 0, + registrations_today=reg_today or 0, + registrations_week=reg_week or 0, + games_today=games_today or 0, + events_last_hour=events_hour, + top_players=[ + { + "username": p["username"], + "games_won": p["games_won"], + "games_played": p["games_played"], + } + for p in top_players + ], + ) + + # ------------------------------------------------------------------------- + # Invite Codes + # ------------------------------------------------------------------------- + + async def create_invite_code( + self, + admin_id: str, + max_uses: int = 1, + expires_days: int = 7, + ip_address: Optional[str] = None, + ) -> str: + """ + Create a new invite code. + + Args: + admin_id: Admin creating the code. + max_uses: Maximum number of uses. + expires_days: Days until expiration. + ip_address: Admin's IP address for audit. + + Returns: + The generated invite code. + """ + code = secrets.token_urlsafe(6).upper()[:8] + expires_at = datetime.now(timezone.utc) + timedelta(days=expires_days) + + async with self.pool.acquire() as conn: + await conn.execute( + """ + INSERT INTO invite_codes (code, created_by, expires_at, max_uses) + VALUES ($1, $2, $3, $4) + """, + code, + admin_id, + expires_at, + max_uses, + ) + + await self.audit( + admin_id, + "create_invite", + "invite_code", + code, + {"max_uses": max_uses, "expires_days": expires_days}, + ip_address, + ) + + logger.info(f"Admin {admin_id} created invite code {code}") + return code + + async def get_invite_codes(self, include_expired: bool = False) -> List[InviteCode]: + """ + Get all invite codes. + + Args: + include_expired: Include expired/inactive codes. + + Returns: + List of invite codes. + """ + async with self.pool.acquire() as conn: + query = """ + SELECT c.code, c.created_by, c.created_at, c.expires_at, + c.max_uses, c.use_count, c.is_active, + u.username as created_by_username + FROM invite_codes c + JOIN users_v2 u ON c.created_by = u.id + """ + if not include_expired: + query += " WHERE c.expires_at > NOW() AND c.is_active = true" + + query += " ORDER BY c.created_at DESC" + + rows = await conn.fetch(query) + + return [ + InviteCode( + code=row["code"], + created_by=str(row["created_by"]), + created_by_username=row["created_by_username"], + created_at=row["created_at"].replace(tzinfo=timezone.utc) if row["created_at"] else None, + expires_at=row["expires_at"].replace(tzinfo=timezone.utc) if row["expires_at"] else None, + max_uses=row["max_uses"], + use_count=row["use_count"], + is_active=row["is_active"], + ) + for row in rows + ] + + async def revoke_invite_code( + self, + admin_id: str, + code: str, + ip_address: Optional[str] = None, + ) -> bool: + """ + Revoke an invite code. + + Args: + admin_id: Admin revoking the code. + code: Code to revoke. + ip_address: Admin's IP address for audit. + + Returns: + True if code was revoked. + """ + async with self.pool.acquire() as conn: + result = await conn.execute( + "UPDATE invite_codes SET is_active = false WHERE code = $1", + code, + ) + + if result == "UPDATE 0": + return False + + await self.audit( + admin_id, + "revoke_invite", + "invite_code", + code, + ip_address=ip_address, + ) + + logger.info(f"Admin {admin_id} revoked invite code {code}") + return True + + async def validate_invite_code(self, code: str) -> bool: + """ + Check if an invite code is valid. + + Args: + code: Code to validate. + + Returns: + True if code is valid and has remaining uses. + """ + async with self.pool.acquire() as conn: + row = await conn.fetchrow( + """ + SELECT max_uses, use_count, expires_at, is_active + FROM invite_codes + WHERE code = $1 + """, + code, + ) + + if not row: + return False + + if not row["is_active"]: + return False + + if row["expires_at"] and row["expires_at"] < datetime.now(timezone.utc): + return False + + if row["use_count"] >= row["max_uses"]: + return False + + return True + + async def use_invite_code(self, code: str) -> bool: + """ + Use an invite code (increment use count). + + Args: + code: Code to use. + + Returns: + True if code was successfully used. + """ + if not await self.validate_invite_code(code): + return False + + async with self.pool.acquire() as conn: + result = await conn.execute( + """ + UPDATE invite_codes + SET use_count = use_count + 1 + WHERE code = $1 AND is_active = true + AND use_count < max_uses + AND expires_at > NOW() + """, + code, + ) + + return result != "UPDATE 0" + + +# Global admin service instance +_admin_service: Optional[AdminService] = None + + +async def get_admin_service( + pool: asyncpg.Pool, + user_store: UserStore, + state_cache=None, +) -> AdminService: + """ + Get or create the global admin service instance. + + Args: + pool: asyncpg connection pool. + user_store: User persistence store. + state_cache: Optional Redis state cache. + + Returns: + AdminService instance. + """ + global _admin_service + if _admin_service is None: + _admin_service = AdminService(pool, user_store, state_cache) + return _admin_service + + +def close_admin_service() -> None: + """Close the global admin service.""" + global _admin_service + _admin_service = None diff --git a/server/services/auth_service.py b/server/services/auth_service.py new file mode 100644 index 0000000..028652d --- /dev/null +++ b/server/services/auth_service.py @@ -0,0 +1,654 @@ +""" +Authentication service for Golf game. + +Provides business logic for user registration, login, password management, +and session handling. +""" + +import logging +import secrets +from dataclasses import dataclass +from datetime import datetime, timezone, timedelta +from typing import Optional + +import bcrypt + +from config import config +from models.user import User, UserRole, UserSession, GuestSession +from stores.user_store import UserStore +from services.email_service import EmailService + +logger = logging.getLogger(__name__) + + +@dataclass +class AuthResult: + """Result of an authentication operation.""" + success: bool + user: Optional[User] = None + token: Optional[str] = None + expires_at: Optional[datetime] = None + error: Optional[str] = None + + +@dataclass +class RegistrationResult: + """Result of a registration operation.""" + success: bool + user: Optional[User] = None + requires_verification: bool = False + error: Optional[str] = None + + +class AuthService: + """ + Authentication service. + + Handles all authentication business logic: + - User registration with optional email verification + - Login/logout with session management + - Password reset flow + - Guest-to-user conversion + - Account deletion (soft delete) + """ + + def __init__( + self, + user_store: UserStore, + email_service: EmailService, + session_expiry_hours: int = 168, + require_email_verification: bool = False, + ): + """ + Initialize auth service. + + Args: + user_store: User persistence store. + email_service: Email sending service. + session_expiry_hours: Session lifetime in hours. + require_email_verification: Whether to require email verification. + """ + self.user_store = user_store + self.email_service = email_service + self.session_expiry_hours = session_expiry_hours + self.require_email_verification = require_email_verification + + @classmethod + async def create(cls, user_store: UserStore) -> "AuthService": + """ + Create AuthService from config. + + Args: + user_store: User persistence store. + """ + from services.email_service import get_email_service + + return cls( + user_store=user_store, + email_service=get_email_service(), + session_expiry_hours=config.SESSION_EXPIRY_HOURS, + require_email_verification=config.REQUIRE_EMAIL_VERIFICATION, + ) + + # ------------------------------------------------------------------------- + # Registration + # ------------------------------------------------------------------------- + + async def register( + self, + username: str, + password: str, + email: Optional[str] = None, + guest_id: Optional[str] = None, + ) -> RegistrationResult: + """ + Register a new user account. + + Args: + username: Desired username. + password: Plain text password. + email: Optional email address. + guest_id: Guest session ID if converting. + + Returns: + RegistrationResult with user or error. + """ + # Validate inputs + if len(username) < 2 or len(username) > 50: + return RegistrationResult(success=False, error="Username must be 2-50 characters") + + if len(password) < 8: + return RegistrationResult(success=False, error="Password must be at least 8 characters") + + # Check for existing username + existing = await self.user_store.get_user_by_username(username) + if existing: + return RegistrationResult(success=False, error="Username already taken") + + # Check for existing email + if email: + existing = await self.user_store.get_user_by_email(email) + if existing: + return RegistrationResult(success=False, error="Email already registered") + + # Hash password + password_hash = self._hash_password(password) + + # Generate verification token if needed + verification_token = None + verification_expires = None + if email and self.require_email_verification: + verification_token = secrets.token_urlsafe(32) + verification_expires = datetime.now(timezone.utc) + timedelta(hours=24) + + # Create user + user = await self.user_store.create_user( + username=username, + password_hash=password_hash, + email=email, + role=UserRole.USER, + guest_id=guest_id, + verification_token=verification_token, + verification_expires=verification_expires, + ) + + if not user: + return RegistrationResult(success=False, error="Failed to create account") + + # Mark guest as converted if applicable + if guest_id: + await self.user_store.mark_guest_converted(guest_id, user.id) + + # Send verification email if needed + requires_verification = False + if email and self.require_email_verification and verification_token: + await self.email_service.send_verification_email( + to=email, + token=verification_token, + username=username, + ) + await self.user_store.log_email(user.id, "verification", email) + requires_verification = True + + return RegistrationResult( + success=True, + user=user, + requires_verification=requires_verification, + ) + + async def verify_email(self, token: str) -> AuthResult: + """ + Verify email with token. + + Args: + token: Verification token from email. + + Returns: + AuthResult with success status. + """ + user = await self.user_store.get_user_by_verification_token(token) + if not user: + return AuthResult(success=False, error="Invalid verification token") + + # Check expiration + if user.verification_expires and user.verification_expires < datetime.now(timezone.utc): + return AuthResult(success=False, error="Verification token expired") + + # Mark as verified + await self.user_store.clear_verification_token(user.id) + + # Refresh user + user = await self.user_store.get_user_by_id(user.id) + + return AuthResult(success=True, user=user) + + async def resend_verification(self, email: str) -> bool: + """ + Resend verification email. + + Args: + email: Email address to send to. + + Returns: + True if email was sent. + """ + user = await self.user_store.get_user_by_email(email) + if not user or user.email_verified: + return False + + # Generate new token + verification_token = secrets.token_urlsafe(32) + verification_expires = datetime.now(timezone.utc) + timedelta(hours=24) + + await self.user_store.update_user( + user.id, + verification_token=verification_token, + verification_expires=verification_expires, + ) + + await self.email_service.send_verification_email( + to=email, + token=verification_token, + username=user.username, + ) + await self.user_store.log_email(user.id, "verification", email) + + return True + + # ------------------------------------------------------------------------- + # Login/Logout + # ------------------------------------------------------------------------- + + async def login( + self, + username: str, + password: str, + device_info: Optional[dict] = None, + ip_address: Optional[str] = None, + ) -> AuthResult: + """ + Authenticate user and create session. + + Args: + username: Username or email. + password: Plain text password. + device_info: Client device information. + ip_address: Client IP address. + + Returns: + AuthResult with session token or error. + """ + # Try username first, then email + user = await self.user_store.get_user_by_username(username) + if not user: + user = await self.user_store.get_user_by_email(username) + + if not user: + return AuthResult(success=False, error="Invalid credentials") + + if not user.can_login(): + return AuthResult(success=False, error="Account is disabled") + + # Check email verification if required + if self.require_email_verification and user.email and not user.email_verified: + return AuthResult(success=False, error="Please verify your email first") + + # Verify password + if not self._verify_password(password, user.password_hash): + return AuthResult(success=False, error="Invalid credentials") + + # Create session + token = secrets.token_urlsafe(32) + expires_at = datetime.now(timezone.utc) + timedelta(hours=self.session_expiry_hours) + + await self.user_store.create_session( + user_id=user.id, + token=token, + expires_at=expires_at, + device_info=device_info, + ip_address=ip_address, + ) + + # Update last login + await self.user_store.update_user(user.id, last_login=datetime.now(timezone.utc)) + + return AuthResult( + success=True, + user=user, + token=token, + expires_at=expires_at, + ) + + async def logout(self, token: str) -> bool: + """ + Invalidate a session. + + Args: + token: Session token to invalidate. + + Returns: + True if session was revoked. + """ + return await self.user_store.revoke_session_by_token(token) + + async def logout_all(self, user_id: str, except_token: Optional[str] = None) -> int: + """ + Invalidate all sessions for a user. + + Args: + user_id: User ID. + except_token: Optional token to keep active. + + Returns: + Number of sessions revoked. + """ + return await self.user_store.revoke_all_sessions(user_id, except_token) + + async def get_user_from_token(self, token: str) -> Optional[User]: + """ + Get user from session token. + + Args: + token: Session token. + + Returns: + User if valid session, None otherwise. + """ + session = await self.user_store.get_session_by_token(token) + if not session or not session.is_valid(): + return None + + # Update last used + await self.user_store.update_session_last_used(session.id) + + user = await self.user_store.get_user_by_id(session.user_id) + if not user or not user.can_login(): + return None + + return user + + # ------------------------------------------------------------------------- + # Password Management + # ------------------------------------------------------------------------- + + async def forgot_password(self, email: str) -> bool: + """ + Initiate password reset flow. + + Args: + email: Email address. + + Returns: + True if reset email was sent (always returns True to prevent enumeration). + """ + user = await self.user_store.get_user_by_email(email) + if not user: + # Don't reveal if email exists + return True + + # Generate reset token + reset_token = secrets.token_urlsafe(32) + reset_expires = datetime.now(timezone.utc) + timedelta(hours=1) + + await self.user_store.update_user( + user.id, + reset_token=reset_token, + reset_expires=reset_expires, + ) + + await self.email_service.send_password_reset_email( + to=email, + token=reset_token, + username=user.username, + ) + await self.user_store.log_email(user.id, "password_reset", email) + + return True + + async def reset_password(self, token: str, new_password: str) -> AuthResult: + """ + Reset password using token. + + Args: + token: Reset token from email. + new_password: New password. + + Returns: + AuthResult with success status. + """ + if len(new_password) < 8: + return AuthResult(success=False, error="Password must be at least 8 characters") + + user = await self.user_store.get_user_by_reset_token(token) + if not user: + return AuthResult(success=False, error="Invalid reset token") + + # Check expiration + if user.reset_expires and user.reset_expires < datetime.now(timezone.utc): + return AuthResult(success=False, error="Reset token expired") + + # Update password + password_hash = self._hash_password(new_password) + await self.user_store.update_user(user.id, password_hash=password_hash) + await self.user_store.clear_reset_token(user.id) + + # Revoke all sessions + await self.user_store.revoke_all_sessions(user.id) + + # Send notification + if user.email: + await self.email_service.send_password_changed_notification( + to=user.email, + username=user.username, + ) + await self.user_store.log_email(user.id, "password_changed", user.email) + + return AuthResult(success=True, user=user) + + async def change_password( + self, + user_id: str, + current_password: str, + new_password: str, + current_token: Optional[str] = None, + ) -> AuthResult: + """ + Change password for authenticated user. + + Args: + user_id: User ID. + current_password: Current password for verification. + new_password: New password. + current_token: Current session token to keep active. + + Returns: + AuthResult with success status. + """ + if len(new_password) < 8: + return AuthResult(success=False, error="Password must be at least 8 characters") + + user = await self.user_store.get_user_by_id(user_id) + if not user: + return AuthResult(success=False, error="User not found") + + # Verify current password + if not self._verify_password(current_password, user.password_hash): + return AuthResult(success=False, error="Current password is incorrect") + + # Update password + password_hash = self._hash_password(new_password) + await self.user_store.update_user(user.id, password_hash=password_hash) + + # Revoke all sessions except current + await self.user_store.revoke_all_sessions(user.id, except_token=current_token) + + # Send notification + if user.email: + await self.email_service.send_password_changed_notification( + to=user.email, + username=user.username, + ) + await self.user_store.log_email(user.id, "password_changed", user.email) + + return AuthResult(success=True, user=user) + + # ------------------------------------------------------------------------- + # User Profile + # ------------------------------------------------------------------------- + + async def update_preferences(self, user_id: str, preferences: dict) -> Optional[User]: + """ + Update user preferences. + + Args: + user_id: User ID. + preferences: New preferences dict. + + Returns: + Updated user or None. + """ + return await self.user_store.update_user(user_id, preferences=preferences) + + async def get_sessions(self, user_id: str) -> list[UserSession]: + """ + Get all active sessions for a user. + + Args: + user_id: User ID. + + Returns: + List of active sessions. + """ + return await self.user_store.get_sessions_for_user(user_id) + + async def revoke_session(self, user_id: str, session_id: str) -> bool: + """ + Revoke a specific session. + + Args: + user_id: User ID (for authorization). + session_id: Session ID to revoke. + + Returns: + True if session was revoked. + """ + # Verify session belongs to user + sessions = await self.user_store.get_sessions_for_user(user_id) + if not any(s.id == session_id for s in sessions): + return False + + return await self.user_store.revoke_session(session_id) + + # ------------------------------------------------------------------------- + # Guest Conversion + # ------------------------------------------------------------------------- + + async def convert_guest( + self, + guest_id: str, + username: str, + password: str, + email: Optional[str] = None, + ) -> RegistrationResult: + """ + Convert guest session to full user account. + + Args: + guest_id: Guest session ID. + username: Desired username. + password: Password. + email: Optional email. + + Returns: + RegistrationResult with user or error. + """ + # Verify guest exists and not already converted + guest = await self.user_store.get_guest_session(guest_id) + if not guest: + return RegistrationResult(success=False, error="Guest session not found") + + if guest.is_converted(): + return RegistrationResult(success=False, error="Guest already converted") + + # Register with guest ID + return await self.register( + username=username, + password=password, + email=email, + guest_id=guest_id, + ) + + # ------------------------------------------------------------------------- + # Account Deletion + # ------------------------------------------------------------------------- + + async def delete_account(self, user_id: str) -> bool: + """ + Soft delete user account. + + Args: + user_id: User ID to delete. + + Returns: + True if account was deleted. + """ + # Revoke all sessions + await self.user_store.revoke_all_sessions(user_id) + + # Soft delete + user = await self.user_store.update_user( + user_id, + is_active=False, + deleted_at=datetime.now(timezone.utc), + ) + + return user is not None + + # ------------------------------------------------------------------------- + # Guest Sessions + # ------------------------------------------------------------------------- + + async def create_guest_session( + self, + guest_id: str, + display_name: Optional[str] = None, + ) -> GuestSession: + """ + Create or get guest session. + + Args: + guest_id: Guest session ID. + display_name: Display name for guest. + + Returns: + GuestSession. + """ + existing = await self.user_store.get_guest_session(guest_id) + if existing: + await self.user_store.update_guest_last_seen(guest_id) + return existing + + return await self.user_store.create_guest_session(guest_id, display_name) + + # ------------------------------------------------------------------------- + # Password Hashing + # ------------------------------------------------------------------------- + + def _hash_password(self, password: str) -> str: + """Hash a password using bcrypt.""" + salt = bcrypt.gensalt() + hashed = bcrypt.hashpw(password.encode(), salt) + return hashed.decode() + + def _verify_password(self, password: str, password_hash: str) -> bool: + """Verify a password against its hash.""" + try: + return bcrypt.checkpw(password.encode(), password_hash.encode()) + except Exception: + return False + + +# Global auth service instance +_auth_service: Optional[AuthService] = None + + +async def get_auth_service(user_store: UserStore) -> AuthService: + """ + Get or create the global auth service instance. + + Args: + user_store: User persistence store. + + Returns: + AuthService instance. + """ + global _auth_service + if _auth_service is None: + _auth_service = await AuthService.create(user_store) + return _auth_service + + +async def close_auth_service() -> None: + """Close the global auth service.""" + global _auth_service + _auth_service = None diff --git a/server/services/email_service.py b/server/services/email_service.py new file mode 100644 index 0000000..885eee6 --- /dev/null +++ b/server/services/email_service.py @@ -0,0 +1,215 @@ +""" +Email service for Golf game authentication. + +Provides email sending via Resend for verification, password reset, and notifications. +""" + +import logging +from typing import Optional + +from config import config + +logger = logging.getLogger(__name__) + + +class EmailService: + """ + Email service using Resend API. + + Handles all transactional emails for authentication: + - Email verification + - Password reset + - Password changed notification + """ + + def __init__(self, api_key: str, from_address: str, base_url: str): + """ + Initialize email service. + + Args: + api_key: Resend API key. + from_address: Sender email address. + base_url: Base URL for verification/reset links. + """ + self.api_key = api_key + self.from_address = from_address + self.base_url = base_url.rstrip("/") + self._client = None + + @classmethod + def create(cls) -> "EmailService": + """Create EmailService from config.""" + return cls( + api_key=config.RESEND_API_KEY, + from_address=config.EMAIL_FROM, + base_url=config.BASE_URL, + ) + + @property + def client(self): + """Lazy-load Resend client.""" + if self._client is None: + try: + import resend + resend.api_key = self.api_key + self._client = resend + except ImportError: + logger.warning("resend package not installed, emails will be logged only") + self._client = None + return self._client + + def is_configured(self) -> bool: + """Check if email service is properly configured.""" + return bool(self.api_key) + + async def send_verification_email( + self, + to: str, + token: str, + username: str, + ) -> Optional[str]: + """ + Send email verification email. + + Args: + to: Recipient email address. + token: Verification token. + username: User's display name. + + Returns: + Resend message ID if sent, None if not configured. + """ + if not self.is_configured(): + logger.info(f"Email not configured. Would send verification to {to}") + return None + + verify_url = f"{self.base_url}/verify-email?token={token}" + + subject = "Verify your Golf Game account" + html = f""" +

Welcome to Golf Game, {username}!

+

Please verify your email address by clicking the link below:

+

Verify Email Address

+

Or copy and paste this URL into your browser:

+

{verify_url}

+

This link will expire in 24 hours.

+

If you didn't create this account, you can safely ignore this email.

+ """ + + return await self._send_email(to, subject, html) + + async def send_password_reset_email( + self, + to: str, + token: str, + username: str, + ) -> Optional[str]: + """ + Send password reset email. + + Args: + to: Recipient email address. + token: Reset token. + username: User's display name. + + Returns: + Resend message ID if sent, None if not configured. + """ + if not self.is_configured(): + logger.info(f"Email not configured. Would send password reset to {to}") + return None + + reset_url = f"{self.base_url}/reset-password?token={token}" + + subject = "Reset your Golf Game password" + html = f""" +

Password Reset Request

+

Hi {username},

+

We received a request to reset your password. Click the link below to set a new password:

+

Reset Password

+

Or copy and paste this URL into your browser:

+

{reset_url}

+

This link will expire in 1 hour.

+

If you didn't request this, you can safely ignore this email. Your password will remain unchanged.

+ """ + + return await self._send_email(to, subject, html) + + async def send_password_changed_notification( + self, + to: str, + username: str, + ) -> Optional[str]: + """ + Send password changed notification email. + + Args: + to: Recipient email address. + username: User's display name. + + Returns: + Resend message ID if sent, None if not configured. + """ + if not self.is_configured(): + logger.info(f"Email not configured. Would send password change notification to {to}") + return None + + subject = "Your Golf Game password was changed" + html = f""" +

Password Changed

+

Hi {username},

+

Your password was successfully changed.

+

If you did not make this change, please contact support immediately.

+ """ + + return await self._send_email(to, subject, html) + + async def _send_email( + self, + to: str, + subject: str, + html: str, + ) -> Optional[str]: + """ + Send an email via Resend. + + Args: + to: Recipient email address. + subject: Email subject. + html: HTML email body. + + Returns: + Resend message ID if sent, None on error. + """ + if not self.client: + logger.warning(f"Resend not available. Email to {to}: {subject}") + return None + + try: + params = { + "from": self.from_address, + "to": [to], + "subject": subject, + "html": html, + } + + response = self.client.Emails.send(params) + message_id = response.get("id") if isinstance(response, dict) else getattr(response, "id", None) + logger.info(f"Email sent to {to}: {message_id}") + return message_id + + except Exception as e: + logger.error(f"Failed to send email to {to}: {e}") + return None + + +# Global email service instance +_email_service: Optional[EmailService] = None + + +def get_email_service() -> EmailService: + """Get or create the global email service instance.""" + global _email_service + if _email_service is None: + _email_service = EmailService.create() + return _email_service diff --git a/server/services/ratelimit.py b/server/services/ratelimit.py new file mode 100644 index 0000000..15b05cf --- /dev/null +++ b/server/services/ratelimit.py @@ -0,0 +1,223 @@ +""" +Redis-based rate limiter service. + +Implements a sliding window counter algorithm using Redis for distributed +rate limiting across multiple server instances. +""" + +import hashlib +import logging +import time +from typing import Optional + +import redis.asyncio as redis +from fastapi import Request, WebSocket + +logger = logging.getLogger(__name__) + + +# Rate limit configurations: (max_requests, window_seconds) +RATE_LIMITS = { + "api_general": (100, 60), # 100 requests per minute + "api_auth": (10, 60), # 10 auth attempts per minute + "api_create_room": (5, 60), # 5 room creations per minute + "websocket_connect": (10, 60), # 10 WS connections per minute + "websocket_message": (30, 10), # 30 messages per 10 seconds + "email_send": (3, 300), # 3 emails per 5 minutes +} + + +class RateLimiter: + """Token bucket rate limiter using Redis.""" + + def __init__(self, redis_client: redis.Redis): + """ + Initialize rate limiter with Redis client. + + Args: + redis_client: Async Redis client for state storage. + """ + self.redis = redis_client + + async def is_allowed( + self, + key: str, + limit: int, + window_seconds: int, + ) -> tuple[bool, dict]: + """ + Check if request is allowed under rate limit. + + Uses a sliding window counter algorithm: + - Divides time into fixed windows + - Counts requests in current window + - Atomically increments and checks limit + + Args: + key: Unique identifier for the rate limit bucket. + limit: Maximum requests allowed in window. + window_seconds: Time window in seconds. + + Returns: + Tuple of (allowed, info) where info contains: + - remaining: requests remaining in window + - reset: seconds until window resets + - limit: the limit that was applied + """ + now = int(time.time()) + window_key = f"ratelimit:{key}:{now // window_seconds}" + + try: + async with self.redis.pipeline(transaction=True) as pipe: + pipe.incr(window_key) + pipe.expire(window_key, window_seconds + 1) # Extra second for safety + results = await pipe.execute() + + current_count = results[0] + remaining = max(0, limit - current_count) + reset = window_seconds - (now % window_seconds) + + info = { + "remaining": remaining, + "reset": reset, + "limit": limit, + } + + allowed = current_count <= limit + if not allowed: + logger.warning(f"Rate limit exceeded for {key}: {current_count}/{limit}") + + return allowed, info + + except redis.RedisError as e: + # If Redis is unavailable, fail open (allow request) + logger.error(f"Rate limiter Redis error: {e}") + return True, {"remaining": limit, "reset": window_seconds, "limit": limit} + + def get_client_key( + self, + request: Request | WebSocket, + user_id: Optional[str] = None, + ) -> str: + """ + Generate rate limit key for client. + + Uses user ID if authenticated, otherwise hashes client IP. + + Args: + request: HTTP request or WebSocket. + user_id: Authenticated user ID, if available. + + Returns: + Unique client identifier string. + """ + if user_id: + return f"user:{user_id}" + + # For anonymous users, use IP hash + client_ip = self._get_client_ip(request) + + # Hash IP for privacy + ip_hash = hashlib.sha256(client_ip.encode()).hexdigest()[:16] + return f"ip:{ip_hash}" + + def _get_client_ip(self, request: Request | WebSocket) -> str: + """ + Extract client IP from request, handling proxies. + + Args: + request: HTTP request or WebSocket. + + Returns: + Client IP address string. + """ + # Check X-Forwarded-For header (from reverse proxy) + forwarded = request.headers.get("X-Forwarded-For") + if forwarded: + # Take the first IP (original client) + return forwarded.split(",")[0].strip() + + # Check X-Real-IP header (nginx) + real_ip = request.headers.get("X-Real-IP") + if real_ip: + return real_ip.strip() + + # Fall back to direct connection + if request.client: + return request.client.host + + return "unknown" + + +class ConnectionMessageLimiter: + """ + In-memory rate limiter for WebSocket message frequency. + + Used to limit messages within a single connection without + requiring Redis round-trips for every message. + """ + + def __init__(self, max_messages: int = 30, window_seconds: int = 10): + """ + Initialize connection message limiter. + + Args: + max_messages: Maximum messages allowed in window. + window_seconds: Time window in seconds. + """ + self.max_messages = max_messages + self.window_seconds = window_seconds + self.timestamps: list[float] = [] + + def check(self) -> bool: + """ + Check if another message is allowed. + + Maintains a sliding window of message timestamps. + + Returns: + True if message is allowed, False if rate limited. + """ + now = time.time() + cutoff = now - self.window_seconds + + # Remove old timestamps + self.timestamps = [t for t in self.timestamps if t > cutoff] + + # Check limit + if len(self.timestamps) >= self.max_messages: + return False + + # Record this message + self.timestamps.append(now) + return True + + def reset(self): + """Reset the limiter (e.g., on reconnection).""" + self.timestamps = [] + + +# Global rate limiter instance +_rate_limiter: Optional[RateLimiter] = None + + +async def get_rate_limiter(redis_client: redis.Redis) -> RateLimiter: + """ + Get or create the global rate limiter instance. + + Args: + redis_client: Redis client for state storage. + + Returns: + RateLimiter instance. + """ + global _rate_limiter + if _rate_limiter is None: + _rate_limiter = RateLimiter(redis_client) + return _rate_limiter + + +def close_rate_limiter(): + """Close the global rate limiter.""" + global _rate_limiter + _rate_limiter = None diff --git a/server/services/recovery_service.py b/server/services/recovery_service.py new file mode 100644 index 0000000..d8948e7 --- /dev/null +++ b/server/services/recovery_service.py @@ -0,0 +1,353 @@ +""" +Game recovery service for rebuilding active games from event store. + +On server restart, all in-memory game state is lost. This service: +1. Queries the event store for active games +2. Rebuilds game state by replaying events +3. Caches the rebuilt state in Redis +4. Handles partial recovery (applying only new events to cached state) + +This ensures games can survive server restarts without data loss. + +Usage: + recovery = RecoveryService(event_store, state_cache) + results = await recovery.recover_all_games() + print(f"Recovered {results['recovered']} games") +""" + +import logging +from dataclasses import dataclass +from typing import Optional, Any + +from stores.event_store import EventStore +from stores.state_cache import StateCache +from models.events import EventType +from models.game_state import RebuiltGameState, rebuild_state, CardState + +logger = logging.getLogger(__name__) + + +@dataclass +class RecoveryResult: + """Result of a game recovery attempt.""" + + game_id: str + room_code: str + success: bool + phase: Optional[str] = None + sequence_num: int = 0 + error: Optional[str] = None + + +class RecoveryService: + """ + Recovers games from event store on startup. + + Works with the event store (PostgreSQL) as source of truth + and state cache (Redis) for fast access during gameplay. + """ + + def __init__( + self, + event_store: EventStore, + state_cache: StateCache, + ): + """ + Initialize recovery service. + + Args: + event_store: PostgreSQL event store. + state_cache: Redis state cache. + """ + self.event_store = event_store + self.state_cache = state_cache + + async def recover_all_games(self) -> dict[str, Any]: + """ + Recover all active games from event store. + + Queries PostgreSQL for active games and rebuilds their state + from events, then caches in Redis. + + Returns: + Dict with recovery statistics: + - recovered: Number of games successfully recovered + - failed: Number of games that failed recovery + - skipped: Number of games skipped (already ended) + - games: List of recovered game info + """ + results = { + "recovered": 0, + "failed": 0, + "skipped": 0, + "games": [], + } + + # Get active games from PostgreSQL + active_games = await self.event_store.get_active_games() + logger.info(f"Found {len(active_games)} active games to recover") + + for game_meta in active_games: + game_id = str(game_meta["id"]) + room_code = game_meta["room_code"] + + try: + result = await self.recover_game(game_id, room_code) + + if result.success: + results["recovered"] += 1 + results["games"].append({ + "game_id": game_id, + "room_code": room_code, + "phase": result.phase, + "sequence": result.sequence_num, + }) + else: + if result.error == "game_ended": + results["skipped"] += 1 + else: + results["failed"] += 1 + logger.warning(f"Failed to recover {game_id}: {result.error}") + + except Exception as e: + logger.error(f"Error recovering game {game_id}: {e}", exc_info=True) + results["failed"] += 1 + + return results + + async def recover_game( + self, + game_id: str, + room_code: Optional[str] = None, + ) -> RecoveryResult: + """ + Recover a single game from event store. + + Args: + game_id: Game UUID. + room_code: Room code (optional, will be read from events). + + Returns: + RecoveryResult with success status and game info. + """ + # Get all events for this game + events = await self.event_store.get_events(game_id) + + if not events: + return RecoveryResult( + game_id=game_id, + room_code=room_code or "", + success=False, + error="no_events", + ) + + # Check if game is actually active (not ended) + last_event = events[-1] + if last_event.event_type == EventType.GAME_ENDED: + return RecoveryResult( + game_id=game_id, + room_code=room_code or "", + success=False, + error="game_ended", + ) + + # Rebuild state from events + state = rebuild_state(events) + + # Get room code from state if not provided + if not room_code: + room_code = state.room_code + + # Convert state to cacheable dict + state_dict = self._state_to_dict(state) + + # Save to Redis cache + await self.state_cache.save_game_state(game_id, state_dict) + + # Also create/update room in cache + await self._ensure_room_in_cache(state) + + logger.info( + f"Recovered game {game_id} (room {room_code}) " + f"at sequence {state.sequence_num}, phase {state.phase.value}" + ) + + return RecoveryResult( + game_id=game_id, + room_code=room_code, + success=True, + phase=state.phase.value, + sequence_num=state.sequence_num, + ) + + async def recover_from_sequence( + self, + game_id: str, + cached_state: dict, + cached_sequence: int, + ) -> Optional[dict]: + """ + Recover game by applying only new events to cached state. + + More efficient than full rebuild when we have a recent cache. + + Args: + game_id: Game UUID. + cached_state: Previously cached state dict. + cached_sequence: Sequence number of cached state. + + Returns: + Updated state dict, or None if no new events. + """ + # Get events after cached sequence + new_events = await self.event_store.get_events( + game_id, + from_sequence=cached_sequence + 1, + ) + + if not new_events: + return None # No new events + + # Rebuild state from cache + new events + state = self._dict_to_state(cached_state) + for event in new_events: + state.apply(event) + + # Convert back to dict + new_state = self._state_to_dict(state) + + # Update cache + await self.state_cache.save_game_state(game_id, new_state) + + return new_state + + async def _ensure_room_in_cache(self, state: RebuiltGameState) -> None: + """ + Ensure room exists in Redis cache after recovery. + + Args: + state: Rebuilt game state. + """ + room_code = state.room_code + if not room_code: + return + + # Check if room already exists + if await self.state_cache.room_exists(room_code): + return + + # Create room in cache + await self.state_cache.create_room( + room_code=room_code, + game_id=state.game_id, + host_id=state.host_id or "", + server_id="recovered", + ) + + # Set room status based on game phase + if state.phase.value == "waiting": + status = "waiting" + elif state.phase.value in ("game_over", "round_over"): + status = "finished" + else: + status = "playing" + + await self.state_cache.set_room_status(room_code, status) + + def _state_to_dict(self, state: RebuiltGameState) -> dict: + """ + Convert RebuiltGameState to dict for caching. + + Args: + state: Game state to convert. + + Returns: + Cacheable dict representation. + """ + return { + "game_id": state.game_id, + "room_code": state.room_code, + "phase": state.phase.value, + "current_round": state.current_round, + "total_rounds": state.total_rounds, + "current_player_idx": state.current_player_idx, + "player_order": state.player_order, + "players": { + pid: { + "id": p.id, + "name": p.name, + "cards": [c.to_dict() for c in p.cards], + "score": p.score, + "total_score": p.total_score, + "rounds_won": p.rounds_won, + "is_cpu": p.is_cpu, + "cpu_profile": p.cpu_profile, + } + for pid, p in state.players.items() + }, + "deck_remaining": state.deck_remaining, + "discard_pile": [c.to_dict() for c in state.discard_pile], + "discard_top": state.discard_pile[-1].to_dict() if state.discard_pile else None, + "drawn_card": state.drawn_card.to_dict() if state.drawn_card else None, + "drawn_from_discard": state.drawn_from_discard, + "options": state.options, + "sequence_num": state.sequence_num, + "finisher_id": state.finisher_id, + "host_id": state.host_id, + "initial_flips_done": list(state.initial_flips_done), + "players_with_final_turn": list(state.players_with_final_turn), + } + + def _dict_to_state(self, d: dict) -> RebuiltGameState: + """ + Convert dict back to RebuiltGameState. + + Args: + d: Cached state dict. + + Returns: + Reconstructed game state. + """ + from models.game_state import GamePhase, PlayerState + + state = RebuiltGameState(game_id=d["game_id"]) + state.room_code = d.get("room_code", "") + state.phase = GamePhase(d.get("phase", "waiting")) + state.current_round = d.get("current_round", 0) + state.total_rounds = d.get("total_rounds", 1) + state.current_player_idx = d.get("current_player_idx", 0) + state.player_order = d.get("player_order", []) + state.deck_remaining = d.get("deck_remaining", 0) + state.options = d.get("options", {}) + state.sequence_num = d.get("sequence_num", 0) + state.finisher_id = d.get("finisher_id") + state.host_id = d.get("host_id") + state.initial_flips_done = set(d.get("initial_flips_done", [])) + state.players_with_final_turn = set(d.get("players_with_final_turn", [])) + state.drawn_from_discard = d.get("drawn_from_discard", False) + + # Rebuild players + players_data = d.get("players", {}) + for pid, pdata in players_data.items(): + player = PlayerState( + id=pdata["id"], + name=pdata["name"], + is_cpu=pdata.get("is_cpu", False), + cpu_profile=pdata.get("cpu_profile"), + score=pdata.get("score", 0), + total_score=pdata.get("total_score", 0), + rounds_won=pdata.get("rounds_won", 0), + ) + player.cards = [CardState.from_dict(c) for c in pdata.get("cards", [])] + state.players[pid] = player + + # Rebuild discard pile + discard_data = d.get("discard_pile", []) + state.discard_pile = [CardState.from_dict(c) for c in discard_data] + + # Rebuild drawn card + drawn = d.get("drawn_card") + if drawn: + state.drawn_card = CardState.from_dict(drawn) + + return state diff --git a/server/services/replay_service.py b/server/services/replay_service.py new file mode 100644 index 0000000..375d263 --- /dev/null +++ b/server/services/replay_service.py @@ -0,0 +1,583 @@ +""" +Replay service for Golf game. + +Provides game replay functionality, share link generation, and game export/import. +Leverages the event-sourced architecture for perfect game reconstruction. +""" + +import json +import logging +import secrets +from dataclasses import dataclass, asdict +from datetime import datetime, timezone, timedelta +from typing import Optional, List + +import asyncpg + +from stores.event_store import EventStore +from models.events import GameEvent, EventType +from models.game_state import rebuild_state, RebuiltGameState, CardState + +logger = logging.getLogger(__name__) + + +# SQL schema for replay/sharing tables +REPLAY_SCHEMA_SQL = """ +-- Public share links for completed games +CREATE TABLE IF NOT EXISTS shared_games ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + game_id UUID NOT NULL, + share_code VARCHAR(12) UNIQUE NOT NULL, + created_by VARCHAR(50), + created_at TIMESTAMPTZ DEFAULT NOW(), + expires_at TIMESTAMPTZ, + view_count INTEGER DEFAULT 0, + is_public BOOLEAN DEFAULT true, + title VARCHAR(100), + description TEXT +); + +CREATE INDEX IF NOT EXISTS idx_shared_games_code ON shared_games(share_code); +CREATE INDEX IF NOT EXISTS idx_shared_games_game ON shared_games(game_id); + +-- Track replay views for analytics +CREATE TABLE IF NOT EXISTS replay_views ( + id SERIAL PRIMARY KEY, + shared_game_id UUID REFERENCES shared_games(id), + viewer_id VARCHAR(50), + viewed_at TIMESTAMPTZ DEFAULT NOW(), + ip_hash VARCHAR(64), + watch_duration_seconds INTEGER +); + +CREATE INDEX IF NOT EXISTS idx_replay_views_shared ON replay_views(shared_game_id); +""" + + +@dataclass +class ReplayFrame: + """Single frame in a replay.""" + event_index: int + event_type: str + event_data: dict + game_state: dict + timestamp: float # Seconds from start + player_id: Optional[str] = None + + +@dataclass +class GameReplay: + """Complete replay of a game.""" + game_id: str + frames: List[ReplayFrame] + total_duration_seconds: float + player_names: List[str] + final_scores: dict + winner: Optional[str] + options: dict + room_code: str + total_rounds: int + + +class ReplayService: + """ + Service for game replay, export, and sharing. + + Provides: + - Replay building from event store + - Share link creation and retrieval + - Game export/import + """ + + EXPORT_VERSION = "1.0" + + def __init__(self, pool: asyncpg.Pool, event_store: EventStore): + """ + Initialize replay service. + + Args: + pool: asyncpg connection pool. + event_store: Event store for retrieving game events. + """ + self.pool = pool + self.event_store = event_store + + async def initialize_schema(self) -> None: + """Create replay tables if they don't exist.""" + async with self.pool.acquire() as conn: + await conn.execute(REPLAY_SCHEMA_SQL) + logger.info("Replay schema initialized") + + # ------------------------------------------------------------------------- + # Replay Building + # ------------------------------------------------------------------------- + + async def build_replay(self, game_id: str) -> GameReplay: + """ + Build complete replay from event store. + + Args: + game_id: Game UUID. + + Returns: + GameReplay with all frames and metadata. + + Raises: + ValueError: If no events found for game. + """ + events = await self.event_store.get_events(game_id) + if not events: + raise ValueError(f"No events found for game {game_id}") + + frames = [] + state = RebuiltGameState(game_id=game_id) + start_time = None + + for i, event in enumerate(events): + if start_time is None: + start_time = event.timestamp + + # Apply event to get state + state.apply(event) + + # Calculate timestamp relative to start + elapsed = (event.timestamp - start_time).total_seconds() + + frames.append(ReplayFrame( + event_index=i, + event_type=event.event_type.value, + event_data=event.data, + game_state=self._state_to_dict(state), + timestamp=elapsed, + player_id=event.player_id, + )) + + # Extract final game info + player_names = [p.name for p in state.players.values()] + final_scores = {p.name: p.total_score for p in state.players.values()} + + # Determine winner (lowest total score) + winner = None + if state.phase.value == "game_over" and state.players: + winner_player = min(state.players.values(), key=lambda p: p.total_score) + winner = winner_player.name + + return GameReplay( + game_id=game_id, + frames=frames, + total_duration_seconds=frames[-1].timestamp if frames else 0, + player_names=player_names, + final_scores=final_scores, + winner=winner, + options=state.options, + room_code=state.room_code, + total_rounds=state.total_rounds, + ) + + async def get_replay_frame( + self, + game_id: str, + frame_index: int + ) -> Optional[ReplayFrame]: + """ + Get a specific frame from a replay. + + Useful for seeking to a specific point without loading entire replay. + + Args: + game_id: Game UUID. + frame_index: Index of frame to retrieve (0-based). + + Returns: + ReplayFrame or None if index out of range. + """ + events = await self.event_store.get_events( + game_id, + from_sequence=1, + to_sequence=frame_index + 1 + ) + + if not events or len(events) <= frame_index: + return None + + state = RebuiltGameState(game_id=game_id) + start_time = events[0].timestamp if events else None + + for event in events: + state.apply(event) + + last_event = events[-1] + elapsed = (last_event.timestamp - start_time).total_seconds() if start_time else 0 + + return ReplayFrame( + event_index=frame_index, + event_type=last_event.event_type.value, + event_data=last_event.data, + game_state=self._state_to_dict(state), + timestamp=elapsed, + player_id=last_event.player_id, + ) + + def _state_to_dict(self, state: RebuiltGameState) -> dict: + """Convert RebuiltGameState to serializable dict.""" + players = [] + for pid in state.player_order: + if pid in state.players: + p = state.players[pid] + players.append({ + "id": p.id, + "name": p.name, + "cards": [c.to_dict() for c in p.cards], + "score": p.score, + "total_score": p.total_score, + "rounds_won": p.rounds_won, + "is_cpu": p.is_cpu, + "all_face_up": p.all_face_up(), + }) + + return { + "phase": state.phase.value, + "players": players, + "current_player_idx": state.current_player_idx, + "current_player_id": state.player_order[state.current_player_idx] if state.player_order else None, + "deck_remaining": state.deck_remaining, + "discard_pile": [c.to_dict() for c in state.discard_pile], + "discard_top": state.discard_pile[-1].to_dict() if state.discard_pile else None, + "drawn_card": state.drawn_card.to_dict() if state.drawn_card else None, + "current_round": state.current_round, + "total_rounds": state.total_rounds, + "finisher_id": state.finisher_id, + "options": state.options, + } + + # ------------------------------------------------------------------------- + # Share Links + # ------------------------------------------------------------------------- + + async def create_share_link( + self, + game_id: str, + user_id: Optional[str] = None, + title: Optional[str] = None, + description: Optional[str] = None, + expires_days: Optional[int] = None, + ) -> str: + """ + Generate shareable link for a game. + + Args: + game_id: Game UUID. + user_id: ID of user creating the share. + title: Optional custom title. + description: Optional description. + expires_days: Days until link expires (None = never). + + Returns: + 12-character share code. + """ + share_code = secrets.token_urlsafe(9)[:12] + + expires_at = None + if expires_days: + expires_at = datetime.now(timezone.utc) + timedelta(days=expires_days) + + async with self.pool.acquire() as conn: + await conn.execute(""" + INSERT INTO shared_games + (game_id, share_code, created_by, title, description, expires_at) + VALUES ($1, $2, $3, $4, $5, $6) + """, game_id, share_code, user_id, title, description, expires_at) + + logger.info(f"Created share link {share_code} for game {game_id}") + return share_code + + async def get_shared_game(self, share_code: str) -> Optional[dict]: + """ + Retrieve shared game by code. + + Args: + share_code: 12-character share code. + + Returns: + Shared game metadata dict, or None if not found/expired. + """ + async with self.pool.acquire() as conn: + row = await conn.fetchrow(""" + SELECT sg.*, g.room_code, g.completed_at, g.num_players, g.num_rounds + FROM shared_games sg + JOIN games_v2 g ON sg.game_id = g.id + WHERE sg.share_code = $1 + AND sg.is_public = true + AND (sg.expires_at IS NULL OR sg.expires_at > NOW()) + """, share_code) + + if row: + # Increment view count + await conn.execute(""" + UPDATE shared_games SET view_count = view_count + 1 + WHERE share_code = $1 + """, share_code) + + return dict(row) + return None + + async def record_replay_view( + self, + shared_game_id: str, + viewer_id: Optional[str] = None, + ip_hash: Optional[str] = None, + duration_seconds: Optional[int] = None, + ) -> None: + """ + Record a replay view for analytics. + + Args: + shared_game_id: UUID of the shared_games record. + viewer_id: Optional user ID of viewer. + ip_hash: Optional hashed IP for rate limiting. + duration_seconds: Optional watch duration. + """ + async with self.pool.acquire() as conn: + await conn.execute(""" + INSERT INTO replay_views + (shared_game_id, viewer_id, ip_hash, watch_duration_seconds) + VALUES ($1, $2, $3, $4) + """, shared_game_id, viewer_id, ip_hash, duration_seconds) + + async def get_user_shared_games(self, user_id: str) -> List[dict]: + """ + Get all shared games created by a user. + + Args: + user_id: User ID. + + Returns: + List of shared game metadata dicts. + """ + async with self.pool.acquire() as conn: + rows = await conn.fetch(""" + SELECT sg.*, g.room_code, g.completed_at + FROM shared_games sg + JOIN games_v2 g ON sg.game_id = g.id + WHERE sg.created_by = $1 + ORDER BY sg.created_at DESC + """, user_id) + return [dict(row) for row in rows] + + async def delete_share_link(self, share_code: str, user_id: str) -> bool: + """ + Delete a share link. + + Args: + share_code: Share code to delete. + user_id: User requesting deletion (must be creator). + + Returns: + True if deleted, False if not found or not authorized. + """ + async with self.pool.acquire() as conn: + result = await conn.execute(""" + DELETE FROM shared_games + WHERE share_code = $1 AND created_by = $2 + """, share_code, user_id) + return result == "DELETE 1" + + # ------------------------------------------------------------------------- + # Export/Import + # ------------------------------------------------------------------------- + + async def export_game(self, game_id: str) -> dict: + """ + Export game as portable JSON format. + + Args: + game_id: Game UUID. + + Returns: + Export data dict suitable for JSON serialization. + """ + replay = await self.build_replay(game_id) + + # Get raw events for export + events = await self.event_store.get_events(game_id) + start_time = events[0].timestamp if events else datetime.now(timezone.utc) + + return { + "version": self.EXPORT_VERSION, + "exported_at": datetime.now(timezone.utc).isoformat(), + "game": { + "id": replay.game_id, + "room_code": replay.room_code, + "players": replay.player_names, + "winner": replay.winner, + "final_scores": replay.final_scores, + "duration_seconds": replay.total_duration_seconds, + "total_rounds": replay.total_rounds, + "options": replay.options, + }, + "events": [ + { + "type": event.event_type.value, + "sequence": event.sequence_num, + "player_id": event.player_id, + "data": event.data, + "timestamp": (event.timestamp - start_time).total_seconds(), + } + for event in events + ], + } + + async def import_game(self, export_data: dict, user_id: str) -> str: + """ + Import a game from exported JSON. + + Creates a new game record with the imported events. + + Args: + export_data: Exported game data. + user_id: User performing the import. + + Returns: + New game ID. + + Raises: + ValueError: If export format is invalid. + """ + version = export_data.get("version") + if version != self.EXPORT_VERSION: + raise ValueError(f"Unsupported export version: {version}") + + if "events" not in export_data or not export_data["events"]: + raise ValueError("Export contains no events") + + # Generate new game ID + import uuid + new_game_id = str(uuid.uuid4()) + + # Calculate base timestamp + base_time = datetime.now(timezone.utc) + + # Import events with new game ID + events = [] + for event_data in export_data["events"]: + event = GameEvent( + event_type=EventType(event_data["type"]), + game_id=new_game_id, + sequence_num=event_data["sequence"], + player_id=event_data.get("player_id"), + data=event_data["data"], + timestamp=base_time + timedelta(seconds=event_data.get("timestamp", 0)), + ) + events.append(event) + + # Batch insert events + await self.event_store.append_batch(events) + + # Create game metadata record + game_info = export_data.get("game", {}) + async with self.pool.acquire() as conn: + await conn.execute(""" + INSERT INTO games_v2 + (id, room_code, status, num_rounds, options, completed_at) + VALUES ($1, $2, 'imported', $3, $4, NOW()) + """, + new_game_id, + f"IMP-{secrets.token_hex(2).upper()}", # Generate room code for imported games + game_info.get("total_rounds", 1), + json.dumps(game_info.get("options", {})), + ) + + logger.info(f"Imported game as {new_game_id} by user {user_id}") + return new_game_id + + # ------------------------------------------------------------------------- + # Game History Queries + # ------------------------------------------------------------------------- + + async def get_user_game_history( + self, + user_id: str, + limit: int = 20, + offset: int = 0, + ) -> List[dict]: + """ + Get game history for a user. + + Args: + user_id: User ID. + limit: Max games to return. + offset: Pagination offset. + + Returns: + List of game summary dicts. + """ + async with self.pool.acquire() as conn: + rows = await conn.fetch(""" + SELECT g.id, g.room_code, g.status, g.completed_at, + g.num_players, g.num_rounds, g.winner_id, + $1 = ANY(g.player_ids) as participated + FROM games_v2 g + WHERE $1 = ANY(g.player_ids) + AND g.status IN ('completed', 'imported') + ORDER BY g.completed_at DESC NULLS LAST + LIMIT $2 OFFSET $3 + """, user_id, limit, offset) + + return [dict(row) for row in rows] + + async def can_view_game(self, user_id: Optional[str], game_id: str) -> bool: + """ + Check if user can view a game replay. + + Users can view games they played in or games that are shared publicly. + + Args: + user_id: User ID (None for anonymous). + game_id: Game UUID. + + Returns: + True if user can view the game. + """ + async with self.pool.acquire() as conn: + # Check if user played in the game + if user_id: + row = await conn.fetchrow(""" + SELECT 1 FROM games_v2 + WHERE id = $1 AND $2 = ANY(player_ids) + """, game_id, user_id) + if row: + return True + + # Check if game has a public share link + row = await conn.fetchrow(""" + SELECT 1 FROM shared_games + WHERE game_id = $1 + AND is_public = true + AND (expires_at IS NULL OR expires_at > NOW()) + """, game_id) + return row is not None + + +# Global instance +_replay_service: Optional[ReplayService] = None + + +async def get_replay_service(pool: asyncpg.Pool, event_store: EventStore) -> ReplayService: + """Get or create the replay service instance.""" + global _replay_service + if _replay_service is None: + _replay_service = ReplayService(pool, event_store) + await _replay_service.initialize_schema() + return _replay_service + + +def set_replay_service(service: ReplayService) -> None: + """Set the global replay service instance.""" + global _replay_service + _replay_service = service + + +def close_replay_service() -> None: + """Close the replay service.""" + global _replay_service + _replay_service = None diff --git a/server/services/spectator.py b/server/services/spectator.py new file mode 100644 index 0000000..05c2024 --- /dev/null +++ b/server/services/spectator.py @@ -0,0 +1,265 @@ +""" +Spectator manager for Golf game. + +Enables spectators to watch live games in progress via WebSocket connections. +Spectators receive game state updates but cannot interact with the game. +""" + +import logging +from dataclasses import dataclass, field +from typing import Dict, List, Optional +from datetime import datetime, timezone + +from fastapi import WebSocket + +logger = logging.getLogger(__name__) + +# Maximum spectators per game to prevent resource exhaustion +MAX_SPECTATORS_PER_GAME = 50 + + +@dataclass +class SpectatorInfo: + """Information about a spectator connection.""" + websocket: WebSocket + joined_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + user_id: Optional[str] = None + username: Optional[str] = None + + +class SpectatorManager: + """ + Manage spectators watching live games. + + Spectators can join any active game and receive real-time updates. + They see the same state as players but cannot take actions. + """ + + def __init__(self): + # game_id -> list of SpectatorInfo + self._spectators: Dict[str, List[SpectatorInfo]] = {} + # websocket -> game_id (for reverse lookup on disconnect) + self._ws_to_game: Dict[WebSocket, str] = {} + + async def add_spectator( + self, + game_id: str, + websocket: WebSocket, + user_id: Optional[str] = None, + username: Optional[str] = None, + ) -> bool: + """ + Add spectator to a game. + + Args: + game_id: Game UUID. + websocket: Spectator's WebSocket connection. + user_id: Optional user ID. + username: Optional display name. + + Returns: + True if added, False if game is at spectator limit. + """ + if game_id not in self._spectators: + self._spectators[game_id] = [] + + # Check spectator limit + if len(self._spectators[game_id]) >= MAX_SPECTATORS_PER_GAME: + logger.warning(f"Game {game_id} at spectator limit ({MAX_SPECTATORS_PER_GAME})") + return False + + info = SpectatorInfo( + websocket=websocket, + user_id=user_id, + username=username or "Spectator", + ) + self._spectators[game_id].append(info) + self._ws_to_game[websocket] = game_id + + logger.info(f"Spectator joined game {game_id} (total: {len(self._spectators[game_id])})") + return True + + async def remove_spectator(self, game_id: str, websocket: WebSocket) -> None: + """ + Remove spectator from a game. + + Args: + game_id: Game UUID. + websocket: Spectator's WebSocket connection. + """ + if game_id in self._spectators: + # Find and remove the spectator + self._spectators[game_id] = [ + info for info in self._spectators[game_id] + if info.websocket != websocket + ] + logger.info(f"Spectator left game {game_id} (remaining: {len(self._spectators[game_id])})") + + # Clean up empty games + if not self._spectators[game_id]: + del self._spectators[game_id] + + # Clean up reverse lookup + self._ws_to_game.pop(websocket, None) + + async def remove_spectator_by_ws(self, websocket: WebSocket) -> None: + """ + Remove spectator by WebSocket (for disconnect handling). + + Args: + websocket: Spectator's WebSocket connection. + """ + game_id = self._ws_to_game.get(websocket) + if game_id: + await self.remove_spectator(game_id, websocket) + + async def broadcast_to_spectators(self, game_id: str, message: dict) -> None: + """ + Send update to all spectators of a game. + + Args: + game_id: Game UUID. + message: Message to broadcast. + """ + if game_id not in self._spectators: + return + + dead_connections: List[SpectatorInfo] = [] + + for info in self._spectators[game_id]: + try: + await info.websocket.send_json(message) + except Exception as e: + logger.debug(f"Failed to send to spectator: {e}") + dead_connections.append(info) + + # Clean up dead connections + for info in dead_connections: + self._spectators[game_id] = [ + s for s in self._spectators[game_id] + if s.websocket != info.websocket + ] + self._ws_to_game.pop(info.websocket, None) + + # Clean up empty games + if game_id in self._spectators and not self._spectators[game_id]: + del self._spectators[game_id] + + async def send_game_state( + self, + game_id: str, + game_state: dict, + event_type: Optional[str] = None, + ) -> None: + """ + Send current game state to all spectators. + + Args: + game_id: Game UUID. + game_state: Current game state dict. + event_type: Optional event type that triggered this update. + """ + message = { + "type": "game_state", + "game_state": game_state, + "spectator_count": self.get_spectator_count(game_id), + } + if event_type: + message["event_type"] = event_type + + await self.broadcast_to_spectators(game_id, message) + + def get_spectator_count(self, game_id: str) -> int: + """ + Get number of spectators for a game. + + Args: + game_id: Game UUID. + + Returns: + Spectator count. + """ + return len(self._spectators.get(game_id, [])) + + def get_spectator_usernames(self, game_id: str) -> list[str]: + """ + Get list of spectator usernames. + + Args: + game_id: Game UUID. + + Returns: + List of spectator usernames. + """ + if game_id not in self._spectators: + return [] + return [ + info.username or "Anonymous" + for info in self._spectators[game_id] + ] + + def get_games_with_spectators(self) -> dict[str, int]: + """ + Get all games that have spectators. + + Returns: + Dict of game_id -> spectator count. + """ + return { + game_id: len(spectators) + for game_id, spectators in self._spectators.items() + if spectators + } + + async def notify_game_ended(self, game_id: str, final_state: dict) -> None: + """ + Notify spectators that a game has ended. + + Args: + game_id: Game UUID. + final_state: Final game state with scores. + """ + await self.broadcast_to_spectators(game_id, { + "type": "game_ended", + "final_state": final_state, + }) + + async def close_all_for_game(self, game_id: str) -> None: + """ + Close all spectator connections for a game. + + Use when a game is being cleaned up. + + Args: + game_id: Game UUID. + """ + if game_id not in self._spectators: + return + + for info in list(self._spectators[game_id]): + try: + await info.websocket.close(code=1000, reason="Game ended") + except Exception: + pass + self._ws_to_game.pop(info.websocket, None) + + del self._spectators[game_id] + logger.info(f"Closed all spectators for game {game_id}") + + +# Global instance +_spectator_manager: Optional[SpectatorManager] = None + + +def get_spectator_manager() -> SpectatorManager: + """Get the global spectator manager instance.""" + global _spectator_manager + if _spectator_manager is None: + _spectator_manager = SpectatorManager() + return _spectator_manager + + +def close_spectator_manager() -> None: + """Close the spectator manager.""" + global _spectator_manager + _spectator_manager = None diff --git a/server/services/stats_service.py b/server/services/stats_service.py new file mode 100644 index 0000000..c96267a --- /dev/null +++ b/server/services/stats_service.py @@ -0,0 +1,977 @@ +""" +Stats service for Golf game leaderboards and achievements. + +Provides player statistics aggregation, leaderboard queries, and achievement tracking. +""" + +import logging +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Optional, List +from uuid import UUID + +import asyncpg + +from stores.event_store import EventStore +from models.events import EventType + +logger = logging.getLogger(__name__) + + +@dataclass +class PlayerStats: + """Full player statistics.""" + user_id: str + username: str + games_played: int = 0 + games_won: int = 0 + win_rate: float = 0.0 + rounds_played: int = 0 + rounds_won: int = 0 + avg_score: float = 0.0 + best_round_score: Optional[int] = None + worst_round_score: Optional[int] = None + knockouts: int = 0 + perfect_rounds: int = 0 + wolfpacks: int = 0 + current_win_streak: int = 0 + best_win_streak: int = 0 + first_game_at: Optional[datetime] = None + last_game_at: Optional[datetime] = None + achievements: List[str] = field(default_factory=list) + + +@dataclass +class LeaderboardEntry: + """Single entry on a leaderboard.""" + rank: int + user_id: str + username: str + value: float + games_played: int + secondary_value: Optional[float] = None + + +@dataclass +class Achievement: + """Achievement definition.""" + id: str + name: str + description: str + icon: str + category: str + threshold: int + + +@dataclass +class UserAchievement: + """Achievement earned by a user.""" + id: str + name: str + description: str + icon: str + earned_at: datetime + game_id: Optional[str] = None + + +class StatsService: + """ + Player statistics and leaderboards service. + + Provides methods for: + - Querying player stats + - Fetching leaderboards by various metrics + - Processing game completion for stats aggregation + - Achievement checking and awarding + """ + + def __init__(self, pool: asyncpg.Pool, event_store: Optional[EventStore] = None): + """ + Initialize stats service. + + Args: + pool: asyncpg connection pool. + event_store: Optional EventStore for event-based stats processing. + """ + self.pool = pool + self.event_store = event_store + + # ------------------------------------------------------------------------- + # Stats Queries + # ------------------------------------------------------------------------- + + async def get_player_stats(self, user_id: str) -> Optional[PlayerStats]: + """ + Get full stats for a specific player. + + Args: + user_id: User UUID. + + Returns: + PlayerStats or None if player not found. + """ + async with self.pool.acquire() as conn: + row = await conn.fetchrow(""" + SELECT s.*, u.username, + ROUND(s.games_won::numeric / NULLIF(s.games_played, 0) * 100, 1) as win_rate, + ROUND(s.total_points::numeric / NULLIF(s.total_rounds, 0), 1) as avg_score_calc + FROM player_stats s + JOIN users_v2 u ON s.user_id = u.id + WHERE s.user_id = $1 + """, user_id) + + if not row: + # Check if user exists but has no stats + user_row = await conn.fetchrow( + "SELECT username FROM users_v2 WHERE id = $1", + user_id + ) + if user_row: + return PlayerStats( + user_id=user_id, + username=user_row["username"], + ) + return None + + # Get achievements + achievements = await conn.fetch(""" + SELECT achievement_id FROM user_achievements + WHERE user_id = $1 + """, user_id) + + return PlayerStats( + user_id=str(row["user_id"]), + username=row["username"], + games_played=row["games_played"] or 0, + games_won=row["games_won"] or 0, + win_rate=float(row["win_rate"] or 0), + rounds_played=row["total_rounds"] or 0, + rounds_won=row["rounds_won"] or 0, + avg_score=float(row["avg_score_calc"] or 0), + best_round_score=row["best_score"], + worst_round_score=row["worst_score"], + knockouts=row["knockouts"] or 0, + perfect_rounds=row["perfect_rounds"] or 0, + wolfpacks=row["wolfpacks"] or 0, + current_win_streak=row["current_win_streak"] or 0, + best_win_streak=row["best_win_streak"] or 0, + first_game_at=row["first_game_at"].replace(tzinfo=timezone.utc) if row["first_game_at"] else None, + last_game_at=row["last_game_at"].replace(tzinfo=timezone.utc) if row["last_game_at"] else None, + achievements=[a["achievement_id"] for a in achievements], + ) + + async def get_leaderboard( + self, + metric: str = "wins", + limit: int = 50, + offset: int = 0, + ) -> List[LeaderboardEntry]: + """ + Get leaderboard by metric. + + Args: + metric: Ranking metric - wins, win_rate, avg_score, knockouts, streak. + limit: Maximum entries to return. + offset: Pagination offset. + + Returns: + List of LeaderboardEntry sorted by metric. + """ + order_map = { + "wins": ("games_won", "DESC"), + "win_rate": ("win_rate", "DESC"), + "avg_score": ("avg_score", "ASC"), # Lower is better + "knockouts": ("knockouts", "DESC"), + "streak": ("best_win_streak", "DESC"), + } + + if metric not in order_map: + metric = "wins" + + column, direction = order_map[metric] + + async with self.pool.acquire() as conn: + # Check if materialized view exists + view_exists = await conn.fetchval( + "SELECT 1 FROM pg_matviews WHERE matviewname = 'leaderboard_overall'" + ) + + if view_exists: + # Use materialized view for performance + rows = await conn.fetch(f""" + SELECT + user_id, username, games_played, games_won, + win_rate, avg_score, knockouts, best_win_streak, + ROW_NUMBER() OVER (ORDER BY {column} {direction}) as rank + FROM leaderboard_overall + ORDER BY {column} {direction} + LIMIT $1 OFFSET $2 + """, limit, offset) + else: + # Fall back to direct query + rows = await conn.fetch(f""" + SELECT + s.user_id, u.username, s.games_played, s.games_won, + ROUND(s.games_won::numeric / NULLIF(s.games_played, 0) * 100, 1) as win_rate, + ROUND(s.total_points::numeric / NULLIF(s.total_rounds, 0), 1) as avg_score, + s.knockouts, s.best_win_streak, + ROW_NUMBER() OVER (ORDER BY {column} {direction}) as rank + FROM player_stats s + JOIN users_v2 u ON s.user_id = u.id + WHERE s.games_played >= 5 + AND u.deleted_at IS NULL + AND (u.is_banned = false OR u.is_banned IS NULL) + ORDER BY {column} {direction} + LIMIT $1 OFFSET $2 + """, limit, offset) + + return [ + LeaderboardEntry( + rank=row["rank"], + user_id=str(row["user_id"]), + username=row["username"], + value=float(row[column] or 0), + games_played=row["games_played"], + secondary_value=float(row["win_rate"] or 0) if metric != "win_rate" else None, + ) + for row in rows + ] + + async def get_player_rank(self, user_id: str, metric: str = "wins") -> Optional[int]: + """ + Get a player's rank on a leaderboard. + + Args: + user_id: User UUID. + metric: Ranking metric. + + Returns: + Rank number or None if not ranked (< 5 games or not found). + """ + order_map = { + "wins": ("games_won", "DESC"), + "win_rate": ("win_rate", "DESC"), + "avg_score": ("avg_score", "ASC"), + "knockouts": ("knockouts", "DESC"), + "streak": ("best_win_streak", "DESC"), + } + + if metric not in order_map: + return None + + column, direction = order_map[metric] + + async with self.pool.acquire() as conn: + # Check if user qualifies (5+ games) + games = await conn.fetchval( + "SELECT games_played FROM player_stats WHERE user_id = $1", + user_id + ) + if not games or games < 5: + return None + + view_exists = await conn.fetchval( + "SELECT 1 FROM pg_matviews WHERE matviewname = 'leaderboard_overall'" + ) + + if view_exists: + row = await conn.fetchrow(f""" + SELECT rank FROM ( + SELECT user_id, ROW_NUMBER() OVER (ORDER BY {column} {direction}) as rank + FROM leaderboard_overall + ) ranked + WHERE user_id = $1 + """, user_id) + else: + row = await conn.fetchrow(f""" + SELECT rank FROM ( + SELECT s.user_id, ROW_NUMBER() OVER (ORDER BY {column} {direction}) as rank + FROM player_stats s + JOIN users_v2 u ON s.user_id = u.id + WHERE s.games_played >= 5 + AND u.deleted_at IS NULL + AND (u.is_banned = false OR u.is_banned IS NULL) + ) ranked + WHERE user_id = $1 + """, user_id) + + return row["rank"] if row else None + + async def refresh_leaderboard(self) -> bool: + """ + Refresh the materialized leaderboard view. + + Returns: + True if refresh succeeded. + """ + async with self.pool.acquire() as conn: + try: + # Check if view exists + view_exists = await conn.fetchval( + "SELECT 1 FROM pg_matviews WHERE matviewname = 'leaderboard_overall'" + ) + if view_exists: + await conn.execute("REFRESH MATERIALIZED VIEW CONCURRENTLY leaderboard_overall") + logger.info("Refreshed leaderboard materialized view") + return True + except Exception as e: + logger.error(f"Failed to refresh leaderboard: {e}") + return False + + # ------------------------------------------------------------------------- + # Achievement Queries + # ------------------------------------------------------------------------- + + async def get_achievements(self) -> List[Achievement]: + """Get all available achievements.""" + async with self.pool.acquire() as conn: + rows = await conn.fetch(""" + SELECT id, name, description, icon, category, threshold + FROM achievements + ORDER BY sort_order + """) + + return [ + Achievement( + id=row["id"], + name=row["name"], + description=row["description"] or "", + icon=row["icon"] or "", + category=row["category"] or "", + threshold=row["threshold"] or 0, + ) + for row in rows + ] + + async def get_user_achievements(self, user_id: str) -> List[UserAchievement]: + """ + Get achievements earned by a user. + + Args: + user_id: User UUID. + + Returns: + List of earned achievements. + """ + async with self.pool.acquire() as conn: + rows = await conn.fetch(""" + SELECT a.id, a.name, a.description, a.icon, ua.earned_at, ua.game_id + FROM user_achievements ua + JOIN achievements a ON ua.achievement_id = a.id + WHERE ua.user_id = $1 + ORDER BY ua.earned_at DESC + """, user_id) + + return [ + UserAchievement( + id=row["id"], + name=row["name"], + description=row["description"] or "", + icon=row["icon"] or "", + earned_at=row["earned_at"].replace(tzinfo=timezone.utc) if row["earned_at"] else datetime.now(timezone.utc), + game_id=str(row["game_id"]) if row["game_id"] else None, + ) + for row in rows + ] + + # ------------------------------------------------------------------------- + # Stats Processing (Game Completion) + # ------------------------------------------------------------------------- + + async def process_game_end(self, game_id: str) -> List[str]: + """ + Process a completed game and update player stats. + + Extracts game data from events and updates player_stats table. + + Args: + game_id: Game UUID. + + Returns: + List of newly awarded achievement IDs. + """ + if not self.event_store: + logger.warning("No event store configured, skipping stats processing") + return [] + + # Get game events + try: + events = await self.event_store.get_events(game_id) + except Exception as e: + logger.error(f"Failed to get events for game {game_id}: {e}") + return [] + + if not events: + logger.warning(f"No events found for game {game_id}") + return [] + + # Extract game data from events + game_data = self._extract_game_data(events) + + if not game_data: + logger.warning(f"Could not extract game data from events for {game_id}") + return [] + + all_new_achievements = [] + + async with self.pool.acquire() as conn: + async with conn.transaction(): + for player_id, player_data in game_data["players"].items(): + # Skip CPU players (they don't have user accounts) + if player_data.get("is_cpu"): + continue + + # Check if this is a valid user UUID + try: + UUID(player_id) + except (ValueError, TypeError): + # Not a UUID - likely a websocket session ID, skip + continue + + # Ensure stats row exists + await conn.execute(""" + INSERT INTO player_stats (user_id) + VALUES ($1) + ON CONFLICT (user_id) DO NOTHING + """, player_id) + + # Calculate values + is_winner = player_id == game_data["winner_id"] + total_score = player_data["total_score"] + rounds_won = player_data["rounds_won"] + num_rounds = game_data["num_rounds"] + knockouts = player_data.get("knockouts", 0) + best_round = player_data.get("best_round") + worst_round = player_data.get("worst_round") + perfect_rounds = player_data.get("perfect_rounds", 0) + wolfpacks = player_data.get("wolfpacks", 0) + has_human_opponents = game_data.get("has_human_opponents", False) + + # Update stats + await conn.execute(""" + UPDATE player_stats SET + games_played = games_played + 1, + games_won = games_won + $2, + total_rounds = total_rounds + $3, + rounds_won = rounds_won + $4, + total_points = total_points + $5, + knockouts = knockouts + $6, + perfect_rounds = perfect_rounds + $7, + wolfpacks = wolfpacks + $8, + best_score = CASE + WHEN best_score IS NULL THEN $9 + WHEN $9 IS NOT NULL AND $9 < best_score THEN $9 + ELSE best_score + END, + worst_score = CASE + WHEN worst_score IS NULL THEN $10 + WHEN $10 IS NOT NULL AND $10 > worst_score THEN $10 + ELSE worst_score + END, + current_win_streak = CASE WHEN $2 = 1 THEN current_win_streak + 1 ELSE 0 END, + best_win_streak = GREATEST(best_win_streak, + CASE WHEN $2 = 1 THEN current_win_streak + 1 ELSE best_win_streak END), + first_game_at = COALESCE(first_game_at, NOW()), + last_game_at = NOW(), + games_vs_humans = games_vs_humans + $11, + games_won_vs_humans = games_won_vs_humans + $12, + updated_at = NOW() + WHERE user_id = $1 + """, + player_id, + 1 if is_winner else 0, + num_rounds, + rounds_won, + total_score, + knockouts, + perfect_rounds, + wolfpacks, + best_round, + worst_round, + 1 if has_human_opponents else 0, + 1 if is_winner and has_human_opponents else 0, + ) + + # Check for new achievements + new_achievements = await self._check_achievements( + conn, player_id, game_id, player_data, is_winner + ) + all_new_achievements.extend(new_achievements) + + logger.info(f"Processed stats for game {game_id}, awarded {len(all_new_achievements)} achievements") + return all_new_achievements + + def _extract_game_data(self, events) -> Optional[dict]: + """ + Extract game statistics from event stream. + + Args: + events: List of GameEvent objects. + + Returns: + Dict with players, num_rounds, winner_id, etc. + """ + data = { + "players": {}, + "num_rounds": 0, + "winner_id": None, + "has_human_opponents": False, + } + + human_count = 0 + + for event in events: + if event.event_type == EventType.PLAYER_JOINED: + is_cpu = event.data.get("is_cpu", False) + if not is_cpu: + human_count += 1 + + data["players"][event.player_id] = { + "is_cpu": is_cpu, + "total_score": 0, + "rounds_won": 0, + "knockouts": 0, + "perfect_rounds": 0, + "wolfpacks": 0, + "best_round": None, + "worst_round": None, + } + + elif event.event_type == EventType.ROUND_ENDED: + data["num_rounds"] += 1 + scores = event.data.get("scores", {}) + finisher_id = event.data.get("finisher_id") + + # Track who went out first (knockout) + if finisher_id and finisher_id in data["players"]: + data["players"][finisher_id]["knockouts"] += 1 + + # Find round winner (lowest score) + if scores: + min_score = min(scores.values()) + for pid, score in scores.items(): + if pid in data["players"]: + p = data["players"][pid] + p["total_score"] += score + + # Track best/worst rounds + if p["best_round"] is None or score < p["best_round"]: + p["best_round"] = score + if p["worst_round"] is None or score > p["worst_round"]: + p["worst_round"] = score + + # Check for perfect round (score <= 0) + if score <= 0: + p["perfect_rounds"] += 1 + + # Award round win + if score == min_score: + p["rounds_won"] += 1 + + # Check for wolfpack (4 Jacks) in final hands + final_hands = event.data.get("final_hands", {}) + for pid, hand in final_hands.items(): + if pid in data["players"]: + jack_count = sum(1 for card in hand if card.get("rank") == "J") + if jack_count >= 4: + data["players"][pid]["wolfpacks"] += 1 + + elif event.event_type == EventType.GAME_ENDED: + data["winner_id"] = event.data.get("winner_id") + + # Mark if there were human opponents + data["has_human_opponents"] = human_count > 1 + + return data if data["num_rounds"] > 0 else None + + async def _check_achievements( + self, + conn: asyncpg.Connection, + user_id: str, + game_id: str, + player_data: dict, + is_winner: bool, + ) -> List[str]: + """ + Check and award new achievements to a player. + + Args: + conn: Database connection (within transaction). + user_id: Player's user ID. + game_id: Current game ID. + player_data: Player's data from this game. + is_winner: Whether player won the game. + + Returns: + List of newly awarded achievement IDs. + """ + new_achievements = [] + + # Get current stats (after update) + stats = await conn.fetchrow(""" + SELECT games_won, knockouts, best_win_streak, current_win_streak, perfect_rounds, wolfpacks + FROM player_stats + WHERE user_id = $1 + """, user_id) + + if not stats: + return [] + + # Get already earned achievements + earned = await conn.fetch(""" + SELECT achievement_id FROM user_achievements WHERE user_id = $1 + """, user_id) + earned_ids = {e["achievement_id"] for e in earned} + + # Check win milestones + wins = stats["games_won"] + if wins >= 1 and "first_win" not in earned_ids: + new_achievements.append("first_win") + if wins >= 10 and "win_10" not in earned_ids: + new_achievements.append("win_10") + if wins >= 50 and "win_50" not in earned_ids: + new_achievements.append("win_50") + if wins >= 100 and "win_100" not in earned_ids: + new_achievements.append("win_100") + + # Check streak achievements + streak = stats["current_win_streak"] + if streak >= 5 and "streak_5" not in earned_ids: + new_achievements.append("streak_5") + if streak >= 10 and "streak_10" not in earned_ids: + new_achievements.append("streak_10") + + # Check knockout achievements + if stats["knockouts"] >= 10 and "knockout_10" not in earned_ids: + new_achievements.append("knockout_10") + + # Check round-specific achievements from this game + best_round = player_data.get("best_round") + if best_round is not None: + if best_round <= 0 and "perfect_round" not in earned_ids: + new_achievements.append("perfect_round") + if best_round < 0 and "negative_round" not in earned_ids: + new_achievements.append("negative_round") + + # Check wolfpack + if player_data.get("wolfpacks", 0) > 0 and "wolfpack" not in earned_ids: + new_achievements.append("wolfpack") + + # Award new achievements + for achievement_id in new_achievements: + try: + await conn.execute(""" + INSERT INTO user_achievements (user_id, achievement_id, game_id) + VALUES ($1, $2, $3) + ON CONFLICT DO NOTHING + """, user_id, achievement_id, game_id) + except Exception as e: + logger.error(f"Failed to award achievement {achievement_id}: {e}") + + return new_achievements + + # ------------------------------------------------------------------------- + # Direct Game State Processing (for legacy games without event sourcing) + # ------------------------------------------------------------------------- + + async def process_game_from_state( + self, + players: list, + winner_id: Optional[str], + num_rounds: int, + player_user_ids: dict[str, str] = None, + ) -> List[str]: + """ + Process game stats directly from game state (for legacy games). + + This is used when games don't have event sourcing. Stats are updated + based on final game state. + + Args: + players: List of game.Player objects with final scores. + winner_id: Player ID of the winner. + num_rounds: Total rounds played. + player_user_ids: Optional mapping of player_id to user_id (for authenticated players). + + Returns: + List of newly awarded achievement IDs. + """ + if not players: + return [] + + # Count human players for has_human_opponents calculation + # For legacy games, we assume all players are human unless otherwise indicated + human_count = len(players) + has_human_opponents = human_count > 1 + + all_new_achievements = [] + + async with self.pool.acquire() as conn: + async with conn.transaction(): + for player in players: + # Get user_id - could be the player_id itself if it's a UUID, + # or mapped via player_user_ids + user_id = None + if player_user_ids and player.id in player_user_ids: + user_id = player_user_ids[player.id] + else: + # Try to use player.id as user_id if it looks like a UUID + try: + UUID(player.id) + user_id = player.id + except (ValueError, TypeError): + # Not a UUID, skip this player + continue + + if not user_id: + continue + + # Ensure stats row exists + await conn.execute(""" + INSERT INTO player_stats (user_id) + VALUES ($1) + ON CONFLICT (user_id) DO NOTHING + """, user_id) + + is_winner = player.id == winner_id + total_score = player.total_score + rounds_won = player.rounds_won + + # We don't have per-round data in legacy mode, so some stats are limited + # Use total_score / num_rounds as an approximation for avg round score + avg_round_score = total_score / num_rounds if num_rounds > 0 else None + + # Update stats + await conn.execute(""" + UPDATE player_stats SET + games_played = games_played + 1, + games_won = games_won + $2, + total_rounds = total_rounds + $3, + rounds_won = rounds_won + $4, + total_points = total_points + $5, + best_score = CASE + WHEN best_score IS NULL THEN $6 + WHEN $6 IS NOT NULL AND $6 < best_score THEN $6 + ELSE best_score + END, + worst_score = CASE + WHEN worst_score IS NULL THEN $7 + WHEN $7 IS NOT NULL AND $7 > worst_score THEN $7 + ELSE worst_score + END, + current_win_streak = CASE WHEN $2 = 1 THEN current_win_streak + 1 ELSE 0 END, + best_win_streak = GREATEST(best_win_streak, + CASE WHEN $2 = 1 THEN current_win_streak + 1 ELSE best_win_streak END), + first_game_at = COALESCE(first_game_at, NOW()), + last_game_at = NOW(), + games_vs_humans = games_vs_humans + $8, + games_won_vs_humans = games_won_vs_humans + $9, + updated_at = NOW() + WHERE user_id = $1 + """, + user_id, + 1 if is_winner else 0, + num_rounds, + rounds_won, + total_score, + avg_round_score, # Approximation for best_score + avg_round_score, # Approximation for worst_score + 1 if has_human_opponents else 0, + 1 if is_winner and has_human_opponents else 0, + ) + + # Check achievements (limited data in legacy mode) + new_achievements = await self._check_achievements_legacy( + conn, user_id, is_winner + ) + all_new_achievements.extend(new_achievements) + + logger.info(f"Processed stats for legacy game with {len(players)} players") + return all_new_achievements + + async def _check_achievements_legacy( + self, + conn: asyncpg.Connection, + user_id: str, + is_winner: bool, + ) -> List[str]: + """ + Check and award achievements for legacy games (limited data). + + Only checks win-based achievements since we don't have round-level data. + """ + new_achievements = [] + + # Get current stats + stats = await conn.fetchrow(""" + SELECT games_won, current_win_streak FROM player_stats + WHERE user_id = $1 + """, user_id) + + if not stats: + return [] + + # Get already earned achievements + earned = await conn.fetch(""" + SELECT achievement_id FROM user_achievements WHERE user_id = $1 + """, user_id) + earned_ids = {e["achievement_id"] for e in earned} + + # Check win milestones + wins = stats["games_won"] + if wins >= 1 and "first_win" not in earned_ids: + new_achievements.append("first_win") + if wins >= 10 and "win_10" not in earned_ids: + new_achievements.append("win_10") + if wins >= 50 and "win_50" not in earned_ids: + new_achievements.append("win_50") + if wins >= 100 and "win_100" not in earned_ids: + new_achievements.append("win_100") + + # Check streak achievements + streak = stats["current_win_streak"] + if streak >= 5 and "streak_5" not in earned_ids: + new_achievements.append("streak_5") + if streak >= 10 and "streak_10" not in earned_ids: + new_achievements.append("streak_10") + + # Award new achievements + for achievement_id in new_achievements: + try: + await conn.execute(""" + INSERT INTO user_achievements (user_id, achievement_id) + VALUES ($1, $2) + ON CONFLICT DO NOTHING + """, user_id, achievement_id) + except Exception as e: + logger.error(f"Failed to award achievement {achievement_id}: {e}") + + return new_achievements + + # ------------------------------------------------------------------------- + # Stats Queue Management + # ------------------------------------------------------------------------- + + async def queue_game_for_processing(self, game_id: str) -> int: + """ + Add a game to the stats processing queue. + + Args: + game_id: Game UUID. + + Returns: + Queue entry ID. + """ + async with self.pool.acquire() as conn: + row = await conn.fetchrow(""" + INSERT INTO stats_queue (game_id) + VALUES ($1) + RETURNING id + """, game_id) + return row["id"] + + async def process_pending_queue(self, limit: int = 100) -> int: + """ + Process pending games in the stats queue. + + Args: + limit: Maximum games to process. + + Returns: + Number of games processed. + """ + processed = 0 + + async with self.pool.acquire() as conn: + # Get pending games + games = await conn.fetch(""" + SELECT id, game_id FROM stats_queue + WHERE status = 'pending' + ORDER BY created_at + LIMIT $1 + """, limit) + + for game in games: + try: + # Mark as processing + await conn.execute(""" + UPDATE stats_queue SET status = 'processing' WHERE id = $1 + """, game["id"]) + + # Process + await self.process_game_end(str(game["game_id"])) + + # Mark complete + await conn.execute(""" + UPDATE stats_queue + SET status = 'completed', processed_at = NOW() + WHERE id = $1 + """, game["id"]) + + processed += 1 + + except Exception as e: + logger.error(f"Failed to process game {game['game_id']}: {e}") + # Mark failed + await conn.execute(""" + UPDATE stats_queue + SET status = 'failed', error_message = $2, processed_at = NOW() + WHERE id = $1 + """, game["id"], str(e)) + + return processed + + async def cleanup_old_queue_entries(self, days: int = 7) -> int: + """ + Clean up old completed/failed queue entries. + + Args: + days: Delete entries older than this many days. + + Returns: + Number of entries deleted. + """ + async with self.pool.acquire() as conn: + result = await conn.execute(""" + DELETE FROM stats_queue + WHERE status IN ('completed', 'failed') + AND processed_at < NOW() - INTERVAL '1 day' * $1 + """, days) + # Parse "DELETE N" result + return int(result.split()[1]) if result else 0 + + +# Global stats service instance +_stats_service: Optional[StatsService] = None + + +async def get_stats_service( + pool: asyncpg.Pool, + event_store: Optional[EventStore] = None, +) -> StatsService: + """ + Get or create the global stats service instance. + + Args: + pool: asyncpg connection pool. + event_store: Optional EventStore. + + Returns: + StatsService instance. + """ + global _stats_service + if _stats_service is None: + _stats_service = StatsService(pool, event_store) + return _stats_service + + +def set_stats_service(service: StatsService) -> None: + """Set the global stats service instance.""" + global _stats_service + _stats_service = service + + +def close_stats_service() -> None: + """Close the global stats service.""" + global _stats_service + _stats_service = None diff --git a/server/stores/__init__.py b/server/stores/__init__.py new file mode 100644 index 0000000..6cb77af --- /dev/null +++ b/server/stores/__init__.py @@ -0,0 +1,26 @@ +"""Stores package for Golf game V2 persistence.""" + +from .event_store import EventStore, ConcurrencyError +from .state_cache import StateCache, get_state_cache, close_state_cache +from .pubsub import GamePubSub, PubSubMessage, MessageType, get_pubsub, close_pubsub +from .user_store import UserStore, get_user_store, close_user_store + +__all__ = [ + # Event store + "EventStore", + "ConcurrencyError", + # State cache + "StateCache", + "get_state_cache", + "close_state_cache", + # Pub/sub + "GamePubSub", + "PubSubMessage", + "MessageType", + "get_pubsub", + "close_pubsub", + # User store + "UserStore", + "get_user_store", + "close_user_store", +] diff --git a/server/stores/event_store.py b/server/stores/event_store.py new file mode 100644 index 0000000..780bf59 --- /dev/null +++ b/server/stores/event_store.py @@ -0,0 +1,485 @@ +""" +PostgreSQL-backed event store for Golf game. + +The event store is an append-only log of all game events. +Events are immutable and ordered by sequence number within each game. + +Features: +- Optimistic concurrency via unique constraint on (game_id, sequence_num) +- Batch appends for atomic multi-event writes +- Streaming for memory-efficient large game replay +- Game metadata table for efficient queries +""" + +import json +import logging +from datetime import datetime, timezone +from typing import Optional, AsyncIterator + +import asyncpg + +from models.events import GameEvent, EventType + +logger = logging.getLogger(__name__) + + +class ConcurrencyError(Exception): + """Raised when optimistic concurrency check fails.""" + pass + + +# SQL schema for event store +SCHEMA_SQL = """ +-- Events table (append-only log) +CREATE TABLE IF NOT EXISTS events ( + id BIGSERIAL PRIMARY KEY, + game_id UUID NOT NULL, + sequence_num INT NOT NULL, + event_type VARCHAR(50) NOT NULL, + player_id VARCHAR(50), + event_data JSONB NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + + -- Ensure events are ordered and unique per game + UNIQUE(game_id, sequence_num) +); + +-- Games metadata (denormalized for queries, not source of truth) +CREATE TABLE IF NOT EXISTS games_v2 ( + id UUID PRIMARY KEY, + room_code VARCHAR(10) NOT NULL, + status VARCHAR(20) DEFAULT 'active', -- active, completed, abandoned + created_at TIMESTAMPTZ DEFAULT NOW(), + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + num_players INT, + num_rounds INT, + options JSONB, + winner_id VARCHAR(50), + host_id VARCHAR(50), + + -- Denormalized for efficient queries + player_ids VARCHAR(50)[] DEFAULT '{}' +); + +-- 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); +CREATE INDEX IF NOT EXISTS idx_events_player ON events(player_id) WHERE player_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_events_created ON events(created_at); + +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'; +""" + + +class EventStore: + """ + PostgreSQL-backed event store. + + Provides methods for appending events and querying event history. + Uses asyncpg for async database access. + """ + + def __init__(self, pool: asyncpg.Pool): + """ + Initialize event store with connection pool. + + Args: + pool: asyncpg connection pool. + """ + self.pool = pool + + @classmethod + async def create(cls, postgres_url: str) -> "EventStore": + """ + Create an EventStore with a new connection pool. + + Args: + postgres_url: PostgreSQL connection URL. + + Returns: + Configured EventStore instance. + """ + pool = await asyncpg.create_pool(postgres_url, min_size=2, max_size=10) + store = cls(pool) + await store.initialize_schema() + return store + + async def initialize_schema(self) -> None: + """Create database tables if they don't exist.""" + async with self.pool.acquire() as conn: + await conn.execute(SCHEMA_SQL) + logger.info("Event store schema initialized") + + async def close(self) -> None: + """Close the connection pool.""" + await self.pool.close() + + # ------------------------------------------------------------------------- + # Event Writes + # ------------------------------------------------------------------------- + + async def append(self, event: GameEvent) -> int: + """ + Append an event to the store. + + Args: + event: The event to append. + + Returns: + The database ID of the inserted event. + + Raises: + ConcurrencyError: If sequence_num already exists for this game. + """ + async with self.pool.acquire() as conn: + try: + row = await conn.fetchrow( + """ + INSERT INTO events (game_id, sequence_num, event_type, player_id, event_data) + VALUES ($1, $2, $3, $4, $5) + RETURNING id + """, + event.game_id, + event.sequence_num, + event.event_type.value, + event.player_id, + json.dumps(event.data), + ) + return row["id"] + except asyncpg.UniqueViolationError: + raise ConcurrencyError( + f"Event {event.sequence_num} already exists for game {event.game_id}" + ) + + async def append_batch(self, events: list[GameEvent]) -> list[int]: + """ + Append multiple events atomically. + + All events are inserted in a single transaction. + If any event fails (e.g., duplicate sequence), all are rolled back. + + Args: + events: List of events to append. + + Returns: + List of database IDs for inserted events. + + Raises: + ConcurrencyError: If any sequence_num already exists. + """ + if not events: + return [] + + async with self.pool.acquire() as conn: + async with conn.transaction(): + ids = [] + for event in events: + try: + row = await conn.fetchrow( + """ + INSERT INTO events (game_id, sequence_num, event_type, player_id, event_data) + VALUES ($1, $2, $3, $4, $5) + RETURNING id + """, + event.game_id, + event.sequence_num, + event.event_type.value, + event.player_id, + json.dumps(event.data), + ) + ids.append(row["id"]) + except asyncpg.UniqueViolationError: + raise ConcurrencyError( + f"Event {event.sequence_num} already exists for game {event.game_id}" + ) + return ids + + # ------------------------------------------------------------------------- + # Event Reads + # ------------------------------------------------------------------------- + + async def get_events( + self, + game_id: str, + from_sequence: int = 0, + to_sequence: Optional[int] = None, + ) -> list[GameEvent]: + """ + Get events for a game, optionally within a sequence range. + + Args: + game_id: Game UUID. + from_sequence: Start sequence (inclusive). + to_sequence: End sequence (inclusive), or None for all. + + Returns: + List of events in sequence order. + """ + async with self.pool.acquire() as conn: + if to_sequence is not None: + rows = await conn.fetch( + """ + SELECT event_type, game_id, sequence_num, player_id, event_data, created_at + FROM events + WHERE game_id = $1 AND sequence_num >= $2 AND sequence_num <= $3 + ORDER BY sequence_num + """, + game_id, + from_sequence, + to_sequence, + ) + else: + rows = await conn.fetch( + """ + SELECT event_type, game_id, sequence_num, player_id, event_data, created_at + FROM events + WHERE game_id = $1 AND sequence_num >= $2 + ORDER BY sequence_num + """, + game_id, + from_sequence, + ) + + return [self._row_to_event(row) for row in rows] + + async def get_latest_sequence(self, game_id: str) -> int: + """ + Get the latest sequence number for a game. + + Args: + game_id: Game UUID. + + Returns: + Latest sequence number, or -1 if no events exist. + """ + async with self.pool.acquire() as conn: + row = await conn.fetchrow( + """ + SELECT COALESCE(MAX(sequence_num), -1) as seq + FROM events + WHERE game_id = $1 + """, + game_id, + ) + return row["seq"] + + async def stream_events( + self, + game_id: str, + from_sequence: int = 0, + ) -> AsyncIterator[GameEvent]: + """ + Stream events for memory-efficient processing. + + Use this for replaying large games without loading all events into memory. + + Args: + game_id: Game UUID. + from_sequence: Start sequence (inclusive). + + Yields: + Events in sequence order. + """ + async with self.pool.acquire() as conn: + async with conn.transaction(): + async for row in conn.cursor( + """ + SELECT event_type, game_id, sequence_num, player_id, event_data, created_at + FROM events + WHERE game_id = $1 AND sequence_num >= $2 + ORDER BY sequence_num + """, + game_id, + from_sequence, + ): + yield self._row_to_event(row) + + async def get_event_count(self, game_id: str) -> int: + """ + Get the total number of events for a game. + + Args: + game_id: Game UUID. + + Returns: + Event count. + """ + async with self.pool.acquire() as conn: + row = await conn.fetchrow( + "SELECT COUNT(*) as count FROM events WHERE game_id = $1", + game_id, + ) + return row["count"] + + # ------------------------------------------------------------------------- + # Game Metadata + # ------------------------------------------------------------------------- + + async def create_game( + self, + game_id: str, + room_code: str, + host_id: str, + options: Optional[dict] = None, + ) -> None: + """ + Create a game metadata record. + + Args: + game_id: Game UUID. + room_code: 4-letter room code. + host_id: Host player ID. + options: GameOptions as dict. + """ + async with self.pool.acquire() as conn: + await conn.execute( + """ + INSERT INTO games_v2 (id, room_code, host_id, options) + VALUES ($1, $2, $3, $4) + ON CONFLICT (id) DO NOTHING + """, + game_id, + room_code, + host_id, + json.dumps(options) if options else None, + ) + + async def update_game_started( + self, + game_id: str, + num_players: int, + num_rounds: int, + player_ids: list[str], + ) -> None: + """ + Update game metadata when game starts. + + Args: + game_id: Game UUID. + num_players: Number of players. + num_rounds: Number of rounds. + player_ids: List of player IDs. + """ + async with self.pool.acquire() as conn: + await conn.execute( + """ + UPDATE games_v2 + SET started_at = NOW(), num_players = $2, num_rounds = $3, player_ids = $4 + WHERE id = $1 + """, + game_id, + num_players, + num_rounds, + player_ids, + ) + + async def update_game_completed( + self, + game_id: str, + winner_id: Optional[str] = None, + ) -> None: + """ + Update game metadata when game completes. + + Args: + game_id: Game UUID. + winner_id: ID of the winner. + """ + async with self.pool.acquire() as conn: + await conn.execute( + """ + UPDATE games_v2 + SET status = 'completed', completed_at = NOW(), winner_id = $2 + WHERE id = $1 + """, + game_id, + winner_id, + ) + + async def get_active_games(self) -> list[dict]: + """ + Get all active games for recovery on server restart. + + Returns: + List of active game metadata dicts. + """ + async with self.pool.acquire() as conn: + rows = await conn.fetch( + """ + SELECT id, room_code, status, created_at, started_at, num_players, + num_rounds, options, host_id, player_ids + FROM games_v2 + WHERE status = 'active' + ORDER BY created_at DESC + """ + ) + return [dict(row) for row in rows] + + async def get_game(self, game_id: str) -> Optional[dict]: + """ + Get game metadata by ID. + + Args: + game_id: Game UUID. + + Returns: + Game metadata dict, or None if not found. + """ + async with self.pool.acquire() as conn: + row = await conn.fetchrow( + """ + SELECT id, room_code, status, created_at, started_at, completed_at, + num_players, num_rounds, options, winner_id, host_id, player_ids + FROM games_v2 + WHERE id = $1 + """, + game_id, + ) + return dict(row) if row else None + + # ------------------------------------------------------------------------- + # Helpers + # ------------------------------------------------------------------------- + + def _row_to_event(self, row: asyncpg.Record) -> GameEvent: + """Convert a database row to a GameEvent.""" + return GameEvent( + event_type=EventType(row["event_type"]), + game_id=str(row["game_id"]), + sequence_num=row["sequence_num"], + player_id=row["player_id"], + data=json.loads(row["event_data"]) if row["event_data"] else {}, + timestamp=row["created_at"].replace(tzinfo=timezone.utc), + ) + + +# Global event store instance (initialized on first use) +_event_store: Optional[EventStore] = None + + +async def get_event_store(postgres_url: str) -> EventStore: + """ + Get or create the global event store instance. + + Args: + postgres_url: PostgreSQL connection URL. + + Returns: + EventStore instance. + """ + global _event_store + if _event_store is None: + _event_store = await EventStore.create(postgres_url) + return _event_store + + +async def close_event_store() -> None: + """Close the global event store connection pool.""" + global _event_store + if _event_store is not None: + await _event_store.close() + _event_store = None diff --git a/server/stores/pubsub.py b/server/stores/pubsub.py new file mode 100644 index 0000000..a741c2c --- /dev/null +++ b/server/stores/pubsub.py @@ -0,0 +1,306 @@ +""" +Redis pub/sub for cross-server game events. + +In a multi-server deployment, each server has its own WebSocket connections. +When a game action occurs, the server handling that action needs to notify +all other servers so they can update their connected clients. + +This module provides: +- Pub/sub channels per room for targeted broadcasting +- Message types for state updates, player events, and broadcasts +- Async listener loop for handling incoming messages +- Clean subscription management + +Usage: + pubsub = GamePubSub(redis_client) + await pubsub.start() + + # Subscribe to room events + async def handle_message(msg: PubSubMessage): + print(f"Received: {msg.type} for room {msg.room_code}") + + await pubsub.subscribe("ABCD", handle_message) + + # Publish to room + await pubsub.publish(PubSubMessage( + type=MessageType.GAME_STATE_UPDATE, + room_code="ABCD", + data={"game_state": {...}}, + )) + + await pubsub.stop() +""" + +import asyncio +import json +import logging +from dataclasses import dataclass +from enum import Enum +from typing import Callable, Awaitable, Optional + +import redis.asyncio as redis + +logger = logging.getLogger(__name__) + + +class MessageType(str, Enum): + """Types of messages that can be published via pub/sub.""" + + # Game state changed (other servers should update their cache) + GAME_STATE_UPDATE = "game_state_update" + + # Player connected to room (for presence tracking) + PLAYER_JOINED = "player_joined" + + # Player disconnected from room + PLAYER_LEFT = "player_left" + + # Room is being closed (game ended or abandoned) + ROOM_CLOSED = "room_closed" + + # Generic broadcast to all clients in room + BROADCAST = "broadcast" + + +@dataclass +class PubSubMessage: + """ + Message sent via Redis pub/sub. + + Attributes: + type: Message type (determines how handlers process it). + room_code: Room this message is for. + data: Message payload (type-specific). + sender_id: Optional server ID of sender (to avoid echo). + """ + + type: MessageType + room_code: str + data: dict + sender_id: Optional[str] = None + + def to_json(self) -> str: + """Serialize to JSON for Redis.""" + return json.dumps({ + "type": self.type.value, + "room_code": self.room_code, + "data": self.data, + "sender_id": self.sender_id, + }) + + @classmethod + def from_json(cls, raw: str) -> "PubSubMessage": + """Deserialize from JSON.""" + d = json.loads(raw) + return cls( + type=MessageType(d["type"]), + room_code=d["room_code"], + data=d.get("data", {}), + sender_id=d.get("sender_id"), + ) + + +# Type alias for message handlers +MessageHandler = Callable[[PubSubMessage], Awaitable[None]] + + +class GamePubSub: + """ + Redis pub/sub for cross-server game events. + + Manages subscriptions to room channels and dispatches incoming + messages to registered handlers. + """ + + CHANNEL_PREFIX = "golf:room:" + + def __init__( + self, + redis_client: redis.Redis, + server_id: str = "default", + ): + """ + Initialize pub/sub with Redis client. + + Args: + redis_client: Async Redis client. + server_id: Unique ID for this server instance. + """ + self.redis = redis_client + self.server_id = server_id + self.pubsub = redis_client.pubsub() + self._handlers: dict[str, list[MessageHandler]] = {} + self._running = False + self._task: Optional[asyncio.Task] = None + + def _channel(self, room_code: str) -> str: + """Get Redis channel name for a room.""" + return f"{self.CHANNEL_PREFIX}{room_code}" + + async def subscribe( + self, + room_code: str, + handler: MessageHandler, + ) -> None: + """ + Subscribe to room events. + + Args: + room_code: Room to subscribe to. + handler: Async function to call on each message. + """ + channel = self._channel(room_code) + if channel not in self._handlers: + self._handlers[channel] = [] + await self.pubsub.subscribe(channel) + logger.debug(f"Subscribed to channel {channel}") + self._handlers[channel].append(handler) + + async def unsubscribe(self, room_code: str) -> None: + """ + Unsubscribe from room events. + + Args: + room_code: Room to unsubscribe from. + """ + channel = self._channel(room_code) + if channel in self._handlers: + del self._handlers[channel] + await self.pubsub.unsubscribe(channel) + logger.debug(f"Unsubscribed from channel {channel}") + + async def remove_handler(self, room_code: str, handler: MessageHandler) -> None: + """ + Remove a specific handler from a room subscription. + + Args: + room_code: Room the handler was registered for. + handler: Handler to remove. + """ + channel = self._channel(room_code) + if channel in self._handlers: + handlers = self._handlers[channel] + if handler in handlers: + handlers.remove(handler) + # If no handlers left, unsubscribe + if not handlers: + await self.unsubscribe(room_code) + + async def publish(self, message: PubSubMessage) -> int: + """ + Publish a message to a room's channel. + + Args: + message: Message to publish. + + Returns: + Number of subscribers that received the message. + """ + # Add sender ID so we can filter out our own messages + message.sender_id = self.server_id + channel = self._channel(message.room_code) + count = await self.redis.publish(channel, message.to_json()) + logger.debug(f"Published {message.type.value} to {channel} ({count} receivers)") + return count + + async def start(self) -> None: + """Start listening for messages.""" + if self._running: + return + + self._running = True + self._task = asyncio.create_task(self._listen()) + logger.info("GamePubSub listener started") + + async def stop(self) -> None: + """Stop listening and clean up.""" + self._running = False + if self._task: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + + await self.pubsub.close() + self._handlers.clear() + logger.info("GamePubSub listener stopped") + + async def _listen(self) -> None: + """Main listener loop.""" + while self._running: + try: + message = await self.pubsub.get_message( + ignore_subscribe_messages=True, + timeout=1.0, + ) + if message and message["type"] == "message": + await self._handle_message(message) + + except asyncio.CancelledError: + break + except redis.ConnectionError as e: + logger.error(f"PubSub connection error: {e}") + await asyncio.sleep(1) + except Exception as e: + logger.error(f"PubSub listener error: {e}", exc_info=True) + await asyncio.sleep(1) + + async def _handle_message(self, raw_message: dict) -> None: + """Handle an incoming Redis message.""" + try: + channel = raw_message["channel"] + if isinstance(channel, bytes): + channel = channel.decode() + + data = raw_message["data"] + if isinstance(data, bytes): + data = data.decode() + + msg = PubSubMessage.from_json(data) + + # Skip messages from ourselves + if msg.sender_id == self.server_id: + return + + handlers = self._handlers.get(channel, []) + for handler in handlers: + try: + await handler(msg) + except Exception as e: + logger.error(f"Error in pubsub handler: {e}", exc_info=True) + + except json.JSONDecodeError as e: + logger.warning(f"Invalid JSON in pubsub message: {e}") + except Exception as e: + logger.error(f"Error processing pubsub message: {e}", exc_info=True) + + +# Global pub/sub instance +_pubsub: Optional[GamePubSub] = None + + +async def get_pubsub(redis_client: redis.Redis, server_id: str = "default") -> GamePubSub: + """ + Get or create the global pub/sub instance. + + Args: + redis_client: Redis client to use. + server_id: Unique ID for this server. + + Returns: + GamePubSub instance. + """ + global _pubsub + if _pubsub is None: + _pubsub = GamePubSub(redis_client, server_id) + return _pubsub + + +async def close_pubsub() -> None: + """Stop and close the global pub/sub instance.""" + global _pubsub + if _pubsub is not None: + await _pubsub.stop() + _pubsub = None diff --git a/server/stores/state_cache.py b/server/stores/state_cache.py new file mode 100644 index 0000000..8a0aebc --- /dev/null +++ b/server/stores/state_cache.py @@ -0,0 +1,389 @@ +""" +Redis-backed live game state cache. + +The state cache stores live game state for fast access during gameplay. +Redis provides: +- Sub-millisecond reads/writes for active game state +- TTL expiration for abandoned games +- Pub/sub for multi-server synchronization +- Atomic operations via pipelines + +This is a CACHE, not the source of truth. Events in PostgreSQL are authoritative. +If Redis data is lost, games can be recovered from the event store. + +Key patterns: +- golf:room:{room_code} -> Hash (room metadata) +- golf:game:{game_id} -> JSON (full game state) +- golf:room:{room_code}:players -> Set (connected player IDs) +- golf:rooms:active -> Set (active room codes) +- golf:player:{player_id}:room -> String (player's current room) +""" + +import json +import logging +from datetime import datetime, timezone, timedelta +from typing import Optional + +import redis.asyncio as redis + +logger = logging.getLogger(__name__) + + +class StateCache: + """Redis-backed live game state cache.""" + + # Key patterns + ROOM_KEY = "golf:room:{room_code}" + GAME_KEY = "golf:game:{game_id}" + ROOM_PLAYERS_KEY = "golf:room:{room_code}:players" + ACTIVE_ROOMS_KEY = "golf:rooms:active" + PLAYER_ROOM_KEY = "golf:player:{player_id}:room" + + # TTLs + ROOM_TTL = timedelta(hours=4) # Inactive rooms expire + GAME_TTL = timedelta(hours=4) + + def __init__(self, redis_client: redis.Redis): + """ + Initialize state cache with Redis client. + + Args: + redis_client: Async Redis client. + """ + self.redis = redis_client + + @classmethod + async def create(cls, redis_url: str) -> "StateCache": + """ + Create a StateCache with a new Redis connection. + + Args: + redis_url: Redis connection URL. + + Returns: + Configured StateCache instance. + """ + client = redis.from_url(redis_url, decode_responses=False) + # Test connection + await client.ping() + logger.info("StateCache connected to Redis") + return cls(client) + + async def close(self) -> None: + """Close the Redis connection.""" + await self.redis.close() + + # ------------------------------------------------------------------------- + # Room Operations + # ------------------------------------------------------------------------- + + async def create_room( + self, + room_code: str, + game_id: str, + host_id: str, + server_id: str = "default", + ) -> None: + """ + Create a new room. + + Args: + room_code: 4-letter room code. + game_id: UUID of the game. + host_id: Player ID of the host. + server_id: Server instance ID (for multi-server). + """ + pipe = self.redis.pipeline() + + room_key = self.ROOM_KEY.format(room_code=room_code) + now = datetime.now(timezone.utc).isoformat() + + # Room metadata + pipe.hset( + room_key, + mapping={ + "game_id": game_id, + "host_id": host_id, + "status": "waiting", + "server_id": server_id, + "created_at": now, + }, + ) + pipe.expire(room_key, int(self.ROOM_TTL.total_seconds())) + + # Add to active rooms + pipe.sadd(self.ACTIVE_ROOMS_KEY, room_code) + + # Track host's room + pipe.set( + self.PLAYER_ROOM_KEY.format(player_id=host_id), + room_code, + ex=int(self.ROOM_TTL.total_seconds()), + ) + + await pipe.execute() + logger.debug(f"Created room {room_code} with game {game_id}") + + async def get_room(self, room_code: str) -> Optional[dict]: + """ + Get room metadata. + + Args: + room_code: Room code to look up. + + Returns: + Room metadata dict, or None if not found. + """ + data = await self.redis.hgetall(self.ROOM_KEY.format(room_code=room_code)) + if not data: + return None + # Decode bytes to strings + return {k.decode(): v.decode() for k, v in data.items()} + + async def room_exists(self, room_code: str) -> bool: + """ + Check if a room exists. + + Args: + room_code: Room code to check. + + Returns: + True if room exists. + """ + return await self.redis.exists(self.ROOM_KEY.format(room_code=room_code)) > 0 + + async def delete_room(self, room_code: str) -> None: + """ + Delete a room and all associated data. + + Args: + room_code: Room code to delete. + """ + room = await self.get_room(room_code) + if not room: + return + + pipe = self.redis.pipeline() + + # Get players to clean up their mappings + players_key = self.ROOM_PLAYERS_KEY.format(room_code=room_code) + players = await self.redis.smembers(players_key) + for player_id in players: + pid = player_id.decode() if isinstance(player_id, bytes) else player_id + pipe.delete(self.PLAYER_ROOM_KEY.format(player_id=pid)) + + # Delete room data + pipe.delete(self.ROOM_KEY.format(room_code=room_code)) + pipe.delete(players_key) + pipe.srem(self.ACTIVE_ROOMS_KEY, room_code) + + # Delete game state if exists + if "game_id" in room: + pipe.delete(self.GAME_KEY.format(game_id=room["game_id"])) + + await pipe.execute() + logger.debug(f"Deleted room {room_code}") + + async def get_active_rooms(self) -> set[str]: + """ + Get all active room codes. + + Returns: + Set of active room codes. + """ + rooms = await self.redis.smembers(self.ACTIVE_ROOMS_KEY) + return {r.decode() if isinstance(r, bytes) else r for r in rooms} + + # ------------------------------------------------------------------------- + # Player Operations + # ------------------------------------------------------------------------- + + async def add_player_to_room(self, room_code: str, player_id: str) -> None: + """ + Add a player to a room. + + Args: + room_code: Room to add player to. + player_id: Player to add. + """ + pipe = self.redis.pipeline() + pipe.sadd(self.ROOM_PLAYERS_KEY.format(room_code=room_code), player_id) + pipe.set( + self.PLAYER_ROOM_KEY.format(player_id=player_id), + room_code, + ex=int(self.ROOM_TTL.total_seconds()), + ) + # Refresh room TTL on activity + pipe.expire( + self.ROOM_KEY.format(room_code=room_code), + int(self.ROOM_TTL.total_seconds()), + ) + await pipe.execute() + + async def remove_player_from_room(self, room_code: str, player_id: str) -> None: + """ + Remove a player from a room. + + Args: + room_code: Room to remove player from. + player_id: Player to remove. + """ + pipe = self.redis.pipeline() + pipe.srem(self.ROOM_PLAYERS_KEY.format(room_code=room_code), player_id) + pipe.delete(self.PLAYER_ROOM_KEY.format(player_id=player_id)) + await pipe.execute() + + async def get_room_players(self, room_code: str) -> set[str]: + """ + Get player IDs in a room. + + Args: + room_code: Room to query. + + Returns: + Set of player IDs. + """ + players = await self.redis.smembers( + self.ROOM_PLAYERS_KEY.format(room_code=room_code) + ) + return {p.decode() if isinstance(p, bytes) else p for p in players} + + async def get_player_room(self, player_id: str) -> Optional[str]: + """ + Get the room a player is in. + + Args: + player_id: Player to look up. + + Returns: + Room code, or None if not in a room. + """ + room = await self.redis.get(self.PLAYER_ROOM_KEY.format(player_id=player_id)) + if room is None: + return None + return room.decode() if isinstance(room, bytes) else room + + # ------------------------------------------------------------------------- + # Game State Operations + # ------------------------------------------------------------------------- + + async def save_game_state(self, game_id: str, state: dict) -> None: + """ + Save full game state. + + Args: + game_id: Game UUID. + state: Game state dict (will be JSON serialized). + """ + await self.redis.set( + self.GAME_KEY.format(game_id=game_id), + json.dumps(state), + ex=int(self.GAME_TTL.total_seconds()), + ) + + async def get_game_state(self, game_id: str) -> Optional[dict]: + """ + Get full game state. + + Args: + game_id: Game UUID. + + Returns: + Game state dict, or None if not found. + """ + data = await self.redis.get(self.GAME_KEY.format(game_id=game_id)) + if not data: + return None + if isinstance(data, bytes): + data = data.decode() + return json.loads(data) + + async def update_game_state(self, game_id: str, updates: dict) -> None: + """ + Partial update to game state (get, merge, set). + + Args: + game_id: Game UUID. + updates: Fields to update. + """ + state = await self.get_game_state(game_id) + if state: + state.update(updates) + await self.save_game_state(game_id, state) + + async def delete_game_state(self, game_id: str) -> None: + """ + Delete game state. + + Args: + game_id: Game UUID. + """ + await self.redis.delete(self.GAME_KEY.format(game_id=game_id)) + + # ------------------------------------------------------------------------- + # Room Status + # ------------------------------------------------------------------------- + + async def set_room_status(self, room_code: str, status: str) -> None: + """ + Update room status. + + Args: + room_code: Room to update. + status: New status (waiting, playing, finished). + """ + await self.redis.hset( + self.ROOM_KEY.format(room_code=room_code), + "status", + status, + ) + + async def refresh_room_ttl(self, room_code: str) -> None: + """ + Refresh room TTL on activity. + + Args: + room_code: Room to refresh. + """ + pipe = self.redis.pipeline() + pipe.expire( + self.ROOM_KEY.format(room_code=room_code), + int(self.ROOM_TTL.total_seconds()), + ) + + room = await self.get_room(room_code) + if room and "game_id" in room: + pipe.expire( + self.GAME_KEY.format(game_id=room["game_id"]), + int(self.GAME_TTL.total_seconds()), + ) + + await pipe.execute() + + +# Global state cache instance (initialized on first use) +_state_cache: Optional[StateCache] = None + + +async def get_state_cache(redis_url: str) -> StateCache: + """ + Get or create the global state cache instance. + + Args: + redis_url: Redis connection URL. + + Returns: + StateCache instance. + """ + global _state_cache + if _state_cache is None: + _state_cache = await StateCache.create(redis_url) + return _state_cache + + +async def close_state_cache() -> None: + """Close the global state cache connection.""" + global _state_cache + if _state_cache is not None: + await _state_cache.close() + _state_cache = None diff --git a/server/stores/user_store.py b/server/stores/user_store.py new file mode 100644 index 0000000..aef91e7 --- /dev/null +++ b/server/stores/user_store.py @@ -0,0 +1,1029 @@ +""" +PostgreSQL-backed user store for Golf game authentication. + +Manages user accounts, sessions, and guest tracking. +""" + +import hashlib +import json +import logging +import secrets +from datetime import datetime, timezone, timedelta +from typing import Optional + +import asyncpg + +from models.user import User, UserRole, UserSession, GuestSession + +logger = logging.getLogger(__name__) + + +# SQL schema for user store +SCHEMA_SQL = """ +-- Users table (V2 auth) +CREATE TABLE IF NOT EXISTS users_v2 ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + username VARCHAR(50) UNIQUE NOT NULL, + email VARCHAR(255) UNIQUE, + password_hash VARCHAR(255) NOT NULL, + role VARCHAR(20) DEFAULT 'user', + email_verified BOOLEAN DEFAULT FALSE, + verification_token VARCHAR(255), + verification_expires TIMESTAMPTZ, + reset_token VARCHAR(255), + reset_expires TIMESTAMPTZ, + guest_id VARCHAR(50), + deleted_at TIMESTAMPTZ, + preferences JSONB DEFAULT '{}', + created_at TIMESTAMPTZ DEFAULT NOW(), + last_login TIMESTAMPTZ, + is_active BOOLEAN DEFAULT TRUE +); + +-- User sessions table +CREATE TABLE IF NOT EXISTS user_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users_v2(id) ON DELETE CASCADE, + token_hash VARCHAR(255) UNIQUE NOT NULL, + device_info JSONB DEFAULT '{}', + ip_address INET, + created_at TIMESTAMPTZ DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + last_used_at TIMESTAMPTZ DEFAULT NOW(), + revoked_at TIMESTAMPTZ +); + +-- Guest sessions table +CREATE TABLE IF NOT EXISTS guest_sessions ( + id VARCHAR(50) PRIMARY KEY, + display_name VARCHAR(50), + created_at TIMESTAMPTZ DEFAULT NOW(), + last_seen_at TIMESTAMPTZ DEFAULT NOW(), + games_played INT DEFAULT 0, + converted_to_user_id UUID REFERENCES users_v2(id), + expires_at TIMESTAMPTZ DEFAULT NOW() + INTERVAL '30 days' +); + +-- Email log table +CREATE TABLE IF NOT EXISTS email_log ( + id BIGSERIAL PRIMARY KEY, + user_id UUID REFERENCES users_v2(id), + email_type VARCHAR(50) NOT NULL, + recipient VARCHAR(255) NOT NULL, + sent_at TIMESTAMPTZ DEFAULT NOW(), + resend_id VARCHAR(100), + status VARCHAR(20) DEFAULT 'sent' +); + +-- Add admin columns to users_v2 if not exists +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'users_v2' AND column_name = 'is_banned') THEN + ALTER TABLE users_v2 ADD COLUMN is_banned BOOLEAN DEFAULT FALSE; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'users_v2' AND column_name = 'ban_reason') THEN + ALTER TABLE users_v2 ADD COLUMN ban_reason TEXT; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'users_v2' AND column_name = 'force_password_reset') THEN + ALTER TABLE users_v2 ADD COLUMN force_password_reset BOOLEAN DEFAULT FALSE; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'users_v2' AND column_name = 'last_seen_at') THEN + ALTER TABLE users_v2 ADD COLUMN last_seen_at TIMESTAMPTZ; + END IF; +END $$; + +-- Admin audit log table +CREATE TABLE IF NOT EXISTS admin_audit_log ( + id BIGSERIAL PRIMARY KEY, + admin_user_id UUID NOT NULL REFERENCES users_v2(id), + action VARCHAR(50) NOT NULL, + target_type VARCHAR(50), + target_id VARCHAR(100), + details JSONB DEFAULT '{}', + ip_address INET, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- User bans table +CREATE TABLE IF NOT EXISTS user_bans ( + id BIGSERIAL PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users_v2(id), + banned_by UUID NOT NULL REFERENCES users_v2(id), + reason TEXT, + banned_at TIMESTAMPTZ DEFAULT NOW(), + expires_at TIMESTAMPTZ, + unbanned_at TIMESTAMPTZ, + unbanned_by UUID REFERENCES users_v2(id) +); + +-- Invite codes table +CREATE TABLE IF NOT EXISTS invite_codes ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(20) UNIQUE NOT NULL, + created_by UUID NOT NULL REFERENCES users_v2(id), + created_at TIMESTAMPTZ DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + max_uses INT DEFAULT 1, + use_count INT DEFAULT 0, + is_active BOOLEAN DEFAULT TRUE +); + +-- Player stats table (extended for V2 leaderboards) +CREATE TABLE IF NOT EXISTS player_stats ( + id BIGSERIAL PRIMARY KEY, + user_id UUID UNIQUE NOT NULL REFERENCES users_v2(id), + games_played INT DEFAULT 0, + games_won INT DEFAULT 0, + best_score INT, + worst_score INT, + total_rounds INT DEFAULT 0, + avg_score DECIMAL(5,2), + -- Extended stats + rounds_won INT DEFAULT 0, + total_points INT DEFAULT 0, + knockouts INT DEFAULT 0, + perfect_rounds INT DEFAULT 0, + wolfpacks INT DEFAULT 0, + current_win_streak INT DEFAULT 0, + best_win_streak INT DEFAULT 0, + first_game_at TIMESTAMPTZ, + last_game_at TIMESTAMPTZ, + games_vs_humans INT DEFAULT 0, + games_won_vs_humans INT DEFAULT 0, + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Add new columns to existing player_stats if they don't exist +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'player_stats' AND column_name = 'rounds_won') THEN + ALTER TABLE player_stats ADD COLUMN rounds_won INT DEFAULT 0; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'player_stats' AND column_name = 'total_points') THEN + ALTER TABLE player_stats ADD COLUMN total_points INT DEFAULT 0; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'player_stats' AND column_name = 'knockouts') THEN + ALTER TABLE player_stats ADD COLUMN knockouts INT DEFAULT 0; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'player_stats' AND column_name = 'perfect_rounds') THEN + ALTER TABLE player_stats ADD COLUMN perfect_rounds INT DEFAULT 0; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'player_stats' AND column_name = 'wolfpacks') THEN + ALTER TABLE player_stats ADD COLUMN wolfpacks INT DEFAULT 0; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'player_stats' AND column_name = 'current_win_streak') THEN + ALTER TABLE player_stats ADD COLUMN current_win_streak INT DEFAULT 0; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'player_stats' AND column_name = 'best_win_streak') THEN + ALTER TABLE player_stats ADD COLUMN best_win_streak INT DEFAULT 0; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'player_stats' AND column_name = 'first_game_at') THEN + ALTER TABLE player_stats ADD COLUMN first_game_at TIMESTAMPTZ; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'player_stats' AND column_name = 'last_game_at') THEN + ALTER TABLE player_stats ADD COLUMN last_game_at TIMESTAMPTZ; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'player_stats' AND column_name = 'games_vs_humans') THEN + ALTER TABLE player_stats ADD COLUMN games_vs_humans INT DEFAULT 0; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'player_stats' AND column_name = 'games_won_vs_humans') THEN + ALTER TABLE player_stats ADD COLUMN games_won_vs_humans INT DEFAULT 0; + END IF; +END $$; + +-- Stats processing queue (for async stats processing) +CREATE TABLE IF NOT EXISTS stats_queue ( + id BIGSERIAL PRIMARY KEY, + game_id UUID NOT NULL, + status VARCHAR(20) DEFAULT 'pending', + created_at TIMESTAMPTZ DEFAULT NOW(), + processed_at TIMESTAMPTZ, + error_message TEXT +); + +-- Achievements definitions +CREATE TABLE IF NOT EXISTS achievements ( + id VARCHAR(50) PRIMARY KEY, + name VARCHAR(100) NOT NULL, + description TEXT, + icon VARCHAR(50), + category VARCHAR(50), + threshold INT, + sort_order INT DEFAULT 0 +); + +-- User achievements (earned achievements) +CREATE TABLE IF NOT EXISTS user_achievements ( + user_id UUID REFERENCES users_v2(id), + achievement_id VARCHAR(50) REFERENCES achievements(id), + earned_at TIMESTAMPTZ DEFAULT NOW(), + game_id UUID, + PRIMARY KEY (user_id, achievement_id) +); + +-- Seed achievements if empty +INSERT INTO achievements (id, name, description, icon, category, threshold, sort_order) +SELECT * FROM (VALUES + ('first_win', 'First Victory', 'Win your first game', '🏆', 'games', 1, 1), + ('win_10', 'Rising Star', 'Win 10 games', '⭐', 'games', 10, 2), + ('win_50', 'Veteran', 'Win 50 games', '🎖️', 'games', 50, 3), + ('win_100', 'Champion', 'Win 100 games', '👑', 'games', 100, 4), + ('perfect_round', 'Perfect', 'Score 0 or less in a round', '💎', 'rounds', 1, 10), + ('negative_round', 'Below Zero', 'Score negative in a round', '❄️', 'rounds', 1, 11), + ('knockout_10', 'Closer', 'Go out first 10 times', '🚪', 'special', 10, 20), + ('wolfpack', 'Wolfpack', 'Get all 4 Jacks', '🐺', 'special', 1, 21), + ('streak_5', 'Hot Streak', 'Win 5 games in a row', '🔥', 'special', 5, 30), + ('streak_10', 'Unstoppable', 'Win 10 games in a row', '⚡', 'special', 10, 31) +) AS v(id, name, description, icon, category, threshold, sort_order) +WHERE NOT EXISTS (SELECT 1 FROM achievements LIMIT 1); + +-- System metrics table +CREATE TABLE IF NOT EXISTS system_metrics ( + id BIGSERIAL PRIMARY KEY, + recorded_at TIMESTAMPTZ DEFAULT NOW(), + active_users INT, + active_games INT, + events_last_hour INT, + registrations_today INT, + games_completed_today INT, + metrics JSONB DEFAULT '{}' +); + +-- Leaderboard materialized view (refreshed periodically) +-- Note: Using DO block to handle case where view already exists +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_matviews WHERE matviewname = 'leaderboard_overall') THEN + EXECUTE ' + CREATE MATERIALIZED VIEW leaderboard_overall AS + SELECT + u.id as user_id, + u.username, + s.games_played, + s.games_won, + ROUND(s.games_won::numeric / NULLIF(s.games_played, 0) * 100, 1) as win_rate, + s.rounds_won, + ROUND(s.total_points::numeric / NULLIF(s.total_rounds, 0), 1) as avg_score, + s.best_score as best_round_score, + s.knockouts, + s.best_win_streak, + s.last_game_at + FROM player_stats s + JOIN users_v2 u ON s.user_id = u.id + WHERE s.games_played >= 5 + AND u.deleted_at IS NULL + AND (u.is_banned = false OR u.is_banned IS NULL) + '; + END IF; +END $$; + +-- Indexes +CREATE INDEX IF NOT EXISTS idx_users_email ON users_v2(email) WHERE email IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_users_username ON users_v2(username); +CREATE INDEX IF NOT EXISTS idx_users_verification ON users_v2(verification_token) WHERE verification_token IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_users_reset ON users_v2(reset_token) WHERE reset_token IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_users_guest ON users_v2(guest_id) WHERE guest_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_users_active ON users_v2(is_active) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_users_banned ON users_v2(is_banned) WHERE is_banned = TRUE; + +CREATE INDEX IF NOT EXISTS idx_sessions_user ON user_sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_sessions_token ON user_sessions(token_hash); +CREATE INDEX IF NOT EXISTS idx_sessions_expires ON user_sessions(expires_at) WHERE revoked_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_sessions_last_used ON user_sessions(last_used_at) WHERE revoked_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_guests_expires ON guest_sessions(expires_at); +CREATE INDEX IF NOT EXISTS idx_guests_converted ON guest_sessions(converted_to_user_id) WHERE converted_to_user_id IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_email_user ON email_log(user_id); +CREATE INDEX IF NOT EXISTS idx_email_type ON email_log(email_type); + +CREATE INDEX IF NOT EXISTS idx_audit_admin ON admin_audit_log(admin_user_id); +CREATE INDEX IF NOT EXISTS idx_audit_target ON admin_audit_log(target_type, target_id); +CREATE INDEX IF NOT EXISTS idx_audit_created ON admin_audit_log(created_at); + +CREATE INDEX IF NOT EXISTS idx_bans_user ON user_bans(user_id); +CREATE INDEX IF NOT EXISTS idx_bans_active ON user_bans(user_id) WHERE unbanned_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_invites_code ON invite_codes(code); +CREATE INDEX IF NOT EXISTS idx_invites_active ON invite_codes(is_active) WHERE is_active = TRUE; + +CREATE INDEX IF NOT EXISTS idx_stats_user ON player_stats(user_id); + +CREATE INDEX IF NOT EXISTS idx_metrics_time ON system_metrics(recorded_at); + +-- Stats queue indexes +CREATE INDEX IF NOT EXISTS idx_stats_queue_pending ON stats_queue(status, created_at) + WHERE status = 'pending'; + +-- User achievements indexes +CREATE INDEX IF NOT EXISTS idx_user_achievements_user ON user_achievements(user_id); + +-- Leaderboard materialized view indexes (created separately) +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_matviews WHERE matviewname = 'leaderboard_overall') THEN + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_leaderboard_overall_user') THEN + CREATE UNIQUE INDEX idx_leaderboard_overall_user ON leaderboard_overall(user_id); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_leaderboard_overall_wins') THEN + CREATE INDEX idx_leaderboard_overall_wins ON leaderboard_overall(games_won DESC); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_leaderboard_overall_rate') THEN + CREATE INDEX idx_leaderboard_overall_rate ON leaderboard_overall(win_rate DESC); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_leaderboard_overall_score') THEN + CREATE INDEX idx_leaderboard_overall_score ON leaderboard_overall(avg_score ASC); + END IF; + END IF; +END $$; +""" + + +def hash_token(token: str) -> str: + """Hash a token using SHA256.""" + return hashlib.sha256(token.encode()).hexdigest() + + +class UserStore: + """ + PostgreSQL-backed store for users and sessions. + + Provides CRUD operations for user accounts, sessions, and guests. + Uses asyncpg for async database access. + """ + + def __init__(self, pool: asyncpg.Pool): + """ + Initialize user store with connection pool. + + Args: + pool: asyncpg connection pool. + """ + self.pool = pool + + @classmethod + async def create(cls, postgres_url: str) -> "UserStore": + """ + Create a UserStore with a new connection pool. + + Args: + postgres_url: PostgreSQL connection URL. + + Returns: + Configured UserStore instance. + """ + pool = await asyncpg.create_pool(postgres_url, min_size=2, max_size=10) + store = cls(pool) + await store.initialize_schema() + return store + + async def initialize_schema(self) -> None: + """Create database tables if they don't exist.""" + async with self.pool.acquire() as conn: + await conn.execute(SCHEMA_SQL) + logger.info("User store schema initialized") + + async def close(self) -> None: + """Close the connection pool.""" + await self.pool.close() + + # ------------------------------------------------------------------------- + # User CRUD + # ------------------------------------------------------------------------- + + async def create_user( + self, + username: str, + password_hash: str, + email: Optional[str] = None, + role: UserRole = UserRole.USER, + guest_id: Optional[str] = None, + verification_token: Optional[str] = None, + verification_expires: Optional[datetime] = None, + ) -> Optional[User]: + """ + Create a new user account. + + Args: + username: Unique username. + password_hash: bcrypt hash of password. + email: Optional email address. + role: User role. + guest_id: Guest session ID if converting. + verification_token: Email verification token. + verification_expires: Token expiration time. + + Returns: + Created User, or None if username/email already exists. + """ + async with self.pool.acquire() as conn: + try: + row = await conn.fetchrow( + """ + INSERT INTO users_v2 (username, password_hash, email, role, guest_id, + verification_token, verification_expires) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id, username, email, password_hash, role, email_verified, + verification_token, verification_expires, reset_token, reset_expires, + guest_id, deleted_at, preferences, created_at, last_login, last_seen_at, + is_active, is_banned, ban_reason, force_password_reset + """, + username, + password_hash, + email, + role.value, + guest_id, + verification_token, + verification_expires, + ) + return self._row_to_user(row) + except asyncpg.UniqueViolationError: + return None + + async def get_user_by_id(self, user_id: str) -> Optional[User]: + """Get user by ID.""" + async with self.pool.acquire() as conn: + row = await conn.fetchrow( + """ + SELECT id, username, email, password_hash, role, email_verified, + verification_token, verification_expires, reset_token, reset_expires, + guest_id, deleted_at, preferences, created_at, last_login, last_seen_at, + is_active, is_banned, ban_reason, force_password_reset + FROM users_v2 + WHERE id = $1 + """, + user_id, + ) + return self._row_to_user(row) if row else None + + async def get_user_by_username(self, username: str) -> Optional[User]: + """Get user by username.""" + async with self.pool.acquire() as conn: + row = await conn.fetchrow( + """ + SELECT id, username, email, password_hash, role, email_verified, + verification_token, verification_expires, reset_token, reset_expires, + guest_id, deleted_at, preferences, created_at, last_login, last_seen_at, + is_active, is_banned, ban_reason, force_password_reset + FROM users_v2 + WHERE LOWER(username) = LOWER($1) + """, + username, + ) + return self._row_to_user(row) if row else None + + async def get_user_by_email(self, email: str) -> Optional[User]: + """Get user by email.""" + async with self.pool.acquire() as conn: + row = await conn.fetchrow( + """ + SELECT id, username, email, password_hash, role, email_verified, + verification_token, verification_expires, reset_token, reset_expires, + guest_id, deleted_at, preferences, created_at, last_login, last_seen_at, + is_active, is_banned, ban_reason, force_password_reset + FROM users_v2 + WHERE LOWER(email) = LOWER($1) + """, + email, + ) + return self._row_to_user(row) if row else None + + async def get_user_by_verification_token(self, token: str) -> Optional[User]: + """Get user by verification token.""" + async with self.pool.acquire() as conn: + row = await conn.fetchrow( + """ + SELECT id, username, email, password_hash, role, email_verified, + verification_token, verification_expires, reset_token, reset_expires, + guest_id, deleted_at, preferences, created_at, last_login, last_seen_at, + is_active, is_banned, ban_reason, force_password_reset + FROM users_v2 + WHERE verification_token = $1 + """, + token, + ) + return self._row_to_user(row) if row else None + + async def get_user_by_reset_token(self, token: str) -> Optional[User]: + """Get user by password reset token.""" + async with self.pool.acquire() as conn: + row = await conn.fetchrow( + """ + SELECT id, username, email, password_hash, role, email_verified, + verification_token, verification_expires, reset_token, reset_expires, + guest_id, deleted_at, preferences, created_at, last_login, last_seen_at, + is_active, is_banned, ban_reason, force_password_reset + FROM users_v2 + WHERE reset_token = $1 + """, + token, + ) + return self._row_to_user(row) if row else None + + async def update_user( + self, + user_id: str, + username: Optional[str] = None, + email: Optional[str] = None, + password_hash: Optional[str] = None, + role: Optional[UserRole] = None, + email_verified: Optional[bool] = None, + verification_token: Optional[str] = None, + verification_expires: Optional[datetime] = None, + reset_token: Optional[str] = None, + reset_expires: Optional[datetime] = None, + preferences: Optional[dict] = None, + last_login: Optional[datetime] = None, + last_seen_at: Optional[datetime] = None, + is_active: Optional[bool] = None, + deleted_at: Optional[datetime] = None, + is_banned: Optional[bool] = None, + ban_reason: Optional[str] = None, + force_password_reset: Optional[bool] = None, + clear_ban_reason: bool = False, + ) -> Optional[User]: + """ + Update user fields. + + Only non-None values are updated. + Use clear_ban_reason=True to explicitly set ban_reason to NULL. + + Returns: + Updated User, or None if user not found or unique constraint violated. + """ + updates = [] + params = [] + param_idx = 1 + + def add_param(value): + nonlocal param_idx + params.append(value) + idx = param_idx + param_idx += 1 + return idx + + if username is not None: + updates.append(f"username = ${add_param(username)}") + if email is not None: + updates.append(f"email = ${add_param(email)}") + if password_hash is not None: + updates.append(f"password_hash = ${add_param(password_hash)}") + if role is not None: + updates.append(f"role = ${add_param(role.value)}") + if email_verified is not None: + updates.append(f"email_verified = ${add_param(email_verified)}") + if verification_token is not None: + updates.append(f"verification_token = ${add_param(verification_token)}") + if verification_expires is not None: + updates.append(f"verification_expires = ${add_param(verification_expires)}") + if reset_token is not None: + updates.append(f"reset_token = ${add_param(reset_token)}") + if reset_expires is not None: + updates.append(f"reset_expires = ${add_param(reset_expires)}") + if preferences is not None: + updates.append(f"preferences = ${add_param(json.dumps(preferences))}") + if last_login is not None: + updates.append(f"last_login = ${add_param(last_login)}") + if last_seen_at is not None: + updates.append(f"last_seen_at = ${add_param(last_seen_at)}") + if is_active is not None: + updates.append(f"is_active = ${add_param(is_active)}") + if deleted_at is not None: + updates.append(f"deleted_at = ${add_param(deleted_at)}") + if is_banned is not None: + updates.append(f"is_banned = ${add_param(is_banned)}") + if ban_reason is not None: + updates.append(f"ban_reason = ${add_param(ban_reason)}") + elif clear_ban_reason: + updates.append("ban_reason = NULL") + if force_password_reset is not None: + updates.append(f"force_password_reset = ${add_param(force_password_reset)}") + + if not updates: + return await self.get_user_by_id(user_id) + + params.append(user_id) + query = f""" + UPDATE users_v2 + SET {', '.join(updates)} + WHERE id = ${param_idx} + RETURNING id, username, email, password_hash, role, email_verified, + verification_token, verification_expires, reset_token, reset_expires, + guest_id, deleted_at, preferences, created_at, last_login, last_seen_at, + is_active, is_banned, ban_reason, force_password_reset + """ + + async with self.pool.acquire() as conn: + try: + row = await conn.fetchrow(query, *params) + return self._row_to_user(row) if row else None + except asyncpg.UniqueViolationError: + return None + + async def clear_verification_token(self, user_id: str) -> bool: + """Clear verification token after successful verification.""" + async with self.pool.acquire() as conn: + result = await conn.execute( + """ + UPDATE users_v2 + SET verification_token = NULL, verification_expires = NULL, email_verified = TRUE + WHERE id = $1 + """, + user_id, + ) + return result == "UPDATE 1" + + async def clear_reset_token(self, user_id: str) -> bool: + """Clear reset token after successful password reset.""" + async with self.pool.acquire() as conn: + result = await conn.execute( + """ + UPDATE users_v2 + SET reset_token = NULL, reset_expires = NULL + WHERE id = $1 + """, + user_id, + ) + return result == "UPDATE 1" + + async def list_users(self, include_inactive: bool = False) -> list[User]: + """List all users.""" + async with self.pool.acquire() as conn: + if include_inactive: + rows = await conn.fetch( + """ + SELECT id, username, email, password_hash, role, email_verified, + verification_token, verification_expires, reset_token, reset_expires, + guest_id, deleted_at, preferences, created_at, last_login, is_active + FROM users_v2 + ORDER BY created_at DESC + """ + ) + else: + rows = await conn.fetch( + """ + SELECT id, username, email, password_hash, role, email_verified, + verification_token, verification_expires, reset_token, reset_expires, + guest_id, deleted_at, preferences, created_at, last_login, is_active + FROM users_v2 + WHERE is_active = TRUE AND deleted_at IS NULL + ORDER BY created_at DESC + """ + ) + return [self._row_to_user(row) for row in rows] + + # ------------------------------------------------------------------------- + # Session CRUD + # ------------------------------------------------------------------------- + + async def create_session( + self, + user_id: str, + token: str, + expires_at: datetime, + device_info: Optional[dict] = None, + ip_address: Optional[str] = None, + ) -> UserSession: + """ + Create a new user session. + + Args: + user_id: User ID. + token: Raw session token (will be hashed). + expires_at: Session expiration time. + device_info: Device/browser info. + ip_address: Client IP address. + + Returns: + Created UserSession. + """ + token_hash = hash_token(token) + async with self.pool.acquire() as conn: + row = await conn.fetchrow( + """ + INSERT INTO user_sessions (user_id, token_hash, expires_at, device_info, ip_address) + VALUES ($1, $2, $3, $4, $5::inet) + RETURNING id, user_id, token_hash, device_info, ip_address, + created_at, expires_at, last_used_at, revoked_at + """, + user_id, + token_hash, + expires_at, + json.dumps(device_info or {}), + ip_address, + ) + return self._row_to_session(row) + + async def get_session_by_token(self, token: str) -> Optional[UserSession]: + """Get session by raw token (will be hashed for lookup).""" + token_hash = hash_token(token) + async with self.pool.acquire() as conn: + row = await conn.fetchrow( + """ + SELECT id, user_id, token_hash, device_info, ip_address, + created_at, expires_at, last_used_at, revoked_at + FROM user_sessions + WHERE token_hash = $1 AND revoked_at IS NULL AND expires_at > NOW() + """, + token_hash, + ) + return self._row_to_session(row) if row else None + + async def get_sessions_for_user(self, user_id: str) -> list[UserSession]: + """Get all active sessions for a user.""" + async with self.pool.acquire() as conn: + rows = await conn.fetch( + """ + SELECT id, user_id, token_hash, device_info, ip_address, + created_at, expires_at, last_used_at, revoked_at + FROM user_sessions + WHERE user_id = $1 AND revoked_at IS NULL AND expires_at > NOW() + ORDER BY last_used_at DESC + """, + user_id, + ) + return [self._row_to_session(row) for row in rows] + + async def update_session_last_used(self, session_id: str) -> bool: + """Update session last_used_at timestamp.""" + async with self.pool.acquire() as conn: + result = await conn.execute( + "UPDATE user_sessions SET last_used_at = NOW() WHERE id = $1", + session_id, + ) + return result == "UPDATE 1" + + async def revoke_session(self, session_id: str) -> bool: + """Revoke a session by ID.""" + async with self.pool.acquire() as conn: + result = await conn.execute( + "UPDATE user_sessions SET revoked_at = NOW() WHERE id = $1", + session_id, + ) + return result == "UPDATE 1" + + async def revoke_session_by_token(self, token: str) -> bool: + """Revoke a session by raw token.""" + token_hash = hash_token(token) + async with self.pool.acquire() as conn: + result = await conn.execute( + "UPDATE user_sessions SET revoked_at = NOW() WHERE token_hash = $1", + token_hash, + ) + return result == "UPDATE 1" + + async def revoke_all_sessions(self, user_id: str, except_token: Optional[str] = None) -> int: + """ + Revoke all sessions for a user. + + Args: + user_id: User ID. + except_token: Optional token to exclude from revocation. + + Returns: + Number of sessions revoked. + """ + async with self.pool.acquire() as conn: + if except_token: + except_hash = hash_token(except_token) + result = await conn.execute( + """ + UPDATE user_sessions + SET revoked_at = NOW() + WHERE user_id = $1 AND revoked_at IS NULL AND token_hash != $2 + """, + user_id, + except_hash, + ) + else: + result = await conn.execute( + "UPDATE user_sessions SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL", + user_id, + ) + # Parse "UPDATE N" result + return int(result.split()[1]) + + async def cleanup_expired_sessions(self) -> int: + """Delete expired sessions. Returns number deleted.""" + async with self.pool.acquire() as conn: + result = await conn.execute( + "DELETE FROM user_sessions WHERE expires_at < NOW()" + ) + return int(result.split()[1]) + + # ------------------------------------------------------------------------- + # Guest Session CRUD + # ------------------------------------------------------------------------- + + async def create_guest_session( + self, + guest_id: str, + display_name: Optional[str] = None, + expires_in_days: int = 30, + ) -> GuestSession: + """Create a new guest session.""" + expires_at = datetime.now(timezone.utc) + timedelta(days=expires_in_days) + async with self.pool.acquire() as conn: + row = await conn.fetchrow( + """ + INSERT INTO guest_sessions (id, display_name, expires_at) + VALUES ($1, $2, $3) + RETURNING id, display_name, created_at, last_seen_at, games_played, + converted_to_user_id, expires_at + """, + guest_id, + display_name, + expires_at, + ) + return self._row_to_guest(row) + + async def get_guest_session(self, guest_id: str) -> Optional[GuestSession]: + """Get guest session by ID.""" + async with self.pool.acquire() as conn: + row = await conn.fetchrow( + """ + SELECT id, display_name, created_at, last_seen_at, games_played, + converted_to_user_id, expires_at + FROM guest_sessions + WHERE id = $1 + """, + guest_id, + ) + return self._row_to_guest(row) if row else None + + async def update_guest_last_seen(self, guest_id: str) -> bool: + """Update guest last_seen_at timestamp.""" + async with self.pool.acquire() as conn: + result = await conn.execute( + "UPDATE guest_sessions SET last_seen_at = NOW() WHERE id = $1", + guest_id, + ) + return result == "UPDATE 1" + + async def increment_guest_games(self, guest_id: str) -> bool: + """Increment guest games_played counter.""" + async with self.pool.acquire() as conn: + result = await conn.execute( + "UPDATE guest_sessions SET games_played = games_played + 1 WHERE id = $1", + guest_id, + ) + return result == "UPDATE 1" + + async def mark_guest_converted(self, guest_id: str, user_id: str) -> bool: + """Mark guest session as converted to user account.""" + async with self.pool.acquire() as conn: + result = await conn.execute( + "UPDATE guest_sessions SET converted_to_user_id = $2 WHERE id = $1", + guest_id, + user_id, + ) + return result == "UPDATE 1" + + async def cleanup_expired_guests(self) -> int: + """Delete expired guest sessions. Returns number deleted.""" + async with self.pool.acquire() as conn: + result = await conn.execute( + "DELETE FROM guest_sessions WHERE expires_at < NOW() AND converted_to_user_id IS NULL" + ) + return int(result.split()[1]) + + # ------------------------------------------------------------------------- + # Email Log + # ------------------------------------------------------------------------- + + async def log_email( + self, + user_id: Optional[str], + email_type: str, + recipient: str, + resend_id: Optional[str] = None, + status: str = "sent", + ) -> int: + """ + Log an email send. + + Returns: + Log entry ID. + """ + async with self.pool.acquire() as conn: + row = await conn.fetchrow( + """ + INSERT INTO email_log (user_id, email_type, recipient, resend_id, status) + VALUES ($1, $2, $3, $4, $5) + RETURNING id + """, + user_id, + email_type, + recipient, + resend_id, + status, + ) + return row["id"] + + async def update_email_status(self, log_id: int, status: str) -> bool: + """Update email log status.""" + async with self.pool.acquire() as conn: + result = await conn.execute( + "UPDATE email_log SET status = $2 WHERE id = $1", + log_id, + status, + ) + return result == "UPDATE 1" + + # ------------------------------------------------------------------------- + # Helpers + # ------------------------------------------------------------------------- + + def _row_to_user(self, row: asyncpg.Record) -> User: + """Convert database row to User.""" + return User( + id=str(row["id"]), + username=row["username"], + email=row["email"], + password_hash=row["password_hash"], + role=UserRole(row["role"]), + email_verified=row["email_verified"], + verification_token=row["verification_token"], + verification_expires=row["verification_expires"].replace(tzinfo=timezone.utc) if row["verification_expires"] else None, + reset_token=row["reset_token"], + reset_expires=row["reset_expires"].replace(tzinfo=timezone.utc) if row["reset_expires"] else None, + guest_id=row["guest_id"], + deleted_at=row["deleted_at"].replace(tzinfo=timezone.utc) if row["deleted_at"] else None, + preferences=json.loads(row["preferences"]) if row["preferences"] else {}, + created_at=row["created_at"].replace(tzinfo=timezone.utc) if row["created_at"] else datetime.now(timezone.utc), + last_login=row["last_login"].replace(tzinfo=timezone.utc) if row["last_login"] else None, + last_seen_at=row["last_seen_at"].replace(tzinfo=timezone.utc) if row.get("last_seen_at") else None, + is_active=row["is_active"], + is_banned=row.get("is_banned", False) or False, + ban_reason=row.get("ban_reason"), + force_password_reset=row.get("force_password_reset", False) or False, + ) + + def _row_to_session(self, row: asyncpg.Record) -> UserSession: + """Convert database row to UserSession.""" + return UserSession( + id=str(row["id"]), + user_id=str(row["user_id"]), + token_hash=row["token_hash"], + device_info=json.loads(row["device_info"]) if row["device_info"] else {}, + ip_address=str(row["ip_address"]) if row["ip_address"] else None, + created_at=row["created_at"].replace(tzinfo=timezone.utc) if row["created_at"] else datetime.now(timezone.utc), + expires_at=row["expires_at"].replace(tzinfo=timezone.utc) if row["expires_at"] else datetime.now(timezone.utc), + last_used_at=row["last_used_at"].replace(tzinfo=timezone.utc) if row["last_used_at"] else datetime.now(timezone.utc), + revoked_at=row["revoked_at"].replace(tzinfo=timezone.utc) if row["revoked_at"] else None, + ) + + def _row_to_guest(self, row: asyncpg.Record) -> GuestSession: + """Convert database row to GuestSession.""" + return GuestSession( + id=row["id"], + display_name=row["display_name"], + created_at=row["created_at"].replace(tzinfo=timezone.utc) if row["created_at"] else datetime.now(timezone.utc), + last_seen_at=row["last_seen_at"].replace(tzinfo=timezone.utc) if row["last_seen_at"] else datetime.now(timezone.utc), + games_played=row["games_played"], + converted_to_user_id=str(row["converted_to_user_id"]) if row["converted_to_user_id"] else None, + expires_at=row["expires_at"].replace(tzinfo=timezone.utc) if row["expires_at"] else datetime.now(timezone.utc), + ) + + +# Global user store instance +_user_store: Optional[UserStore] = None + + +async def get_user_store(postgres_url: str) -> UserStore: + """ + Get or create the global user store instance. + + Args: + postgres_url: PostgreSQL connection URL. + + Returns: + UserStore instance. + """ + global _user_store + if _user_store is None: + _user_store = await UserStore.create(postgres_url) + return _user_store + + +async def close_user_store() -> None: + """Close the global user store connection pool.""" + global _user_store + if _user_store is not None: + await _user_store.close() + _user_store = None diff --git a/server/tests/__init__.py b/server/tests/__init__.py new file mode 100644 index 0000000..6d10e85 --- /dev/null +++ b/server/tests/__init__.py @@ -0,0 +1 @@ +"""Tests package for Golf game.""" diff --git a/server/tests/test_event_replay.py b/server/tests/test_event_replay.py new file mode 100644 index 0000000..2f79e37 --- /dev/null +++ b/server/tests/test_event_replay.py @@ -0,0 +1,431 @@ +""" +Tests for event sourcing and state replay. + +These tests verify that: +1. Events are emitted correctly from game actions +2. State can be rebuilt from events +3. Rebuilt state matches original game state +4. Events are applied in correct sequence order +""" + +import pytest +from typing import Optional + +from game import Game, GamePhase, GameOptions, Player +from models.events import GameEvent, EventType +from models.game_state import RebuiltGameState, rebuild_state + + +class EventCollector: + """Helper class to collect events from a game.""" + + def __init__(self): + self.events: list[GameEvent] = [] + + def collect(self, event: GameEvent) -> None: + """Callback to collect an event.""" + self.events.append(event) + + def clear(self) -> None: + """Clear collected events.""" + self.events = [] + + +def create_test_game( + num_players: int = 2, + options: Optional[GameOptions] = None, +) -> tuple[Game, EventCollector]: + """ + Create a game with event collection enabled. + + Returns: + Tuple of (Game, EventCollector). + """ + game = Game() + collector = EventCollector() + game.set_event_emitter(collector.collect) + + # Emit game created + game.emit_game_created("TEST", "p1") + + # Add players + for i in range(num_players): + player = Player(id=f"p{i+1}", name=f"Player {i+1}") + game.add_player(player) + + return game, collector + + +class TestEventEmission: + """Test that events are emitted correctly.""" + + def test_game_created_event(self): + """Game created event should be first event.""" + game, collector = create_test_game(num_players=0) + + assert len(collector.events) == 1 + event = collector.events[0] + assert event.event_type == EventType.GAME_CREATED + assert event.sequence_num == 1 + assert event.data["room_code"] == "TEST" + + def test_player_joined_events(self): + """Player joined events should be emitted for each player.""" + game, collector = create_test_game(num_players=3) + + # game_created + 3 player_joined + assert len(collector.events) == 4 + + joined_events = [e for e in collector.events if e.event_type == EventType.PLAYER_JOINED] + assert len(joined_events) == 3 + + for i, event in enumerate(joined_events): + assert event.player_id == f"p{i+1}" + assert event.data["player_name"] == f"Player {i+1}" + + def test_game_started_and_round_started_events(self): + """Starting game should emit game_started and round_started.""" + game, collector = create_test_game(num_players=2) + initial_count = len(collector.events) + + game.start_game(num_decks=1, num_rounds=3, options=GameOptions()) + + new_events = collector.events[initial_count:] + + # Should have game_started and round_started + event_types = [e.event_type for e in new_events] + assert EventType.GAME_STARTED in event_types + assert EventType.ROUND_STARTED in event_types + + # Verify round_started has deck_seed + round_started = next(e for e in new_events if e.event_type == EventType.ROUND_STARTED) + assert "deck_seed" in round_started.data + assert "dealt_cards" in round_started.data + assert "first_discard" in round_started.data + + def test_initial_flip_event(self): + """Initial flip should emit event with card positions.""" + game, collector = create_test_game(num_players=2) + game.start_game(num_decks=1, num_rounds=1, options=GameOptions(initial_flips=2)) + + initial_count = len(collector.events) + game.flip_initial_cards("p1", [0, 1]) + + new_events = collector.events[initial_count:] + flip_events = [e for e in new_events if e.event_type == EventType.INITIAL_FLIP] + + assert len(flip_events) == 1 + event = flip_events[0] + assert event.player_id == "p1" + assert event.data["positions"] == [0, 1] + assert len(event.data["cards"]) == 2 + + def test_draw_card_event(self): + """Drawing a card should emit card_drawn event.""" + game, collector = create_test_game(num_players=2) + game.start_game(num_decks=1, num_rounds=1, options=GameOptions(initial_flips=0)) + + initial_count = len(collector.events) + card = game.draw_card("p1", "deck") + + assert card is not None + new_events = collector.events[initial_count:] + draw_events = [e for e in new_events if e.event_type == EventType.CARD_DRAWN] + + assert len(draw_events) == 1 + event = draw_events[0] + assert event.player_id == "p1" + assert event.data["source"] == "deck" + assert event.data["card"]["rank"] == card.rank.value + + def test_swap_card_event(self): + """Swapping a card should emit card_swapped event.""" + game, collector = create_test_game(num_players=2) + game.start_game(num_decks=1, num_rounds=1, options=GameOptions(initial_flips=0)) + game.draw_card("p1", "deck") + + initial_count = len(collector.events) + old_card = game.swap_card("p1", 0) + + assert old_card is not None + new_events = collector.events[initial_count:] + swap_events = [e for e in new_events if e.event_type == EventType.CARD_SWAPPED] + + assert len(swap_events) == 1 + event = swap_events[0] + assert event.player_id == "p1" + assert event.data["position"] == 0 + + def test_discard_card_event(self): + """Discarding drawn card should emit card_discarded event.""" + game, collector = create_test_game(num_players=2) + game.start_game(num_decks=1, num_rounds=1, options=GameOptions(initial_flips=0)) + drawn = game.draw_card("p1", "deck") + + initial_count = len(collector.events) + game.discard_drawn("p1") + + new_events = collector.events[initial_count:] + discard_events = [e for e in new_events if e.event_type == EventType.CARD_DISCARDED] + + assert len(discard_events) == 1 + event = discard_events[0] + assert event.player_id == "p1" + assert event.data["card"]["rank"] == drawn.rank.value + + +class TestDeckSeeding: + """Test deterministic deck shuffling.""" + + def test_same_seed_same_order(self): + """Same seed should produce same card order.""" + from game import Deck + + deck1 = Deck(num_decks=1, seed=12345) + deck2 = Deck(num_decks=1, seed=12345) + + cards1 = [deck1.draw() for _ in range(10)] + cards2 = [deck2.draw() for _ in range(10)] + + for c1, c2 in zip(cards1, cards2): + assert c1.rank == c2.rank + assert c1.suit == c2.suit + + def test_different_seed_different_order(self): + """Different seeds should produce different order.""" + from game import Deck + + deck1 = Deck(num_decks=1, seed=12345) + deck2 = Deck(num_decks=1, seed=54321) + + cards1 = [deck1.draw() for _ in range(52)] + cards2 = [deck2.draw() for _ in range(52)] + + # At least some cards should be different + differences = sum( + 1 for c1, c2 in zip(cards1, cards2) + if c1.rank != c2.rank or c1.suit != c2.suit + ) + assert differences > 10 # Very unlikely to have <10 differences + + +class TestEventSequencing: + """Test event sequence ordering.""" + + def test_sequence_numbers_increment(self): + """Event sequence numbers should increment monotonically.""" + game, collector = create_test_game(num_players=2) + game.start_game(num_decks=1, num_rounds=1, options=GameOptions(initial_flips=0)) + + # Play a few turns + game.draw_card("p1", "deck") + game.discard_drawn("p1") + game.draw_card("p2", "deck") + game.swap_card("p2", 0) + + sequences = [e.sequence_num for e in collector.events] + for i in range(1, len(sequences)): + assert sequences[i] == sequences[i-1] + 1, \ + f"Sequence gap: {sequences[i-1]} -> {sequences[i]}" + + def test_all_events_have_game_id(self): + """All events should have the same game_id.""" + game, collector = create_test_game(num_players=2) + game.start_game(num_decks=1, num_rounds=1, options=GameOptions(initial_flips=0)) + + game_id = game.game_id + for event in collector.events: + assert event.game_id == game_id + + +class TestStateRebuilder: + """Test rebuilding state from events.""" + + def test_rebuild_empty_events_raises(self): + """Cannot rebuild from empty event list.""" + with pytest.raises(ValueError): + rebuild_state([]) + + def test_rebuild_basic_game(self): + """Can rebuild state from basic game events.""" + game, collector = create_test_game(num_players=2) + game.start_game(num_decks=1, num_rounds=1, options=GameOptions(initial_flips=2)) + + # Do initial flips + game.flip_initial_cards("p1", [0, 1]) + game.flip_initial_cards("p2", [0, 1]) + + # Rebuild state + state = rebuild_state(collector.events) + + assert state.game_id == game.game_id + assert state.room_code == "TEST" + assert len(state.players) == 2 + # Compare enum values since they're from different modules + assert state.phase.value == "playing" + assert state.current_round == 1 + + def test_rebuild_matches_player_cards(self): + """Rebuilt player cards should match original.""" + game, collector = create_test_game(num_players=2) + game.start_game(num_decks=1, num_rounds=1, options=GameOptions(initial_flips=2)) + + game.flip_initial_cards("p1", [0, 1]) + game.flip_initial_cards("p2", [0, 1]) + + # Rebuild and compare + state = rebuild_state(collector.events) + + for player in game.players: + rebuilt_player = state.get_player(player.id) + assert rebuilt_player is not None + assert len(rebuilt_player.cards) == 6 + + for i, (orig, rebuilt) in enumerate(zip(player.cards, rebuilt_player.cards)): + assert rebuilt.rank == orig.rank.value, f"Rank mismatch at position {i}" + assert rebuilt.suit == orig.suit.value, f"Suit mismatch at position {i}" + assert rebuilt.face_up == orig.face_up, f"Face up mismatch at position {i}" + + def test_rebuild_after_turns(self): + """Rebuilt state should match after several turns.""" + game, collector = create_test_game(num_players=2) + game.start_game(num_decks=1, num_rounds=1, options=GameOptions(initial_flips=0)) + + # Play several turns + for _ in range(5): + current = game.current_player() + if not current: + break + + game.draw_card(current.id, "deck") + game.discard_drawn(current.id) + + if game.phase == GamePhase.ROUND_OVER: + break + + # Rebuild and verify + state = rebuild_state(collector.events) + + assert state.current_player_idx == game.current_player_index + assert len(state.discard_pile) > 0 + + def test_rebuild_sequence_validation(self): + """Applying events out of order should fail.""" + game, collector = create_test_game(num_players=2) + game.start_game(num_decks=1, num_rounds=1, options=GameOptions(initial_flips=0)) + + # Skip first event + events = collector.events[1:] + + with pytest.raises(ValueError, match="Expected sequence"): + rebuild_state(events) + + +class TestFullGameReplay: + """Test complete game replay scenarios.""" + + def test_play_and_replay_single_round(self): + """Play a full round and verify replay matches.""" + game, collector = create_test_game(num_players=2) + game.start_game(num_decks=1, num_rounds=1, options=GameOptions(initial_flips=2)) + + # Initial flips + game.flip_initial_cards("p1", [0, 1]) + game.flip_initial_cards("p2", [0, 1]) + + # Play until round ends + turn_count = 0 + max_turns = 100 + while game.phase not in (GamePhase.ROUND_OVER, GamePhase.GAME_OVER) and turn_count < max_turns: + current = game.current_player() + if not current: + break + + game.draw_card(current.id, "deck") + game.discard_drawn(current.id) + turn_count += 1 + + # Rebuild and verify final state + state = rebuild_state(collector.events) + + # Phase should match + assert state.phase.value == game.phase.value + + # Scores should match (if round is over) + if game.phase == GamePhase.ROUND_OVER: + for player in game.players: + rebuilt_player = state.get_player(player.id) + assert rebuilt_player is not None + assert rebuilt_player.score == player.score + + def test_partial_replay(self): + """Can replay to any point in the game.""" + game, collector = create_test_game(num_players=2) + game.start_game(num_decks=1, num_rounds=1, options=GameOptions(initial_flips=0)) + + # Play several turns + for _ in range(10): + current = game.current_player() + if not current or game.phase == GamePhase.ROUND_OVER: + break + game.draw_card(current.id, "deck") + game.discard_drawn(current.id) + + # Replay to different points + for n in range(1, len(collector.events) + 1): + partial_events = collector.events[:n] + state = rebuild_state(partial_events) + assert state.sequence_num == n + + def test_swap_action_replay(self): + """Verify swap actions are correctly replayed.""" + game, collector = create_test_game(num_players=2) + game.start_game(num_decks=1, num_rounds=1, options=GameOptions(initial_flips=0)) + + # Do a swap + drawn = game.draw_card("p1", "deck") + old_card = game.get_player("p1").cards[0] + game.swap_card("p1", 0) + + # Rebuild and verify + state = rebuild_state(collector.events) + rebuilt_player = state.get_player("p1") + + # The swapped card should be in the hand + assert rebuilt_player.cards[0].rank == drawn.rank.value + assert rebuilt_player.cards[0].face_up is True + + # The old card should be on discard pile + assert state.discard_pile[-1].rank == old_card.rank.value + + +class TestEventSerialization: + """Test event serialization/deserialization.""" + + def test_event_to_dict_roundtrip(self): + """Events can be serialized and deserialized.""" + game, collector = create_test_game(num_players=2) + game.start_game(num_decks=1, num_rounds=1, options=GameOptions(initial_flips=0)) + + for event in collector.events: + event_dict = event.to_dict() + restored = GameEvent.from_dict(event_dict) + + assert restored.event_type == event.event_type + assert restored.game_id == event.game_id + assert restored.sequence_num == event.sequence_num + assert restored.player_id == event.player_id + assert restored.data == event.data + + def test_event_to_json_roundtrip(self): + """Events can be JSON serialized and deserialized.""" + game, collector = create_test_game(num_players=2) + game.start_game(num_decks=1, num_rounds=1, options=GameOptions(initial_flips=0)) + + for event in collector.events: + json_str = event.to_json() + restored = GameEvent.from_json(json_str) + + assert restored.event_type == event.event_type + assert restored.game_id == event.game_id + assert restored.sequence_num == event.sequence_num diff --git a/server/tests/test_persistence.py b/server/tests/test_persistence.py new file mode 100644 index 0000000..56f8d10 --- /dev/null +++ b/server/tests/test_persistence.py @@ -0,0 +1,564 @@ +""" +Tests for V2 Persistence & Recovery components. + +These tests cover: +- StateCache: Redis-backed game state caching +- GamePubSub: Cross-server event broadcasting +- RecoveryService: Game recovery from event store + +Tests use fakeredis for isolated Redis testing. +""" + +import asyncio +import json +import pytest +from unittest.mock import AsyncMock, MagicMock, patch +from datetime import datetime, timezone + +# Import the modules under test +from stores.state_cache import StateCache +from stores.pubsub import GamePubSub, PubSubMessage, MessageType +from services.recovery_service import RecoveryService, RecoveryResult +from models.events import ( + GameEvent, EventType, + game_created, player_joined, game_started, round_started, +) +from models.game_state import RebuiltGameState, GamePhase + + +# ============================================================================= +# Fixtures +# ============================================================================= + +@pytest.fixture +def mock_redis(): + """Create a mock Redis client for testing.""" + mock = AsyncMock() + + # Track stored data + data = {} + sets = {} + hashes = {} + + async def mock_set(key, value, ex=None): + data[key] = value + + async def mock_get(key): + return data.get(key) + + async def mock_delete(*keys): + for key in keys: + data.pop(key, None) + sets.pop(key, None) + hashes.pop(key, None) + + async def mock_exists(key): + return 1 if key in data or key in hashes else 0 + + async def mock_sadd(key, *values): + if key not in sets: + sets[key] = set() + sets[key].update(values) + return len(values) + + async def mock_srem(key, *values): + if key in sets: + for v in values: + sets[key].discard(v) + + async def mock_smembers(key): + return sets.get(key, set()) + + async def mock_hset(key, field=None, value=None, mapping=None, **kwargs): + """Mock hset supporting both hset(key, field, value) and hset(key, mapping={})""" + if key not in hashes: + hashes[key] = {} + if mapping: + for k, v in mapping.items(): + hashes[key][k.encode() if isinstance(k, str) else k] = v.encode() if isinstance(v, str) else v + elif field is not None and value is not None: + hashes[key][field.encode() if isinstance(field, str) else field] = value.encode() if isinstance(value, str) else value + + async def mock_hgetall(key): + return hashes.get(key, {}) + + async def mock_expire(key, seconds): + pass # No-op for testing + + def mock_pipeline(): + pipe = AsyncMock() + + async def pipe_hset(key, field=None, value=None, mapping=None, **kwargs): + await mock_hset(key, field, value, mapping, **kwargs) + + async def pipe_sadd(key, *values): + await mock_sadd(key, *values) + + async def pipe_set(key, value, ex=None): + await mock_set(key, value, ex) + + pipe.hset = pipe_hset + pipe.expire = AsyncMock() + pipe.sadd = pipe_sadd + pipe.set = pipe_set + pipe.srem = AsyncMock() + pipe.delete = AsyncMock() + + async def execute(): + return [] + + pipe.execute = execute + return pipe + + mock.set = mock_set + mock.get = mock_get + mock.delete = mock_delete + mock.exists = mock_exists + mock.sadd = mock_sadd + mock.srem = mock_srem + mock.smembers = mock_smembers + mock.hset = mock_hset + mock.hgetall = mock_hgetall + mock.expire = mock_expire + mock.pipeline = mock_pipeline + mock.ping = AsyncMock(return_value=True) + mock.close = AsyncMock() + + # Store references for assertions + mock._data = data + mock._sets = sets + mock._hashes = hashes + + return mock + + +@pytest.fixture +def state_cache(mock_redis): + """Create a StateCache with mock Redis.""" + return StateCache(mock_redis) + + +@pytest.fixture +def mock_event_store(): + """Create a mock EventStore.""" + mock = AsyncMock() + mock.get_events = AsyncMock(return_value=[]) + mock.get_active_games = AsyncMock(return_value=[]) + return mock + + +# ============================================================================= +# StateCache Tests +# ============================================================================= + +class TestStateCache: + """Tests for StateCache class.""" + + @pytest.mark.asyncio + async def test_create_room(self, state_cache, mock_redis): + """Test creating a new room.""" + await state_cache.create_room( + room_code="ABCD", + game_id="game-123", + host_id="player-1", + server_id="server-1", + ) + + # Verify room was created via pipeline + # (Pipeline operations are mocked, just verify no errors) + assert True # Room creation succeeded + + @pytest.mark.asyncio + async def test_room_exists_true(self, state_cache, mock_redis): + """Test room_exists returns True when room exists.""" + mock_redis._hashes["golf:room:ABCD"] = {b"game_id": b"123"} + + result = await state_cache.room_exists("ABCD") + assert result is True + + @pytest.mark.asyncio + async def test_room_exists_false(self, state_cache, mock_redis): + """Test room_exists returns False when room doesn't exist.""" + result = await state_cache.room_exists("XXXX") + assert result is False + + @pytest.mark.asyncio + async def test_get_active_rooms(self, state_cache, mock_redis): + """Test getting active rooms.""" + mock_redis._sets["golf:rooms:active"] = {"ABCD", "EFGH"} + + rooms = await state_cache.get_active_rooms() + assert rooms == {"ABCD", "EFGH"} + + @pytest.mark.asyncio + async def test_save_and_get_game_state(self, state_cache, mock_redis): + """Test saving and retrieving game state.""" + state = { + "game_id": "game-123", + "phase": "playing", + "players": {"p1": {"name": "Alice"}}, + } + + await state_cache.save_game_state("game-123", state) + + # Verify it was stored + key = "golf:game:game-123" + assert key in mock_redis._data + + # Retrieve it + retrieved = await state_cache.get_game_state("game-123") + assert retrieved == state + + @pytest.mark.asyncio + async def test_get_nonexistent_game_state(self, state_cache, mock_redis): + """Test getting state for non-existent game returns None.""" + result = await state_cache.get_game_state("nonexistent") + assert result is None + + @pytest.mark.asyncio + async def test_add_player_to_room(self, state_cache, mock_redis): + """Test adding a player to a room.""" + await state_cache.add_player_to_room("ABCD", "player-2") + + # Pipeline was used successfully (no exception thrown) + # The actual data verification would require integration tests + assert True # add_player_to_room completed without error + + @pytest.mark.asyncio + async def test_get_room_players(self, state_cache, mock_redis): + """Test getting players in a room.""" + mock_redis._sets["golf:room:ABCD:players"] = {"player-1", "player-2"} + + players = await state_cache.get_room_players("ABCD") + assert players == {"player-1", "player-2"} + + @pytest.mark.asyncio + async def test_get_player_room(self, state_cache, mock_redis): + """Test getting the room a player is in.""" + mock_redis._data["golf:player:player-1:room"] = b"ABCD" + + room = await state_cache.get_player_room("player-1") + assert room == "ABCD" + + @pytest.mark.asyncio + async def test_get_player_room_not_in_room(self, state_cache, mock_redis): + """Test getting room for player not in any room.""" + room = await state_cache.get_player_room("unknown-player") + assert room is None + + +# ============================================================================= +# GamePubSub Tests +# ============================================================================= + +class TestGamePubSub: + """Tests for GamePubSub class.""" + + @pytest.fixture + def mock_pubsub_redis(self): + """Create mock Redis with pubsub support.""" + mock = AsyncMock() + mock_pubsub = AsyncMock() + mock_pubsub.subscribe = AsyncMock() + mock_pubsub.unsubscribe = AsyncMock() + mock_pubsub.get_message = AsyncMock(return_value=None) + mock_pubsub.close = AsyncMock() + mock.pubsub = MagicMock(return_value=mock_pubsub) + mock.publish = AsyncMock(return_value=1) + return mock, mock_pubsub + + @pytest.mark.asyncio + async def test_subscribe_to_room(self, mock_pubsub_redis): + """Test subscribing to room events.""" + redis_client, mock_ps = mock_pubsub_redis + pubsub = GamePubSub(redis_client, server_id="test-server") + + handler = AsyncMock() + await pubsub.subscribe("ABCD", handler) + + mock_ps.subscribe.assert_called_once_with("golf:room:ABCD") + assert "golf:room:ABCD" in pubsub._handlers + + @pytest.mark.asyncio + async def test_unsubscribe_from_room(self, mock_pubsub_redis): + """Test unsubscribing from room events.""" + redis_client, mock_ps = mock_pubsub_redis + pubsub = GamePubSub(redis_client, server_id="test-server") + + handler = AsyncMock() + await pubsub.subscribe("ABCD", handler) + await pubsub.unsubscribe("ABCD") + + mock_ps.unsubscribe.assert_called_once_with("golf:room:ABCD") + assert "golf:room:ABCD" not in pubsub._handlers + + @pytest.mark.asyncio + async def test_publish_message(self, mock_pubsub_redis): + """Test publishing a message.""" + redis_client, _ = mock_pubsub_redis + pubsub = GamePubSub(redis_client, server_id="test-server") + + message = PubSubMessage( + type=MessageType.GAME_STATE_UPDATE, + room_code="ABCD", + data={"phase": "playing"}, + ) + count = await pubsub.publish(message) + + assert count == 1 + redis_client.publish.assert_called_once() + call_args = redis_client.publish.call_args + assert call_args[0][0] == "golf:room:ABCD" + + def test_pubsub_message_serialization(self): + """Test PubSubMessage JSON serialization.""" + message = PubSubMessage( + type=MessageType.PLAYER_JOINED, + room_code="ABCD", + data={"player_name": "Alice"}, + sender_id="server-1", + ) + + json_str = message.to_json() + parsed = PubSubMessage.from_json(json_str) + + assert parsed.type == MessageType.PLAYER_JOINED + assert parsed.room_code == "ABCD" + assert parsed.data == {"player_name": "Alice"} + assert parsed.sender_id == "server-1" + + +# ============================================================================= +# RecoveryService Tests +# ============================================================================= + +class TestRecoveryService: + """Tests for RecoveryService class.""" + + @pytest.fixture + def mock_dependencies(self, mock_event_store, state_cache): + """Create mocked dependencies for RecoveryService.""" + return mock_event_store, state_cache + + def create_test_events(self, game_id: str = "game-123") -> list[GameEvent]: + """Create a sequence of test events for recovery.""" + return [ + game_created( + game_id=game_id, + sequence_num=1, + room_code="ABCD", + host_id="player-1", + options={"rounds": 9}, + ), + player_joined( + game_id=game_id, + sequence_num=2, + player_id="player-1", + player_name="Alice", + ), + player_joined( + game_id=game_id, + sequence_num=3, + player_id="player-2", + player_name="Bob", + ), + game_started( + game_id=game_id, + sequence_num=4, + player_order=["player-1", "player-2"], + num_decks=1, + num_rounds=9, + options={"rounds": 9}, + ), + round_started( + game_id=game_id, + sequence_num=5, + round_num=1, + deck_seed=12345, + dealt_cards={ + "player-1": [ + {"rank": "K", "suit": "hearts"}, + {"rank": "5", "suit": "diamonds"}, + {"rank": "A", "suit": "clubs"}, + {"rank": "7", "suit": "spades"}, + {"rank": "Q", "suit": "hearts"}, + {"rank": "3", "suit": "clubs"}, + ], + "player-2": [ + {"rank": "10", "suit": "spades"}, + {"rank": "2", "suit": "hearts"}, + {"rank": "J", "suit": "diamonds"}, + {"rank": "9", "suit": "clubs"}, + {"rank": "4", "suit": "hearts"}, + {"rank": "8", "suit": "spades"}, + ], + }, + first_discard={"rank": "6", "suit": "diamonds"}, + ), + ] + + @pytest.mark.asyncio + async def test_recover_game_success(self, mock_dependencies): + """Test successful game recovery.""" + event_store, state_cache = mock_dependencies + events = self.create_test_events() + event_store.get_events.return_value = events + + recovery = RecoveryService(event_store, state_cache) + result = await recovery.recover_game("game-123", "ABCD") + + assert result.success is True + assert result.game_id == "game-123" + assert result.room_code == "ABCD" + assert result.phase == "initial_flip" + assert result.sequence_num == 5 + + @pytest.mark.asyncio + async def test_recover_game_no_events(self, mock_dependencies): + """Test recovery with no events returns failure.""" + event_store, state_cache = mock_dependencies + event_store.get_events.return_value = [] + + recovery = RecoveryService(event_store, state_cache) + result = await recovery.recover_game("game-123") + + assert result.success is False + assert result.error == "no_events" + + @pytest.mark.asyncio + async def test_recover_game_already_ended(self, mock_dependencies): + """Test recovery skips ended games.""" + event_store, state_cache = mock_dependencies + + # Create events ending with GAME_ENDED + events = self.create_test_events() + events.append(GameEvent( + event_type=EventType.GAME_ENDED, + game_id="game-123", + sequence_num=6, + data={"final_scores": {}, "rounds_won": {}}, + )) + event_store.get_events.return_value = events + + recovery = RecoveryService(event_store, state_cache) + result = await recovery.recover_game("game-123") + + assert result.success is False + assert result.error == "game_ended" + + @pytest.mark.asyncio + async def test_recover_all_games(self, mock_dependencies): + """Test recovering multiple games.""" + event_store, state_cache = mock_dependencies + + # Set up two active games + event_store.get_active_games.return_value = [ + {"id": "game-1", "room_code": "AAAA"}, + {"id": "game-2", "room_code": "BBBB"}, + ] + + # Each game has events + event_store.get_events.side_effect = [ + self.create_test_events("game-1"), + self.create_test_events("game-2"), + ] + + recovery = RecoveryService(event_store, state_cache) + results = await recovery.recover_all_games() + + assert results["recovered"] == 2 + assert results["failed"] == 0 + assert results["skipped"] == 0 + assert len(results["games"]) == 2 + + @pytest.mark.asyncio + async def test_state_to_dict_conversion(self, mock_dependencies): + """Test state to dict conversion for caching.""" + event_store, state_cache = mock_dependencies + events = self.create_test_events() + event_store.get_events.return_value = events + + recovery = RecoveryService(event_store, state_cache) + result = await recovery.recover_game("game-123") + + # Verify recovery succeeded + assert result.success is True + + # Verify state was cached (game_id key should be set) + game_key = "golf:game:game-123" + assert game_key in state_cache.redis._data + + @pytest.mark.asyncio + async def test_dict_to_state_conversion(self, mock_dependencies): + """Test dict to state conversion for recovery.""" + event_store, state_cache = mock_dependencies + recovery = RecoveryService(event_store, state_cache) + + state_dict = { + "game_id": "game-123", + "room_code": "ABCD", + "phase": "playing", + "current_round": 1, + "total_rounds": 9, + "current_player_idx": 0, + "player_order": ["player-1", "player-2"], + "deck_remaining": 40, + "options": {}, + "sequence_num": 5, + "finisher_id": None, + "host_id": "player-1", + "initial_flips_done": ["player-1"], + "players_with_final_turn": [], + "drawn_from_discard": False, + "players": { + "player-1": { + "id": "player-1", + "name": "Alice", + "cards": [ + {"rank": "K", "suit": "hearts", "face_up": True}, + ], + "score": 0, + "total_score": 0, + "rounds_won": 0, + "is_cpu": False, + "cpu_profile": None, + }, + }, + "discard_pile": [{"rank": "6", "suit": "diamonds", "face_up": True}], + "drawn_card": None, + } + + state = recovery._dict_to_state(state_dict) + + assert state.game_id == "game-123" + assert state.room_code == "ABCD" + assert state.phase == GamePhase.PLAYING + assert state.current_round == 1 + assert "player-1" in state.players + assert state.players["player-1"].name == "Alice" + assert len(state.discard_pile) == 1 + + +# ============================================================================= +# Integration Tests (require actual Redis - skip if not available) +# ============================================================================= + +@pytest.mark.skip(reason="Requires actual Redis - run manually with docker-compose") +class TestIntegration: + """Integration tests requiring actual Redis.""" + + @pytest.mark.asyncio + async def test_full_recovery_cycle(self): + """Test complete recovery cycle with real Redis.""" + # This would test the actual flow: + # 1. Create game events + # 2. Store in PostgreSQL + # 3. Cache state in Redis + # 4. "Restart" - clear local state + # 5. Recover from PostgreSQL + # 6. Verify state matches + pass + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/server/tests/test_replay.py b/server/tests/test_replay.py new file mode 100644 index 0000000..66fe31e --- /dev/null +++ b/server/tests/test_replay.py @@ -0,0 +1,302 @@ +""" +Tests for the replay service. + +Verifies: +- Replay building from events +- Share link creation and retrieval +- Export/import roundtrip +- Access control +""" + +import pytest +import json +from datetime import datetime, timezone, timedelta +from unittest.mock import AsyncMock, MagicMock, patch + +from models.events import GameEvent, EventType +from models.game_state import RebuiltGameState, rebuild_state + + +class TestReplayBuilding: + """Test replay construction from events.""" + + def test_rebuild_state_from_events(self): + """Verify state can be rebuilt from a sequence of events.""" + events = [ + GameEvent( + event_type=EventType.GAME_CREATED, + game_id="test-game-1", + sequence_num=1, + player_id=None, + data={ + "room_code": "ABCD", + "host_id": "player-1", + "options": {}, + }, + timestamp=datetime.now(timezone.utc), + ), + GameEvent( + event_type=EventType.PLAYER_JOINED, + game_id="test-game-1", + sequence_num=2, + player_id="player-1", + data={ + "player_name": "Alice", + "is_cpu": False, + }, + timestamp=datetime.now(timezone.utc), + ), + GameEvent( + event_type=EventType.PLAYER_JOINED, + game_id="test-game-1", + sequence_num=3, + player_id="player-2", + data={ + "player_name": "Bob", + "is_cpu": False, + }, + timestamp=datetime.now(timezone.utc), + ), + ] + + state = rebuild_state(events) + + assert state.game_id == "test-game-1" + assert state.room_code == "ABCD" + assert len(state.players) == 2 + assert "player-1" in state.players + assert "player-2" in state.players + assert state.players["player-1"].name == "Alice" + assert state.players["player-2"].name == "Bob" + assert state.sequence_num == 3 + + def test_rebuild_state_partial(self): + """Can rebuild state to any point in event history.""" + events = [ + GameEvent( + event_type=EventType.GAME_CREATED, + game_id="test-game-1", + sequence_num=1, + player_id=None, + data={ + "room_code": "ABCD", + "host_id": "player-1", + "options": {}, + }, + timestamp=datetime.now(timezone.utc), + ), + GameEvent( + event_type=EventType.PLAYER_JOINED, + game_id="test-game-1", + sequence_num=2, + player_id="player-1", + data={ + "player_name": "Alice", + "is_cpu": False, + }, + timestamp=datetime.now(timezone.utc), + ), + GameEvent( + event_type=EventType.PLAYER_JOINED, + game_id="test-game-1", + sequence_num=3, + player_id="player-2", + data={ + "player_name": "Bob", + "is_cpu": False, + }, + timestamp=datetime.now(timezone.utc), + ), + ] + + # Rebuild only first 2 events + state = rebuild_state(events[:2]) + assert len(state.players) == 1 + assert state.sequence_num == 2 + + # Rebuild all events + state = rebuild_state(events) + assert len(state.players) == 2 + assert state.sequence_num == 3 + + +class TestExportImport: + """Test game export and import.""" + + def test_export_format(self): + """Verify exported format matches expected structure.""" + export_data = { + "version": "1.0", + "exported_at": "2024-01-15T12:00:00Z", + "game": { + "id": "test-game-1", + "room_code": "ABCD", + "players": ["Alice", "Bob"], + "winner": "Alice", + "final_scores": {"Alice": 15, "Bob": 23}, + "duration_seconds": 300.5, + "total_rounds": 1, + "options": {}, + }, + "events": [ + { + "type": "game_created", + "sequence": 1, + "player_id": None, + "data": {"room_code": "ABCD", "host_id": "p1", "options": {}}, + "timestamp": 0.0, + }, + ], + } + + assert export_data["version"] == "1.0" + assert "exported_at" in export_data + assert "game" in export_data + assert "events" in export_data + assert export_data["game"]["players"] == ["Alice", "Bob"] + + def test_import_validates_version(self): + """Import should reject unsupported versions.""" + invalid_export = { + "version": "2.0", # Unsupported version + "events": [], + } + + # This would be tested with the actual service + assert invalid_export["version"] != "1.0" + + +class TestShareLinks: + """Test share link functionality.""" + + def test_share_code_format(self): + """Share codes should be 12 characters.""" + import secrets + share_code = secrets.token_urlsafe(9)[:12] + + assert len(share_code) == 12 + # URL-safe characters only + assert all(c.isalnum() or c in '-_' for c in share_code) + + def test_expiry_calculation(self): + """Verify expiry date calculation.""" + now = datetime.now(timezone.utc) + expires_days = 7 + expires_at = now + timedelta(days=expires_days) + + assert expires_at > now + assert (expires_at - now).days == 7 + + +class TestSpectatorManager: + """Test spectator management.""" + + @pytest.mark.asyncio + async def test_add_remove_spectator(self): + """Test adding and removing spectators.""" + from services.spectator import SpectatorManager + + manager = SpectatorManager() + ws = AsyncMock() + + # Add spectator + result = await manager.add_spectator("game-1", ws, user_id="user-1") + assert result is True + assert manager.get_spectator_count("game-1") == 1 + + # Remove spectator + await manager.remove_spectator("game-1", ws) + assert manager.get_spectator_count("game-1") == 0 + + @pytest.mark.asyncio + async def test_spectator_limit(self): + """Test spectator limit enforcement.""" + from services.spectator import SpectatorManager, MAX_SPECTATORS_PER_GAME + + manager = SpectatorManager() + + # Add max spectators + for i in range(MAX_SPECTATORS_PER_GAME): + ws = AsyncMock() + result = await manager.add_spectator("game-1", ws) + assert result is True + + # Try to add one more + ws = AsyncMock() + result = await manager.add_spectator("game-1", ws) + assert result is False + + @pytest.mark.asyncio + async def test_broadcast_to_spectators(self): + """Test broadcasting messages to spectators.""" + from services.spectator import SpectatorManager + + manager = SpectatorManager() + ws1 = AsyncMock() + ws2 = AsyncMock() + + await manager.add_spectator("game-1", ws1) + await manager.add_spectator("game-1", ws2) + + message = {"type": "game_update", "data": "test"} + await manager.broadcast_to_spectators("game-1", message) + + ws1.send_json.assert_called_once_with(message) + ws2.send_json.assert_called_once_with(message) + + @pytest.mark.asyncio + async def test_dead_connection_cleanup(self): + """Test cleanup of dead WebSocket connections.""" + from services.spectator import SpectatorManager + + manager = SpectatorManager() + + # Add a spectator that will fail on send + ws = AsyncMock() + ws.send_json.side_effect = Exception("Connection closed") + + await manager.add_spectator("game-1", ws) + assert manager.get_spectator_count("game-1") == 1 + + # Broadcast should clean up dead connection + await manager.broadcast_to_spectators("game-1", {"type": "test"}) + assert manager.get_spectator_count("game-1") == 0 + + +class TestReplayFrames: + """Test replay frame construction.""" + + def test_frame_timestamps(self): + """Verify frame timestamps are relative to game start.""" + start_time = datetime.now(timezone.utc) + + events = [ + GameEvent( + event_type=EventType.GAME_CREATED, + game_id="test-game-1", + sequence_num=1, + player_id=None, + data={"room_code": "ABCD", "host_id": "p1", "options": {}}, + timestamp=start_time, + ), + GameEvent( + event_type=EventType.PLAYER_JOINED, + game_id="test-game-1", + sequence_num=2, + player_id="player-1", + data={"player_name": "Alice", "is_cpu": False}, + timestamp=start_time + timedelta(seconds=5), + ), + ] + + # First event should have timestamp 0 + elapsed_0 = (events[0].timestamp - start_time).total_seconds() + assert elapsed_0 == 0.0 + + # Second event should have timestamp 5 + elapsed_1 = (events[1].timestamp - start_time).total_seconds() + assert elapsed_1 == 5.0 + + +if __name__ == "__main__": + pytest.main([__file__, "-v"])