Huge v2 uplift, now deployable with real user management and tooling!

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Aaron D. Lee
2026-01-27 11:32:15 -05:00
parent c912a56c2d
commit bea85e6b28
61 changed files with 25153 additions and 362 deletions

37
Dockerfile Normal file
View File

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

522
V2_BUILD_PLAN.md Normal file
View File

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

633
client/admin.css Normal file
View File

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

368
client/admin.html Normal file
View File

@@ -0,0 +1,368 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Golf Admin Dashboard</title>
<link rel="stylesheet" href="admin.css">
</head>
<body>
<!-- Login Screen -->
<div id="login-screen" class="screen">
<div class="login-container">
<h1>Golf Admin</h1>
<form id="login-form">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" required>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required>
</div>
<button type="submit" class="btn btn-primary">Login</button>
<p id="login-error" class="error"></p>
</form>
</div>
</div>
<!-- Dashboard Screen -->
<div id="dashboard-screen" class="screen hidden">
<nav class="admin-nav">
<div class="nav-brand">
<h1>Golf Admin</h1>
</div>
<div class="nav-links">
<a href="#" data-panel="dashboard" class="nav-link active">Dashboard</a>
<a href="#" data-panel="users" class="nav-link">Users</a>
<a href="#" data-panel="games" class="nav-link">Games</a>
<a href="#" data-panel="invites" class="nav-link">Invites</a>
<a href="#" data-panel="audit" class="nav-link">Audit Log</a>
</div>
<div class="nav-user">
<span id="admin-username"></span>
<button id="logout-btn" class="btn btn-small">Logout</button>
</div>
</nav>
<main class="admin-content">
<!-- Dashboard Panel -->
<section id="dashboard-panel" class="panel">
<h2>System Overview</h2>
<div class="stats-grid">
<div class="stat-card">
<span class="stat-value" id="stat-active-users">-</span>
<span class="stat-label">Active Users (1h)</span>
</div>
<div class="stat-card">
<span class="stat-value" id="stat-active-games">-</span>
<span class="stat-label">Active Games</span>
</div>
<div class="stat-card">
<span class="stat-value" id="stat-total-users">-</span>
<span class="stat-label">Total Users</span>
</div>
<div class="stat-card">
<span class="stat-value" id="stat-games-today">-</span>
<span class="stat-label">Games Today</span>
</div>
<div class="stat-card">
<span class="stat-value" id="stat-reg-today">-</span>
<span class="stat-label">Registrations Today</span>
</div>
<div class="stat-card">
<span class="stat-value" id="stat-reg-week">-</span>
<span class="stat-label">Registrations (7d)</span>
</div>
<div class="stat-card">
<span class="stat-value" id="stat-total-games">-</span>
<span class="stat-label">Total Games</span>
</div>
<div class="stat-card">
<span class="stat-value" id="stat-events-hour">-</span>
<span class="stat-label">Events (1h)</span>
</div>
</div>
<div class="panel-section">
<h3>Top Players</h3>
<table id="top-players-table" class="data-table">
<thead>
<tr>
<th>#</th>
<th>Username</th>
<th>Wins</th>
<th>Games</th>
<th>Win Rate</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</section>
<!-- Users Panel -->
<section id="users-panel" class="panel hidden">
<h2>User Management</h2>
<div class="panel-toolbar">
<div class="search-bar">
<input type="text" id="user-search" placeholder="Search by username or email...">
<button id="user-search-btn" class="btn">Search</button>
</div>
<label class="checkbox-label">
<input type="checkbox" id="include-banned" checked>
Include banned
</label>
</div>
<table id="users-table" class="data-table">
<thead>
<tr>
<th>Username</th>
<th>Email</th>
<th>Role</th>
<th>Status</th>
<th>Games</th>
<th>Joined</th>
<th>Actions</th>
</tr>
</thead>
<tbody></tbody>
</table>
<div class="pagination">
<button id="users-prev" class="btn btn-small" disabled>Previous</button>
<span id="users-page-info">Page 1</span>
<button id="users-next" class="btn btn-small">Next</button>
</div>
</section>
<!-- Games Panel -->
<section id="games-panel" class="panel hidden">
<h2>Active Games</h2>
<button id="refresh-games-btn" class="btn">Refresh</button>
<table id="games-table" class="data-table">
<thead>
<tr>
<th>Room Code</th>
<th>Players</th>
<th>Phase</th>
<th>Round</th>
<th>Status</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody></tbody>
</table>
</section>
<!-- Invites Panel -->
<section id="invites-panel" class="panel hidden">
<h2>Invite Codes</h2>
<div class="panel-toolbar">
<div class="create-invite-form">
<label>
Max Uses:
<input type="number" id="invite-max-uses" value="1" min="1" max="100">
</label>
<label>
Expires in (days):
<input type="number" id="invite-expires-days" value="7" min="1" max="365">
</label>
<button id="create-invite-btn" class="btn btn-primary">Create Invite</button>
</div>
<label class="checkbox-label">
<input type="checkbox" id="include-expired">
Show expired
</label>
</div>
<table id="invites-table" class="data-table">
<thead>
<tr>
<th>Code</th>
<th>Uses</th>
<th>Remaining</th>
<th>Created By</th>
<th>Expires</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody></tbody>
</table>
</section>
<!-- Audit Log Panel -->
<section id="audit-panel" class="panel hidden">
<h2>Audit Log</h2>
<div class="panel-toolbar">
<div class="filter-bar">
<select id="audit-action-filter">
<option value="">All Actions</option>
<option value="ban_user">Ban User</option>
<option value="unban_user">Unban User</option>
<option value="force_password_reset">Force Password Reset</option>
<option value="change_role">Change Role</option>
<option value="impersonate_user">Impersonate</option>
<option value="view_game">View Game</option>
<option value="end_game">End Game</option>
<option value="create_invite">Create Invite</option>
<option value="revoke_invite">Revoke Invite</option>
</select>
<select id="audit-target-filter">
<option value="">All Targets</option>
<option value="user">Users</option>
<option value="game">Games</option>
<option value="invite_code">Invites</option>
</select>
<button id="audit-filter-btn" class="btn">Filter</button>
</div>
</div>
<table id="audit-table" class="data-table">
<thead>
<tr>
<th>Time</th>
<th>Admin</th>
<th>Action</th>
<th>Target</th>
<th>Details</th>
<th>IP</th>
</tr>
</thead>
<tbody></tbody>
</table>
<div class="pagination">
<button id="audit-prev" class="btn btn-small" disabled>Previous</button>
<span id="audit-page-info">Page 1</span>
<button id="audit-next" class="btn btn-small">Next</button>
</div>
</section>
</main>
</div>
<!-- User Detail Modal -->
<div id="user-modal" class="modal hidden">
<div class="modal-content">
<div class="modal-header">
<h3>User Details</h3>
<button class="modal-close">&times;</button>
</div>
<div class="modal-body">
<div class="user-detail-grid">
<div class="detail-row">
<span class="detail-label">Username:</span>
<span id="detail-username" class="detail-value"></span>
</div>
<div class="detail-row">
<span class="detail-label">Email:</span>
<span id="detail-email" class="detail-value"></span>
</div>
<div class="detail-row">
<span class="detail-label">Role:</span>
<span id="detail-role" class="detail-value"></span>
</div>
<div class="detail-row">
<span class="detail-label">Status:</span>
<span id="detail-status" class="detail-value"></span>
</div>
<div class="detail-row">
<span class="detail-label">Games Played:</span>
<span id="detail-games-played" class="detail-value"></span>
</div>
<div class="detail-row">
<span class="detail-label">Games Won:</span>
<span id="detail-games-won" class="detail-value"></span>
</div>
<div class="detail-row">
<span class="detail-label">Joined:</span>
<span id="detail-joined" class="detail-value"></span>
</div>
<div class="detail-row">
<span class="detail-label">Last Login:</span>
<span id="detail-last-login" class="detail-value"></span>
</div>
</div>
<div class="user-actions">
<h4>Actions</h4>
<div class="action-buttons">
<button id="action-ban" class="btn btn-danger">Ban User</button>
<button id="action-unban" class="btn btn-success hidden">Unban User</button>
<button id="action-reset-pw" class="btn btn-warning">Force Password Reset</button>
<button id="action-make-admin" class="btn">Make Admin</button>
<button id="action-remove-admin" class="btn hidden">Remove Admin</button>
<button id="action-impersonate" class="btn">Impersonate (Read-Only)</button>
</div>
</div>
<div id="ban-history-section">
<h4>Ban History</h4>
<table id="ban-history-table" class="data-table small">
<thead>
<tr>
<th>Date</th>
<th>Reason</th>
<th>By</th>
<th>Status</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Ban User Modal -->
<div id="ban-modal" class="modal hidden">
<div class="modal-content modal-small">
<div class="modal-header">
<h3>Ban User</h3>
<button class="modal-close">&times;</button>
</div>
<div class="modal-body">
<form id="ban-form">
<div class="form-group">
<label for="ban-reason">Reason:</label>
<textarea id="ban-reason" required placeholder="Enter reason for ban..."></textarea>
</div>
<div class="form-group">
<label for="ban-duration">Duration (days, leave empty for permanent):</label>
<input type="number" id="ban-duration" min="1" max="365" placeholder="Permanent">
</div>
<div class="form-actions">
<button type="button" class="btn modal-close">Cancel</button>
<button type="submit" class="btn btn-danger">Ban User</button>
</div>
</form>
</div>
</div>
</div>
<!-- End Game Modal -->
<div id="end-game-modal" class="modal hidden">
<div class="modal-content modal-small">
<div class="modal-header">
<h3>End Game</h3>
<button class="modal-close">&times;</button>
</div>
<div class="modal-body">
<form id="end-game-form">
<div class="form-group">
<label for="end-game-reason">Reason:</label>
<textarea id="end-game-reason" required placeholder="Enter reason for ending game..."></textarea>
</div>
<div class="form-actions">
<button type="button" class="btn modal-close">Cancel</button>
<button type="submit" class="btn btn-danger">End Game</button>
</div>
</form>
</div>
</div>
</div>
<!-- Toast Container -->
<div id="toast-container"></div>
<script src="admin.js"></script>
</body>
</html>

809
client/admin.js Normal file
View File

@@ -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 '<span class="badge badge-danger">Banned</span>';
}
if (!user.is_active) {
return '<span class="badge badge-muted">Inactive</span>';
}
if (user.force_password_reset) {
return '<span class="badge badge-warning">Reset Required</span>';
}
if (!user.email_verified && user.email) {
return '<span class="badge badge-warning">Unverified</span>';
}
return '<span class="badge badge-success">Active</span>';
}
// =============================================================================
// 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 += `
<tr>
<td>${index + 1}</td>
<td>${escapeHtml(player.username)}</td>
<td>${player.games_won}</td>
<td>${player.games_played}</td>
<td>${winRate}%</td>
</tr>
`;
});
if (stats.top_players.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="text-muted">No players yet</td></tr>';
}
} 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 += `
<tr>
<td>${escapeHtml(user.username)}</td>
<td>${escapeHtml(user.email || '-')}</td>
<td><span class="badge badge-${user.role === 'admin' ? 'info' : 'muted'}">${user.role}</span></td>
<td>${getStatusBadge(user)}</td>
<td>${user.games_played} (${user.games_won} wins)</td>
<td>${formatDateShort(user.created_at)}</td>
<td>
<button class="btn btn-small" onclick="viewUser('${user.id}')">View</button>
</td>
</tr>
`;
});
if (data.users.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="text-muted">No users found</td></tr>';
}
// 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
? `<span class="badge badge-success">Unbanned</span>`
: (ban.expires_at && new Date(ban.expires_at) < new Date()
? `<span class="badge badge-muted">Expired</span>`
: `<span class="badge badge-danger">Active</span>`);
historyBody.innerHTML += `
<tr>
<td>${formatDateShort(ban.banned_at)}</td>
<td>${escapeHtml(ban.reason || '-')}</td>
<td>${escapeHtml(ban.banned_by)}</td>
<td>${status}</td>
</tr>
`;
});
if (history.history.length === 0) {
historyBody.innerHTML = '<tr><td colspan="4" class="text-muted">No ban history</td></tr>';
}
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 += `
<tr>
<td><strong>${escapeHtml(game.room_code)}</strong></td>
<td>${game.player_count}</td>
<td>${game.phase || game.status || '-'}</td>
<td>${game.current_round || '-'}</td>
<td><span class="badge badge-${game.status === 'playing' ? 'success' : 'info'}">${game.status}</span></td>
<td>${formatDate(game.created_at)}</td>
<td>
<button class="btn btn-small btn-danger" onclick="promptEndGame('${game.game_id}')">End</button>
</td>
</tr>
`;
});
if (data.games.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="text-muted">No active games</td></tr>';
}
} 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
? '<span class="badge badge-danger">Revoked</span>'
: isExpired
? '<span class="badge badge-muted">Expired</span>'
: invite.remaining_uses <= 0
? '<span class="badge badge-warning">Used Up</span>'
: '<span class="badge badge-success">Active</span>';
tbody.innerHTML += `
<tr>
<td><code>${escapeHtml(invite.code)}</code></td>
<td>${invite.use_count} / ${invite.max_uses}</td>
<td>${invite.remaining_uses}</td>
<td>${escapeHtml(invite.created_by_username)}</td>
<td>${formatDate(invite.expires_at)}</td>
<td>${status}</td>
<td>
${invite.is_active && !isExpired && invite.remaining_uses > 0
? `<button class="btn btn-small btn-danger" onclick="promptRevokeInvite('${invite.code}')">Revoke</button>`
: '-'
}
</td>
</tr>
`;
});
if (data.codes.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="text-muted">No invite codes</td></tr>';
}
} 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
? `<code class="text-small">${escapeHtml(JSON.stringify(entry.details))}</code>`
: '-';
tbody.innerHTML += `
<tr>
<td>${formatDate(entry.created_at)}</td>
<td>${escapeHtml(entry.admin_username)}</td>
<td><span class="badge badge-info">${entry.action}</span></td>
<td>${entry.target_type ? `${entry.target_type}: ${entry.target_id || '-'}` : '-'}</td>
<td>${details}</td>
<td class="text-muted text-small">${entry.ip_address || '-'}</td>
</tr>
`;
});
if (data.entries.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="text-muted">No audit entries</td></tr>';
}
// 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();
});

View File

@@ -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');
}
}
}

View File

@@ -8,10 +8,22 @@
</head>
<body>
<div id="app">
<!-- Auth Bar (shown when logged in) -->
<div id="auth-bar" class="auth-bar hidden">
<span id="auth-username"></span>
<button id="auth-logout-btn" class="btn btn-small">Logout</button>
</div>
<!-- Lobby Screen -->
<div id="lobby-screen" class="screen active">
<h1><span class="golfer-swing">🏌️</span><span class="kicked-ball"></span> <span class="golf-title">Golf</span></h1>
<p class="subtitle">6-Card Golf Card Game <button id="rules-btn" class="btn btn-small btn-rules">Rules</button></p>
<p class="subtitle">6-Card Golf Card Game <button id="rules-btn" class="btn btn-small btn-rules">Rules</button> <button id="leaderboard-btn" class="btn btn-small leaderboard-btn">Leaderboard</button></p>
<!-- Auth buttons for guests -->
<div id="auth-buttons" class="auth-buttons">
<button id="login-btn" class="btn btn-small">Login</button>
<button id="signup-btn" class="btn btn-small btn-primary">Sign Up</button>
</div>
<div class="form-group">
<label for="player-name">Your Name</label>
@@ -637,6 +649,83 @@ TOTAL: 0 + 8 + 16 = 24 points</pre>
</section>
</div>
</div>
<!-- Leaderboard Screen -->
<div id="leaderboard-screen" class="screen">
<div class="leaderboard-container">
<button id="leaderboard-back-btn" class="btn leaderboard-back-btn">&laquo; Back</button>
<div class="leaderboard-header">
<h1>Leaderboard</h1>
<p class="leaderboard-subtitle">Top players ranked by performance</p>
</div>
<div class="leaderboard-tabs" id="leaderboard-tabs">
<button class="leaderboard-tab active" data-metric="wins">Wins</button>
<button class="leaderboard-tab" data-metric="win_rate">Win Rate</button>
<button class="leaderboard-tab" data-metric="avg_score">Avg Score</button>
<button class="leaderboard-tab" data-metric="knockouts">Knockouts</button>
<button class="leaderboard-tab" data-metric="streak">Best Streak</button>
</div>
<div id="leaderboard-content">
<div class="leaderboard-loading">Loading...</div>
</div>
</div>
</div>
<!-- Replay Screen -->
<div id="replay-screen" class="screen">
<header class="replay-header">
<h2 id="replay-title">Game Replay</h2>
<div id="replay-meta" class="replay-meta"></div>
</header>
<div id="replay-board" class="replay-board-container">
<!-- Board renders here -->
</div>
<div id="replay-event-description" class="event-description"></div>
<div id="replay-controls" class="replay-controls">
<button id="replay-btn-start" class="replay-btn" title="Go to start"></button>
<button id="replay-btn-prev" class="replay-btn" title="Previous"></button>
<button id="replay-btn-play" class="replay-btn replay-btn-play" title="Play/Pause"></button>
<button id="replay-btn-next" class="replay-btn" title="Next"></button>
<button id="replay-btn-end" class="replay-btn" title="Go to end"></button>
<div class="timeline">
<input type="range" min="0" max="0" value="0" id="replay-timeline" class="timeline-slider">
<span id="replay-frame-counter" class="frame-counter">0 / 0</span>
</div>
<div class="speed-control">
<label>Speed:</label>
<select id="replay-speed" class="speed-select">
<option value="0.5">0.5x</option>
<option value="1" selected>1x</option>
<option value="2">2x</option>
<option value="4">4x</option>
</select>
</div>
</div>
<div class="replay-actions">
<button id="replay-btn-share" class="btn btn-small">Share Replay</button>
<button id="replay-btn-export" class="btn btn-small">Export JSON</button>
<button id="replay-btn-back" class="btn btn-small btn-secondary">Back to Menu</button>
</div>
</div>
</div>
<!-- Player Stats Modal -->
<div id="player-stats-modal" class="modal player-stats-modal hidden">
<div class="modal-content">
<button class="modal-close-btn" id="player-stats-close">&times;</button>
<div id="player-stats-content">
<div class="leaderboard-loading">Loading...</div>
</div>
</div>
</div>
<!-- CPU Select Modal -->
@@ -651,9 +740,53 @@ TOTAL: 0 + 8 + 16 = 24 points</pre>
</div>
</div>
<!-- Auth Modal -->
<div id="auth-modal" class="modal hidden">
<div class="modal-content modal-auth">
<button id="auth-modal-close" class="modal-close-btn">&times;</button>
<!-- Login Form -->
<div id="login-form-container">
<h3>Login</h3>
<form id="login-form">
<div class="form-group">
<input type="text" id="login-username" placeholder="Username" required>
</div>
<div class="form-group">
<input type="password" id="login-password" placeholder="Password" required>
</div>
<p id="login-error" class="error"></p>
<button type="submit" class="btn btn-primary btn-full">Login</button>
</form>
<p class="auth-switch">Don't have an account? <a href="#" id="show-signup">Sign up</a></p>
</div>
<!-- Signup Form -->
<div id="signup-form-container" class="hidden">
<h3>Sign Up</h3>
<form id="signup-form">
<div class="form-group">
<input type="text" id="signup-username" placeholder="Username" required minlength="3" maxlength="20">
</div>
<div class="form-group">
<input type="email" id="signup-email" placeholder="Email (optional)">
</div>
<div class="form-group">
<input type="password" id="signup-password" placeholder="Password" required minlength="8">
</div>
<p id="signup-error" class="error"></p>
<button type="submit" class="btn btn-primary btn-full">Create Account</button>
</form>
<p class="auth-switch">Already have an account? <a href="#" id="show-login">Login</a></p>
</div>
</div>
</div>
<script src="card-manager.js"></script>
<script src="state-differ.js"></script>
<script src="animation-queue.js"></script>
<script src="leaderboard.js"></script>
<script src="replay.js"></script>
<script src="app.js"></script>
</body>
</html>

314
client/leaderboard.js Normal file
View File

@@ -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 = '<div class="leaderboard-loading">Loading...</div>';
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 = `
<div class="leaderboard-empty">
<p>Failed to load leaderboard</p>
<button class="btn btn-small btn-secondary" onclick="leaderboard.loadLeaderboard('${metric}')">Retry</button>
</div>
`;
}
}
renderLeaderboard(data, metric) {
const entries = data.entries || [];
if (entries.length === 0) {
this.elements.content.innerHTML = `
<div class="leaderboard-empty">
<p>No players on the leaderboard yet.</p>
<p>Play 5+ games to appear here!</p>
</div>
`;
return;
}
const formatValue = this.metricFormats[metric] || (v => v);
const currentUserId = this.getCurrentUserId();
let html = `
<table class="leaderboard-table">
<thead>
<tr>
<th class="rank-col">#</th>
<th class="username-col">Player</th>
<th class="value-col">${this.metricLabels[metric]}</th>
<th class="games-col">Games</th>
</tr>
</thead>
<tbody>
`;
entries.forEach(entry => {
const isMe = entry.user_id === currentUserId;
const medal = this.getMedal(entry.rank);
html += `
<tr class="${isMe ? 'my-row' : ''}">
<td class="rank-col">${medal || entry.rank}</td>
<td class="username-col">
<span class="player-link" data-user-id="${entry.user_id}">
${this.escapeHtml(entry.username)}${isMe ? ' (you)' : ''}
</span>
</td>
<td class="value-col">${formatValue(entry.value)}</td>
<td class="games-col">${entry.games_played}</td>
</tr>
`;
});
html += '</tbody></table>';
this.elements.content.innerHTML = html;
}
getMedal(rank) {
switch (rank) {
case 1: return '<span class="medal">&#x1F947;</span>';
case 2: return '<span class="medal">&#x1F948;</span>';
case 3: return '<span class="medal">&#x1F949;</span>';
default: return null;
}
}
async showPlayerStats(userId) {
this.elements.statsModal.classList.remove('hidden');
this.elements.statsContent.innerHTML = '<div class="leaderboard-loading">Loading...</div>';
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 = `
<div class="leaderboard-empty">
<p>Failed to load player stats</p>
</div>
`;
}
}
renderPlayerStats(stats, achievements) {
const currentUserId = this.getCurrentUserId();
const isMe = stats.user_id === currentUserId;
let html = `
<div class="player-stats-header">
<h3>${this.escapeHtml(stats.username)}${isMe ? ' (you)' : ''}</h3>
${stats.games_played >= 5 ? '<p class="rank-badge">Ranked Player</p>' : '<p class="rank-badge">Unranked (needs 5+ games)</p>'}
</div>
<div class="stats-grid">
<div class="stat-item">
<div class="stat-value">${stats.games_won}</div>
<div class="stat-label">Wins</div>
</div>
<div class="stat-item">
<div class="stat-value">${stats.win_rate.toFixed(1)}%</div>
<div class="stat-label">Win Rate</div>
</div>
<div class="stat-item">
<div class="stat-value">${stats.games_played}</div>
<div class="stat-label">Games</div>
</div>
<div class="stat-item">
<div class="stat-value">${stats.avg_score.toFixed(1)}</div>
<div class="stat-label">Avg Score</div>
</div>
<div class="stat-item">
<div class="stat-value">${stats.best_round_score ?? '-'}</div>
<div class="stat-label">Best Round</div>
</div>
<div class="stat-item">
<div class="stat-value">${stats.knockouts}</div>
<div class="stat-label">Knockouts</div>
</div>
<div class="stat-item">
<div class="stat-value">${stats.best_win_streak}</div>
<div class="stat-label">Best Streak</div>
</div>
<div class="stat-item">
<div class="stat-value">${stats.rounds_played}</div>
<div class="stat-label">Rounds</div>
</div>
</div>
`;
// Achievements section
if (achievements.length > 0) {
html += `
<div class="achievements-section">
<h4>Achievements (${achievements.length})</h4>
<div class="achievements-grid">
`;
achievements.forEach(a => {
html += `
<div class="achievement-badge" title="${this.escapeHtml(a.description)}">
<span class="icon">${a.icon}</span>
<span class="name">${this.escapeHtml(a.name)}</span>
</div>
`;
});
html += '</div></div>';
}
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();

587
client/replay.js Normal file
View File

@@ -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 = `<span>${players}</span> | <span>${rounds}</span> | <span>${duration}</span>`;
}
}
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 = '<div class="replay-players">';
state.players.forEach((player, idx) => {
const isCurrent = player.id === currentPlayerId;
html += `
<div class="replay-player ${isCurrent ? 'is-current' : ''}">
<div class="replay-player-header">
<span class="replay-player-name">${this.escapeHtml(player.name)}</span>
<span class="replay-player-score">Score: ${player.score} | Total: ${player.total_score}</span>
</div>
<div class="replay-player-cards">
${this.renderPlayerCards(player.cards)}
</div>
</div>
`;
});
html += '</div>';
// Center area (deck and discard)
html += `
<div class="replay-center">
<div class="replay-deck">
<div class="card card-back">
<span class="deck-count">${state.deck_remaining}</span>
</div>
</div>
<div class="replay-discard">
${state.discard_top ? this.renderCard(state.discard_top, true) : '<div class="card card-empty"></div>'}
</div>
${state.drawn_card ? `
<div class="replay-drawn">
<span class="drawn-label">Drawn:</span>
${this.renderCard(state.drawn_card, true)}
</div>
` : ''}
</div>
`;
// Game info
html += `
<div class="replay-info">
<span>Round ${state.current_round} / ${state.total_rounds}</span>
<span>Phase: ${this.formatPhase(state.phase)}</span>
</div>
`;
this.replayBoard.innerHTML = html;
}
renderPlayerCards(cards) {
let html = '<div class="replay-cards-grid">';
// Render as 2 rows x 3 columns
for (let row = 0; row < 2; row++) {
html += '<div class="replay-cards-row">';
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 += '<div class="card card-empty"></div>';
}
}
html += '</div>';
}
html += '</div>';
return html;
}
renderCard(card, revealed = false) {
if (!revealed || !card.face_up) {
return '<div class="card card-back"></div>';
}
const suit = card.suit;
const rank = card.rank;
const isRed = suit === 'hearts' || suit === 'diamonds';
const suitSymbol = { hearts: '♥', diamonds: '♦', clubs: '♣', spades: '♠' }[suit] || '';
return `
<div class="card ${isRed ? 'card-red' : 'card-black'}">
<span class="card-rank">${rank}</span>
<span class="card-suit">${suitSymbol}</span>
</div>
`;
}
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 = `
<span class="event-time">${time}</span>
<span class="event-text">${desc}</span>
`;
}
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 = `
<div class="modal-content">
<h3>Share This Game</h3>
<div class="form-group">
<label for="share-title">Title (optional)</label>
<input type="text" id="share-title" placeholder="Epic comeback win!">
</div>
<div class="form-group">
<label for="share-expiry">Expires in</label>
<select id="share-expiry">
<option value="">Never</option>
<option value="7">7 days</option>
<option value="30">30 days</option>
<option value="90">90 days</option>
</select>
</div>
<div id="share-result" class="hidden">
<p>Share this link:</p>
<div class="share-link-container">
<input type="text" id="share-link" readonly>
<button class="btn btn-small" id="share-copy-btn">Copy</button>
</div>
</div>
<div class="modal-actions">
<button class="btn btn-primary" id="share-generate-btn">Generate Link</button>
<button class="btn btn-secondary" id="share-cancel-btn">Cancel</button>
</div>
</div>
`;
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 = `
<div class="replay-error">
<p>${this.escapeHtml(message)}</p>
<button class="btn btn-primary" onclick="replayViewer.hide()">Back to Lobby</button>
</div>
`;
}
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 };
}

View File

@@ -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%;
}
}

58
docker-compose.dev.yml Normal file
View File

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

139
docker-compose.prod.yml Normal file
View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

1179
docs/v2/V2_04_ADMIN_TOOLS.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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 = `
<div class="leaderboard">
<div class="leaderboard-tabs">
<button class="tab ${this.metric === 'wins' ? 'active' : ''}" data-metric="wins">Wins</button>
<button class="tab ${this.metric === 'win_rate' ? 'active' : ''}" data-metric="win_rate">Win Rate</button>
<button class="tab ${this.metric === 'avg_score' ? 'active' : ''}" data-metric="avg_score">Avg Score</button>
</div>
<table class="leaderboard-table">
<thead>
<tr>
<th>#</th>
<th>Player</th>
<th>${this.getMetricLabel()}</th>
<th>Games</th>
</tr>
</thead>
<tbody>
${data.entries.map(e => `
<tr>
<td class="rank">${this.getRankBadge(e.rank)}</td>
<td class="username">${e.username}</td>
<td class="value">${this.formatValue(e.value)}</td>
<td class="games">${e.games_played}</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
// 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

View File

@@ -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 = `
<div class="replay-board">
${state.players.map(p => this.renderPlayerHand(p)).join('')}
<div class="replay-center">
<div class="deck-area">
<div class="card deck-card">
<span class="card-back"></span>
</div>
${state.discard_top ? this.renderCard(state.discard_top) : ''}
</div>
</div>
</div>
`;
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 = `
<div class="replay-controls">
<button class="btn-start" title="Go to start">⏮</button>
<button class="btn-prev" title="Previous">⏪</button>
<button class="btn-play" title="Play/Pause">▶</button>
<button class="btn-next" title="Next">⏩</button>
<button class="btn-end" title="Go to end">⏭</button>
<div class="timeline">
<input type="range" min="0" max="${this.frames.length - 1}"
value="0" class="timeline-slider">
<span class="frame-counter">1 / ${this.frames.length}</span>
</div>
<div class="speed-control">
<label>Speed:</label>
<select class="speed-select">
<option value="0.5">0.5x</option>
<option value="1" selected>1x</option>
<option value="2">2x</option>
<option value="4">4x</option>
</select>
</div>
</div>
`;
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
<!-- client/replay.html or section in index.html -->
<div id="replay-view" class="view hidden">
<header class="replay-header">
<h2 class="replay-title">Game Replay</h2>
<div class="replay-meta">
<span class="player-names"></span>
<span class="game-duration"></span>
</div>
</header>
<div class="replay-board-container">
<!-- Board renders here -->
</div>
<div class="event-description"></div>
<div class="controls-container">
<!-- Controls render here -->
</div>
<div class="replay-actions">
<button class="btn-share">Share Replay</button>
<button class="btn-export">Export JSON</button>
<button class="btn-back">Back to Menu</button>
</div>
</div>
```
### 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 = `
<div class="modal-content">
<h3>Share This Game</h3>
<div class="share-options">
<label>
<span>Title (optional):</span>
<input type="text" id="share-title" placeholder="Epic comeback win!">
</label>
<label>
<span>Expires in:</span>
<select id="share-expiry">
<option value="">Never</option>
<option value="7">7 days</option>
<option value="30">30 days</option>
<option value="90">90 days</option>
</select>
</label>
</div>
<div class="share-result hidden">
<p>Share this link:</p>
<div class="share-link-container">
<input type="text" id="share-link" readonly>
<button class="btn-copy">Copy</button>
</div>
</div>
<div class="modal-actions">
<button class="btn-generate">Generate Link</button>
<button class="btn-cancel">Cancel</button>
</div>
</div>
`;
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 => `
<div class="history-item">
<span class="game-date">${formatDate(game.played_at)}</span>
<span class="game-result">${game.won ? 'Won' : 'Lost'}</span>
<span class="game-score">${game.score} pts</span>
<a href="/replay/${game.id}" class="btn-replay">Watch Replay</a>
</div>
`).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

999
docs/v2/V2_07_PRODUCTION.md Normal file
View File

@@ -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 <noreply@golf.example.com>"
# 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.

View File

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

55
server/.env.example Normal file
View File

@@ -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 <noreply@yourdomain.com>
# 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

View File

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

View File

@@ -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 <noreply@example.com>"
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 <noreply@example.com>"),
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),

View File

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

251
server/logging_config.py Normal file
View File

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

View File

@@ -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 <token>"
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."""

View File

@@ -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",
]

View File

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

View File

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

View File

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

19
server/models/__init__.py Normal file
View File

@@ -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",
]

574
server/models/events.py Normal file
View File

@@ -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,
},
)

535
server/models/game_state.py Normal file
View File

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

287
server/models/user.py Normal file
View File

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

View File

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

View File

@@ -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",
]

419
server/routers/admin.py Normal file
View File

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

506
server/routers/auth.py Normal file
View File

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

171
server/routers/health.py Normal file
View File

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

490
server/routers/replay.py Normal file
View File

@@ -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()
],
}

385
server/routers/stats.py Normal file
View File

@@ -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
],
}

View File

@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""
Create an admin user for the Golf game.
Usage:
python scripts/create_admin.py <username> <password> [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()

View File

@@ -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",
]

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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"""
<h2>Welcome to Golf Game, {username}!</h2>
<p>Please verify your email address by clicking the link below:</p>
<p><a href="{verify_url}">Verify Email Address</a></p>
<p>Or copy and paste this URL into your browser:</p>
<p>{verify_url}</p>
<p>This link will expire in 24 hours.</p>
<p>If you didn't create this account, you can safely ignore this email.</p>
"""
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"""
<h2>Password Reset Request</h2>
<p>Hi {username},</p>
<p>We received a request to reset your password. Click the link below to set a new password:</p>
<p><a href="{reset_url}">Reset Password</a></p>
<p>Or copy and paste this URL into your browser:</p>
<p>{reset_url}</p>
<p>This link will expire in 1 hour.</p>
<p>If you didn't request this, you can safely ignore this email. Your password will remain unchanged.</p>
"""
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"""
<h2>Password Changed</h2>
<p>Hi {username},</p>
<p>Your password was successfully changed.</p>
<p>If you did not make this change, please contact support immediately.</p>
"""
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

View File

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

View File

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

View File

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

View File

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

View File

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

26
server/stores/__init__.py Normal file
View File

@@ -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",
]

View File

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

306
server/stores/pubsub.py Normal file
View File

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

View File

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

1029
server/stores/user_store.py Normal file

File diff suppressed because it is too large Load Diff

1
server/tests/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Tests package for Golf game."""

View File

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

View File

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

302
server/tests/test_replay.py Normal file
View File

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