Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6950769bc3 | ||
|
|
724bf87c43 | ||
|
|
15135c404e | ||
|
|
0c8d2b4a9c | ||
|
|
0b0873350c | ||
|
|
f27020f21b | ||
|
|
1dbfb3f14b | ||
|
|
ba85a11d1a | ||
|
|
d2e78da7d2 | ||
|
|
546e63ffed | ||
|
|
93b753dedb | ||
|
|
bea85e6b28 | ||
|
|
c912a56c2d | ||
|
|
36a71799b5 | ||
|
|
33e3f124ed | ||
|
|
23657f6b0c | ||
|
|
c72fe44cfa | ||
|
|
059edfb3d9 | ||
|
|
13a490b417 | ||
|
|
67021b2b51 | ||
|
|
e9909fa967 | ||
|
|
20c882e5f1 | ||
|
|
0f44464c4f | ||
|
|
f80bab3b4b | ||
|
|
d9073f862c |
86
.env.example
Normal file
86
.env.example
Normal file
@@ -0,0 +1,86 @@
|
||||
# =============================================================================
|
||||
# Golf Game Server Configuration
|
||||
# =============================================================================
|
||||
# Copy this file to .env and customize as needed.
|
||||
# All values shown are defaults.
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Server Settings
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Host to bind to (0.0.0.0 for all interfaces)
|
||||
HOST=0.0.0.0
|
||||
|
||||
# Port to listen on
|
||||
PORT=8000
|
||||
|
||||
# Enable debug mode (more verbose logging, auto-reload)
|
||||
DEBUG=false
|
||||
|
||||
# Logging level: DEBUG, INFO, WARNING, ERROR, CRITICAL
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Database
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# SQLite database for game logs and stats
|
||||
# For PostgreSQL: postgresql://user:pass@host:5432/dbname
|
||||
DATABASE_URL=sqlite:///games.db
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Room Settings
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Maximum players per game room
|
||||
MAX_PLAYERS_PER_ROOM=6
|
||||
|
||||
# Room timeout in minutes (inactive rooms are cleaned up)
|
||||
ROOM_TIMEOUT_MINUTES=60
|
||||
|
||||
# Length of room codes (e.g., 4 = "ABCD")
|
||||
ROOM_CODE_LENGTH=4
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Security & Authentication (for future auth system)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Secret key for JWT tokens (generate with: python -c "import secrets; print(secrets.token_hex(32))")
|
||||
SECRET_KEY=
|
||||
|
||||
# Enable invite-only mode (requires invitation to register)
|
||||
INVITE_ONLY=false
|
||||
|
||||
# Comma-separated list of admin email addresses
|
||||
ADMIN_EMAILS=
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Game Defaults
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Default number of rounds (holes) per game
|
||||
DEFAULT_ROUNDS=9
|
||||
|
||||
# Cards to flip at start of each round (0, 1, or 2)
|
||||
DEFAULT_INITIAL_FLIPS=2
|
||||
|
||||
# Enable jokers in deck by default
|
||||
DEFAULT_USE_JOKERS=false
|
||||
|
||||
# Require flipping a card after discarding from deck
|
||||
DEFAULT_FLIP_ON_DISCARD=false
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Card Values (Standard 6-Card Golf)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Customize point values for cards. Normally you shouldn't change these.
|
||||
|
||||
CARD_ACE=1
|
||||
CARD_TWO=-2
|
||||
CARD_KING=0
|
||||
CARD_JOKER=-2
|
||||
|
||||
# House rule values
|
||||
CARD_SUPER_KINGS=-2 # King value when super_kings enabled
|
||||
CARD_TEN_PENNY=1 # 10 value when ten_penny enabled
|
||||
CARD_LUCKY_SWING_JOKER=-5 # Joker value when lucky_swing enabled
|
||||
37
Dockerfile
Normal file
37
Dockerfile
Normal 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"]
|
||||
52
README.md
52
README.md
@@ -30,32 +30,17 @@ uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
||||
|
||||
Open `http://localhost:8000` in your browser.
|
||||
|
||||
## Game Rules
|
||||
## How to Play
|
||||
|
||||
See [server/RULES.md](server/RULES.md) for complete rules documentation.
|
||||
**6-Card Golf** is a card game where you try to get the **lowest score** across multiple rounds (holes).
|
||||
|
||||
### Basic Scoring
|
||||
- Each player has 6 cards in a 2×3 grid (most start face-down)
|
||||
- On your turn: **draw** a card, then **swap** it with one of yours or **discard** it
|
||||
- **Column pairs** (same rank top & bottom) score **0 points** — very powerful!
|
||||
- When any player reveals all 6 cards, everyone else gets one final turn
|
||||
- Lowest total score after all rounds wins
|
||||
|
||||
| Card | Points |
|
||||
|------|--------|
|
||||
| Ace | 1 |
|
||||
| 2 | **-2** |
|
||||
| 3-10 | Face value |
|
||||
| Jack, Queen | 10 |
|
||||
| King | **0** |
|
||||
| Joker | -2 |
|
||||
|
||||
**Column pairs** (same rank in a column) score **0 points**.
|
||||
|
||||
### Turn Structure
|
||||
|
||||
1. Draw from deck OR take from discard pile
|
||||
2. **If from deck:** Swap with a card OR discard and flip a face-down card
|
||||
3. **If from discard:** Must swap (cannot re-discard)
|
||||
|
||||
### Ending
|
||||
|
||||
When a player reveals all 6 cards, others get one final turn. Lowest score wins.
|
||||
**For detailed rules, card values, and house rule explanations, see the in-game Rules page or [server/RULES.md](server/RULES.md).**
|
||||
|
||||
## AI Personalities
|
||||
|
||||
@@ -72,23 +57,14 @@ When a player reveals all 6 cards, others get one final turn. Lowest score wins.
|
||||
|
||||
## House Rules
|
||||
|
||||
### Point Modifiers
|
||||
- `super_kings` - Kings worth -2 (instead of 0)
|
||||
- `ten_penny` - 10s worth 1 (instead of 10)
|
||||
- `lucky_swing` - Single Joker worth -5
|
||||
- `eagle_eye` - Paired Jokers score -8
|
||||
The game supports 15+ optional house rules including:
|
||||
|
||||
### Bonuses & Penalties
|
||||
- `knock_bonus` - First to go out gets -5
|
||||
- `underdog_bonus` - Lowest scorer gets -3
|
||||
- `knock_penalty` - +10 if you go out but aren't lowest
|
||||
- `tied_shame` - +5 penalty for tied scores
|
||||
- `blackjack` - Score of exactly 21 becomes 0
|
||||
- **Flip Modes** - Standard, Speed Golf (must flip after discard), Suspense (optional flip near endgame)
|
||||
- **Point Modifiers** - Super Kings (-2), Ten Penny (10=1), Lucky Swing Joker (-5)
|
||||
- **Bonuses & Penalties** - Knock bonus/penalty, Underdog bonus, Tied Shame, Blackjack (21→0)
|
||||
- **Joker Variants** - Standard, Eagle Eye (paired Jokers = -8)
|
||||
|
||||
### Gameplay Options
|
||||
- `flip_on_discard` - Must flip a card when discarding from deck
|
||||
- `use_jokers` - Add Jokers to deck
|
||||
- `eagle_eye` - Paired Jokers score -8 instead of canceling
|
||||
See the in-game Rules page or [server/RULES.md](server/RULES.md) for complete explanations.
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
522
V2_BUILD_PLAN.md
Normal file
522
V2_BUILD_PLAN.md
Normal 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.
|
||||
247
bin/Activate.ps1
Normal file
247
bin/Activate.ps1
Normal file
@@ -0,0 +1,247 @@
|
||||
<#
|
||||
.Synopsis
|
||||
Activate a Python virtual environment for the current PowerShell session.
|
||||
|
||||
.Description
|
||||
Pushes the python executable for a virtual environment to the front of the
|
||||
$Env:PATH environment variable and sets the prompt to signify that you are
|
||||
in a Python virtual environment. Makes use of the command line switches as
|
||||
well as the `pyvenv.cfg` file values present in the virtual environment.
|
||||
|
||||
.Parameter VenvDir
|
||||
Path to the directory that contains the virtual environment to activate. The
|
||||
default value for this is the parent of the directory that the Activate.ps1
|
||||
script is located within.
|
||||
|
||||
.Parameter Prompt
|
||||
The prompt prefix to display when this virtual environment is activated. By
|
||||
default, this prompt is the name of the virtual environment folder (VenvDir)
|
||||
surrounded by parentheses and followed by a single space (ie. '(.venv) ').
|
||||
|
||||
.Example
|
||||
Activate.ps1
|
||||
Activates the Python virtual environment that contains the Activate.ps1 script.
|
||||
|
||||
.Example
|
||||
Activate.ps1 -Verbose
|
||||
Activates the Python virtual environment that contains the Activate.ps1 script,
|
||||
and shows extra information about the activation as it executes.
|
||||
|
||||
.Example
|
||||
Activate.ps1 -VenvDir C:\Users\MyUser\Common\.venv
|
||||
Activates the Python virtual environment located in the specified location.
|
||||
|
||||
.Example
|
||||
Activate.ps1 -Prompt "MyPython"
|
||||
Activates the Python virtual environment that contains the Activate.ps1 script,
|
||||
and prefixes the current prompt with the specified string (surrounded in
|
||||
parentheses) while the virtual environment is active.
|
||||
|
||||
.Notes
|
||||
On Windows, it may be required to enable this Activate.ps1 script by setting the
|
||||
execution policy for the user. You can do this by issuing the following PowerShell
|
||||
command:
|
||||
|
||||
PS C:\> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
|
||||
|
||||
For more information on Execution Policies:
|
||||
https://go.microsoft.com/fwlink/?LinkID=135170
|
||||
|
||||
#>
|
||||
Param(
|
||||
[Parameter(Mandatory = $false)]
|
||||
[String]
|
||||
$VenvDir,
|
||||
[Parameter(Mandatory = $false)]
|
||||
[String]
|
||||
$Prompt
|
||||
)
|
||||
|
||||
<# Function declarations --------------------------------------------------- #>
|
||||
|
||||
<#
|
||||
.Synopsis
|
||||
Remove all shell session elements added by the Activate script, including the
|
||||
addition of the virtual environment's Python executable from the beginning of
|
||||
the PATH variable.
|
||||
|
||||
.Parameter NonDestructive
|
||||
If present, do not remove this function from the global namespace for the
|
||||
session.
|
||||
|
||||
#>
|
||||
function global:deactivate ([switch]$NonDestructive) {
|
||||
# Revert to original values
|
||||
|
||||
# The prior prompt:
|
||||
if (Test-Path -Path Function:_OLD_VIRTUAL_PROMPT) {
|
||||
Copy-Item -Path Function:_OLD_VIRTUAL_PROMPT -Destination Function:prompt
|
||||
Remove-Item -Path Function:_OLD_VIRTUAL_PROMPT
|
||||
}
|
||||
|
||||
# The prior PYTHONHOME:
|
||||
if (Test-Path -Path Env:_OLD_VIRTUAL_PYTHONHOME) {
|
||||
Copy-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME -Destination Env:PYTHONHOME
|
||||
Remove-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME
|
||||
}
|
||||
|
||||
# The prior PATH:
|
||||
if (Test-Path -Path Env:_OLD_VIRTUAL_PATH) {
|
||||
Copy-Item -Path Env:_OLD_VIRTUAL_PATH -Destination Env:PATH
|
||||
Remove-Item -Path Env:_OLD_VIRTUAL_PATH
|
||||
}
|
||||
|
||||
# Just remove the VIRTUAL_ENV altogether:
|
||||
if (Test-Path -Path Env:VIRTUAL_ENV) {
|
||||
Remove-Item -Path env:VIRTUAL_ENV
|
||||
}
|
||||
|
||||
# Just remove VIRTUAL_ENV_PROMPT altogether.
|
||||
if (Test-Path -Path Env:VIRTUAL_ENV_PROMPT) {
|
||||
Remove-Item -Path env:VIRTUAL_ENV_PROMPT
|
||||
}
|
||||
|
||||
# Just remove the _PYTHON_VENV_PROMPT_PREFIX altogether:
|
||||
if (Get-Variable -Name "_PYTHON_VENV_PROMPT_PREFIX" -ErrorAction SilentlyContinue) {
|
||||
Remove-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Scope Global -Force
|
||||
}
|
||||
|
||||
# Leave deactivate function in the global namespace if requested:
|
||||
if (-not $NonDestructive) {
|
||||
Remove-Item -Path function:deactivate
|
||||
}
|
||||
}
|
||||
|
||||
<#
|
||||
.Description
|
||||
Get-PyVenvConfig parses the values from the pyvenv.cfg file located in the
|
||||
given folder, and returns them in a map.
|
||||
|
||||
For each line in the pyvenv.cfg file, if that line can be parsed into exactly
|
||||
two strings separated by `=` (with any amount of whitespace surrounding the =)
|
||||
then it is considered a `key = value` line. The left hand string is the key,
|
||||
the right hand is the value.
|
||||
|
||||
If the value starts with a `'` or a `"` then the first and last character is
|
||||
stripped from the value before being captured.
|
||||
|
||||
.Parameter ConfigDir
|
||||
Path to the directory that contains the `pyvenv.cfg` file.
|
||||
#>
|
||||
function Get-PyVenvConfig(
|
||||
[String]
|
||||
$ConfigDir
|
||||
) {
|
||||
Write-Verbose "Given ConfigDir=$ConfigDir, obtain values in pyvenv.cfg"
|
||||
|
||||
# Ensure the file exists, and issue a warning if it doesn't (but still allow the function to continue).
|
||||
$pyvenvConfigPath = Join-Path -Resolve -Path $ConfigDir -ChildPath 'pyvenv.cfg' -ErrorAction Continue
|
||||
|
||||
# An empty map will be returned if no config file is found.
|
||||
$pyvenvConfig = @{ }
|
||||
|
||||
if ($pyvenvConfigPath) {
|
||||
|
||||
Write-Verbose "File exists, parse `key = value` lines"
|
||||
$pyvenvConfigContent = Get-Content -Path $pyvenvConfigPath
|
||||
|
||||
$pyvenvConfigContent | ForEach-Object {
|
||||
$keyval = $PSItem -split "\s*=\s*", 2
|
||||
if ($keyval[0] -and $keyval[1]) {
|
||||
$val = $keyval[1]
|
||||
|
||||
# Remove extraneous quotations around a string value.
|
||||
if ("'""".Contains($val.Substring(0, 1))) {
|
||||
$val = $val.Substring(1, $val.Length - 2)
|
||||
}
|
||||
|
||||
$pyvenvConfig[$keyval[0]] = $val
|
||||
Write-Verbose "Adding Key: '$($keyval[0])'='$val'"
|
||||
}
|
||||
}
|
||||
}
|
||||
return $pyvenvConfig
|
||||
}
|
||||
|
||||
|
||||
<# Begin Activate script --------------------------------------------------- #>
|
||||
|
||||
# Determine the containing directory of this script
|
||||
$VenvExecPath = Split-Path -Parent $MyInvocation.MyCommand.Definition
|
||||
$VenvExecDir = Get-Item -Path $VenvExecPath
|
||||
|
||||
Write-Verbose "Activation script is located in path: '$VenvExecPath'"
|
||||
Write-Verbose "VenvExecDir Fullname: '$($VenvExecDir.FullName)"
|
||||
Write-Verbose "VenvExecDir Name: '$($VenvExecDir.Name)"
|
||||
|
||||
# Set values required in priority: CmdLine, ConfigFile, Default
|
||||
# First, get the location of the virtual environment, it might not be
|
||||
# VenvExecDir if specified on the command line.
|
||||
if ($VenvDir) {
|
||||
Write-Verbose "VenvDir given as parameter, using '$VenvDir' to determine values"
|
||||
}
|
||||
else {
|
||||
Write-Verbose "VenvDir not given as a parameter, using parent directory name as VenvDir."
|
||||
$VenvDir = $VenvExecDir.Parent.FullName.TrimEnd("\\/")
|
||||
Write-Verbose "VenvDir=$VenvDir"
|
||||
}
|
||||
|
||||
# Next, read the `pyvenv.cfg` file to determine any required value such
|
||||
# as `prompt`.
|
||||
$pyvenvCfg = Get-PyVenvConfig -ConfigDir $VenvDir
|
||||
|
||||
# Next, set the prompt from the command line, or the config file, or
|
||||
# just use the name of the virtual environment folder.
|
||||
if ($Prompt) {
|
||||
Write-Verbose "Prompt specified as argument, using '$Prompt'"
|
||||
}
|
||||
else {
|
||||
Write-Verbose "Prompt not specified as argument to script, checking pyvenv.cfg value"
|
||||
if ($pyvenvCfg -and $pyvenvCfg['prompt']) {
|
||||
Write-Verbose " Setting based on value in pyvenv.cfg='$($pyvenvCfg['prompt'])'"
|
||||
$Prompt = $pyvenvCfg['prompt'];
|
||||
}
|
||||
else {
|
||||
Write-Verbose " Setting prompt based on parent's directory's name. (Is the directory name passed to venv module when creating the virtual environment)"
|
||||
Write-Verbose " Got leaf-name of $VenvDir='$(Split-Path -Path $venvDir -Leaf)'"
|
||||
$Prompt = Split-Path -Path $venvDir -Leaf
|
||||
}
|
||||
}
|
||||
|
||||
Write-Verbose "Prompt = '$Prompt'"
|
||||
Write-Verbose "VenvDir='$VenvDir'"
|
||||
|
||||
# Deactivate any currently active virtual environment, but leave the
|
||||
# deactivate function in place.
|
||||
deactivate -nondestructive
|
||||
|
||||
# Now set the environment variable VIRTUAL_ENV, used by many tools to determine
|
||||
# that there is an activated venv.
|
||||
$env:VIRTUAL_ENV = $VenvDir
|
||||
|
||||
if (-not $Env:VIRTUAL_ENV_DISABLE_PROMPT) {
|
||||
|
||||
Write-Verbose "Setting prompt to '$Prompt'"
|
||||
|
||||
# Set the prompt to include the env name
|
||||
# Make sure _OLD_VIRTUAL_PROMPT is global
|
||||
function global:_OLD_VIRTUAL_PROMPT { "" }
|
||||
Copy-Item -Path function:prompt -Destination function:_OLD_VIRTUAL_PROMPT
|
||||
New-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Description "Python virtual environment prompt prefix" -Scope Global -Option ReadOnly -Visibility Public -Value $Prompt
|
||||
|
||||
function global:prompt {
|
||||
Write-Host -NoNewline -ForegroundColor Green "($_PYTHON_VENV_PROMPT_PREFIX) "
|
||||
_OLD_VIRTUAL_PROMPT
|
||||
}
|
||||
$env:VIRTUAL_ENV_PROMPT = $Prompt
|
||||
}
|
||||
|
||||
# Clear PYTHONHOME
|
||||
if (Test-Path -Path Env:PYTHONHOME) {
|
||||
Copy-Item -Path Env:PYTHONHOME -Destination Env:_OLD_VIRTUAL_PYTHONHOME
|
||||
Remove-Item -Path Env:PYTHONHOME
|
||||
}
|
||||
|
||||
# Add the venv to the PATH
|
||||
Copy-Item -Path Env:PATH -Destination Env:_OLD_VIRTUAL_PATH
|
||||
$Env:PATH = "$VenvExecDir$([System.IO.Path]::PathSeparator)$Env:PATH"
|
||||
76
bin/activate
Normal file
76
bin/activate
Normal file
@@ -0,0 +1,76 @@
|
||||
# This file must be used with "source bin/activate" *from bash*
|
||||
# You cannot run it directly
|
||||
|
||||
deactivate () {
|
||||
# reset old environment variables
|
||||
if [ -n "${_OLD_VIRTUAL_PATH:-}" ] ; then
|
||||
PATH="${_OLD_VIRTUAL_PATH:-}"
|
||||
export PATH
|
||||
unset _OLD_VIRTUAL_PATH
|
||||
fi
|
||||
if [ -n "${_OLD_VIRTUAL_PYTHONHOME:-}" ] ; then
|
||||
PYTHONHOME="${_OLD_VIRTUAL_PYTHONHOME:-}"
|
||||
export PYTHONHOME
|
||||
unset _OLD_VIRTUAL_PYTHONHOME
|
||||
fi
|
||||
|
||||
# This should detect bash and zsh, which have a hash command that must
|
||||
# be called to get it to forget past commands. Without forgetting
|
||||
# past commands the $PATH changes we made may not be respected
|
||||
if [ -n "${BASH:-}" -o -n "${ZSH_VERSION:-}" ] ; then
|
||||
hash -r 2> /dev/null
|
||||
fi
|
||||
|
||||
if [ -n "${_OLD_VIRTUAL_PS1:-}" ] ; then
|
||||
PS1="${_OLD_VIRTUAL_PS1:-}"
|
||||
export PS1
|
||||
unset _OLD_VIRTUAL_PS1
|
||||
fi
|
||||
|
||||
unset VIRTUAL_ENV
|
||||
unset VIRTUAL_ENV_PROMPT
|
||||
if [ ! "${1:-}" = "nondestructive" ] ; then
|
||||
# Self destruct!
|
||||
unset -f deactivate
|
||||
fi
|
||||
}
|
||||
|
||||
# unset irrelevant variables
|
||||
deactivate nondestructive
|
||||
|
||||
# on Windows, a path can contain colons and backslashes and has to be converted:
|
||||
if [ "$OSTYPE" = "cygwin" ] || [ "$OSTYPE" = "msys" ] ; then
|
||||
# transform D:\path\to\venv to /d/path/to/venv on MSYS
|
||||
# and to /cygdrive/d/path/to/venv on Cygwin
|
||||
export VIRTUAL_ENV=$(cygpath "/home/alee/Sources/golfgame")
|
||||
else
|
||||
# use the path as-is
|
||||
export VIRTUAL_ENV="/home/alee/Sources/golfgame"
|
||||
fi
|
||||
|
||||
_OLD_VIRTUAL_PATH="$PATH"
|
||||
PATH="$VIRTUAL_ENV/bin:$PATH"
|
||||
export PATH
|
||||
|
||||
# unset PYTHONHOME if set
|
||||
# this will fail if PYTHONHOME is set to the empty string (which is bad anyway)
|
||||
# could use `if (set -u; : $PYTHONHOME) ;` in bash
|
||||
if [ -n "${PYTHONHOME:-}" ] ; then
|
||||
_OLD_VIRTUAL_PYTHONHOME="${PYTHONHOME:-}"
|
||||
unset PYTHONHOME
|
||||
fi
|
||||
|
||||
if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT:-}" ] ; then
|
||||
_OLD_VIRTUAL_PS1="${PS1:-}"
|
||||
PS1="(golfgame) ${PS1:-}"
|
||||
export PS1
|
||||
VIRTUAL_ENV_PROMPT="(golfgame) "
|
||||
export VIRTUAL_ENV_PROMPT
|
||||
fi
|
||||
|
||||
# This should detect bash and zsh, which have a hash command that must
|
||||
# be called to get it to forget past commands. Without forgetting
|
||||
# past commands the $PATH changes we made may not be respected
|
||||
if [ -n "${BASH:-}" -o -n "${ZSH_VERSION:-}" ] ; then
|
||||
hash -r 2> /dev/null
|
||||
fi
|
||||
27
bin/activate.csh
Normal file
27
bin/activate.csh
Normal file
@@ -0,0 +1,27 @@
|
||||
# This file must be used with "source bin/activate.csh" *from csh*.
|
||||
# You cannot run it directly.
|
||||
|
||||
# Created by Davide Di Blasi <davidedb@gmail.com>.
|
||||
# Ported to Python 3.3 venv by Andrew Svetlov <andrew.svetlov@gmail.com>
|
||||
|
||||
alias deactivate 'test $?_OLD_VIRTUAL_PATH != 0 && setenv PATH "$_OLD_VIRTUAL_PATH" && unset _OLD_VIRTUAL_PATH; rehash; test $?_OLD_VIRTUAL_PROMPT != 0 && set prompt="$_OLD_VIRTUAL_PROMPT" && unset _OLD_VIRTUAL_PROMPT; unsetenv VIRTUAL_ENV; unsetenv VIRTUAL_ENV_PROMPT; test "\!:*" != "nondestructive" && unalias deactivate'
|
||||
|
||||
# Unset irrelevant variables.
|
||||
deactivate nondestructive
|
||||
|
||||
setenv VIRTUAL_ENV "/home/alee/Sources/golfgame"
|
||||
|
||||
set _OLD_VIRTUAL_PATH="$PATH"
|
||||
setenv PATH "$VIRTUAL_ENV/bin:$PATH"
|
||||
|
||||
|
||||
set _OLD_VIRTUAL_PROMPT="$prompt"
|
||||
|
||||
if (! "$?VIRTUAL_ENV_DISABLE_PROMPT") then
|
||||
set prompt = "(golfgame) $prompt"
|
||||
setenv VIRTUAL_ENV_PROMPT "(golfgame) "
|
||||
endif
|
||||
|
||||
alias pydoc python -m pydoc
|
||||
|
||||
rehash
|
||||
69
bin/activate.fish
Normal file
69
bin/activate.fish
Normal file
@@ -0,0 +1,69 @@
|
||||
# This file must be used with "source <venv>/bin/activate.fish" *from fish*
|
||||
# (https://fishshell.com/). You cannot run it directly.
|
||||
|
||||
function deactivate -d "Exit virtual environment and return to normal shell environment"
|
||||
# reset old environment variables
|
||||
if test -n "$_OLD_VIRTUAL_PATH"
|
||||
set -gx PATH $_OLD_VIRTUAL_PATH
|
||||
set -e _OLD_VIRTUAL_PATH
|
||||
end
|
||||
if test -n "$_OLD_VIRTUAL_PYTHONHOME"
|
||||
set -gx PYTHONHOME $_OLD_VIRTUAL_PYTHONHOME
|
||||
set -e _OLD_VIRTUAL_PYTHONHOME
|
||||
end
|
||||
|
||||
if test -n "$_OLD_FISH_PROMPT_OVERRIDE"
|
||||
set -e _OLD_FISH_PROMPT_OVERRIDE
|
||||
# prevents error when using nested fish instances (Issue #93858)
|
||||
if functions -q _old_fish_prompt
|
||||
functions -e fish_prompt
|
||||
functions -c _old_fish_prompt fish_prompt
|
||||
functions -e _old_fish_prompt
|
||||
end
|
||||
end
|
||||
|
||||
set -e VIRTUAL_ENV
|
||||
set -e VIRTUAL_ENV_PROMPT
|
||||
if test "$argv[1]" != "nondestructive"
|
||||
# Self-destruct!
|
||||
functions -e deactivate
|
||||
end
|
||||
end
|
||||
|
||||
# Unset irrelevant variables.
|
||||
deactivate nondestructive
|
||||
|
||||
set -gx VIRTUAL_ENV "/home/alee/Sources/golfgame"
|
||||
|
||||
set -gx _OLD_VIRTUAL_PATH $PATH
|
||||
set -gx PATH "$VIRTUAL_ENV/bin" $PATH
|
||||
|
||||
# Unset PYTHONHOME if set.
|
||||
if set -q PYTHONHOME
|
||||
set -gx _OLD_VIRTUAL_PYTHONHOME $PYTHONHOME
|
||||
set -e PYTHONHOME
|
||||
end
|
||||
|
||||
if test -z "$VIRTUAL_ENV_DISABLE_PROMPT"
|
||||
# fish uses a function instead of an env var to generate the prompt.
|
||||
|
||||
# Save the current fish_prompt function as the function _old_fish_prompt.
|
||||
functions -c fish_prompt _old_fish_prompt
|
||||
|
||||
# With the original prompt function renamed, we can override with our own.
|
||||
function fish_prompt
|
||||
# Save the return status of the last command.
|
||||
set -l old_status $status
|
||||
|
||||
# Output the venv prompt; color taken from the blue of the Python logo.
|
||||
printf "%s%s%s" (set_color 4B8BBE) "(golfgame) " (set_color normal)
|
||||
|
||||
# Restore the return status of the previous command.
|
||||
echo "exit $old_status" | .
|
||||
# Output the original/"old" prompt.
|
||||
_old_fish_prompt
|
||||
end
|
||||
|
||||
set -gx _OLD_FISH_PROMPT_OVERRIDE "$VIRTUAL_ENV"
|
||||
set -gx VIRTUAL_ENV_PROMPT "(golfgame) "
|
||||
end
|
||||
8
bin/pip
Executable file
8
bin/pip
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/home/alee/Sources/golfgame/bin/python
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from pip._internal.cli.main import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
8
bin/pip3
Executable file
8
bin/pip3
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/home/alee/Sources/golfgame/bin/python
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from pip._internal.cli.main import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
8
bin/pip3.12
Executable file
8
bin/pip3.12
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/home/alee/Sources/golfgame/bin/python
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from pip._internal.cli.main import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
1
bin/python
Symbolic link
1
bin/python
Symbolic link
@@ -0,0 +1 @@
|
||||
/home/alee/.pyenv/versions/3.12.0/bin/python
|
||||
1
bin/python3
Symbolic link
1
bin/python3
Symbolic link
@@ -0,0 +1 @@
|
||||
python
|
||||
1
bin/python3.12
Symbolic link
1
bin/python3.12
Symbolic link
@@ -0,0 +1 @@
|
||||
python
|
||||
633
client/admin.css
Normal file
633
client/admin.css
Normal 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
368
client/admin.html
Normal 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">×</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">×</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">×</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
809
client/admin.js
Normal 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();
|
||||
});
|
||||
377
client/animation-queue.js
Normal file
377
client/animation-queue.js
Normal file
@@ -0,0 +1,377 @@
|
||||
// AnimationQueue - Sequences card animations properly
|
||||
// Ensures animations play in order without overlap
|
||||
|
||||
class AnimationQueue {
|
||||
constructor(cardManager, getSlotRect, getLocationRect, playSound) {
|
||||
this.cardManager = cardManager;
|
||||
this.getSlotRect = getSlotRect; // Function to get slot position
|
||||
this.getLocationRect = getLocationRect; // Function to get deck/discard position
|
||||
this.playSound = playSound || (() => {}); // Sound callback
|
||||
this.queue = [];
|
||||
this.processing = false;
|
||||
this.animationInProgress = false;
|
||||
|
||||
// Timing configuration (ms)
|
||||
// Rhythm: action → settle → action → breathe
|
||||
this.timing = {
|
||||
flipDuration: 540, // Must match CSS .card-inner transition (0.54s)
|
||||
moveDuration: 270,
|
||||
pauseAfterFlip: 144, // Brief settle after flip before move
|
||||
pauseAfterDiscard: 550, // Let discard land + pulse (400ms) + settle
|
||||
pauseBeforeNewCard: 150, // Anticipation before new card moves in
|
||||
pauseAfterSwapComplete: 400, // Breathing room after swap completes
|
||||
pauseBetweenAnimations: 90
|
||||
};
|
||||
}
|
||||
|
||||
// Add movements to the queue and start processing
|
||||
async enqueue(movements, onComplete) {
|
||||
if (!movements || movements.length === 0) {
|
||||
if (onComplete) onComplete();
|
||||
return;
|
||||
}
|
||||
|
||||
// Add completion callback to last movement
|
||||
const movementsWithCallback = movements.map((m, i) => ({
|
||||
...m,
|
||||
onComplete: i === movements.length - 1 ? onComplete : null
|
||||
}));
|
||||
|
||||
this.queue.push(...movementsWithCallback);
|
||||
|
||||
if (!this.processing) {
|
||||
await this.processQueue();
|
||||
}
|
||||
}
|
||||
|
||||
// Process queued animations one at a time
|
||||
async processQueue() {
|
||||
if (this.processing) return;
|
||||
this.processing = true;
|
||||
this.animationInProgress = true;
|
||||
|
||||
while (this.queue.length > 0) {
|
||||
const movement = this.queue.shift();
|
||||
|
||||
try {
|
||||
await this.animate(movement);
|
||||
} catch (e) {
|
||||
console.error('Animation error:', e);
|
||||
}
|
||||
|
||||
// Callback after last movement
|
||||
if (movement.onComplete) {
|
||||
movement.onComplete();
|
||||
}
|
||||
|
||||
// Pause between animations
|
||||
if (this.queue.length > 0) {
|
||||
await this.delay(this.timing.pauseBetweenAnimations);
|
||||
}
|
||||
}
|
||||
|
||||
this.processing = false;
|
||||
this.animationInProgress = false;
|
||||
}
|
||||
|
||||
// Route to appropriate animation
|
||||
async animate(movement) {
|
||||
switch (movement.type) {
|
||||
case 'flip':
|
||||
await this.animateFlip(movement);
|
||||
break;
|
||||
case 'swap':
|
||||
await this.animateSwap(movement);
|
||||
break;
|
||||
case 'discard':
|
||||
await this.animateDiscard(movement);
|
||||
break;
|
||||
case 'draw-deck':
|
||||
await this.animateDrawDeck(movement);
|
||||
break;
|
||||
case 'draw-discard':
|
||||
await this.animateDrawDiscard(movement);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Animate a card flip
|
||||
async animateFlip(movement) {
|
||||
const { playerId, position, faceUp, card } = movement;
|
||||
|
||||
// Get slot position
|
||||
const slotRect = this.getSlotRect(playerId, position);
|
||||
if (!slotRect || slotRect.width === 0 || slotRect.height === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create animation card at slot position
|
||||
const animCard = this.createAnimCard();
|
||||
this.cardManager.cardLayer.appendChild(animCard);
|
||||
this.setCardPosition(animCard, slotRect);
|
||||
|
||||
const inner = animCard.querySelector('.card-inner');
|
||||
const front = animCard.querySelector('.card-face-front');
|
||||
|
||||
// Set up what we're flipping to (front face)
|
||||
this.setCardFront(front, card);
|
||||
|
||||
// Start face down (flipped = showing back)
|
||||
inner.classList.add('flipped');
|
||||
|
||||
// Force a reflow to ensure the initial state is applied
|
||||
animCard.offsetHeight;
|
||||
|
||||
// Animate the flip
|
||||
this.playSound('flip');
|
||||
await this.delay(50); // Brief pause before flip
|
||||
|
||||
// Remove flipped to trigger animation to front
|
||||
inner.classList.remove('flipped');
|
||||
|
||||
await this.delay(this.timing.flipDuration);
|
||||
await this.delay(this.timing.pauseAfterFlip);
|
||||
|
||||
// Clean up
|
||||
animCard.remove();
|
||||
}
|
||||
|
||||
// Animate a card swap (hand card to discard, drawn card to hand)
|
||||
async animateSwap(movement) {
|
||||
const { playerId, position, oldCard, newCard } = movement;
|
||||
|
||||
// Get positions
|
||||
const slotRect = this.getSlotRect(playerId, position);
|
||||
const discardRect = this.getLocationRect('discard');
|
||||
const holdingRect = this.getLocationRect('holding');
|
||||
|
||||
if (!slotRect || !discardRect || slotRect.width === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a temporary card element for the animation
|
||||
const animCard = this.createAnimCard();
|
||||
this.cardManager.cardLayer.appendChild(animCard);
|
||||
|
||||
// Position at slot
|
||||
this.setCardPosition(animCard, slotRect);
|
||||
|
||||
// Start face down (showing back)
|
||||
const inner = animCard.querySelector('.card-inner');
|
||||
const front = animCard.querySelector('.card-face-front');
|
||||
inner.classList.add('flipped');
|
||||
|
||||
// Step 1: If card was face down, flip to reveal it
|
||||
this.setCardFront(front, oldCard);
|
||||
if (!oldCard.face_up) {
|
||||
this.playSound('flip');
|
||||
inner.classList.remove('flipped');
|
||||
await this.delay(this.timing.flipDuration);
|
||||
await this.delay(this.timing.pauseAfterFlip);
|
||||
} else {
|
||||
// Already face up, just show it immediately
|
||||
inner.classList.remove('flipped');
|
||||
}
|
||||
|
||||
// Step 2: Move card to discard pile
|
||||
this.playSound('card');
|
||||
animCard.classList.add('moving');
|
||||
this.setCardPosition(animCard, discardRect);
|
||||
await this.delay(this.timing.moveDuration);
|
||||
animCard.classList.remove('moving');
|
||||
|
||||
// Let discard land and pulse settle
|
||||
await this.delay(this.timing.pauseAfterDiscard);
|
||||
|
||||
// Step 3: Create second card for the new card coming into hand
|
||||
const newAnimCard = this.createAnimCard();
|
||||
this.cardManager.cardLayer.appendChild(newAnimCard);
|
||||
|
||||
// New card starts at holding/discard position
|
||||
this.setCardPosition(newAnimCard, holdingRect || discardRect);
|
||||
const newInner = newAnimCard.querySelector('.card-inner');
|
||||
const newFront = newAnimCard.querySelector('.card-face-front');
|
||||
|
||||
// Show new card (it's face up from the drawn card)
|
||||
this.setCardFront(newFront, newCard);
|
||||
newInner.classList.remove('flipped');
|
||||
|
||||
// Brief anticipation before new card moves
|
||||
await this.delay(this.timing.pauseBeforeNewCard);
|
||||
|
||||
// Step 4: Move new card to the hand slot
|
||||
this.playSound('card');
|
||||
newAnimCard.classList.add('moving');
|
||||
this.setCardPosition(newAnimCard, slotRect);
|
||||
await this.delay(this.timing.moveDuration);
|
||||
newAnimCard.classList.remove('moving');
|
||||
|
||||
// Breathing room after swap completes
|
||||
await this.delay(this.timing.pauseAfterSwapComplete);
|
||||
animCard.remove();
|
||||
newAnimCard.remove();
|
||||
}
|
||||
|
||||
// Create a temporary animation card element
|
||||
createAnimCard() {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'real-card anim-card';
|
||||
card.innerHTML = `
|
||||
<div class="card-inner">
|
||||
<div class="card-face card-face-front"></div>
|
||||
<div class="card-face card-face-back"><span>?</span></div>
|
||||
</div>
|
||||
`;
|
||||
return card;
|
||||
}
|
||||
|
||||
// Set card position
|
||||
setCardPosition(card, rect) {
|
||||
card.style.left = `${rect.left}px`;
|
||||
card.style.top = `${rect.top}px`;
|
||||
card.style.width = `${rect.width}px`;
|
||||
card.style.height = `${rect.height}px`;
|
||||
}
|
||||
|
||||
// Set card front content
|
||||
setCardFront(frontEl, cardData) {
|
||||
frontEl.className = 'card-face card-face-front';
|
||||
|
||||
if (!cardData) return;
|
||||
|
||||
if (cardData.rank === '★') {
|
||||
frontEl.classList.add('joker');
|
||||
const jokerIcon = cardData.suit === 'hearts' ? '🐉' : '👹';
|
||||
frontEl.innerHTML = `<span class="joker-icon">${jokerIcon}</span><span class="joker-label">Joker</span>`;
|
||||
} else {
|
||||
const isRed = cardData.suit === 'hearts' || cardData.suit === 'diamonds';
|
||||
frontEl.classList.add(isRed ? 'red' : 'black');
|
||||
const suitSymbol = this.getSuitSymbol(cardData.suit);
|
||||
frontEl.innerHTML = `${cardData.rank}<br>${suitSymbol}`;
|
||||
}
|
||||
}
|
||||
|
||||
getSuitSymbol(suit) {
|
||||
const symbols = { hearts: '♥', diamonds: '♦', clubs: '♣', spades: '♠' };
|
||||
return symbols[suit] || '';
|
||||
}
|
||||
|
||||
// Animate discarding a card (from hand to discard pile) - called for other players
|
||||
async animateDiscard(movement) {
|
||||
const { card, fromPlayerId, fromPosition } = movement;
|
||||
|
||||
// If no specific position, animate from opponent's area
|
||||
const discardRect = this.getLocationRect('discard');
|
||||
if (!discardRect) return;
|
||||
|
||||
let startRect;
|
||||
|
||||
if (fromPosition !== null && fromPosition !== undefined) {
|
||||
startRect = this.getSlotRect(fromPlayerId, fromPosition);
|
||||
}
|
||||
|
||||
// Fallback: use discard position offset upward
|
||||
if (!startRect) {
|
||||
startRect = {
|
||||
left: discardRect.left,
|
||||
top: discardRect.top - 80,
|
||||
width: discardRect.width,
|
||||
height: discardRect.height
|
||||
};
|
||||
}
|
||||
|
||||
// Create animation card
|
||||
const animCard = this.createAnimCard();
|
||||
this.cardManager.cardLayer.appendChild(animCard);
|
||||
this.setCardPosition(animCard, startRect);
|
||||
|
||||
const inner = animCard.querySelector('.card-inner');
|
||||
const front = animCard.querySelector('.card-face-front');
|
||||
|
||||
// Show the card that was discarded
|
||||
this.setCardFront(front, card);
|
||||
inner.classList.remove('flipped');
|
||||
|
||||
// Move to discard
|
||||
this.playSound('card');
|
||||
animCard.classList.add('moving');
|
||||
this.setCardPosition(animCard, discardRect);
|
||||
await this.delay(this.timing.moveDuration);
|
||||
animCard.classList.remove('moving');
|
||||
|
||||
// Same timing as player swap - let discard land and pulse settle
|
||||
await this.delay(this.timing.pauseAfterDiscard);
|
||||
|
||||
// Clean up
|
||||
animCard.remove();
|
||||
}
|
||||
|
||||
// Animate drawing from deck
|
||||
async animateDrawDeck(movement) {
|
||||
const { playerId } = movement;
|
||||
|
||||
const deckRect = this.getLocationRect('deck');
|
||||
const holdingRect = this.getLocationRect('holding');
|
||||
|
||||
if (!deckRect || !holdingRect) return;
|
||||
|
||||
// Create animation card at deck position (face down)
|
||||
const animCard = this.createAnimCard();
|
||||
this.cardManager.cardLayer.appendChild(animCard);
|
||||
this.setCardPosition(animCard, deckRect);
|
||||
|
||||
const inner = animCard.querySelector('.card-inner');
|
||||
inner.classList.add('flipped'); // Show back
|
||||
|
||||
// Move to holding position
|
||||
this.playSound('card');
|
||||
animCard.classList.add('moving');
|
||||
this.setCardPosition(animCard, holdingRect);
|
||||
await this.delay(this.timing.moveDuration);
|
||||
animCard.classList.remove('moving');
|
||||
|
||||
// Brief settle before state updates
|
||||
await this.delay(this.timing.pauseBeforeNewCard);
|
||||
|
||||
// Clean up - renderGame will show the holding card state
|
||||
animCard.remove();
|
||||
}
|
||||
|
||||
// Animate drawing from discard
|
||||
async animateDrawDiscard(movement) {
|
||||
const { playerId } = movement;
|
||||
|
||||
// Discard to holding is mostly visual feedback
|
||||
// The card "lifts" slightly
|
||||
|
||||
const discardRect = this.getLocationRect('discard');
|
||||
const holdingRect = this.getLocationRect('holding');
|
||||
|
||||
if (!discardRect || !holdingRect) return;
|
||||
|
||||
// Just play sound - visual handled by CSS :holding state
|
||||
this.playSound('card');
|
||||
|
||||
await this.delay(this.timing.moveDuration);
|
||||
}
|
||||
|
||||
// Check if animations are currently playing
|
||||
isAnimating() {
|
||||
return this.animationInProgress;
|
||||
}
|
||||
|
||||
// Clear the queue (for interruption)
|
||||
clear() {
|
||||
this.queue = [];
|
||||
}
|
||||
|
||||
// Utility delay
|
||||
delay(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
|
||||
// Export for use in app.js
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = AnimationQueue;
|
||||
}
|
||||
1626
client/app.js
1626
client/app.js
File diff suppressed because it is too large
Load Diff
259
client/card-manager.js
Normal file
259
client/card-manager.js
Normal file
@@ -0,0 +1,259 @@
|
||||
// CardManager - Manages persistent card DOM elements
|
||||
// Cards are REAL elements that exist in ONE place and move between locations
|
||||
|
||||
class CardManager {
|
||||
constructor(cardLayer) {
|
||||
this.cardLayer = cardLayer;
|
||||
// Map of "playerId-position" -> card element
|
||||
this.handCards = new Map();
|
||||
// Special cards
|
||||
this.deckCard = null;
|
||||
this.discardCard = null;
|
||||
this.holdingCard = null;
|
||||
}
|
||||
|
||||
// Initialize cards for a game state
|
||||
initializeCards(gameState, playerId, getSlotRect, getDeckRect, getDiscardRect) {
|
||||
this.clear();
|
||||
|
||||
// Create cards for each player's hand
|
||||
for (const player of gameState.players) {
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const card = player.cards[i];
|
||||
const slotKey = `${player.id}-${i}`;
|
||||
const cardEl = this.createCardElement(card);
|
||||
|
||||
// Position at slot (will be updated later if rect not ready)
|
||||
const rect = getSlotRect(player.id, i);
|
||||
if (rect && rect.width > 0) {
|
||||
this.positionCard(cardEl, rect);
|
||||
} else {
|
||||
// Start invisible, will be positioned by updateAllPositions
|
||||
cardEl.style.opacity = '0';
|
||||
}
|
||||
|
||||
this.handCards.set(slotKey, {
|
||||
element: cardEl,
|
||||
cardData: card,
|
||||
playerId: player.id,
|
||||
position: i
|
||||
});
|
||||
|
||||
this.cardLayer.appendChild(cardEl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create a card DOM element with 3D flip structure
|
||||
createCardElement(cardData) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'real-card';
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="card-inner">
|
||||
<div class="card-face card-face-front"></div>
|
||||
<div class="card-face card-face-back"><span>?</span></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.updateCardAppearance(card, cardData);
|
||||
return card;
|
||||
}
|
||||
|
||||
// Update card visual state (face up/down, content)
|
||||
updateCardAppearance(cardEl, cardData) {
|
||||
const inner = cardEl.querySelector('.card-inner');
|
||||
const front = cardEl.querySelector('.card-face-front');
|
||||
|
||||
// Reset front classes
|
||||
front.className = 'card-face card-face-front';
|
||||
|
||||
if (!cardData || !cardData.face_up || !cardData.rank) {
|
||||
// Face down or no data
|
||||
inner.classList.add('flipped');
|
||||
front.innerHTML = '';
|
||||
} else {
|
||||
// Face up with data
|
||||
inner.classList.remove('flipped');
|
||||
|
||||
if (cardData.rank === '★') {
|
||||
front.classList.add('joker');
|
||||
const icon = cardData.suit === 'hearts' ? '🐉' : '👹';
|
||||
front.innerHTML = `<span class="joker-icon">${icon}</span><span class="joker-label">Joker</span>`;
|
||||
} else {
|
||||
const isRed = cardData.suit === 'hearts' || cardData.suit === 'diamonds';
|
||||
front.classList.add(isRed ? 'red' : 'black');
|
||||
front.innerHTML = `${cardData.rank}<br>${this.getSuitSymbol(cardData.suit)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getSuitSymbol(suit) {
|
||||
return { hearts: '♥', diamonds: '♦', clubs: '♣', spades: '♠' }[suit] || '';
|
||||
}
|
||||
|
||||
// Position a card at a rect
|
||||
positionCard(cardEl, rect, animate = false) {
|
||||
if (animate) {
|
||||
cardEl.classList.add('moving');
|
||||
}
|
||||
|
||||
cardEl.style.left = `${rect.left}px`;
|
||||
cardEl.style.top = `${rect.top}px`;
|
||||
cardEl.style.width = `${rect.width}px`;
|
||||
cardEl.style.height = `${rect.height}px`;
|
||||
|
||||
if (animate) {
|
||||
setTimeout(() => cardEl.classList.remove('moving'), 350);
|
||||
}
|
||||
}
|
||||
|
||||
// Get a hand card by player and position
|
||||
getHandCard(playerId, position) {
|
||||
return this.handCards.get(`${playerId}-${position}`);
|
||||
}
|
||||
|
||||
// Update all card positions to match current slot positions
|
||||
// Returns number of cards successfully positioned
|
||||
updateAllPositions(getSlotRect) {
|
||||
let positioned = 0;
|
||||
for (const [key, cardInfo] of this.handCards) {
|
||||
const rect = getSlotRect(cardInfo.playerId, cardInfo.position);
|
||||
if (rect && rect.width > 0) {
|
||||
this.positionCard(cardInfo.element, rect, false);
|
||||
// Restore visibility if it was hidden
|
||||
cardInfo.element.style.opacity = '1';
|
||||
positioned++;
|
||||
}
|
||||
}
|
||||
return positioned;
|
||||
}
|
||||
|
||||
// Animate a card flip
|
||||
async flipCard(playerId, position, newCardData, duration = 400) {
|
||||
const cardInfo = this.getHandCard(playerId, position);
|
||||
if (!cardInfo) return;
|
||||
|
||||
const inner = cardInfo.element.querySelector('.card-inner');
|
||||
const front = cardInfo.element.querySelector('.card-face-front');
|
||||
|
||||
// Set up the front content before flip
|
||||
front.className = 'card-face card-face-front';
|
||||
if (newCardData.rank === '★') {
|
||||
front.classList.add('joker');
|
||||
const icon = newCardData.suit === 'hearts' ? '🐉' : '👹';
|
||||
front.innerHTML = `<span class="joker-icon">${icon}</span><span class="joker-label">Joker</span>`;
|
||||
} else {
|
||||
const isRed = newCardData.suit === 'hearts' || newCardData.suit === 'diamonds';
|
||||
front.classList.add(isRed ? 'red' : 'black');
|
||||
front.innerHTML = `${newCardData.rank}<br>${this.getSuitSymbol(newCardData.suit)}`;
|
||||
}
|
||||
|
||||
// Animate flip
|
||||
inner.classList.remove('flipped');
|
||||
|
||||
await this.delay(duration);
|
||||
|
||||
cardInfo.cardData = newCardData;
|
||||
}
|
||||
|
||||
// Animate a swap: hand card goes to discard, new card comes to hand
|
||||
async animateSwap(playerId, position, oldCardData, newCardData, getSlotRect, getDiscardRect, duration = 300) {
|
||||
const cardInfo = this.getHandCard(playerId, position);
|
||||
if (!cardInfo) return;
|
||||
|
||||
const slotRect = getSlotRect(playerId, position);
|
||||
const discardRect = getDiscardRect();
|
||||
|
||||
if (!slotRect || !discardRect) return;
|
||||
if (!oldCardData || !oldCardData.rank) {
|
||||
// Can't animate without card data - just update appearance
|
||||
this.updateCardAppearance(cardInfo.element, newCardData);
|
||||
cardInfo.cardData = newCardData;
|
||||
return;
|
||||
}
|
||||
|
||||
const cardEl = cardInfo.element;
|
||||
const inner = cardEl.querySelector('.card-inner');
|
||||
const front = cardEl.querySelector('.card-face-front');
|
||||
|
||||
// Step 1: If face down, flip to reveal the old card
|
||||
if (!oldCardData.face_up) {
|
||||
// Set front to show old card
|
||||
front.className = 'card-face card-face-front';
|
||||
if (oldCardData.rank === '★') {
|
||||
front.classList.add('joker');
|
||||
const icon = oldCardData.suit === 'hearts' ? '🐉' : '👹';
|
||||
front.innerHTML = `<span class="joker-icon">${icon}</span><span class="joker-label">Joker</span>`;
|
||||
} else {
|
||||
const isRed = oldCardData.suit === 'hearts' || oldCardData.suit === 'diamonds';
|
||||
front.classList.add(isRed ? 'red' : 'black');
|
||||
front.innerHTML = `${oldCardData.rank}<br>${this.getSuitSymbol(oldCardData.suit)}`;
|
||||
}
|
||||
|
||||
inner.classList.remove('flipped');
|
||||
await this.delay(400);
|
||||
}
|
||||
|
||||
// Step 2: Move card to discard
|
||||
cardEl.classList.add('moving');
|
||||
this.positionCard(cardEl, discardRect);
|
||||
await this.delay(duration + 50);
|
||||
cardEl.classList.remove('moving');
|
||||
|
||||
// Pause to show the discarded card
|
||||
await this.delay(250);
|
||||
|
||||
// Step 3: Update card to show new card and move back to hand
|
||||
front.className = 'card-face card-face-front';
|
||||
if (newCardData.rank === '★') {
|
||||
front.classList.add('joker');
|
||||
const icon = newCardData.suit === 'hearts' ? '🐉' : '👹';
|
||||
front.innerHTML = `<span class="joker-icon">${icon}</span><span class="joker-label">Joker</span>`;
|
||||
} else {
|
||||
const isRed = newCardData.suit === 'hearts' || newCardData.suit === 'diamonds';
|
||||
front.classList.add(isRed ? 'red' : 'black');
|
||||
front.innerHTML = `${newCardData.rank}<br>${this.getSuitSymbol(newCardData.suit)}`;
|
||||
}
|
||||
|
||||
if (!newCardData.face_up) {
|
||||
inner.classList.add('flipped');
|
||||
}
|
||||
|
||||
cardEl.classList.add('moving');
|
||||
this.positionCard(cardEl, slotRect);
|
||||
await this.delay(duration + 50);
|
||||
cardEl.classList.remove('moving');
|
||||
|
||||
cardInfo.cardData = newCardData;
|
||||
}
|
||||
|
||||
// Set holding state for a card (drawn card highlight)
|
||||
setHolding(playerId, position, isHolding) {
|
||||
const cardInfo = this.getHandCard(playerId, position);
|
||||
if (cardInfo) {
|
||||
cardInfo.element.classList.toggle('holding', isHolding);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear all cards
|
||||
clear() {
|
||||
for (const [key, cardInfo] of this.handCards) {
|
||||
cardInfo.element.remove();
|
||||
}
|
||||
this.handCards.clear();
|
||||
|
||||
if (this.holdingCard) {
|
||||
this.holdingCard.remove();
|
||||
this.holdingCard = null;
|
||||
}
|
||||
}
|
||||
|
||||
delay(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = CardManager;
|
||||
}
|
||||
67
client/golfball-logo.svg
Normal file
67
client/golfball-logo.svg
Normal file
@@ -0,0 +1,67 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -4 100 104" width="100" height="100">
|
||||
<defs>
|
||||
<!-- Gradient for 3D ball effect - transparent base -->
|
||||
<radialGradient id="ballGradient" cx="30%" cy="25%" r="65%" fx="25%" fy="20%">
|
||||
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.95"/>
|
||||
<stop offset="50%" stop-color="#f5f5f5" stop-opacity="0.9"/>
|
||||
<stop offset="80%" stop-color="#e0e0e0" stop-opacity="0.85"/>
|
||||
<stop offset="100%" stop-color="#c8c8c8" stop-opacity="0.8"/>
|
||||
</radialGradient>
|
||||
|
||||
<!-- Dimple shading gradient -->
|
||||
<radialGradient id="dimpleGrad" cx="40%" cy="35%" r="60%">
|
||||
<stop offset="0%" stop-color="#d0d0d0"/>
|
||||
<stop offset="100%" stop-color="#b8b8b8"/>
|
||||
</radialGradient>
|
||||
|
||||
<!-- Clip for dimples to stay within ball -->
|
||||
<clipPath id="ballClip">
|
||||
<circle cx="50" cy="44" r="45"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<!-- Main ball base -->
|
||||
<circle cx="50" cy="44" r="46" fill="url(#ballGradient)"/>
|
||||
|
||||
<!-- Dimples - balanced pattern with more spacing -->
|
||||
<g clip-path="url(#ballClip)" fill="url(#dimpleGrad)" opacity="0.5">
|
||||
<!-- Outer ring -->
|
||||
<circle cx="50" cy="2" r="3.5"/>
|
||||
<circle cx="74" cy="12" r="3.5"/>
|
||||
<circle cx="88" cy="38" r="3.5"/>
|
||||
<circle cx="85" cy="64" r="3.5"/>
|
||||
<circle cx="62" cy="84" r="3.5"/>
|
||||
<circle cx="38" cy="84" r="3.5"/>
|
||||
<circle cx="15" cy="64" r="3.5"/>
|
||||
<circle cx="12" cy="38" r="3.5"/>
|
||||
<circle cx="26" cy="12" r="3.5"/>
|
||||
|
||||
<!-- Middle ring - slightly offset -->
|
||||
<circle cx="62" cy="16" r="3.2"/>
|
||||
<circle cx="79" cy="50" r="3.2"/>
|
||||
<circle cx="68" cy="72" r="3.2"/>
|
||||
<circle cx="50" cy="80" r="3.2"/>
|
||||
<circle cx="32" cy="72" r="3.2"/>
|
||||
<circle cx="21" cy="50" r="3.2"/>
|
||||
<circle cx="38" cy="16" r="3.2"/>
|
||||
|
||||
<!-- Inner - avoiding center -->
|
||||
<circle cx="50" cy="10" r="2.8"/>
|
||||
<circle cx="70" cy="32" r="2.8"/>
|
||||
<circle cx="30" cy="32" r="2.8"/>
|
||||
<circle cx="72" cy="58" r="2.8"/>
|
||||
<circle cx="28" cy="58" r="2.8"/>
|
||||
</g>
|
||||
|
||||
<!-- Subtle inner shadow for depth -->
|
||||
<circle cx="50" cy="44" r="45" fill="none" stroke="#a0a0a0" stroke-width="1" opacity="0.3"/>
|
||||
|
||||
<!-- Outer edge highlight -->
|
||||
<circle cx="50" cy="44" r="46" fill="none" stroke="#ffffff" stroke-width="0.5" opacity="0.5"/>
|
||||
|
||||
<!-- Card suits - single row, larger -->
|
||||
<text x="22" y="51" font-family="Arial, sans-serif" font-size="32" font-weight="bold" fill="#1a1a1a" text-anchor="middle">♣</text>
|
||||
<text x="41" y="51" font-family="Arial, sans-serif" font-size="32" font-weight="bold" fill="#cc0000" text-anchor="middle">♦</text>
|
||||
<text x="59" y="51" font-family="Arial, sans-serif" font-size="32" font-weight="bold" fill="#1a1a1a" text-anchor="middle">♠</text>
|
||||
<text x="77" y="51" font-family="Arial, sans-serif" font-size="32" font-weight="bold" fill="#cc0000" text-anchor="middle">♥</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
@@ -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>🏌️ Golf</h1>
|
||||
<p class="subtitle">6-Card Golf Card Game</p>
|
||||
<h1><img src="golfball-logo.svg" alt="" class="golfball-logo"><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> <button id="leaderboard-btn" class="btn btn-small leaderboard-btn">Leaderboard</button></p>
|
||||
|
||||
<!-- Auth buttons for guests (hidden until auth check confirms not logged in) -->
|
||||
<div id="auth-buttons" class="auth-buttons hidden">
|
||||
<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>
|
||||
@@ -39,9 +51,11 @@
|
||||
<!-- Waiting Room Screen -->
|
||||
<div id="waiting-screen" class="screen">
|
||||
<div class="room-code-banner">
|
||||
<span class="room-code-label">ROOM CODE</span>
|
||||
<span class="room-code-value" id="display-room-code"></span>
|
||||
<button class="room-code-copy" id="copy-room-code" title="Copy to clipboard">📋</button>
|
||||
<div class="room-code-buttons">
|
||||
<button class="room-code-copy" id="copy-room-code" title="Copy code">📋</button>
|
||||
<button class="room-code-copy" id="share-room-link" title="Copy invite link">🌐</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="waiting-layout">
|
||||
@@ -50,19 +64,19 @@
|
||||
<h3>Players</h3>
|
||||
<ul id="players-list"></ul>
|
||||
</div>
|
||||
<div id="cpu-controls-section" class="cpu-controls-section hidden">
|
||||
<h4>Add CPU Opponents</h4>
|
||||
<div class="cpu-controls">
|
||||
<button id="remove-cpu-btn" class="btn btn-small btn-danger" title="Remove last CPU">−</button>
|
||||
<button id="add-cpu-btn" class="btn btn-small btn-success" title="Add CPU player">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<button id="leave-room-btn" class="btn btn-danger">Leave Room</button>
|
||||
</div>
|
||||
|
||||
<div id="host-settings" class="settings hidden">
|
||||
<h3>Game Settings</h3>
|
||||
<div class="basic-settings-row">
|
||||
<div class="form-group">
|
||||
<label>CPU Players</label>
|
||||
<div class="cpu-controls">
|
||||
<button id="remove-cpu-btn" class="btn btn-small btn-danger">(-) Delete</button>
|
||||
<button id="add-cpu-btn" class="btn btn-small btn-success">(+) Add</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="num-decks">Decks</label>
|
||||
<select id="num-decks">
|
||||
@@ -94,20 +108,34 @@
|
||||
<summary>Advanced Options</summary>
|
||||
|
||||
<div class="advanced-options-grid">
|
||||
<!-- Left Column: Variants & Jokers -->
|
||||
<!-- Left Column: Gameplay & Jokers -->
|
||||
<div class="options-column">
|
||||
<div class="options-category">
|
||||
<h4>Variants</h4>
|
||||
<h4>Gameplay</h4>
|
||||
<div class="checkbox-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="flip-on-discard">
|
||||
<span>Flip on Discard</span>
|
||||
<span class="rule-desc">Flip card when discarding from deck</span>
|
||||
<div class="select-option">
|
||||
<label for="flip-mode">Flip on Discard</label>
|
||||
<select id="flip-mode">
|
||||
<option value="never">Standard (no flip)</option>
|
||||
<option value="always">Speed Golf (must flip)</option>
|
||||
<option value="endgame">Endgame (opt. flip late in game)</option>
|
||||
</select>
|
||||
<span class="rule-desc">After discarding a drawn card</span>
|
||||
</div>
|
||||
<label class="checkbox-label inline">
|
||||
<input type="checkbox" id="flip-as-action">
|
||||
<span>Flip as Action</span>
|
||||
<span class="rule-desc"><span class="suit suit-black">♣</span>flip instead of draw</span>
|
||||
</label>
|
||||
<label class="checkbox-label">
|
||||
<label class="checkbox-label inline">
|
||||
<input type="checkbox" id="knock-penalty">
|
||||
<span>Knock Penalty</span>
|
||||
<span class="rule-desc">+10 if you go out but don't have lowest</span>
|
||||
<span class="rule-desc"><span class="suit suit-red">♦</span>+10 if not lowest</span>
|
||||
</label>
|
||||
<label class="checkbox-label inline">
|
||||
<input type="checkbox" id="knock-early">
|
||||
<span>Early Knock</span>
|
||||
<span class="rule-desc"><span class="suit suit-black">♠</span>flip all (≤2) to go out</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -122,68 +150,84 @@
|
||||
<label class="radio-label">
|
||||
<input type="radio" name="joker-mode" value="standard">
|
||||
<span>Standard</span>
|
||||
<span class="rule-desc">2 per deck, -2 pts / 0 paired</span>
|
||||
<span class="rule-desc"><span class="suit suit-black">♠</span>2 per deck, -2 / 0 paired</span>
|
||||
</label>
|
||||
<label class="radio-label">
|
||||
<input type="radio" name="joker-mode" value="lucky-swing">
|
||||
<span>Lucky Swing</span>
|
||||
<span class="rule-desc">1-2-3 decks - 1 Joker, -5 pt</span>
|
||||
<span class="rule-desc"><span class="suit suit-red">♥</span>1 Joker total, -5!</span>
|
||||
</label>
|
||||
<label class="radio-label">
|
||||
<input type="radio" name="joker-mode" value="eagle-eye">
|
||||
<span>Eagle-Eyed</span>
|
||||
<span class="rule-desc">★ = +2 pts, -4 pts paired</span>
|
||||
<span class="rule-desc"><span class="suit suit-black">♣</span>+2 / -4 paired</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Card Values & Bonuses -->
|
||||
<div class="options-column">
|
||||
<div class="options-category">
|
||||
<h4>Point Modifiers</h4>
|
||||
<h4>Card Values</h4>
|
||||
<div class="checkbox-group">
|
||||
<label class="checkbox-label inline">
|
||||
<input type="checkbox" id="super-kings">
|
||||
<span>Super Kings</span>
|
||||
<span class="rule-desc">K = -2 pts</span>
|
||||
<span class="rule-desc"><span class="suit suit-red">♦</span>K = -2</span>
|
||||
</label>
|
||||
<label class="checkbox-label inline">
|
||||
<input type="checkbox" id="ten-penny">
|
||||
<span>Ten Penny</span>
|
||||
<span class="rule-desc">10 = 1 pt</span>
|
||||
<span class="rule-desc"><span class="suit suit-black">♠</span>10 = 1</span>
|
||||
</label>
|
||||
<label class="checkbox-label inline">
|
||||
<input type="checkbox" id="one-eyed-jacks">
|
||||
<span>One-Eyed Jacks</span>
|
||||
<span class="rule-desc"><span class="suit suit-red">♥</span>J♥/J♠ = 0</span>
|
||||
</label>
|
||||
<label class="checkbox-label inline">
|
||||
<input type="checkbox" id="negative-pairs-keep-value">
|
||||
<span>Negative Pairs</span>
|
||||
<span class="rule-desc"><span class="suit suit-black">♣</span>paired 2s/Jokers = -4</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Bonuses & Gameplay -->
|
||||
<div class="options-column">
|
||||
<div class="options-category">
|
||||
<h4>Bonuses & Penalties</h4>
|
||||
<div class="checkbox-group">
|
||||
<label class="checkbox-label">
|
||||
<label class="checkbox-label inline">
|
||||
<input type="checkbox" id="knock-bonus">
|
||||
<span>Knock Out Bonus</span>
|
||||
<span class="rule-desc">-5 for going out first</span>
|
||||
<span>Knock Bonus</span>
|
||||
<span class="rule-desc"><span class="suit suit-red">♦</span>-5 going out first</span>
|
||||
</label>
|
||||
<label class="checkbox-label">
|
||||
<label class="checkbox-label inline">
|
||||
<input type="checkbox" id="underdog-bonus">
|
||||
<span>Underdog Bonus</span>
|
||||
<span class="rule-desc">-3 for lowest score each hole</span>
|
||||
<span>Underdog</span>
|
||||
<span class="rule-desc"><span class="suit suit-black">♠</span>-3 lowest score</span>
|
||||
</label>
|
||||
<label class="checkbox-label">
|
||||
<label class="checkbox-label inline">
|
||||
<input type="checkbox" id="tied-shame">
|
||||
<span>Tied Shame</span>
|
||||
<span class="rule-desc">+5 if you tie with someone</span>
|
||||
<span class="rule-desc"><span class="suit suit-red">♥</span>+5 if tied</span>
|
||||
</label>
|
||||
<label class="checkbox-label inline">
|
||||
<input type="checkbox" id="blackjack">
|
||||
<span>Blackjack</span>
|
||||
<span class="rule-desc">21 pts = 0 pts</span>
|
||||
<span class="rule-desc"><span class="suit suit-black">♣</span>score 21 = 0</span>
|
||||
</label>
|
||||
<label class="checkbox-label inline">
|
||||
<input type="checkbox" id="four-of-a-kind">
|
||||
<span>Four of a Kind</span>
|
||||
<span class="rule-desc"><span class="suit suit-red">♦</span>-20 bonus</span>
|
||||
</label>
|
||||
<label class="checkbox-label inline">
|
||||
<input type="checkbox" id="wolfpack">
|
||||
<span>Wolfpack</span>
|
||||
<span class="rule-desc">2 pairs of Jacks = -5 pts</span>
|
||||
<span class="rule-desc"><span class="suit suit-black">♠</span>4 Jacks = -20</span>
|
||||
</label>
|
||||
<p id="wolfpack-combo-note" class="combo-note hidden">🃏 4 Jacks = -40 (stacks!)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -200,22 +244,29 @@
|
||||
|
||||
<!-- Game Screen -->
|
||||
<div id="game-screen" class="screen">
|
||||
<!-- Card layer for persistent card elements -->
|
||||
<div id="card-layer"></div>
|
||||
<div class="game-layout">
|
||||
<div class="game-main">
|
||||
<div class="game-header">
|
||||
<div class="header-col header-col-left">
|
||||
<div class="round-info">Hole <span id="current-round">1</span>/<span id="total-rounds">9</span></div>
|
||||
<div class="turn-info" id="turn-info">Your turn</div>
|
||||
<div class="score-info">Showing: <span id="your-score">0</span></div>
|
||||
<div class="header-buttons">
|
||||
<button id="leave-game-btn" class="btn btn-small btn-danger">Leave</button>
|
||||
<button id="mute-btn" class="mute-btn" title="Toggle sound">🔊</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="active-rules-bar" class="active-rules-bar hidden">
|
||||
<span class="rules-label">Rules:</span>
|
||||
<span id="active-rules-list" class="rules-list"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-col header-col-center">
|
||||
<div id="status-message" class="status-message"></div>
|
||||
<div id="final-turn-badge" class="final-turn-badge hidden">⚡ FINAL TURN</div>
|
||||
</div>
|
||||
<div class="header-col header-col-right">
|
||||
<span id="game-username" class="game-username hidden"></span>
|
||||
<button id="game-logout-btn" class="btn btn-small hidden">Logout</button>
|
||||
<button id="mute-btn" class="mute-btn" title="Toggle sound">🔊</button>
|
||||
<button id="leave-game-btn" class="btn btn-small btn-danger">Leave</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="game-table">
|
||||
<div id="opponents-row" class="opponents-row"></div>
|
||||
@@ -223,25 +274,54 @@
|
||||
<div class="player-row">
|
||||
<div class="table-center">
|
||||
<div class="deck-area">
|
||||
<!-- Held card slot (left of deck) -->
|
||||
<div id="held-card-slot" class="held-card-slot hidden">
|
||||
<div id="held-card-display" class="card card-front">
|
||||
<span id="held-card-content"></span>
|
||||
</div>
|
||||
<span class="held-label">Holding</span>
|
||||
</div>
|
||||
<div id="deck" class="card card-back">
|
||||
<span>?</span>
|
||||
</div>
|
||||
<div class="discard-stack">
|
||||
<div id="discard" class="card">
|
||||
<span id="discard-content"></span>
|
||||
</div>
|
||||
<!-- Floating held card (appears larger over discard when holding) -->
|
||||
<div id="held-card-floating" class="card card-front held-card-floating hidden">
|
||||
<span id="held-card-floating-content"></span>
|
||||
</div>
|
||||
<button id="discard-btn" class="btn btn-small hidden">Discard</button>
|
||||
<button id="skip-flip-btn" class="btn btn-small btn-secondary hidden">Skip Flip</button>
|
||||
<button id="knock-early-btn" class="btn btn-small btn-danger hidden">Knock!</button>
|
||||
</div>
|
||||
<div id="drawn-card-area" class="hidden">
|
||||
<div id="drawn-card" class="card"></div>
|
||||
<button id="discard-btn" class="btn btn-small">Discard</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="player-section">
|
||||
<div id="flip-prompt" class="flip-prompt hidden"></div>
|
||||
<div class="player-area">
|
||||
<h4 id="player-header"><span class="player-name">You</span><span id="your-score" class="player-showing">0</span></h4>
|
||||
<div id="player-cards" class="card-grid"></div>
|
||||
</div>
|
||||
<div id="toast" class="toast hidden"></div>
|
||||
</div>
|
||||
|
||||
<!-- Animation overlay for card movements -->
|
||||
<div id="swap-animation" class="swap-animation hidden">
|
||||
<!-- Card being discarded from hand -->
|
||||
<div id="swap-card-from-hand" class="swap-card">
|
||||
<div class="swap-card-inner">
|
||||
<div class="swap-card-front"></div>
|
||||
<div class="swap-card-back">?</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Drawn card being held (animates to hand) -->
|
||||
<div id="held-card" class="swap-card hidden">
|
||||
<div class="swap-card-inner">
|
||||
<div class="swap-card-front"></div>
|
||||
<div class="swap-card-back">?</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -256,6 +336,10 @@
|
||||
<!-- Right panel: Scores -->
|
||||
<div id="scoreboard" class="side-panel right-panel">
|
||||
<h4>Scores</h4>
|
||||
<div id="game-buttons" class="game-buttons hidden">
|
||||
<button id="next-round-btn" class="btn btn-next-round hidden">Next Hole</button>
|
||||
<button id="new-game-btn" class="btn btn-small btn-secondary hidden">New Game</button>
|
||||
</div>
|
||||
<table id="score-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -267,12 +351,403 @@
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
<div id="game-buttons" class="game-buttons hidden">
|
||||
<button id="next-round-btn" class="btn btn-small btn-primary hidden">Next Hole</button>
|
||||
<button id="new-game-btn" class="btn btn-small btn-secondary hidden">New Game</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rules Screen -->
|
||||
<div id="rules-screen" class="screen">
|
||||
<div class="rules-container">
|
||||
<button id="rules-back-btn" class="btn rules-back-btn">« Back</button>
|
||||
|
||||
<div class="rules-header">
|
||||
<h1><span class="golfer-logo">🏌️</span> <span class="golf-title">Golf Rules</span></h1>
|
||||
<p class="rules-subtitle">6-Card Golf Card Game - Complete Guide</p>
|
||||
</div>
|
||||
|
||||
<!-- Table of Contents -->
|
||||
<nav class="rules-toc">
|
||||
<div class="toc-title">Quick Navigation</div>
|
||||
<div class="toc-links">
|
||||
<a href="#rules-basic" class="toc-link">
|
||||
<span class="toc-icon">🎯</span>
|
||||
<span class="toc-text">Basic Rules</span>
|
||||
</a>
|
||||
<a href="#rules-card-values" class="toc-link">
|
||||
<span class="toc-icon">🃏</span>
|
||||
<span class="toc-text">Card Values</span>
|
||||
</a>
|
||||
<a href="#rules-pairing" class="toc-link">
|
||||
<span class="toc-icon">👯</span>
|
||||
<span class="toc-text">Column Pairing</span>
|
||||
</a>
|
||||
<a href="#rules-turn" class="toc-link">
|
||||
<span class="toc-icon">🔄</span>
|
||||
<span class="toc-text">Turn Structure</span>
|
||||
</a>
|
||||
<a href="#rules-flip-mode" class="toc-link">
|
||||
<span class="toc-icon">🔃</span>
|
||||
<span class="toc-text">Flip Modes</span>
|
||||
</a>
|
||||
<a href="#rules-house-rules" class="toc-link">
|
||||
<span class="toc-icon">🏠</span>
|
||||
<span class="toc-text">House Rules</span>
|
||||
</a>
|
||||
<a href="#rules-faq" class="toc-link">
|
||||
<span class="toc-icon">❓</span>
|
||||
<span class="toc-text">FAQ</span>
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<section id="rules-basic" class="rules-section">
|
||||
<h2>Basic Rules</h2>
|
||||
<p><strong>6-Card Golf</strong> is a card game where players try to achieve the <strong>lowest score</strong> over multiple rounds ("holes"). Like golf, lower is better!</p>
|
||||
<ul>
|
||||
<li>Each player has <strong>6 cards</strong> arranged in a 2-row by 3-column grid</li>
|
||||
<li>Most cards start <strong>face-down</strong> (hidden from everyone)</li>
|
||||
<li>On your turn: <strong>draw one card</strong>, then either <strong>swap it</strong> with one of yours or <strong>discard it</strong></li>
|
||||
<li>When any player reveals <strong>all 6 of their cards</strong>, everyone else gets <strong>one final turn</strong></li>
|
||||
<li>After all rounds ("holes") are played, the player with the <strong>lowest total score wins</strong></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="rules-card-values" class="rules-section">
|
||||
<h2>Card Values</h2>
|
||||
<table class="rules-table">
|
||||
<thead>
|
||||
<tr><th>Card</th><th>Points</th><th>Notes</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>Joker</td><td class="value-negative">-2</td><td>Best card! (requires Jokers to be enabled)</td></tr>
|
||||
<tr><td>2</td><td class="value-negative">-2</td><td>Excellent - gives you negative points!</td></tr>
|
||||
<tr><td>Ace (A)</td><td class="value-low">1</td><td>Very low and safe</td></tr>
|
||||
<tr><td>King (K)</td><td class="value-zero">0</td><td>Zero points - great for making pairs!</td></tr>
|
||||
<tr><td>3 through 10</td><td>Face value</td><td>3=3 pts, 4=4 pts, ..., 10=10 pts</td></tr>
|
||||
<tr><td>Jack (J), Queen (Q)</td><td class="value-high">10</td><td>High cards - replace these quickly!</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section id="rules-pairing" class="rules-section">
|
||||
<h2>Column Pairing (IMPORTANT!)</h2>
|
||||
<p><strong>This is the most important rule to understand:</strong></p>
|
||||
<p>If both cards in a <strong>vertical column</strong> have the <strong>same rank</strong> (like two 8s or two Jacks), that entire column scores <strong>0 points</strong> - regardless of what the cards are worth individually!</p>
|
||||
|
||||
<div class="rules-example">
|
||||
<h4>Example:</h4>
|
||||
<pre>
|
||||
Your 6-card grid:
|
||||
Col1 Col2 Col3
|
||||
[8] [5] [7] ← Top row
|
||||
[8] [3] [9] ← Bottom row
|
||||
|
||||
Column 1: 8 + 8 = PAIR! = 0 points (not 16!)
|
||||
Column 2: 5 + 3 = 8 points
|
||||
Column 3: 7 + 9 = 16 points
|
||||
|
||||
TOTAL: 0 + 8 + 16 = 24 points</pre>
|
||||
</div>
|
||||
|
||||
<p class="rules-warning"><strong>IMPORTANT:</strong> When you pair cards, you get 0 points for that column - even if the cards have negative values! Two 2s paired = 0 points (not -4). Two Jokers paired = 0 points (not -4). <em>Exception: The "Negative Pairs Keep Value" house rule changes this - paired negative cards keep their -4 value!</em></p>
|
||||
</section>
|
||||
|
||||
<section id="rules-turn" class="rules-section">
|
||||
<h2>Turn Structure (Step by Step)</h2>
|
||||
|
||||
<h3>Step 1: Draw a Card</h3>
|
||||
<p>You MUST draw exactly one card. Choose from:</p>
|
||||
<ul>
|
||||
<li><strong>The Deck</strong> (face-down pile) - You don't know what you'll get!</li>
|
||||
<li><strong>The Discard Pile</strong> (face-up pile) - You can see exactly what card you're taking</li>
|
||||
</ul>
|
||||
|
||||
<h3>Step 2: Use or Discard the Card</h3>
|
||||
|
||||
<div class="rules-case">
|
||||
<h4>If you drew from the DECK:</h4>
|
||||
<p>You have two options:</p>
|
||||
<ul>
|
||||
<li><strong>SWAP:</strong> Replace any one of your 6 cards with the drawn card. The old card goes to the discard pile.</li>
|
||||
<li><strong>DISCARD:</strong> Put the drawn card directly on the discard pile without using it.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="rules-case">
|
||||
<h4>If you drew from the DISCARD PILE:</h4>
|
||||
<p>You MUST swap - you cannot put the same card back on the discard pile.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="rules-flip-mode" class="rules-section">
|
||||
<h2>Flip on Discard Rules (3 Modes)</h2>
|
||||
<p>This setting affects what happens when you draw from the deck and choose to <strong>discard</strong> (not swap):</p>
|
||||
|
||||
<div class="rules-mode">
|
||||
<h3>Standard Mode (No Flip)</h3>
|
||||
<p class="mode-summary">Default setting. Discarding ends your turn immediately.</p>
|
||||
<p><strong>How it works:</strong> When you draw from the deck and decide not to use it, you simply discard it and your turn is over. Nothing else happens.</p>
|
||||
<p><strong>Strategic impact:</strong> Information is precious. You only learn what's in your hand by actively swapping cards, so there's more gambling on face-down cards. Rewards good memory and tracking what opponents discard.</p>
|
||||
<p><strong>Best for:</strong> Traditional gameplay, longer games, players who enjoy mystery and risk.</p>
|
||||
</div>
|
||||
|
||||
<div class="rules-mode">
|
||||
<h3>Speed Golf Mode (Must Flip)</h3>
|
||||
<p class="mode-summary">Every discard reveals one of your hidden cards.</p>
|
||||
<p><strong>How it works:</strong> When you draw from the deck and discard, you MUST also flip over one of your face-down cards. This is mandatory - you cannot skip it.</p>
|
||||
<p><strong>Strategic impact:</strong> Even "bad" draws give you information. Reduces the luck factor since everyone makes more informed decisions. Games naturally end faster with less hidden information.</p>
|
||||
<p><strong>Best for:</strong> Quick games, players who prefer skill over luck.</p>
|
||||
</div>
|
||||
|
||||
<div class="rules-mode">
|
||||
<h3>Endgame Mode (Catch-Up Flip)</h3>
|
||||
<p class="mode-summary">Optional flip activates when any player has only 1 hidden card left.</p>
|
||||
<p><strong>How it works:</strong></p>
|
||||
<ul>
|
||||
<li>Early in the round: Discarding ends your turn (like Standard mode)</li>
|
||||
<li><strong>When ANY player has 1 or fewer face-down cards:</strong> After discarding, you MAY choose to flip one of your hidden cards OR skip</li>
|
||||
</ul>
|
||||
<p><strong>Strategic impact:</strong> This is a <strong>catch-up mechanic</strong>. When someone is about to go out, trailing players can accelerate their information gathering to find pairs or swap out bad cards. The leader (who triggered this) doesn't benefit since they have no hidden cards left. Reduces the "runaway leader" problem and keeps games competitive.</p>
|
||||
<p><strong>Best for:</strong> Competitive play where you want trailing players to have a fighting chance.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="rules-house-rules" class="rules-section">
|
||||
<h2>House Rules (Optional Variants)</h2>
|
||||
|
||||
<div class="rules-mode">
|
||||
<h3>Point Modifiers</h3>
|
||||
<div class="house-rule">
|
||||
<h4>Super Kings</h4>
|
||||
<p>Kings are worth <strong>-2 points</strong> instead of 0.</p>
|
||||
<p class="strategic-impact"><strong>Strategic impact:</strong> Kings become valuable to keep unpaired, not just pairing fodder. Creates interesting decisions - do you pair Kings for 0, or keep them separate for -4 total?</p>
|
||||
</div>
|
||||
<div class="house-rule">
|
||||
<h4>Ten Penny</h4>
|
||||
<p>10s are worth <strong>1 point</strong> instead of 10.</p>
|
||||
<p class="strategic-impact"><strong>Strategic impact:</strong> Removes the "10 disaster" - drawing a 10 is no longer a crisis. Queens and Jacks become the only truly bad cards. Makes the game more forgiving.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rules-mode">
|
||||
<h3>Joker Variants</h3>
|
||||
<div class="house-rule">
|
||||
<h4>Standard Jokers</h4>
|
||||
<p>2 Jokers per deck, each worth <strong>-2 points</strong>.</p>
|
||||
<p class="strategic-impact"><strong>Strategic impact:</strong> Jokers are great to find but pairing them is wasteful (0 points instead of -4). Best kept in different columns. Adds 2 premium cards to hunt for.</p>
|
||||
</div>
|
||||
<div class="house-rule">
|
||||
<h4>Lucky Swing</h4>
|
||||
<p>Only <strong>1 Joker</strong> in the entire deck, worth <strong>-5 points</strong>.</p>
|
||||
<p class="strategic-impact"><strong>Strategic impact:</strong> High variance. Whoever finds this rare card gets a significant advantage. Increases the luck factor - sometimes you get it, sometimes your opponent does.</p>
|
||||
</div>
|
||||
<div class="house-rule">
|
||||
<h4>Eagle Eye</h4>
|
||||
<p>Jokers are worth <strong>+2 unpaired</strong>, but <strong>-4 when paired</strong>.</p>
|
||||
<p class="strategic-impact"><strong>Strategic impact:</strong> Risk/reward Jokers. Finding one actually hurts you (+2) until you commit to finding the second. Rewards aggressive searching and creates tense decisions about whether to keep hunting or cut your losses.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rules-mode">
|
||||
<h3>Going Out Rules</h3>
|
||||
<div class="house-rule">
|
||||
<h4>Knock Penalty</h4>
|
||||
<p><strong>+10 points</strong> if you go out but don't have the lowest score.</p>
|
||||
<p class="strategic-impact"><strong>Strategic impact:</strong> Discourages reckless rushing. You need to be confident you're winning before going out. Rewards patience and reading your opponents' likely scores. Can backfire spectacularly if you misjudge.</p>
|
||||
</div>
|
||||
<div class="house-rule">
|
||||
<h4>Knock Bonus</h4>
|
||||
<p><strong>-5 points</strong> for going out first (regardless of who wins).</p>
|
||||
<p class="strategic-impact"><strong>Strategic impact:</strong> Encourages racing to finish, even with a mediocre hand. The 5-point bonus might make up for a slightly worse score. Speeds up gameplay.</p>
|
||||
</div>
|
||||
<p class="combo-note"><em>Combining Knock Penalty + Knock Bonus creates high-stakes "going out" decisions: -5 if you win, +10 if you lose!</em></p>
|
||||
</div>
|
||||
|
||||
<div class="rules-mode">
|
||||
<h3>Scoring Bonuses</h3>
|
||||
<div class="house-rule">
|
||||
<h4>Underdog Bonus</h4>
|
||||
<p>Round winner gets <strong>-3 points</strong> extra.</p>
|
||||
<p class="strategic-impact"><strong>Strategic impact:</strong> Amplifies winning - the best player each round pulls further ahead. Can lead to snowballing leads over multiple holes. Rewards consistency.</p>
|
||||
</div>
|
||||
<div class="house-rule">
|
||||
<h4>Tied Shame</h4>
|
||||
<p>If you tie another player's score, <strong>both get +5 penalty</strong>.</p>
|
||||
<p class="strategic-impact"><strong>Strategic impact:</strong> Punishes playing it safe. If you suspect a tie, you need to take risks to differentiate your score. Creates interesting late-round decisions.</p>
|
||||
</div>
|
||||
<div class="house-rule">
|
||||
<h4>Blackjack</h4>
|
||||
<p>Score of exactly <strong>21 becomes 0</strong>.</p>
|
||||
<p class="strategic-impact"><strong>Strategic impact:</strong> A "hail mary" comeback. If you're stuck at 21, you're suddenly in great shape. Mostly luck, but adds exciting moments when it happens.</p>
|
||||
</div>
|
||||
<div class="house-rule">
|
||||
<h4>Wolfpack</h4>
|
||||
<p>Having <strong>all 4 Jacks</strong> (2 pairs) gives <strong>-20 bonus</strong>.</p>
|
||||
<p class="strategic-impact"><strong>Strategic impact:</strong> Extremely rare but now a significant reward! Turns a potential disaster (40 points of Jacks) into a triumph. The huge bonus makes it worth celebrating when achieved, though still not worth actively pursuing.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rules-mode">
|
||||
<h3>New Variants</h3>
|
||||
<div class="house-rule">
|
||||
<h4>Flip as Action</h4>
|
||||
<p>Use your turn to flip one of your face-down cards without drawing. Ends your turn immediately.</p>
|
||||
<p class="strategic-impact"><strong>Strategic impact:</strong> Lets you gather information without risking a bad deck draw. Conservative players can learn their hand safely. However, you miss the chance to actively improve your hand - you're just learning what you have.</p>
|
||||
</div>
|
||||
<div class="house-rule">
|
||||
<h4>Four of a Kind</h4>
|
||||
<p>Having 4 cards of the same rank across two columns scores <strong>-20 bonus</strong>.</p>
|
||||
<p class="strategic-impact"><strong>Strategic impact:</strong> Rewards collecting matching cards beyond just column pairs. Changes whether you should take a third or fourth copy of a rank. If you already have two pairs of 8s, that's -20 extra! Stacks with Wolfpack: four Jacks = -40 total.</p>
|
||||
</div>
|
||||
<div class="house-rule">
|
||||
<h4>Negative Pairs Keep Value</h4>
|
||||
<p>When you pair 2s or Jokers in a column, they keep their combined <strong>-4 points</strong> instead of becoming 0.</p>
|
||||
<p class="strategic-impact"><strong>Strategic impact:</strong> Major change! Pairing your best cards is now beneficial. Two 2s paired = -4 points, not 0. This encourages hunting for duplicate negative cards and fundamentally changes how you value 2s and Jokers.</p>
|
||||
</div>
|
||||
<div class="house-rule">
|
||||
<h4>One-Eyed Jacks</h4>
|
||||
<p>The Jack of Hearts (J♥) and Jack of Spades (J♠) - the "one-eyed" Jacks - are worth <strong>0 points</strong> instead of 10.</p>
|
||||
<p class="strategic-impact"><strong>Strategic impact:</strong> Two of the four Jacks become safe cards, comparable to Kings. J♥ and J♠ are now good cards to keep! Only J♣ and J♦ remain dangerous. Reduces the "Jack disaster" probability by half.</p>
|
||||
</div>
|
||||
<div class="house-rule">
|
||||
<h4>Early Knock</h4>
|
||||
<p>If you have <strong>2 or fewer face-down cards</strong>, you may use your turn to flip all remaining cards at once and immediately end the round. Click the "Knock!" button during your draw phase.</p>
|
||||
<p class="strategic-impact"><strong>Strategic impact:</strong> A high-risk, high-reward option! If you're confident your hidden cards are low, you can knock early to surprise opponents. But if those hidden cards are bad, you've just locked in a terrible score. Best used when you've deduced your face-down cards are safe (like after drawing and discarding duplicates).</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="rules-faq" class="rules-section">
|
||||
<h2>Frequently Asked Questions</h2>
|
||||
|
||||
<div class="faq-item">
|
||||
<h4>Q: Can I look at my face-down cards?</h4>
|
||||
<p>A: No! Once the game starts, you cannot peek at your own face-down cards. You only see them when they get flipped face-up (either by swapping or by the flip-on-discard rule).</p>
|
||||
</div>
|
||||
|
||||
<div class="faq-item">
|
||||
<h4>Q: Can I swap a face-down card without looking at it first?</h4>
|
||||
<p>A: Yes! In fact, that's often the best strategy - if you have a card that seems high based on probability, swap it out before you even see it.</p>
|
||||
</div>
|
||||
|
||||
<div class="faq-item">
|
||||
<h4>Q: What happens when someone reveals all their cards?</h4>
|
||||
<p>A: Once ANY player has all 6 cards face-up, every other player gets exactly ONE more turn. Then the round ends and scores are calculated.</p>
|
||||
</div>
|
||||
|
||||
<div class="faq-item">
|
||||
<h4>Q: Do I have to go out (reveal all cards) to win?</h4>
|
||||
<p>A: No! You can win the round even with face-down cards. The player with the lowest score wins, regardless of how many cards are revealed.</p>
|
||||
</div>
|
||||
|
||||
<div class="faq-item">
|
||||
<h4>Q: When do pairs count?</h4>
|
||||
<p>A: Pairs only count in VERTICAL columns (top card + bottom card in the same column). Horizontal or diagonal matches don't create pairs.</p>
|
||||
</div>
|
||||
|
||||
<div class="faq-item">
|
||||
<h4>Q: Can I make a pair with face-down cards?</h4>
|
||||
<p>A: Face-down cards are still counted for scoring, but since you can't see them, you're gambling that they might form a pair. At the end of the round, all cards are revealed and pairs are calculated.</p>
|
||||
</div>
|
||||
|
||||
<div class="faq-item">
|
||||
<h4>Q: What if the deck runs out of cards?</h4>
|
||||
<p>A: The discard pile (except the top card) is shuffled to create a new deck.</p>
|
||||
</div>
|
||||
|
||||
<div class="faq-item">
|
||||
<h4>Q: In Endgame mode, when exactly can I flip?</h4>
|
||||
<p>A: The optional flip activates the moment ANY player (including you) has 1 or fewer face-down cards remaining. From that point until the round ends, whenever you discard from the deck, you'll get the option to flip or skip.</p>
|
||||
</div>
|
||||
|
||||
<div class="faq-item">
|
||||
<h4>Q: How does Endgame mode help trailing players?</h4>
|
||||
<p>A: When someone is close to going out, they've likely optimized their hand already. The optional flip lets everyone else accelerate their information gathering - flipping cards to find pairs or identify which cards to swap out. The leader doesn't benefit (they have no hidden cards left), so it's purely a catch-up mechanic.</p>
|
||||
</div>
|
||||
|
||||
<div class="faq-item">
|
||||
<h4>Q: Why would I skip the flip in Endgame mode?</h4>
|
||||
<p>A: If you're already winning or your remaining hidden cards are statistically likely to be good, you might prefer not to risk revealing a disaster. It's a calculated gamble!</p>
|
||||
</div>
|
||||
</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">« 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">×</button>
|
||||
<div id="player-stats-content">
|
||||
<div class="leaderboard-loading">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -288,6 +763,53 @@
|
||||
</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">×</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
314
client/leaderboard.js
Normal 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">🥇</span>';
|
||||
case 2: return '<span class="medal">🥈</span>';
|
||||
case 3: return '<span class="medal">🥉</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
587
client/replay.js
Normal 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 };
|
||||
}
|
||||
164
client/state-differ.js
Normal file
164
client/state-differ.js
Normal file
@@ -0,0 +1,164 @@
|
||||
// StateDiffer - Detects what changed between game states
|
||||
// Generates movement instructions for the animation queue
|
||||
|
||||
class StateDiffer {
|
||||
constructor() {
|
||||
this.previousState = null;
|
||||
}
|
||||
|
||||
// Compare old and new state, return array of movements
|
||||
diff(oldState, newState) {
|
||||
const movements = [];
|
||||
|
||||
if (!oldState || !newState) {
|
||||
return movements;
|
||||
}
|
||||
|
||||
// Check for initial flip phase - still animate initial flips
|
||||
if (oldState.waiting_for_initial_flip && !newState.waiting_for_initial_flip) {
|
||||
// Initial flip just completed - detect which cards were flipped
|
||||
for (const newPlayer of newState.players) {
|
||||
const oldPlayer = oldState.players.find(p => p.id === newPlayer.id);
|
||||
if (oldPlayer) {
|
||||
for (let i = 0; i < 6; i++) {
|
||||
if (!oldPlayer.cards[i].face_up && newPlayer.cards[i].face_up) {
|
||||
movements.push({
|
||||
type: 'flip',
|
||||
playerId: newPlayer.id,
|
||||
position: i,
|
||||
faceUp: true,
|
||||
card: newPlayer.cards[i]
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return movements;
|
||||
}
|
||||
|
||||
// Still in initial flip selection - no animations
|
||||
if (newState.waiting_for_initial_flip) {
|
||||
return movements;
|
||||
}
|
||||
|
||||
// Check for turn change - the previous player just acted
|
||||
const previousPlayerId = oldState.current_player_id;
|
||||
const currentPlayerId = newState.current_player_id;
|
||||
const turnChanged = previousPlayerId !== currentPlayerId;
|
||||
|
||||
// Detect if a swap happened (discard changed AND a hand position changed)
|
||||
const newTop = newState.discard_top;
|
||||
const oldTop = oldState.discard_top;
|
||||
const discardChanged = newTop && (!oldTop ||
|
||||
oldTop.rank !== newTop.rank ||
|
||||
oldTop.suit !== newTop.suit);
|
||||
|
||||
// Find hand changes for the player who just played
|
||||
if (turnChanged && previousPlayerId) {
|
||||
const oldPlayer = oldState.players.find(p => p.id === previousPlayerId);
|
||||
const newPlayer = newState.players.find(p => p.id === previousPlayerId);
|
||||
|
||||
if (oldPlayer && newPlayer) {
|
||||
// First pass: detect swaps (card identity changed)
|
||||
const swappedPositions = new Set();
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const oldCard = oldPlayer.cards[i];
|
||||
const newCard = newPlayer.cards[i];
|
||||
|
||||
// Card identity changed = swap happened at this position
|
||||
if (this.cardIdentityChanged(oldCard, newCard)) {
|
||||
swappedPositions.add(i);
|
||||
|
||||
// Use discard_top for the revealed card (more reliable for opponents)
|
||||
const revealedCard = newState.discard_top || { ...oldCard, face_up: true };
|
||||
|
||||
movements.push({
|
||||
type: 'swap',
|
||||
playerId: previousPlayerId,
|
||||
position: i,
|
||||
oldCard: revealedCard,
|
||||
newCard: newCard
|
||||
});
|
||||
break; // Only one swap per turn
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: detect flips (card went from face_down to face_up, not a swap)
|
||||
for (let i = 0; i < 6; i++) {
|
||||
if (swappedPositions.has(i)) continue; // Skip if already detected as swap
|
||||
|
||||
const oldCard = oldPlayer.cards[i];
|
||||
const newCard = newPlayer.cards[i];
|
||||
|
||||
if (this.cardWasFlipped(oldCard, newCard)) {
|
||||
movements.push({
|
||||
type: 'flip',
|
||||
playerId: previousPlayerId,
|
||||
position: i,
|
||||
faceUp: true,
|
||||
card: newCard
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Detect drawing (current player just drew)
|
||||
if (newState.has_drawn_card && !oldState.has_drawn_card) {
|
||||
// Discard pile decreased = drew from discard
|
||||
const drewFromDiscard = !newState.discard_top ||
|
||||
(oldState.discard_top &&
|
||||
(!newState.discard_top ||
|
||||
oldState.discard_top.rank !== newState.discard_top.rank ||
|
||||
oldState.discard_top.suit !== newState.discard_top.suit));
|
||||
|
||||
movements.push({
|
||||
type: drewFromDiscard ? 'draw-discard' : 'draw-deck',
|
||||
playerId: currentPlayerId
|
||||
});
|
||||
}
|
||||
|
||||
return movements;
|
||||
}
|
||||
|
||||
// Check if the card identity (rank+suit) changed between old and new
|
||||
// Returns true if definitely different cards, false if same or unknown
|
||||
cardIdentityChanged(oldCard, newCard) {
|
||||
// If both have rank/suit data, compare directly
|
||||
if (oldCard.rank && newCard.rank) {
|
||||
return oldCard.rank !== newCard.rank || oldCard.suit !== newCard.suit;
|
||||
}
|
||||
// Can't determine - assume same card (flip, not swap)
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if a card was just flipped (same card, now face up)
|
||||
cardWasFlipped(oldCard, newCard) {
|
||||
return !oldCard.face_up && newCard.face_up;
|
||||
}
|
||||
|
||||
// Get a summary of movements for debugging
|
||||
summarize(movements) {
|
||||
return movements.map(m => {
|
||||
switch (m.type) {
|
||||
case 'flip':
|
||||
return `Flip: Player ${m.playerId} position ${m.position}`;
|
||||
case 'swap':
|
||||
return `Swap: Player ${m.playerId} position ${m.position}`;
|
||||
case 'discard':
|
||||
return `Discard: ${m.card.rank}${m.card.suit} from player ${m.fromPlayerId}`;
|
||||
case 'draw-deck':
|
||||
return `Draw from deck: Player ${m.playerId}`;
|
||||
case 'draw-discard':
|
||||
return `Draw from discard: Player ${m.playerId}`;
|
||||
default:
|
||||
return `Unknown: ${m.type}`;
|
||||
}
|
||||
}).join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
// Export for use in app.js
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = StateDiffer;
|
||||
}
|
||||
2435
client/style.css
2435
client/style.css
File diff suppressed because it is too large
Load Diff
58
docker-compose.dev.yml
Normal file
58
docker-compose.dev.yml
Normal 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
139
docker-compose.prod.yml
Normal 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
|
||||
327
docs/v2/V2_00_MASTER_PLAN.md
Normal file
327
docs/v2/V2_00_MASTER_PLAN.md
Normal 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)
|
||||
867
docs/v2/V2_01_EVENT_SOURCING.md
Normal file
867
docs/v2/V2_01_EVENT_SOURCING.md
Normal 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
|
||||
870
docs/v2/V2_02_PERSISTENCE.md
Normal file
870
docs/v2/V2_02_PERSISTENCE.md
Normal 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
|
||||
1255
docs/v2/V2_03_USER_ACCOUNTS.md
Normal file
1255
docs/v2/V2_03_USER_ACCOUNTS.md
Normal file
File diff suppressed because it is too large
Load Diff
1179
docs/v2/V2_04_ADMIN_TOOLS.md
Normal file
1179
docs/v2/V2_04_ADMIN_TOOLS.md
Normal file
File diff suppressed because it is too large
Load Diff
871
docs/v2/V2_05_STATS_LEADERBOARDS.md
Normal file
871
docs/v2/V2_05_STATS_LEADERBOARDS.md
Normal 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
|
||||
976
docs/v2/V2_06_REPLAY_EXPORT.md
Normal file
976
docs/v2/V2_06_REPLAY_EXPORT.md
Normal 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
999
docs/v2/V2_07_PRODUCTION.md
Normal 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.
|
||||
117
pyproject.toml
Normal file
117
pyproject.toml
Normal file
@@ -0,0 +1,117 @@
|
||||
[project]
|
||||
name = "golfgame"
|
||||
version = "0.1.0"
|
||||
description = "6-Card Golf card game with AI opponents"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
license = {text = "MIT"}
|
||||
authors = [
|
||||
{name = "alee"}
|
||||
]
|
||||
keywords = ["card-game", "golf", "websocket", "fastapi", "ai"]
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Framework :: FastAPI",
|
||||
"Intended Audience :: End Users/Desktop",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Topic :: Games/Entertainment :: Board Games",
|
||||
]
|
||||
|
||||
dependencies = [
|
||||
"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",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest-asyncio>=0.23.0",
|
||||
"pytest-cov>=4.1.0",
|
||||
"ruff>=0.1.0",
|
||||
"mypy>=1.8.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
golfgame = "server.main:run"
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/alee/golfgame"
|
||||
Repository = "https://github.com/alee/golfgame"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["server"]
|
||||
|
||||
# ============================================================================
|
||||
# Tool Configuration
|
||||
# ============================================================================
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["server"]
|
||||
python_files = ["test_*.py"]
|
||||
python_functions = ["test_*"]
|
||||
addopts = "-v --tb=short"
|
||||
asyncio_mode = "auto"
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py311"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = [
|
||||
"E", # pycodestyle errors
|
||||
"W", # pycodestyle warnings
|
||||
"F", # Pyflakes
|
||||
"I", # isort
|
||||
"B", # flake8-bugbear
|
||||
"C4", # flake8-comprehensions
|
||||
"UP", # pyupgrade
|
||||
]
|
||||
ignore = [
|
||||
"E501", # line too long (handled by formatter)
|
||||
"B008", # do not perform function calls in argument defaults
|
||||
]
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
known-first-party = ["server"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.11"
|
||||
warn_return_any = true
|
||||
warn_unused_ignores = true
|
||||
disallow_untyped_defs = true
|
||||
ignore_missing_imports = true
|
||||
|
||||
# ============================================================================
|
||||
# Game Configuration Defaults
|
||||
# ============================================================================
|
||||
# These can be overridden via environment variables
|
||||
# See .env.example for documentation
|
||||
|
||||
[tool.golfgame]
|
||||
# Server settings
|
||||
host = "0.0.0.0"
|
||||
port = 8000
|
||||
debug = false
|
||||
log_level = "INFO"
|
||||
|
||||
# Database
|
||||
database_url = "sqlite:///server/games.db"
|
||||
|
||||
# Game defaults
|
||||
default_rounds = 9
|
||||
max_players_per_room = 6
|
||||
room_timeout_minutes = 60
|
||||
|
||||
# Card values (standard 6-Card Golf)
|
||||
# These are defined in server/constants.py
|
||||
5
pyvenv.cfg
Normal file
5
pyvenv.cfg
Normal file
@@ -0,0 +1,5 @@
|
||||
home = /home/alee/.pyenv/versions/3.12.0/bin
|
||||
include-system-site-packages = false
|
||||
version = 3.12.0
|
||||
executable = /home/alee/.pyenv/versions/3.12.0/bin/python3.12
|
||||
command = /home/alee/.pyenv/versions/3.12.0/bin/python -m venv /home/alee/Sources/golfgame
|
||||
55
server/.env.example
Normal file
55
server/.env.example
Normal 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
|
||||
957
server/RULES.md
957
server/RULES.md
File diff suppressed because it is too large
Load Diff
1054
server/ai.py
1054
server/ai.py
File diff suppressed because it is too large
Load Diff
602
server/auth.py
Normal file
602
server/auth.py
Normal file
@@ -0,0 +1,602 @@
|
||||
"""
|
||||
Authentication and user management for Golf game.
|
||||
|
||||
Features:
|
||||
- User accounts stored in SQLite
|
||||
- Admin accounts can manage other users
|
||||
- Invite codes (room codes) allow new user registration
|
||||
- Session-based authentication via tokens
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
import sqlite3
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from config import config
|
||||
|
||||
|
||||
class UserRole(Enum):
|
||||
"""User roles for access control."""
|
||||
USER = "user"
|
||||
ADMIN = "admin"
|
||||
|
||||
|
||||
@dataclass
|
||||
class User:
|
||||
"""User account."""
|
||||
id: str
|
||||
username: str
|
||||
email: Optional[str]
|
||||
password_hash: str
|
||||
role: UserRole
|
||||
created_at: datetime
|
||||
last_login: Optional[datetime]
|
||||
is_active: bool
|
||||
invited_by: Optional[str] # Username of who invited them
|
||||
|
||||
def is_admin(self) -> bool:
|
||||
return self.role == UserRole.ADMIN
|
||||
|
||||
def to_dict(self, include_sensitive: bool = False) -> dict:
|
||||
"""Convert to dictionary for API responses."""
|
||||
data = {
|
||||
"id": self.id,
|
||||
"username": self.username,
|
||||
"email": self.email,
|
||||
"role": self.role.value,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"last_login": self.last_login.isoformat() if self.last_login else None,
|
||||
"is_active": self.is_active,
|
||||
"invited_by": self.invited_by,
|
||||
}
|
||||
if include_sensitive:
|
||||
data["password_hash"] = self.password_hash
|
||||
return data
|
||||
|
||||
|
||||
@dataclass
|
||||
class Session:
|
||||
"""User session."""
|
||||
token: str
|
||||
user_id: str
|
||||
created_at: datetime
|
||||
expires_at: datetime
|
||||
|
||||
def is_expired(self) -> bool:
|
||||
return datetime.now() > self.expires_at
|
||||
|
||||
|
||||
@dataclass
|
||||
class InviteCode:
|
||||
"""Invite code for user registration."""
|
||||
code: str
|
||||
created_by: str # User ID who created the invite
|
||||
created_at: datetime
|
||||
expires_at: Optional[datetime]
|
||||
max_uses: int
|
||||
use_count: int
|
||||
is_active: bool
|
||||
|
||||
def is_valid(self) -> bool:
|
||||
if not self.is_active:
|
||||
return False
|
||||
if self.expires_at and datetime.now() > self.expires_at:
|
||||
return False
|
||||
if self.max_uses > 0 and self.use_count >= self.max_uses:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class AuthManager:
|
||||
"""Manages user authentication and authorization."""
|
||||
|
||||
def __init__(self, db_path: str = "games.db"):
|
||||
self.db_path = Path(db_path)
|
||||
self._init_db()
|
||||
self._ensure_admin()
|
||||
|
||||
def _init_db(self):
|
||||
"""Initialize auth database schema."""
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.executescript("""
|
||||
-- Users table
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
email TEXT UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT DEFAULT 'user',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_login TIMESTAMP,
|
||||
is_active BOOLEAN DEFAULT 1,
|
||||
invited_by TEXT
|
||||
);
|
||||
|
||||
-- Sessions table
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
user_id TEXT REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMP NOT NULL
|
||||
);
|
||||
|
||||
-- Invite codes table
|
||||
CREATE TABLE IF NOT EXISTS invite_codes (
|
||||
code TEXT PRIMARY KEY,
|
||||
created_by TEXT REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMP,
|
||||
max_uses INTEGER DEFAULT 1,
|
||||
use_count INTEGER DEFAULT 0,
|
||||
is_active BOOLEAN DEFAULT 1
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_invite_codes_active ON invite_codes(is_active);
|
||||
""")
|
||||
|
||||
def _ensure_admin(self):
|
||||
"""Ensure at least one admin account exists (without password - must be set on first login)."""
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
cursor = conn.execute(
|
||||
"SELECT COUNT(*) FROM users WHERE role = ?",
|
||||
(UserRole.ADMIN.value,)
|
||||
)
|
||||
admin_count = cursor.fetchone()[0]
|
||||
|
||||
if admin_count == 0:
|
||||
# Check if admin emails are configured
|
||||
if config.ADMIN_EMAILS:
|
||||
# Create admin accounts for configured emails (no password yet)
|
||||
for email in config.ADMIN_EMAILS:
|
||||
username = email.split("@")[0]
|
||||
self._create_user_without_password(
|
||||
username=username,
|
||||
email=email,
|
||||
role=UserRole.ADMIN,
|
||||
)
|
||||
print(f"Created admin account: {username} - password must be set on first login")
|
||||
else:
|
||||
# Create default admin if no admins exist (no password yet)
|
||||
self._create_user_without_password(
|
||||
username="admin",
|
||||
role=UserRole.ADMIN,
|
||||
)
|
||||
print("Created default admin account - password must be set on first login")
|
||||
print("Set ADMIN_EMAILS in .env to configure admin accounts.")
|
||||
|
||||
def _create_user_without_password(
|
||||
self,
|
||||
username: str,
|
||||
email: Optional[str] = None,
|
||||
role: UserRole = UserRole.USER,
|
||||
) -> Optional[str]:
|
||||
"""Create a user without a password (for first-time setup)."""
|
||||
user_id = secrets.token_hex(16)
|
||||
# Empty password_hash indicates password needs to be set
|
||||
password_hash = ""
|
||||
|
||||
try:
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO users (id, username, email, password_hash, role)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(user_id, username, email, password_hash, role.value),
|
||||
)
|
||||
return user_id
|
||||
except sqlite3.IntegrityError:
|
||||
return None
|
||||
|
||||
def needs_password_setup(self, username: str) -> bool:
|
||||
"""Check if user needs to set up their password (first login)."""
|
||||
user = self.get_user_by_username(username)
|
||||
if not user:
|
||||
return False
|
||||
return user.password_hash == ""
|
||||
|
||||
def setup_password(self, username: str, new_password: str) -> Optional[User]:
|
||||
"""Set password for first-time setup. Only works if password is not yet set."""
|
||||
user = self.get_user_by_username(username)
|
||||
if not user:
|
||||
return None
|
||||
if user.password_hash != "":
|
||||
return None # Password already set
|
||||
|
||||
password_hash = self._hash_password(new_password)
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.execute(
|
||||
"UPDATE users SET password_hash = ?, last_login = ? WHERE id = ?",
|
||||
(password_hash, datetime.now(), user.id)
|
||||
)
|
||||
|
||||
return self.get_user_by_id(user.id)
|
||||
|
||||
@staticmethod
|
||||
def _hash_password(password: str) -> str:
|
||||
"""Hash a password using SHA-256 with salt."""
|
||||
salt = secrets.token_hex(16)
|
||||
hash_input = f"{salt}:{password}".encode()
|
||||
password_hash = hashlib.sha256(hash_input).hexdigest()
|
||||
return f"{salt}:{password_hash}"
|
||||
|
||||
@staticmethod
|
||||
def _verify_password(password: str, stored_hash: str) -> bool:
|
||||
"""Verify a password against its hash."""
|
||||
try:
|
||||
salt, hash_value = stored_hash.split(":")
|
||||
hash_input = f"{salt}:{password}".encode()
|
||||
computed_hash = hashlib.sha256(hash_input).hexdigest()
|
||||
return secrets.compare_digest(computed_hash, hash_value)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
def create_user(
|
||||
self,
|
||||
username: str,
|
||||
password: str,
|
||||
email: Optional[str] = None,
|
||||
role: UserRole = UserRole.USER,
|
||||
invited_by: Optional[str] = None,
|
||||
) -> Optional[User]:
|
||||
"""Create a new user account."""
|
||||
user_id = secrets.token_hex(16)
|
||||
password_hash = self._hash_password(password)
|
||||
|
||||
try:
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO users (id, username, email, password_hash, role, invited_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(user_id, username, email, password_hash, role.value, invited_by),
|
||||
)
|
||||
return self.get_user_by_id(user_id)
|
||||
except sqlite3.IntegrityError:
|
||||
return None # Username or email already exists
|
||||
|
||||
def get_user_by_id(self, user_id: str) -> Optional[User]:
|
||||
"""Get user by ID."""
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.execute(
|
||||
"SELECT * FROM users WHERE id = ?",
|
||||
(user_id,)
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
return self._row_to_user(row)
|
||||
return None
|
||||
|
||||
def get_user_by_username(self, username: str) -> Optional[User]:
|
||||
"""Get user by username."""
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.execute(
|
||||
"SELECT * FROM users WHERE username = ?",
|
||||
(username,)
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
return self._row_to_user(row)
|
||||
return None
|
||||
|
||||
def _row_to_user(self, row: sqlite3.Row) -> User:
|
||||
"""Convert database row to User object."""
|
||||
return User(
|
||||
id=row["id"],
|
||||
username=row["username"],
|
||||
email=row["email"],
|
||||
password_hash=row["password_hash"],
|
||||
role=UserRole(row["role"]),
|
||||
created_at=datetime.fromisoformat(row["created_at"]) if row["created_at"] else None,
|
||||
last_login=datetime.fromisoformat(row["last_login"]) if row["last_login"] else None,
|
||||
is_active=bool(row["is_active"]),
|
||||
invited_by=row["invited_by"],
|
||||
)
|
||||
|
||||
def authenticate(self, username: str, password: str) -> Optional[User]:
|
||||
"""Authenticate user with username and password."""
|
||||
user = self.get_user_by_username(username)
|
||||
if not user:
|
||||
return None
|
||||
if not user.is_active:
|
||||
return None
|
||||
if not self._verify_password(password, user.password_hash):
|
||||
return None
|
||||
|
||||
# Update last login
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.execute(
|
||||
"UPDATE users SET last_login = ? WHERE id = ?",
|
||||
(datetime.now(), user.id)
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
def create_session(self, user: User, duration_hours: int = 24) -> Session:
|
||||
"""Create a new session for a user."""
|
||||
token = secrets.token_urlsafe(32)
|
||||
created_at = datetime.now()
|
||||
expires_at = created_at + timedelta(hours=duration_hours)
|
||||
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO sessions (token, user_id, created_at, expires_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(token, user.id, created_at, expires_at)
|
||||
)
|
||||
|
||||
return Session(
|
||||
token=token,
|
||||
user_id=user.id,
|
||||
created_at=created_at,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
def get_session(self, token: str) -> Optional[Session]:
|
||||
"""Get session by token."""
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.execute(
|
||||
"SELECT * FROM sessions WHERE token = ?",
|
||||
(token,)
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
session = Session(
|
||||
token=row["token"],
|
||||
user_id=row["user_id"],
|
||||
created_at=datetime.fromisoformat(row["created_at"]),
|
||||
expires_at=datetime.fromisoformat(row["expires_at"]),
|
||||
)
|
||||
if not session.is_expired():
|
||||
return session
|
||||
# Clean up expired session
|
||||
self.invalidate_session(token)
|
||||
return None
|
||||
|
||||
def get_user_from_session(self, token: str) -> Optional[User]:
|
||||
"""Get user from session token."""
|
||||
session = self.get_session(token)
|
||||
if session:
|
||||
return self.get_user_by_id(session.user_id)
|
||||
return None
|
||||
|
||||
def invalidate_session(self, token: str):
|
||||
"""Invalidate a session."""
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.execute("DELETE FROM sessions WHERE token = ?", (token,))
|
||||
|
||||
def invalidate_user_sessions(self, user_id: str):
|
||||
"""Invalidate all sessions for a user."""
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.execute("DELETE FROM sessions WHERE user_id = ?", (user_id,))
|
||||
|
||||
# =========================================================================
|
||||
# Invite Codes
|
||||
# =========================================================================
|
||||
|
||||
def create_invite_code(
|
||||
self,
|
||||
created_by: str,
|
||||
max_uses: int = 1,
|
||||
expires_in_days: Optional[int] = 7,
|
||||
) -> InviteCode:
|
||||
"""Create a new invite code."""
|
||||
code = secrets.token_urlsafe(8).upper()[:8] # 8 character code
|
||||
created_at = datetime.now()
|
||||
expires_at = created_at + timedelta(days=expires_in_days) if expires_in_days else None
|
||||
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO invite_codes (code, created_by, created_at, expires_at, max_uses)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(code, created_by, created_at, expires_at, max_uses)
|
||||
)
|
||||
|
||||
return InviteCode(
|
||||
code=code,
|
||||
created_by=created_by,
|
||||
created_at=created_at,
|
||||
expires_at=expires_at,
|
||||
max_uses=max_uses,
|
||||
use_count=0,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
def get_invite_code(self, code: str) -> Optional[InviteCode]:
|
||||
"""Get invite code by code string."""
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.execute(
|
||||
"SELECT * FROM invite_codes WHERE code = ?",
|
||||
(code.upper(),)
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
return InviteCode(
|
||||
code=row["code"],
|
||||
created_by=row["created_by"],
|
||||
created_at=datetime.fromisoformat(row["created_at"]),
|
||||
expires_at=datetime.fromisoformat(row["expires_at"]) if row["expires_at"] else None,
|
||||
max_uses=row["max_uses"],
|
||||
use_count=row["use_count"],
|
||||
is_active=bool(row["is_active"]),
|
||||
)
|
||||
return None
|
||||
|
||||
def use_invite_code(self, code: str) -> bool:
|
||||
"""Mark an invite code as used. Returns False if invalid."""
|
||||
invite = self.get_invite_code(code)
|
||||
if not invite or not invite.is_valid():
|
||||
return False
|
||||
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.execute(
|
||||
"UPDATE invite_codes SET use_count = use_count + 1 WHERE code = ?",
|
||||
(code.upper(),)
|
||||
)
|
||||
return True
|
||||
|
||||
def validate_room_code_as_invite(self, room_code: str) -> bool:
|
||||
"""
|
||||
Check if a room code is valid for registration.
|
||||
Room codes from active games act as invite codes.
|
||||
"""
|
||||
# First check if it's an explicit invite code
|
||||
invite = self.get_invite_code(room_code)
|
||||
if invite and invite.is_valid():
|
||||
return True
|
||||
|
||||
# Check if it's an active room code (from room manager)
|
||||
# This will be checked by the caller since we don't have room_manager here
|
||||
return False
|
||||
|
||||
# =========================================================================
|
||||
# Admin Functions
|
||||
# =========================================================================
|
||||
|
||||
def list_users(self, include_inactive: bool = False) -> list[User]:
|
||||
"""List all users (admin function)."""
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
if include_inactive:
|
||||
cursor = conn.execute("SELECT * FROM users ORDER BY created_at DESC")
|
||||
else:
|
||||
cursor = conn.execute(
|
||||
"SELECT * FROM users WHERE is_active = 1 ORDER BY created_at DESC"
|
||||
)
|
||||
return [self._row_to_user(row) for row in cursor.fetchall()]
|
||||
|
||||
def update_user(
|
||||
self,
|
||||
user_id: str,
|
||||
username: Optional[str] = None,
|
||||
email: Optional[str] = None,
|
||||
role: Optional[UserRole] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
) -> Optional[User]:
|
||||
"""Update user details (admin function)."""
|
||||
updates = []
|
||||
params = []
|
||||
|
||||
if username is not None:
|
||||
updates.append("username = ?")
|
||||
params.append(username)
|
||||
if email is not None:
|
||||
updates.append("email = ?")
|
||||
params.append(email)
|
||||
if role is not None:
|
||||
updates.append("role = ?")
|
||||
params.append(role.value)
|
||||
if is_active is not None:
|
||||
updates.append("is_active = ?")
|
||||
params.append(is_active)
|
||||
|
||||
if not updates:
|
||||
return self.get_user_by_id(user_id)
|
||||
|
||||
params.append(user_id)
|
||||
|
||||
try:
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.execute(
|
||||
f"UPDATE users SET {', '.join(updates)} WHERE id = ?",
|
||||
params
|
||||
)
|
||||
return self.get_user_by_id(user_id)
|
||||
except sqlite3.IntegrityError:
|
||||
return None
|
||||
|
||||
def change_password(self, user_id: str, new_password: str) -> bool:
|
||||
"""Change user password."""
|
||||
password_hash = self._hash_password(new_password)
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
cursor = conn.execute(
|
||||
"UPDATE users SET password_hash = ? WHERE id = ?",
|
||||
(password_hash, user_id)
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def delete_user(self, user_id: str) -> bool:
|
||||
"""Delete a user (admin function). Actually just deactivates."""
|
||||
# Invalidate all sessions first
|
||||
self.invalidate_user_sessions(user_id)
|
||||
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
cursor = conn.execute(
|
||||
"UPDATE users SET is_active = 0 WHERE id = ?",
|
||||
(user_id,)
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def list_invite_codes(self, created_by: Optional[str] = None) -> list[InviteCode]:
|
||||
"""List invite codes (admin function)."""
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
if created_by:
|
||||
cursor = conn.execute(
|
||||
"SELECT * FROM invite_codes WHERE created_by = ? ORDER BY created_at DESC",
|
||||
(created_by,)
|
||||
)
|
||||
else:
|
||||
cursor = conn.execute(
|
||||
"SELECT * FROM invite_codes ORDER BY created_at DESC"
|
||||
)
|
||||
return [
|
||||
InviteCode(
|
||||
code=row["code"],
|
||||
created_by=row["created_by"],
|
||||
created_at=datetime.fromisoformat(row["created_at"]),
|
||||
expires_at=datetime.fromisoformat(row["expires_at"]) if row["expires_at"] else None,
|
||||
max_uses=row["max_uses"],
|
||||
use_count=row["use_count"],
|
||||
is_active=bool(row["is_active"]),
|
||||
)
|
||||
for row in cursor.fetchall()
|
||||
]
|
||||
|
||||
def deactivate_invite_code(self, code: str) -> bool:
|
||||
"""Deactivate an invite code (admin function)."""
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
cursor = conn.execute(
|
||||
"UPDATE invite_codes SET is_active = 0 WHERE code = ?",
|
||||
(code.upper(),)
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def cleanup_expired_sessions(self):
|
||||
"""Remove expired sessions from database."""
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM sessions WHERE expires_at < ?",
|
||||
(datetime.now(),)
|
||||
)
|
||||
|
||||
|
||||
# Global auth manager instance (lazy initialization)
|
||||
_auth_manager: Optional[AuthManager] = None
|
||||
|
||||
|
||||
def get_auth_manager() -> AuthManager:
|
||||
"""Get or create the global auth manager instance."""
|
||||
global _auth_manager
|
||||
if _auth_manager is None:
|
||||
_auth_manager = AuthManager()
|
||||
return _auth_manager
|
||||
217
server/config.py
Normal file
217
server/config.py
Normal file
@@ -0,0 +1,217 @@
|
||||
"""
|
||||
Centralized configuration for Golf game server.
|
||||
|
||||
Configuration is loaded from (in order of precedence):
|
||||
1. Environment variables
|
||||
2. .env file (if exists)
|
||||
3. Default values
|
||||
|
||||
Usage:
|
||||
from config import config
|
||||
print(config.PORT)
|
||||
print(config.card_values)
|
||||
"""
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
# Load .env file if it exists
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
# 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:
|
||||
pass # python-dotenv not installed, use env vars only
|
||||
|
||||
|
||||
def get_env(key: str, default: str = "") -> str:
|
||||
"""Get environment variable with default."""
|
||||
return os.environ.get(key, default)
|
||||
|
||||
|
||||
def get_env_bool(key: str, default: bool = False) -> bool:
|
||||
"""Get boolean environment variable."""
|
||||
val = os.environ.get(key, "").lower()
|
||||
if val in ("true", "1", "yes", "on"):
|
||||
return True
|
||||
if val in ("false", "0", "no", "off"):
|
||||
return False
|
||||
return default
|
||||
|
||||
|
||||
def get_env_int(key: str, default: int = 0) -> int:
|
||||
"""Get integer environment variable."""
|
||||
try:
|
||||
return int(os.environ.get(key, str(default)))
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
@dataclass
|
||||
class CardValues:
|
||||
"""Card point values - the single source of truth."""
|
||||
ACE: int = 1
|
||||
TWO: int = -2
|
||||
THREE: int = 3
|
||||
FOUR: int = 4
|
||||
FIVE: int = 5
|
||||
SIX: int = 6
|
||||
SEVEN: int = 7
|
||||
EIGHT: int = 8
|
||||
NINE: int = 9
|
||||
TEN: int = 10
|
||||
JACK: int = 10
|
||||
QUEEN: int = 10
|
||||
KING: int = 0
|
||||
JOKER: int = -2
|
||||
|
||||
# House rule modifiers
|
||||
SUPER_KINGS: int = -2 # King value when super_kings enabled
|
||||
TEN_PENNY: int = 1 # 10 value when ten_penny enabled
|
||||
LUCKY_SWING_JOKER: int = -5 # Joker value when lucky_swing enabled
|
||||
|
||||
def to_dict(self) -> dict[str, int]:
|
||||
"""Get card values as dictionary for game use."""
|
||||
return {
|
||||
'A': self.ACE,
|
||||
'2': self.TWO,
|
||||
'3': self.THREE,
|
||||
'4': self.FOUR,
|
||||
'5': self.FIVE,
|
||||
'6': self.SIX,
|
||||
'7': self.SEVEN,
|
||||
'8': self.EIGHT,
|
||||
'9': self.NINE,
|
||||
'10': self.TEN,
|
||||
'J': self.JACK,
|
||||
'Q': self.QUEEN,
|
||||
'K': self.KING,
|
||||
'★': self.JOKER,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class GameDefaults:
|
||||
"""Default game settings."""
|
||||
rounds: int = 9
|
||||
initial_flips: int = 2
|
||||
use_jokers: bool = False
|
||||
flip_mode: str = "never" # "never", "always", or "endgame"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ServerConfig:
|
||||
"""Server configuration."""
|
||||
HOST: str = "0.0.0.0"
|
||||
PORT: int = 8000
|
||||
DEBUG: bool = False
|
||||
LOG_LEVEL: str = "INFO"
|
||||
|
||||
# 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
|
||||
ROOM_CODE_LENGTH: int = 4
|
||||
|
||||
# Security (for future auth system)
|
||||
SECRET_KEY: str = ""
|
||||
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)
|
||||
|
||||
# Game defaults
|
||||
game_defaults: GameDefaults = field(default_factory=GameDefaults)
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "ServerConfig":
|
||||
"""Load configuration from environment variables."""
|
||||
admin_emails_str = get_env("ADMIN_EMAILS", "")
|
||||
admin_emails = [e.strip() for e in admin_emails_str.split(",") if e.strip()]
|
||||
|
||||
return cls(
|
||||
HOST=get_env("HOST", "0.0.0.0"),
|
||||
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),
|
||||
KING=get_env_int("CARD_KING", 0),
|
||||
JOKER=get_env_int("CARD_JOKER", -2),
|
||||
SUPER_KINGS=get_env_int("CARD_SUPER_KINGS", -2),
|
||||
TEN_PENNY=get_env_int("CARD_TEN_PENNY", 1),
|
||||
LUCKY_SWING_JOKER=get_env_int("CARD_LUCKY_SWING_JOKER", -5),
|
||||
),
|
||||
game_defaults=GameDefaults(
|
||||
rounds=get_env_int("DEFAULT_ROUNDS", 9),
|
||||
initial_flips=get_env_int("DEFAULT_INITIAL_FLIPS", 2),
|
||||
use_jokers=get_env_bool("DEFAULT_USE_JOKERS", False),
|
||||
flip_mode=get_env("DEFAULT_FLIP_MODE", "never"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# Global config instance - loaded once at module import
|
||||
config = ServerConfig.from_env()
|
||||
|
||||
|
||||
def reload_config() -> ServerConfig:
|
||||
"""Reload configuration from environment (useful for testing)."""
|
||||
global config
|
||||
config = ServerConfig.from_env()
|
||||
return config
|
||||
@@ -1,6 +1,44 @@
|
||||
# Card values - Single source of truth for all card scoring
|
||||
# Per RULES.md: A=1, 2=-2, 3-10=face, J/Q=10, K=0, Joker=-2
|
||||
DEFAULT_CARD_VALUES = {
|
||||
"""
|
||||
Card value constants for 6-Card Golf.
|
||||
|
||||
This module is the single source of truth for all card point values.
|
||||
House rule modifications are defined here and applied in game.py.
|
||||
|
||||
Configuration can be customized via environment variables.
|
||||
See config.py and .env.example for details.
|
||||
|
||||
Standard Golf Scoring:
|
||||
- Ace: 1 point
|
||||
- Two: -2 points (special - only negative non-joker)
|
||||
- 3-9: Face value
|
||||
- 10, Jack, Queen: 10 points
|
||||
- King: 0 points
|
||||
- Joker: -2 points (when enabled)
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
# Try to load from config (which reads env vars), fall back to hardcoded defaults
|
||||
try:
|
||||
from config import config
|
||||
_use_config = True
|
||||
except ImportError:
|
||||
_use_config = False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Card Values - Single Source of Truth
|
||||
# =============================================================================
|
||||
|
||||
if _use_config:
|
||||
# Load from environment-aware config
|
||||
DEFAULT_CARD_VALUES: dict[str, int] = config.card_values.to_dict()
|
||||
SUPER_KINGS_VALUE: int = config.card_values.SUPER_KINGS
|
||||
TEN_PENNY_VALUE: int = config.card_values.TEN_PENNY
|
||||
LUCKY_SWING_JOKER_VALUE: int = config.card_values.LUCKY_SWING_JOKER
|
||||
else:
|
||||
# Hardcoded defaults (fallback)
|
||||
DEFAULT_CARD_VALUES: dict[str, int] = {
|
||||
'A': 1,
|
||||
'2': -2,
|
||||
'3': 3,
|
||||
@@ -14,17 +52,51 @@ DEFAULT_CARD_VALUES = {
|
||||
'J': 10,
|
||||
'Q': 10,
|
||||
'K': 0,
|
||||
'★': -2, # Joker (standard)
|
||||
}
|
||||
|
||||
# House rule modifications (per RULES.md House Rules section)
|
||||
SUPER_KINGS_VALUE = -2 # K worth -2 instead of 0
|
||||
LUCKY_SEVENS_VALUE = 0 # 7 worth 0 instead of 7
|
||||
TEN_PENNY_VALUE = 1 # 10 worth 1 instead of 10
|
||||
LUCKY_SWING_JOKER_VALUE = -5 # Joker worth -5 in Lucky Swing mode
|
||||
'★': -2, # Joker (standard mode)
|
||||
}
|
||||
SUPER_KINGS_VALUE: int = -2 # Kings worth -2 instead of 0
|
||||
TEN_PENNY_VALUE: int = 1 # 10s worth 1 instead of 10
|
||||
LUCKY_SWING_JOKER_VALUE: int = -5 # Single joker worth -5
|
||||
|
||||
|
||||
def get_card_value_for_rank(rank_str: str, options: dict | None = None) -> int:
|
||||
# =============================================================================
|
||||
# Bonus/Penalty Constants
|
||||
# =============================================================================
|
||||
|
||||
WOLFPACK_BONUS: int = -20 # All 4 Jacks (2 pairs) bonus (was -5, buffed)
|
||||
FOUR_OF_A_KIND_BONUS: int = -20 # Four equal cards in two columns bonus
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Game Constants
|
||||
# =============================================================================
|
||||
|
||||
if _use_config:
|
||||
MAX_PLAYERS = config.MAX_PLAYERS_PER_ROOM
|
||||
ROOM_CODE_LENGTH = config.ROOM_CODE_LENGTH
|
||||
ROOM_TIMEOUT_MINUTES = config.ROOM_TIMEOUT_MINUTES
|
||||
DEFAULT_ROUNDS = config.game_defaults.rounds
|
||||
DEFAULT_INITIAL_FLIPS = config.game_defaults.initial_flips
|
||||
DEFAULT_USE_JOKERS = config.game_defaults.use_jokers
|
||||
DEFAULT_FLIP_MODE = config.game_defaults.flip_mode
|
||||
else:
|
||||
MAX_PLAYERS = 6
|
||||
ROOM_CODE_LENGTH = 4
|
||||
ROOM_TIMEOUT_MINUTES = 60
|
||||
DEFAULT_ROUNDS = 9
|
||||
DEFAULT_INITIAL_FLIPS = 2
|
||||
DEFAULT_USE_JOKERS = False
|
||||
DEFAULT_FLIP_MODE = "never"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Helper Functions
|
||||
# =============================================================================
|
||||
|
||||
def get_card_value_for_rank(
|
||||
rank_str: str,
|
||||
options: Optional[dict] = None,
|
||||
) -> int:
|
||||
"""
|
||||
Get point value for a card rank string, with house rules applied.
|
||||
|
||||
@@ -45,8 +117,6 @@ def get_card_value_for_rank(rank_str: str, options: dict | None = None) -> int:
|
||||
value = LUCKY_SWING_JOKER_VALUE
|
||||
elif rank_str == 'K' and options.get('super_kings'):
|
||||
value = SUPER_KINGS_VALUE
|
||||
elif rank_str == '7' and options.get('lucky_sevens'):
|
||||
value = LUCKY_SEVENS_VALUE
|
||||
elif rank_str == '10' and options.get('ten_penny'):
|
||||
value = TEN_PENNY_VALUE
|
||||
|
||||
|
||||
1136
server/game.py
1136
server/game.py
File diff suppressed because it is too large
Load Diff
@@ -70,20 +70,17 @@ class GameLogger:
|
||||
"""Log start of a new game. Returns game_id."""
|
||||
game_id = str(uuid.uuid4())
|
||||
options_dict = {
|
||||
"flip_on_discard": options.flip_on_discard,
|
||||
"flip_mode": options.flip_mode,
|
||||
"initial_flips": options.initial_flips,
|
||||
"knock_penalty": options.knock_penalty,
|
||||
"use_jokers": options.use_jokers,
|
||||
"lucky_swing": options.lucky_swing,
|
||||
"super_kings": options.super_kings,
|
||||
"lucky_sevens": options.lucky_sevens,
|
||||
"ten_penny": options.ten_penny,
|
||||
"knock_bonus": options.knock_bonus,
|
||||
"underdog_bonus": options.underdog_bonus,
|
||||
"tied_shame": options.tied_shame,
|
||||
"blackjack": options.blackjack,
|
||||
"queens_wild": options.queens_wild,
|
||||
"four_of_a_kind": options.four_of_a_kind,
|
||||
"eagle_eye": options.eagle_eye,
|
||||
}
|
||||
|
||||
|
||||
BIN
server/games.db
Normal file
BIN
server/games.db
Normal file
Binary file not shown.
251
server/logging_config.py
Normal file
251
server/logging_config.py
Normal 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))
|
||||
799
server/main.py
799
server/main.py
@@ -1,32 +1,479 @@
|
||||
"""FastAPI WebSocket server for Golf card game."""
|
||||
|
||||
import uuid
|
||||
import asyncio
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
import logging
|
||||
import os
|
||||
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 ai import GolfAI, process_cpu_turn, get_all_profiles, reset_all_profiles, cleanup_room_profiles
|
||||
from game_log import get_logger
|
||||
|
||||
app = FastAPI(title="Golf Card Game")
|
||||
# Import production components
|
||||
from logging_config import setup_logging
|
||||
|
||||
# Initialize Sentry if configured
|
||||
if config.SENTRY_DSN:
|
||||
try:
|
||||
import sentry_sdk
|
||||
from sentry_sdk.integrations.fastapi import FastApiIntegration
|
||||
from sentry_sdk.integrations.starlette import StarletteIntegration
|
||||
|
||||
sentry_sdk.init(
|
||||
dsn=config.SENTRY_DSN,
|
||||
environment=config.ENVIRONMENT,
|
||||
traces_sample_rate=0.1 if config.ENVIRONMENT == "production" else 1.0,
|
||||
integrations=[
|
||||
StarletteIntegration(transaction_style="endpoint"),
|
||||
FastApiIntegration(transaction_style="endpoint"),
|
||||
],
|
||||
)
|
||||
logging.getLogger(__name__).info("Sentry error tracking initialized")
|
||||
except ImportError:
|
||||
logging.getLogger(__name__).warning("sentry-sdk not installed, error tracking disabled")
|
||||
|
||||
# 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
|
||||
|
||||
# Note: Uvicorn handles SIGINT/SIGTERM and triggers lifespan cleanup automatically
|
||||
|
||||
# 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()
|
||||
|
||||
# Clean up all rooms and release CPU profiles
|
||||
for room in list(room_manager.rooms.values()):
|
||||
for cpu in list(room.get_cpu_players()):
|
||||
room.remove_player(cpu.id)
|
||||
room_manager.rooms.clear()
|
||||
reset_all_profiles()
|
||||
logger.info("All rooms and CPU profiles cleaned up")
|
||||
|
||||
# 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 _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,
|
||||
)
|
||||
|
||||
# Rate limiting middleware (uses global _rate_limiter set in lifespan)
|
||||
# We create a wrapper that safely handles the case when rate limiter isn't ready
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
|
||||
class LazyRateLimitMiddleware(BaseHTTPMiddleware):
|
||||
"""Rate limiting middleware that uses the global _rate_limiter when available."""
|
||||
|
||||
async def dispatch(self, request, call_next):
|
||||
global _rate_limiter
|
||||
|
||||
# Skip if rate limiter not initialized or disabled
|
||||
if not _rate_limiter or not config.RATE_LIMIT_ENABLED:
|
||||
return await call_next(request)
|
||||
|
||||
# Import here to avoid circular imports
|
||||
from services.ratelimit import RATE_LIMITS
|
||||
|
||||
path = request.url.path
|
||||
|
||||
# Skip health checks and static files
|
||||
if path in ("/health", "/ready", "/metrics"):
|
||||
return await call_next(request)
|
||||
if path.endswith((".js", ".css", ".html", ".ico", ".png", ".jpg", ".svg")):
|
||||
return await call_next(request)
|
||||
|
||||
# Determine rate limit tier
|
||||
if path.startswith("/api/auth"):
|
||||
limit, window = RATE_LIMITS["api_auth"]
|
||||
elif path == "/api/rooms" and request.method == "POST":
|
||||
limit, window = RATE_LIMITS["api_create_room"]
|
||||
elif "email" in path or "verify" in path:
|
||||
limit, window = RATE_LIMITS["email_send"]
|
||||
elif path.startswith("/api"):
|
||||
limit, window = RATE_LIMITS["api_general"]
|
||||
else:
|
||||
return await call_next(request)
|
||||
|
||||
# Get client key and check rate limit
|
||||
client_key = _rate_limiter.get_client_key(request)
|
||||
full_key = f"{path}:{client_key}"
|
||||
|
||||
allowed, info = await _rate_limiter.is_allowed(full_key, limit, window)
|
||||
|
||||
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
|
||||
|
||||
|
||||
app.add_middleware(LazyRateLimitMiddleware)
|
||||
|
||||
room_manager = RoomManager()
|
||||
|
||||
# Initialize game logger database at startup
|
||||
_game_logger = get_logger()
|
||||
logger.info(f"Game analytics database initialized at: {_game_logger.db_path}")
|
||||
|
||||
@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 Dependencies (for use in other routes)
|
||||
# =============================================================================
|
||||
|
||||
from models.user import User
|
||||
|
||||
|
||||
async def get_current_user(authorization: Optional[str] = Header(None)) -> Optional[User]:
|
||||
"""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_user(user: Optional[User] = Depends(get_current_user)) -> 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(user: User = Depends(require_user)) -> User:
|
||||
"""Require admin user."""
|
||||
if not user.is_admin():
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
return user
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Debug Endpoints (CPU Profile Management)
|
||||
# =============================================================================
|
||||
|
||||
@app.get("/api/debug/cpu-profiles")
|
||||
async def get_cpu_profile_status():
|
||||
"""Get current CPU profile allocation status."""
|
||||
from ai import _room_used_profiles, _cpu_profiles, CPU_PROFILES
|
||||
return {
|
||||
"total_profiles": len(CPU_PROFILES),
|
||||
"room_profiles": {
|
||||
room_code: list(profiles)
|
||||
for room_code, profiles in _room_used_profiles.items()
|
||||
},
|
||||
"cpu_mappings": {
|
||||
cpu_id: {"room": room_code, "profile": profile.name}
|
||||
for cpu_id, (room_code, profile) in _cpu_profiles.items()
|
||||
},
|
||||
"active_rooms": len(room_manager.rooms),
|
||||
"rooms": {
|
||||
code: {
|
||||
"players": len(room.players),
|
||||
"cpu_players": [p.name for p in room.get_cpu_players()],
|
||||
}
|
||||
for code, room in room_manager.rooms.items()
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/debug/reset-cpu-profiles")
|
||||
async def reset_cpu_profiles():
|
||||
"""Reset all CPU profiles (emergency cleanup)."""
|
||||
reset_all_profiles()
|
||||
return {"status": "ok", "message": "All CPU profiles reset"}
|
||||
|
||||
|
||||
MAX_CONCURRENT_GAMES = 4
|
||||
|
||||
|
||||
def count_user_games(user_id: str) -> int:
|
||||
"""Count how many games this authenticated user is currently in."""
|
||||
count = 0
|
||||
for room in room_manager.rooms.values():
|
||||
for player in room.players.values():
|
||||
if player.auth_user_id == user_id:
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
@app.websocket("/ws")
|
||||
async def websocket_endpoint(websocket: WebSocket):
|
||||
await websocket.accept()
|
||||
|
||||
player_id = str(uuid.uuid4())
|
||||
# Extract token from query param for optional authentication
|
||||
token = websocket.query_params.get("token")
|
||||
authenticated_user = None
|
||||
if token and _auth_service:
|
||||
try:
|
||||
authenticated_user = await _auth_service.get_user_from_token(token)
|
||||
except Exception as e:
|
||||
logger.debug(f"WebSocket auth failed: {e}")
|
||||
|
||||
# Each connection gets a unique ID (allows multi-tab play)
|
||||
connection_id = str(uuid.uuid4())
|
||||
player_id = connection_id
|
||||
|
||||
# Track auth user separately for stats/limits (can be None)
|
||||
auth_user_id = str(authenticated_user.id) if authenticated_user else None
|
||||
|
||||
if authenticated_user:
|
||||
logger.debug(f"WebSocket authenticated as user {auth_user_id}, connection {connection_id}")
|
||||
else:
|
||||
logger.debug(f"WebSocket connected anonymously as {connection_id}")
|
||||
|
||||
current_room: Room | None = None
|
||||
|
||||
try:
|
||||
@@ -35,15 +482,27 @@ async def websocket_endpoint(websocket: WebSocket):
|
||||
msg_type = data.get("type")
|
||||
|
||||
if msg_type == "create_room":
|
||||
# Check concurrent game limit for authenticated users
|
||||
if auth_user_id and count_user_games(auth_user_id) >= MAX_CONCURRENT_GAMES:
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
"message": f"Maximum {MAX_CONCURRENT_GAMES} concurrent games allowed",
|
||||
})
|
||||
continue
|
||||
|
||||
player_name = data.get("player_name", "Player")
|
||||
# Use authenticated user's name if available
|
||||
if authenticated_user and authenticated_user.display_name:
|
||||
player_name = authenticated_user.display_name
|
||||
room = room_manager.create_room()
|
||||
room.add_player(player_id, player_name, websocket)
|
||||
room.add_player(player_id, player_name, websocket, auth_user_id)
|
||||
current_room = room
|
||||
|
||||
await websocket.send_json({
|
||||
"type": "room_created",
|
||||
"room_code": room.code,
|
||||
"player_id": player_id,
|
||||
"authenticated": authenticated_user is not None,
|
||||
})
|
||||
|
||||
await room.broadcast({
|
||||
@@ -55,6 +514,14 @@ async def websocket_endpoint(websocket: WebSocket):
|
||||
room_code = data.get("room_code", "").upper()
|
||||
player_name = data.get("player_name", "Player")
|
||||
|
||||
# Check concurrent game limit for authenticated users
|
||||
if auth_user_id and count_user_games(auth_user_id) >= MAX_CONCURRENT_GAMES:
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
"message": f"Maximum {MAX_CONCURRENT_GAMES} concurrent games allowed",
|
||||
})
|
||||
continue
|
||||
|
||||
room = room_manager.get_room(room_code)
|
||||
if not room:
|
||||
await websocket.send_json({
|
||||
@@ -77,13 +544,17 @@ async def websocket_endpoint(websocket: WebSocket):
|
||||
})
|
||||
continue
|
||||
|
||||
room.add_player(player_id, player_name, websocket)
|
||||
# Use authenticated user's name if available
|
||||
if authenticated_user and authenticated_user.display_name:
|
||||
player_name = authenticated_user.display_name
|
||||
room.add_player(player_id, player_name, websocket, auth_user_id)
|
||||
current_room = room
|
||||
|
||||
await websocket.send_json({
|
||||
"type": "room_joined",
|
||||
"room_code": room.code,
|
||||
"player_id": player_id,
|
||||
"authenticated": authenticated_user is not None,
|
||||
})
|
||||
|
||||
await room.broadcast({
|
||||
@@ -177,7 +648,7 @@ async def websocket_endpoint(websocket: WebSocket):
|
||||
# Build game options
|
||||
options = GameOptions(
|
||||
# Standard options
|
||||
flip_on_discard=data.get("flip_on_discard", False),
|
||||
flip_mode=data.get("flip_mode", "never"),
|
||||
initial_flips=max(0, min(2, data.get("initial_flips", 2))),
|
||||
knock_penalty=data.get("knock_penalty", False),
|
||||
use_jokers=data.get("use_jokers", False),
|
||||
@@ -192,17 +663,24 @@ async def websocket_endpoint(websocket: WebSocket):
|
||||
blackjack=data.get("blackjack", False),
|
||||
eagle_eye=data.get("eagle_eye", False),
|
||||
wolfpack=data.get("wolfpack", False),
|
||||
# House Rules - New Variants
|
||||
flip_as_action=data.get("flip_as_action", False),
|
||||
four_of_a_kind=data.get("four_of_a_kind", False),
|
||||
negative_pairs_keep_value=data.get("negative_pairs_keep_value", False),
|
||||
one_eyed_jacks=data.get("one_eyed_jacks", False),
|
||||
knock_early=data.get("knock_early", False),
|
||||
)
|
||||
|
||||
# Validate settings
|
||||
num_decks = max(1, min(3, num_decks))
|
||||
num_rounds = max(1, min(18, num_rounds))
|
||||
|
||||
async with current_room.game_lock:
|
||||
current_room.game.start_game(num_decks, num_rounds, options)
|
||||
|
||||
# Log game start for AI analysis
|
||||
logger = get_logger()
|
||||
current_room.game_log_id = logger.log_game_start(
|
||||
game_logger = get_logger()
|
||||
current_room.game_log_id = game_logger.log_game_start(
|
||||
room_code=current_room.code,
|
||||
num_players=len(current_room.players),
|
||||
options=options,
|
||||
@@ -231,6 +709,7 @@ async def websocket_endpoint(websocket: WebSocket):
|
||||
continue
|
||||
|
||||
positions = data.get("positions", [])
|
||||
async with current_room.game_lock:
|
||||
if current_room.game.flip_initial_cards(player_id, positions):
|
||||
await broadcast_game_state(current_room)
|
||||
|
||||
@@ -242,13 +721,32 @@ async def websocket_endpoint(websocket: WebSocket):
|
||||
continue
|
||||
|
||||
source = data.get("source", "deck")
|
||||
async with current_room.game_lock:
|
||||
# Capture discard top before draw (for logging decision context)
|
||||
discard_before_draw = current_room.game.discard_top()
|
||||
card = current_room.game.draw_card(player_id, source)
|
||||
|
||||
if card:
|
||||
# Log draw decision for human player
|
||||
if current_room.game_log_id:
|
||||
game_logger = get_logger()
|
||||
player = current_room.game.get_player(player_id)
|
||||
if player:
|
||||
reason = f"took {discard_before_draw.rank.value} from discard" if source == "discard" else "drew from deck"
|
||||
game_logger.log_move(
|
||||
game_id=current_room.game_log_id,
|
||||
player=player,
|
||||
is_cpu=False,
|
||||
action="take_discard" if source == "discard" else "draw_deck",
|
||||
card=card,
|
||||
game=current_room.game,
|
||||
decision_reason=reason,
|
||||
)
|
||||
|
||||
# Send drawn card only to the player who drew
|
||||
await websocket.send_json({
|
||||
"type": "card_drawn",
|
||||
"card": card.to_dict(reveal=True),
|
||||
"card": card.to_dict(),
|
||||
"source": source,
|
||||
})
|
||||
|
||||
@@ -259,40 +757,188 @@ async def websocket_endpoint(websocket: WebSocket):
|
||||
continue
|
||||
|
||||
position = data.get("position", 0)
|
||||
async with current_room.game_lock:
|
||||
# Capture drawn card before swap for logging
|
||||
drawn_card = current_room.game.drawn_card
|
||||
player = current_room.game.get_player(player_id)
|
||||
old_card = player.cards[position] if player and 0 <= position < len(player.cards) else None
|
||||
|
||||
discarded = current_room.game.swap_card(player_id, position)
|
||||
|
||||
if discarded:
|
||||
# Log swap decision for human player
|
||||
if current_room.game_log_id and drawn_card and player:
|
||||
game_logger = get_logger()
|
||||
old_rank = old_card.rank.value if old_card else "?"
|
||||
game_logger.log_move(
|
||||
game_id=current_room.game_log_id,
|
||||
player=player,
|
||||
is_cpu=False,
|
||||
action="swap",
|
||||
card=drawn_card,
|
||||
position=position,
|
||||
game=current_room.game,
|
||||
decision_reason=f"swapped {drawn_card.rank.value} into position {position}, replaced {old_rank}",
|
||||
)
|
||||
|
||||
await broadcast_game_state(current_room)
|
||||
# Let client swap animation complete (~550ms), then pause to show result
|
||||
# Total 1.0s = 550ms animation + 450ms visible pause
|
||||
await asyncio.sleep(1.0)
|
||||
await check_and_run_cpu_turn(current_room)
|
||||
|
||||
elif msg_type == "discard":
|
||||
if not current_room:
|
||||
continue
|
||||
|
||||
async with current_room.game_lock:
|
||||
# Capture drawn card before discard for logging
|
||||
drawn_card = current_room.game.drawn_card
|
||||
player = current_room.game.get_player(player_id)
|
||||
|
||||
if current_room.game.discard_drawn(player_id):
|
||||
# Log discard decision for human player
|
||||
if current_room.game_log_id and drawn_card and player:
|
||||
game_logger = get_logger()
|
||||
game_logger.log_move(
|
||||
game_id=current_room.game_log_id,
|
||||
player=player,
|
||||
is_cpu=False,
|
||||
action="discard",
|
||||
card=drawn_card,
|
||||
game=current_room.game,
|
||||
decision_reason=f"discarded {drawn_card.rank.value}",
|
||||
)
|
||||
|
||||
await broadcast_game_state(current_room)
|
||||
|
||||
if current_room.game.flip_on_discard:
|
||||
# Version 1: Check if player has face-down cards to flip
|
||||
# Check if player has face-down cards to flip
|
||||
player = current_room.game.get_player(player_id)
|
||||
has_face_down = player and any(not c.face_up for c in player.cards)
|
||||
|
||||
if has_face_down:
|
||||
await websocket.send_json({
|
||||
"type": "can_flip",
|
||||
"optional": current_room.game.flip_is_optional,
|
||||
})
|
||||
else:
|
||||
# Let client animation complete before CPU turn
|
||||
await asyncio.sleep(0.5)
|
||||
await check_and_run_cpu_turn(current_room)
|
||||
else:
|
||||
# Version 2 (default): Turn ended, check for CPU
|
||||
# Turn ended - let client animation complete before CPU turn
|
||||
# (player discard swoop animation is ~500ms: 350ms swoop + 150ms settle)
|
||||
logger.debug(f"Player discarded, waiting 0.5s before CPU turn")
|
||||
await asyncio.sleep(0.5)
|
||||
logger.debug(f"Post-discard delay complete, checking for CPU turn")
|
||||
await check_and_run_cpu_turn(current_room)
|
||||
|
||||
elif msg_type == "cancel_draw":
|
||||
if not current_room:
|
||||
continue
|
||||
|
||||
async with current_room.game_lock:
|
||||
if current_room.game.cancel_discard_draw(player_id):
|
||||
await broadcast_game_state(current_room)
|
||||
|
||||
elif msg_type == "flip_card":
|
||||
if not current_room:
|
||||
continue
|
||||
|
||||
position = data.get("position", 0)
|
||||
async with current_room.game_lock:
|
||||
player = current_room.game.get_player(player_id)
|
||||
current_room.game.flip_and_end_turn(player_id, position)
|
||||
|
||||
# Log flip decision for human player
|
||||
if current_room.game_log_id and player and 0 <= position < len(player.cards):
|
||||
game_logger = get_logger()
|
||||
flipped_card = player.cards[position]
|
||||
game_logger.log_move(
|
||||
game_id=current_room.game_log_id,
|
||||
player=player,
|
||||
is_cpu=False,
|
||||
action="flip",
|
||||
card=flipped_card,
|
||||
position=position,
|
||||
game=current_room.game,
|
||||
decision_reason=f"flipped card at position {position}",
|
||||
)
|
||||
|
||||
await broadcast_game_state(current_room)
|
||||
await check_and_run_cpu_turn(current_room)
|
||||
|
||||
elif msg_type == "skip_flip":
|
||||
if not current_room:
|
||||
continue
|
||||
|
||||
async with current_room.game_lock:
|
||||
player = current_room.game.get_player(player_id)
|
||||
if current_room.game.skip_flip_and_end_turn(player_id):
|
||||
# Log skip flip decision for human player
|
||||
if current_room.game_log_id and player:
|
||||
game_logger = get_logger()
|
||||
game_logger.log_move(
|
||||
game_id=current_room.game_log_id,
|
||||
player=player,
|
||||
is_cpu=False,
|
||||
action="skip_flip",
|
||||
card=None,
|
||||
game=current_room.game,
|
||||
decision_reason="skipped optional flip (endgame mode)",
|
||||
)
|
||||
|
||||
await broadcast_game_state(current_room)
|
||||
await check_and_run_cpu_turn(current_room)
|
||||
|
||||
elif msg_type == "flip_as_action":
|
||||
if not current_room:
|
||||
continue
|
||||
|
||||
position = data.get("position", 0)
|
||||
async with current_room.game_lock:
|
||||
player = current_room.game.get_player(player_id)
|
||||
if current_room.game.flip_card_as_action(player_id, position):
|
||||
# Log flip-as-action for human player
|
||||
if current_room.game_log_id and player and 0 <= position < len(player.cards):
|
||||
game_logger = get_logger()
|
||||
flipped_card = player.cards[position]
|
||||
game_logger.log_move(
|
||||
game_id=current_room.game_log_id,
|
||||
player=player,
|
||||
is_cpu=False,
|
||||
action="flip_as_action",
|
||||
card=flipped_card,
|
||||
position=position,
|
||||
game=current_room.game,
|
||||
decision_reason=f"used flip-as-action to reveal position {position}",
|
||||
)
|
||||
|
||||
await broadcast_game_state(current_room)
|
||||
await check_and_run_cpu_turn(current_room)
|
||||
|
||||
elif msg_type == "knock_early":
|
||||
if not current_room:
|
||||
continue
|
||||
|
||||
async with current_room.game_lock:
|
||||
player = current_room.game.get_player(player_id)
|
||||
if current_room.game.knock_early(player_id):
|
||||
# Log knock early for human player
|
||||
if current_room.game_log_id and player:
|
||||
game_logger = get_logger()
|
||||
face_down_count = sum(1 for c in player.cards if not c.face_up)
|
||||
game_logger.log_move(
|
||||
game_id=current_room.game_log_id,
|
||||
player=player,
|
||||
is_cpu=False,
|
||||
action="knock_early",
|
||||
card=None,
|
||||
game=current_room.game,
|
||||
decision_reason=f"knocked early, revealing {face_down_count} hidden cards",
|
||||
)
|
||||
|
||||
await broadcast_game_state(current_room)
|
||||
await check_and_run_cpu_turn(current_room)
|
||||
|
||||
@@ -304,6 +950,7 @@ async def websocket_endpoint(websocket: WebSocket):
|
||||
if not room_player or not room_player.is_host:
|
||||
continue
|
||||
|
||||
async with current_room.game_lock:
|
||||
if current_room.game.start_next_round():
|
||||
# CPU players do their initial flips
|
||||
for cpu in current_room.get_cpu_players():
|
||||
@@ -354,9 +1001,11 @@ async def websocket_endpoint(websocket: WebSocket):
|
||||
})
|
||||
|
||||
# Clean up the room
|
||||
room_code = current_room.code
|
||||
for cpu in list(current_room.get_cpu_players()):
|
||||
current_room.remove_player(cpu.id)
|
||||
room_manager.remove_room(current_room.code)
|
||||
cleanup_room_profiles(room_code)
|
||||
room_manager.remove_room(room_code)
|
||||
current_room = None
|
||||
|
||||
except WebSocketDisconnect:
|
||||
@@ -364,8 +1013,45 @@ async def websocket_endpoint(websocket: WebSocket):
|
||||
await handle_player_leave(current_room, player_id)
|
||||
|
||||
|
||||
async def _process_stats_safe(room: Room):
|
||||
"""
|
||||
Process game stats in a fire-and-forget manner.
|
||||
|
||||
This is called via asyncio.create_task to avoid blocking game completion
|
||||
notifications while stats are being processed.
|
||||
"""
|
||||
try:
|
||||
# Build mapping - use auth_user_id for authenticated players
|
||||
# Only authenticated players get their stats tracked
|
||||
player_user_ids = {}
|
||||
for player_id, room_player in room.players.items():
|
||||
if not room_player.is_cpu and room_player.auth_user_id:
|
||||
player_user_ids[player_id] = room_player.auth_user_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,
|
||||
)
|
||||
logger.debug(f"Stats processed for room {room.code}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to process game stats: {e}")
|
||||
|
||||
|
||||
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:
|
||||
@@ -401,10 +1087,14 @@ 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 asynchronously (fire-and-forget) to avoid delaying game over notifications
|
||||
if _stats_service and room.game.players:
|
||||
asyncio.create_task(_process_stats_safe(room))
|
||||
|
||||
scores = [
|
||||
{"name": p.name, "total": p.total_score, "rounds_won": p.rounds_won}
|
||||
for p in room.game.players
|
||||
@@ -454,6 +1144,7 @@ async def check_and_run_cpu_turn(room: Room):
|
||||
|
||||
async def handle_player_leave(room: Room, player_id: str):
|
||||
"""Handle a player leaving a room."""
|
||||
room_code = room.code
|
||||
room_player = room.remove_player(player_id)
|
||||
|
||||
# If no human players left, clean up the room entirely
|
||||
@@ -461,7 +1152,9 @@ async def handle_player_leave(room: Room, player_id: str):
|
||||
# Remove all remaining CPU players to release their profiles
|
||||
for cpu in list(room.get_cpu_players()):
|
||||
room.remove_player(cpu.id)
|
||||
room_manager.remove_room(room.code)
|
||||
# Clean up any remaining profile tracking for this room
|
||||
cleanup_room_profiles(room_code)
|
||||
room_manager.remove_room(room_code)
|
||||
elif room_player:
|
||||
await room.broadcast({
|
||||
"type": "player_left",
|
||||
@@ -485,3 +1178,65 @@ if os.path.exists(client_path):
|
||||
@app.get("/app.js")
|
||||
async def serve_js():
|
||||
return FileResponse(os.path.join(client_path, "app.js"), media_type="application/javascript")
|
||||
|
||||
@app.get("/card-manager.js")
|
||||
async def serve_card_manager():
|
||||
return FileResponse(os.path.join(client_path, "card-manager.js"), media_type="application/javascript")
|
||||
|
||||
@app.get("/state-differ.js")
|
||||
async def serve_state_differ():
|
||||
return FileResponse(os.path.join(client_path, "state-differ.js"), media_type="application/javascript")
|
||||
|
||||
@app.get("/animation-queue.js")
|
||||
async def serve_animation_queue():
|
||||
return FileResponse(os.path.join(client_path, "animation-queue.js"), media_type="application/javascript")
|
||||
|
||||
@app.get("/leaderboard.js")
|
||||
async def serve_leaderboard_js():
|
||||
return FileResponse(os.path.join(client_path, "leaderboard.js"), media_type="application/javascript")
|
||||
|
||||
@app.get("/golfball-logo.svg")
|
||||
async def serve_golfball_logo():
|
||||
return FileResponse(os.path.join(client_path, "golfball-logo.svg"), media_type="image/svg+xml")
|
||||
|
||||
# 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."""
|
||||
import uvicorn
|
||||
|
||||
logger.info(f"Starting Golf server on {config.HOST}:{config.PORT}")
|
||||
logger.info(f"Debug mode: {config.DEBUG}")
|
||||
|
||||
uvicorn.run(
|
||||
"main:app",
|
||||
host=config.HOST,
|
||||
port=config.PORT,
|
||||
reload=config.DEBUG,
|
||||
log_level=config.LOG_LEVEL.lower(),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
|
||||
18
server/middleware/__init__.py
Normal file
18
server/middleware/__init__.py
Normal 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",
|
||||
]
|
||||
173
server/middleware/ratelimit.py
Normal file
173
server/middleware/ratelimit.py
Normal 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 "/"
|
||||
93
server/middleware/request_id.py
Normal file
93
server/middleware/request_id.py
Normal 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)
|
||||
140
server/middleware/security.py
Normal file
140
server/middleware/security.py
Normal 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
19
server/models/__init__.py
Normal 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
574
server/models/events.py
Normal 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,
|
||||
},
|
||||
)
|
||||
561
server/models/game_state.py
Normal file
561
server/models/game_state.py
Normal file
@@ -0,0 +1,561 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
Handles both full card data and minimal face-down data gracefully.
|
||||
|
||||
Args:
|
||||
d: Dictionary with card data. May contain:
|
||||
- Full data: {rank, suit, face_up}
|
||||
- Minimal face-down: {face_up: False}
|
||||
|
||||
Returns:
|
||||
CardState instance.
|
||||
|
||||
Raises:
|
||||
ValueError: If face_up is True but rank/suit are missing.
|
||||
"""
|
||||
face_up = d.get("face_up", False)
|
||||
rank = d.get("rank")
|
||||
suit = d.get("suit")
|
||||
|
||||
# If card is face-up, we must have rank and suit
|
||||
if face_up and (rank is None or suit is None):
|
||||
raise ValueError("Face-up card must have rank and suit")
|
||||
|
||||
# For face-down cards with missing data, use placeholder values
|
||||
# This handles improperly serialized data from older versions
|
||||
if rank is None:
|
||||
rank = "?" # Placeholder for unknown
|
||||
if suit is None:
|
||||
suit = "?" # Placeholder for unknown
|
||||
|
||||
return cls(rank=rank, suit=suit, face_up=face_up)
|
||||
|
||||
|
||||
@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
287
server/models/user.py
Normal 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),
|
||||
)
|
||||
@@ -1,3 +1,16 @@
|
||||
fastapi==0.109.0
|
||||
uvicorn[standard]==0.27.0
|
||||
websockets==12.0
|
||||
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
|
||||
|
||||
196
server/room.py
196
server/room.py
@@ -1,57 +1,133 @@
|
||||
"""Room management for multiplayer games."""
|
||||
"""
|
||||
Room management for multiplayer Golf games.
|
||||
|
||||
This module handles room creation, player management, and WebSocket
|
||||
communication for multiplayer game sessions.
|
||||
|
||||
A Room contains:
|
||||
- A unique 4-letter code for joining
|
||||
- A collection of RoomPlayers (human or CPU)
|
||||
- A Game instance with the actual game state
|
||||
- Settings for number of decks, rounds, etc.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import random
|
||||
import string
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
from ai import assign_profile, assign_specific_profile, get_profile, release_profile, cleanup_room_profiles
|
||||
from game import Game, Player
|
||||
from ai import assign_profile, release_profile, get_profile, assign_specific_profile
|
||||
|
||||
|
||||
@dataclass
|
||||
class RoomPlayer:
|
||||
"""
|
||||
A player in a game room (lobby-level representation).
|
||||
|
||||
This is separate from game.Player - RoomPlayer tracks room-level info
|
||||
like WebSocket connections and host status, while game.Player tracks
|
||||
in-game state like cards and scores.
|
||||
|
||||
Attributes:
|
||||
id: Unique player identifier (connection_id for multi-tab support).
|
||||
name: Display name.
|
||||
websocket: WebSocket connection (None for CPU players).
|
||||
is_host: Whether this player controls game settings.
|
||||
is_cpu: Whether this is an AI-controlled player.
|
||||
auth_user_id: Authenticated user ID for stats/limits (None for guests).
|
||||
"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
websocket: Optional[WebSocket] = None
|
||||
is_host: bool = False
|
||||
is_cpu: bool = False
|
||||
auth_user_id: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Room:
|
||||
"""
|
||||
A game room/lobby that can host a multiplayer Golf game.
|
||||
|
||||
Attributes:
|
||||
code: 4-letter room code for joining (e.g., "ABCD").
|
||||
players: Dict mapping player IDs to RoomPlayer objects.
|
||||
game: The Game instance containing actual game state.
|
||||
settings: Room settings (decks, rounds, etc.).
|
||||
game_log_id: SQLite log ID for analytics (if logging enabled).
|
||||
game_lock: asyncio.Lock for serializing game mutations to prevent race conditions.
|
||||
"""
|
||||
|
||||
code: str
|
||||
players: dict[str, RoomPlayer] = field(default_factory=dict)
|
||||
game: Game = field(default_factory=Game)
|
||||
settings: dict = field(default_factory=lambda: {"decks": 1, "rounds": 1})
|
||||
game_log_id: Optional[str] = None # For SQLite logging
|
||||
game_log_id: Optional[str] = None
|
||||
game_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||
|
||||
def add_player(self, player_id: str, name: str, websocket: WebSocket) -> RoomPlayer:
|
||||
def add_player(
|
||||
self,
|
||||
player_id: str,
|
||||
name: str,
|
||||
websocket: WebSocket,
|
||||
auth_user_id: Optional[str] = None,
|
||||
) -> RoomPlayer:
|
||||
"""
|
||||
Add a human player to the room.
|
||||
|
||||
The first player to join becomes the host.
|
||||
|
||||
Args:
|
||||
player_id: Unique identifier for the player (connection_id).
|
||||
name: Display name.
|
||||
websocket: The player's WebSocket connection.
|
||||
auth_user_id: Authenticated user ID for stats/limits (None for guests).
|
||||
|
||||
Returns:
|
||||
The created RoomPlayer object.
|
||||
"""
|
||||
is_host = len(self.players) == 0
|
||||
room_player = RoomPlayer(
|
||||
id=player_id,
|
||||
name=name,
|
||||
websocket=websocket,
|
||||
is_host=is_host,
|
||||
auth_user_id=auth_user_id,
|
||||
)
|
||||
self.players[player_id] = room_player
|
||||
|
||||
# Add to game
|
||||
game_player = Player(id=player_id, name=name)
|
||||
self.game.add_player(game_player)
|
||||
|
||||
return room_player
|
||||
|
||||
def add_cpu_player(self, cpu_id: str, profile_name: Optional[str] = None) -> Optional[RoomPlayer]:
|
||||
# Get a CPU profile (specific or random)
|
||||
def add_cpu_player(
|
||||
self,
|
||||
cpu_id: str,
|
||||
profile_name: Optional[str] = None,
|
||||
) -> Optional[RoomPlayer]:
|
||||
"""
|
||||
Add a CPU player to the room.
|
||||
|
||||
Args:
|
||||
cpu_id: Unique identifier for the CPU player.
|
||||
profile_name: Specific AI profile to use, or None for random.
|
||||
|
||||
Returns:
|
||||
The created RoomPlayer, or None if profile unavailable.
|
||||
"""
|
||||
if profile_name:
|
||||
profile = assign_specific_profile(cpu_id, profile_name)
|
||||
profile = assign_specific_profile(cpu_id, profile_name, self.code)
|
||||
else:
|
||||
profile = assign_profile(cpu_id)
|
||||
profile = assign_profile(cpu_id, self.code)
|
||||
|
||||
if not profile:
|
||||
return None # Profile not available
|
||||
return None
|
||||
|
||||
room_player = RoomPlayer(
|
||||
id=cpu_id,
|
||||
@@ -62,20 +138,33 @@ class Room:
|
||||
)
|
||||
self.players[cpu_id] = room_player
|
||||
|
||||
# Add to game
|
||||
game_player = Player(id=cpu_id, name=profile.name)
|
||||
self.game.add_player(game_player)
|
||||
|
||||
return room_player
|
||||
|
||||
def remove_player(self, player_id: str) -> Optional[RoomPlayer]:
|
||||
if player_id in self.players:
|
||||
"""
|
||||
Remove a player from the room.
|
||||
|
||||
Handles host reassignment if the host leaves, and releases
|
||||
CPU profiles back to the pool.
|
||||
|
||||
Args:
|
||||
player_id: ID of the player to remove.
|
||||
|
||||
Returns:
|
||||
The removed RoomPlayer, or None if not found.
|
||||
"""
|
||||
if player_id not in self.players:
|
||||
return None
|
||||
|
||||
room_player = self.players.pop(player_id)
|
||||
self.game.remove_player(player_id)
|
||||
|
||||
# Release CPU profile back to the pool
|
||||
# Release CPU profile back to the room's pool
|
||||
if room_player.is_cpu:
|
||||
release_profile(room_player.name)
|
||||
release_profile(room_player.name, self.code)
|
||||
|
||||
# Assign new host if needed
|
||||
if room_player.is_host and self.players:
|
||||
@@ -83,18 +172,30 @@ class Room:
|
||||
next_host.is_host = True
|
||||
|
||||
return room_player
|
||||
return None
|
||||
|
||||
def get_player(self, player_id: str) -> Optional[RoomPlayer]:
|
||||
"""Get a player by ID, or None if not found."""
|
||||
return self.players.get(player_id)
|
||||
|
||||
def is_empty(self) -> bool:
|
||||
"""Check if the room has no players."""
|
||||
return len(self.players) == 0
|
||||
|
||||
def player_list(self) -> list[dict]:
|
||||
"""
|
||||
Get list of players for client display.
|
||||
|
||||
Returns:
|
||||
List of dicts with id, name, is_host, is_cpu, and style (for CPUs).
|
||||
"""
|
||||
result = []
|
||||
for p in self.players.values():
|
||||
player_data = {"id": p.id, "name": p.name, "is_host": p.is_host, "is_cpu": p.is_cpu}
|
||||
player_data = {
|
||||
"id": p.id,
|
||||
"name": p.name,
|
||||
"is_host": p.is_host,
|
||||
"is_cpu": p.is_cpu,
|
||||
}
|
||||
if p.is_cpu:
|
||||
profile = get_profile(p.id)
|
||||
if profile:
|
||||
@@ -103,12 +204,21 @@ class Room:
|
||||
return result
|
||||
|
||||
def get_cpu_players(self) -> list[RoomPlayer]:
|
||||
"""Get all CPU players in the room."""
|
||||
return [p for p in self.players.values() if p.is_cpu]
|
||||
|
||||
def human_player_count(self) -> int:
|
||||
"""Count the number of human (non-CPU) players."""
|
||||
return sum(1 for p in self.players.values() if not p.is_cpu)
|
||||
|
||||
async def broadcast(self, message: dict, exclude: Optional[str] = None):
|
||||
async def broadcast(self, message: dict, exclude: Optional[str] = None) -> None:
|
||||
"""
|
||||
Send a message to all human players in the room.
|
||||
|
||||
Args:
|
||||
message: JSON-serializable message dict.
|
||||
exclude: Optional player ID to skip.
|
||||
"""
|
||||
for player_id, player in self.players.items():
|
||||
if player_id != exclude and player.websocket and not player.is_cpu:
|
||||
try:
|
||||
@@ -116,7 +226,14 @@ class Room:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def send_to(self, player_id: str, message: dict):
|
||||
async def send_to(self, player_id: str, message: dict) -> None:
|
||||
"""
|
||||
Send a message to a specific player.
|
||||
|
||||
Args:
|
||||
player_id: ID of the recipient player.
|
||||
message: JSON-serializable message dict.
|
||||
"""
|
||||
player = self.players.get(player_id)
|
||||
if player and player.websocket and not player.is_cpu:
|
||||
try:
|
||||
@@ -126,29 +243,68 @@ class Room:
|
||||
|
||||
|
||||
class RoomManager:
|
||||
def __init__(self):
|
||||
"""
|
||||
Manages all active game rooms.
|
||||
|
||||
Provides room creation with unique codes, lookup, and cleanup.
|
||||
A single RoomManager instance is used by the server.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize an empty room manager."""
|
||||
self.rooms: dict[str, Room] = {}
|
||||
|
||||
def _generate_code(self) -> str:
|
||||
"""Generate a unique 4-letter room code."""
|
||||
while True:
|
||||
code = "".join(random.choices(string.ascii_uppercase, k=4))
|
||||
if code not in self.rooms:
|
||||
return code
|
||||
|
||||
def create_room(self) -> Room:
|
||||
"""
|
||||
Create a new room with a unique code.
|
||||
|
||||
Returns:
|
||||
The newly created Room.
|
||||
"""
|
||||
code = self._generate_code()
|
||||
room = Room(code=code)
|
||||
self.rooms[code] = room
|
||||
return room
|
||||
|
||||
def get_room(self, code: str) -> Optional[Room]:
|
||||
"""
|
||||
Get a room by its code (case-insensitive).
|
||||
|
||||
Args:
|
||||
code: The 4-letter room code.
|
||||
|
||||
Returns:
|
||||
The Room if found, None otherwise.
|
||||
"""
|
||||
return self.rooms.get(code.upper())
|
||||
|
||||
def remove_room(self, code: str):
|
||||
def remove_room(self, code: str) -> None:
|
||||
"""
|
||||
Delete a room.
|
||||
|
||||
Args:
|
||||
code: The room code to remove.
|
||||
"""
|
||||
if code in self.rooms:
|
||||
del self.rooms[code]
|
||||
|
||||
def find_player_room(self, player_id: str) -> Optional[Room]:
|
||||
"""
|
||||
Find which room a player is in.
|
||||
|
||||
Args:
|
||||
player_id: The player ID to search for.
|
||||
|
||||
Returns:
|
||||
The Room containing the player, or None.
|
||||
"""
|
||||
for room in self.rooms.values():
|
||||
if player_id in room.players:
|
||||
return room
|
||||
|
||||
9
server/routers/__init__.py
Normal file
9
server/routers/__init__.py
Normal 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
419
server/routers/admin.py
Normal 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
506
server/routers/auth.py
Normal 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
171
server/routers/health.py
Normal 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
|
||||
501
server/routers/replay.py
Normal file
501
server/routers/replay.py
Normal file
@@ -0,0 +1,501 @@
|
||||
"""
|
||||
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.
|
||||
Supports optional authentication via token query parameter.
|
||||
"""
|
||||
await websocket.accept()
|
||||
|
||||
# Optional authentication for spectators
|
||||
token = websocket.query_params.get("token")
|
||||
spectator_user = None
|
||||
if token and _auth_service:
|
||||
try:
|
||||
spectator_user = await _auth_service.get_user_from_token(token)
|
||||
except Exception:
|
||||
pass # Anonymous spectator
|
||||
|
||||
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(),
|
||||
"authenticated": spectator_user is not None,
|
||||
})
|
||||
|
||||
# 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
385
server/routers/stats.py
Normal 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
|
||||
],
|
||||
}
|
||||
@@ -26,7 +26,7 @@ def run_game_for_scores(num_players: int = 4) -> dict[str, int]:
|
||||
game.add_player(player)
|
||||
player_profiles[player.id] = profile
|
||||
|
||||
options = GameOptions(initial_flips=2, flip_on_discard=False, use_jokers=False)
|
||||
options = GameOptions(initial_flips=2, flip_mode="never", use_jokers=False)
|
||||
game.start_game(num_decks=1, num_rounds=1, options=options)
|
||||
|
||||
# Initial flips
|
||||
|
||||
BIN
server/score_distribution.png
Normal file
BIN
server/score_distribution.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 65 KiB |
96
server/scripts/create_admin.py
Normal file
96
server/scripts/create_admin.py
Normal 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()
|
||||
33
server/services/__init__.py
Normal file
33
server/services/__init__.py
Normal 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",
|
||||
]
|
||||
1243
server/services/admin_service.py
Normal file
1243
server/services/admin_service.py
Normal file
File diff suppressed because it is too large
Load Diff
654
server/services/auth_service.py
Normal file
654
server/services/auth_service.py
Normal 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
|
||||
215
server/services/email_service.py
Normal file
215
server/services/email_service.py
Normal 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
|
||||
223
server/services/ratelimit.py
Normal file
223
server/services/ratelimit.py
Normal 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
|
||||
359
server/services/recovery_service.py
Normal file
359
server/services/recovery_service.py
Normal file
@@ -0,0 +1,359 @@
|
||||
"""
|
||||
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
|
||||
from game import GameOptions
|
||||
|
||||
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)
|
||||
# Reconstruct GameOptions as proper object for attribute access
|
||||
options_dict = d.get("options", {})
|
||||
if isinstance(options_dict, dict):
|
||||
state.options = GameOptions(**options_dict)
|
||||
else:
|
||||
state.options = options_dict
|
||||
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
|
||||
583
server/services/replay_service.py
Normal file
583
server/services/replay_service.py
Normal 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
|
||||
265
server/services/spectator.py
Normal file
265
server/services/spectator.py
Normal 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
|
||||
977
server/services/stats_service.py
Normal file
977
server/services/stats_service.py
Normal 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
|
||||
@@ -20,8 +20,10 @@ from typing import Optional
|
||||
from game import Game, Player, GamePhase, GameOptions
|
||||
from ai import (
|
||||
GolfAI, CPUProfile, CPU_PROFILES,
|
||||
get_ai_card_value, has_worse_visible_card
|
||||
get_ai_card_value, has_worse_visible_card,
|
||||
filter_bad_pair_positions, get_column_partner_position
|
||||
)
|
||||
from game import Rank
|
||||
from game_log import GameLogger
|
||||
|
||||
|
||||
@@ -36,6 +38,15 @@ class SimulationStats:
|
||||
self.player_scores: dict[str, list[int]] = {}
|
||||
self.decisions: dict[str, dict] = {} # player -> {action: count}
|
||||
|
||||
# Dumb move tracking
|
||||
self.discarded_jokers = 0
|
||||
self.discarded_twos = 0
|
||||
self.discarded_kings = 0
|
||||
self.took_bad_card_without_pair = 0
|
||||
self.paired_negative_cards = 0
|
||||
self.swapped_good_for_bad = 0
|
||||
self.total_opportunities = 0 # Total decision points
|
||||
|
||||
def record_game(self, game: Game, winner_name: str):
|
||||
self.games_played += 1
|
||||
self.total_rounds += game.current_round
|
||||
@@ -57,6 +68,40 @@ class SimulationStats:
|
||||
self.decisions[player_name][action] = 0
|
||||
self.decisions[player_name][action] += 1
|
||||
|
||||
def record_dumb_move(self, move_type: str):
|
||||
"""Record a dumb move for analysis."""
|
||||
if move_type == "discarded_joker":
|
||||
self.discarded_jokers += 1
|
||||
elif move_type == "discarded_two":
|
||||
self.discarded_twos += 1
|
||||
elif move_type == "discarded_king":
|
||||
self.discarded_kings += 1
|
||||
elif move_type == "took_bad_without_pair":
|
||||
self.took_bad_card_without_pair += 1
|
||||
elif move_type == "paired_negative":
|
||||
self.paired_negative_cards += 1
|
||||
elif move_type == "swapped_good_for_bad":
|
||||
self.swapped_good_for_bad += 1
|
||||
|
||||
def record_opportunity(self):
|
||||
"""Record a decision opportunity for rate calculation."""
|
||||
self.total_opportunities += 1
|
||||
|
||||
@property
|
||||
def dumb_move_rate(self) -> float:
|
||||
"""Calculate overall dumb move rate."""
|
||||
total_dumb = (
|
||||
self.discarded_jokers +
|
||||
self.discarded_twos +
|
||||
self.discarded_kings +
|
||||
self.took_bad_card_without_pair +
|
||||
self.paired_negative_cards +
|
||||
self.swapped_good_for_bad
|
||||
)
|
||||
if self.total_opportunities == 0:
|
||||
return 0.0
|
||||
return total_dumb / self.total_opportunities * 100
|
||||
|
||||
def report(self) -> str:
|
||||
lines = [
|
||||
"=" * 50,
|
||||
@@ -95,6 +140,21 @@ class SimulationStats:
|
||||
pct = count / max(1, total) * 100
|
||||
lines.append(f" {action}: {count} ({pct:.1f}%)")
|
||||
|
||||
lines.append("")
|
||||
lines.append("DUMB MOVE ANALYSIS:")
|
||||
lines.append(f" Total decision opportunities: {self.total_opportunities}")
|
||||
lines.append(f" Dumb move rate: {self.dumb_move_rate:.3f}%")
|
||||
lines.append("")
|
||||
lines.append(" Blunders (should be 0):")
|
||||
lines.append(f" Discarded Jokers: {self.discarded_jokers}")
|
||||
lines.append(f" Discarded 2s: {self.discarded_twos}")
|
||||
lines.append(f" Took bad card without pair: {self.took_bad_card_without_pair}")
|
||||
lines.append(f" Paired negative cards: {self.paired_negative_cards}")
|
||||
lines.append("")
|
||||
lines.append(" Mistakes (should be < 0.1%):")
|
||||
lines.append(f" Discarded Kings: {self.discarded_kings}")
|
||||
lines.append(f" Swapped good for bad: {self.swapped_good_for_bad}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@@ -134,6 +194,27 @@ def run_cpu_turn(
|
||||
action = "take_discard" if take_discard else "draw_deck"
|
||||
stats.record_turn(player.name, action)
|
||||
|
||||
# Check for dumb move: taking bad card from discard without good reason
|
||||
if take_discard:
|
||||
drawn_val = get_ai_card_value(drawn, game.options)
|
||||
# Bad cards are 8, 9, 10, J, Q (value >= 8)
|
||||
if drawn_val >= 8:
|
||||
# Check if there's pair potential
|
||||
has_pair_potential = False
|
||||
for i, card in enumerate(player.cards):
|
||||
if card.face_up and card.rank == drawn.rank:
|
||||
partner_pos = get_column_partner_position(i)
|
||||
if not player.cards[partner_pos].face_up:
|
||||
has_pair_potential = True
|
||||
break
|
||||
|
||||
# Check if player has a WORSE visible card to replace
|
||||
has_worse_to_replace = has_worse_visible_card(player, drawn_val, game.options)
|
||||
|
||||
# Only flag as dumb if no pair potential AND no worse card to replace
|
||||
if not has_pair_potential and not has_worse_to_replace:
|
||||
stats.record_dumb_move("took_bad_without_pair")
|
||||
|
||||
# Log draw decision
|
||||
if logger and game_id:
|
||||
reason = f"took {discard_top.rank.value} from discard" if take_discard else "drew from deck"
|
||||
@@ -154,7 +235,9 @@ def run_cpu_turn(
|
||||
if swap_pos is None and game.drawn_from_discard:
|
||||
face_down = [i for i, c in enumerate(player.cards) if not c.face_up]
|
||||
if face_down:
|
||||
swap_pos = random.choice(face_down)
|
||||
# Use filter to avoid bad pairs with negative cards
|
||||
safe_positions = filter_bad_pair_positions(face_down, drawn, player, game.options)
|
||||
swap_pos = random.choice(safe_positions)
|
||||
else:
|
||||
# Find worst card using house rules
|
||||
worst_pos = 0
|
||||
@@ -166,8 +249,27 @@ def run_cpu_turn(
|
||||
worst_pos = i
|
||||
swap_pos = worst_pos
|
||||
|
||||
# Record this as a decision opportunity for dumb move rate calculation
|
||||
stats.record_opportunity()
|
||||
|
||||
if swap_pos is not None:
|
||||
old_card = player.cards[swap_pos]
|
||||
|
||||
# Check for dumb moves: swapping good card for bad
|
||||
drawn_val = get_ai_card_value(drawn, game.options)
|
||||
old_val = get_ai_card_value(old_card, game.options)
|
||||
if old_card.face_up and old_val < drawn_val and old_val <= 1:
|
||||
stats.record_dumb_move("swapped_good_for_bad")
|
||||
|
||||
# Check for dumb move: creating bad pair with negative card
|
||||
partner_pos = get_column_partner_position(swap_pos)
|
||||
partner = player.cards[partner_pos]
|
||||
if (partner.face_up and
|
||||
partner.rank == drawn.rank and
|
||||
drawn_val < 0 and
|
||||
not (game.options.eagle_eye and drawn.rank == Rank.JOKER)):
|
||||
stats.record_dumb_move("paired_negative")
|
||||
|
||||
game.swap_card(player.id, swap_pos)
|
||||
action = "swap"
|
||||
stats.record_turn(player.name, action)
|
||||
@@ -184,6 +286,14 @@ def run_cpu_turn(
|
||||
decision_reason=f"swapped {drawn.rank.value} for {old_card.rank.value} at pos {swap_pos}",
|
||||
)
|
||||
else:
|
||||
# Check for dumb moves: discarding excellent cards
|
||||
if drawn.rank == Rank.JOKER:
|
||||
stats.record_dumb_move("discarded_joker")
|
||||
elif drawn.rank == Rank.TWO:
|
||||
stats.record_dumb_move("discarded_two")
|
||||
elif drawn.rank == Rank.KING:
|
||||
stats.record_dumb_move("discarded_king")
|
||||
|
||||
game.discard_drawn(player.id)
|
||||
action = "discard"
|
||||
stats.record_turn(player.name, action)
|
||||
@@ -302,7 +412,7 @@ def run_simulation(
|
||||
# Default options
|
||||
options = GameOptions(
|
||||
initial_flips=2,
|
||||
flip_on_discard=False,
|
||||
flip_mode="never",
|
||||
use_jokers=False,
|
||||
)
|
||||
|
||||
@@ -340,7 +450,7 @@ def run_detailed_game(num_players: int = 4):
|
||||
|
||||
options = GameOptions(
|
||||
initial_flips=2,
|
||||
flip_on_discard=False,
|
||||
flip_mode="never",
|
||||
use_jokers=False,
|
||||
)
|
||||
|
||||
|
||||
26
server/stores/__init__.py
Normal file
26
server/stores/__init__.py
Normal 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",
|
||||
]
|
||||
485
server/stores/event_store.py
Normal file
485
server/stores/event_store.py
Normal 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
306
server/stores/pubsub.py
Normal 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
|
||||
401
server/stores/state_cache.py
Normal file
401
server/stores/state_cache.py
Normal file
@@ -0,0 +1,401 @@
|
||||
"""
|
||||
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 - extended to 24 hours to prevent active games from expiring
|
||||
ROOM_TTL = timedelta(hours=24) # Inactive rooms expire
|
||||
GAME_TTL = timedelta(hours=24)
|
||||
|
||||
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()
|
||||
|
||||
async def touch_game(self, game_id: str) -> None:
|
||||
"""
|
||||
Refresh game TTL on any activity.
|
||||
|
||||
Call this on game actions to prevent active games from expiring.
|
||||
|
||||
Args:
|
||||
game_id: Game UUID to refresh.
|
||||
"""
|
||||
key = self.GAME_KEY.format(game_id=game_id)
|
||||
await self.redis.expire(key, int(self.GAME_TTL.total_seconds()))
|
||||
|
||||
|
||||
# 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
1029
server/stores/user_store.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -40,9 +40,6 @@ class TestCardValues:
|
||||
opts = {'super_kings': True}
|
||||
assert get_card_value('K', opts) == -2
|
||||
|
||||
opts = {'lucky_sevens': True}
|
||||
assert get_card_value('7', opts) == 0
|
||||
|
||||
opts = {'ten_penny': True}
|
||||
assert get_card_value('10', opts) == 1
|
||||
|
||||
|
||||
288
server/test_auth.py
Normal file
288
server/test_auth.py
Normal file
@@ -0,0 +1,288 @@
|
||||
"""
|
||||
Tests for the authentication system.
|
||||
|
||||
Run with: pytest test_auth.py -v
|
||||
"""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
import tempfile
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from auth import AuthManager, User, UserRole, Session, InviteCode
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_manager():
|
||||
"""Create a fresh auth manager with temporary database."""
|
||||
# Use a temporary file for testing
|
||||
fd, path = tempfile.mkstemp(suffix=".db")
|
||||
os.close(fd)
|
||||
|
||||
# Create manager (this will create default admin)
|
||||
manager = AuthManager(db_path=path)
|
||||
|
||||
yield manager
|
||||
|
||||
# Cleanup
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
class TestUserCreation:
|
||||
"""Test user creation and retrieval."""
|
||||
|
||||
def test_create_user(self, auth_manager):
|
||||
"""Can create a new user."""
|
||||
user = auth_manager.create_user(
|
||||
username="testuser",
|
||||
password="password123",
|
||||
email="test@example.com",
|
||||
)
|
||||
|
||||
assert user is not None
|
||||
assert user.username == "testuser"
|
||||
assert user.email == "test@example.com"
|
||||
assert user.role == UserRole.USER
|
||||
assert user.is_active is True
|
||||
|
||||
def test_create_duplicate_username_fails(self, auth_manager):
|
||||
"""Cannot create user with duplicate username."""
|
||||
auth_manager.create_user(username="testuser", password="pass1")
|
||||
user2 = auth_manager.create_user(username="testuser", password="pass2")
|
||||
|
||||
assert user2 is None
|
||||
|
||||
def test_create_duplicate_email_fails(self, auth_manager):
|
||||
"""Cannot create user with duplicate email."""
|
||||
auth_manager.create_user(
|
||||
username="user1",
|
||||
password="pass1",
|
||||
email="test@example.com"
|
||||
)
|
||||
user2 = auth_manager.create_user(
|
||||
username="user2",
|
||||
password="pass2",
|
||||
email="test@example.com"
|
||||
)
|
||||
|
||||
assert user2 is None
|
||||
|
||||
def test_create_admin_user(self, auth_manager):
|
||||
"""Can create admin user."""
|
||||
user = auth_manager.create_user(
|
||||
username="newadmin",
|
||||
password="adminpass",
|
||||
role=UserRole.ADMIN,
|
||||
)
|
||||
|
||||
assert user is not None
|
||||
assert user.is_admin() is True
|
||||
|
||||
def test_get_user_by_id(self, auth_manager):
|
||||
"""Can retrieve user by ID."""
|
||||
created = auth_manager.create_user(username="testuser", password="pass")
|
||||
retrieved = auth_manager.get_user_by_id(created.id)
|
||||
|
||||
assert retrieved is not None
|
||||
assert retrieved.username == "testuser"
|
||||
|
||||
def test_get_user_by_username(self, auth_manager):
|
||||
"""Can retrieve user by username."""
|
||||
auth_manager.create_user(username="testuser", password="pass")
|
||||
retrieved = auth_manager.get_user_by_username("testuser")
|
||||
|
||||
assert retrieved is not None
|
||||
assert retrieved.username == "testuser"
|
||||
|
||||
|
||||
class TestAuthentication:
|
||||
"""Test login and session management."""
|
||||
|
||||
def test_authenticate_valid_credentials(self, auth_manager):
|
||||
"""Can authenticate with valid credentials."""
|
||||
auth_manager.create_user(username="testuser", password="correctpass")
|
||||
user = auth_manager.authenticate("testuser", "correctpass")
|
||||
|
||||
assert user is not None
|
||||
assert user.username == "testuser"
|
||||
|
||||
def test_authenticate_invalid_password(self, auth_manager):
|
||||
"""Invalid password returns None."""
|
||||
auth_manager.create_user(username="testuser", password="correctpass")
|
||||
user = auth_manager.authenticate("testuser", "wrongpass")
|
||||
|
||||
assert user is None
|
||||
|
||||
def test_authenticate_nonexistent_user(self, auth_manager):
|
||||
"""Nonexistent user returns None."""
|
||||
user = auth_manager.authenticate("nonexistent", "anypass")
|
||||
|
||||
assert user is None
|
||||
|
||||
def test_authenticate_inactive_user(self, auth_manager):
|
||||
"""Inactive user cannot authenticate."""
|
||||
created = auth_manager.create_user(username="testuser", password="pass")
|
||||
auth_manager.update_user(created.id, is_active=False)
|
||||
|
||||
user = auth_manager.authenticate("testuser", "pass")
|
||||
|
||||
assert user is None
|
||||
|
||||
def test_create_session(self, auth_manager):
|
||||
"""Can create session for authenticated user."""
|
||||
user = auth_manager.create_user(username="testuser", password="pass")
|
||||
session = auth_manager.create_session(user)
|
||||
|
||||
assert session is not None
|
||||
assert session.user_id == user.id
|
||||
assert session.is_expired() is False
|
||||
|
||||
def test_get_user_from_session(self, auth_manager):
|
||||
"""Can get user from valid session token."""
|
||||
user = auth_manager.create_user(username="testuser", password="pass")
|
||||
session = auth_manager.create_session(user)
|
||||
|
||||
retrieved = auth_manager.get_user_from_session(session.token)
|
||||
|
||||
assert retrieved is not None
|
||||
assert retrieved.id == user.id
|
||||
|
||||
def test_invalid_session_token(self, auth_manager):
|
||||
"""Invalid session token returns None."""
|
||||
user = auth_manager.get_user_from_session("invalid_token")
|
||||
|
||||
assert user is None
|
||||
|
||||
def test_invalidate_session(self, auth_manager):
|
||||
"""Can invalidate a session."""
|
||||
user = auth_manager.create_user(username="testuser", password="pass")
|
||||
session = auth_manager.create_session(user)
|
||||
|
||||
auth_manager.invalidate_session(session.token)
|
||||
retrieved = auth_manager.get_user_from_session(session.token)
|
||||
|
||||
assert retrieved is None
|
||||
|
||||
|
||||
class TestInviteCodes:
|
||||
"""Test invite code functionality."""
|
||||
|
||||
def test_create_invite_code(self, auth_manager):
|
||||
"""Can create invite code."""
|
||||
admin = auth_manager.get_user_by_username("admin")
|
||||
invite = auth_manager.create_invite_code(created_by=admin.id)
|
||||
|
||||
assert invite is not None
|
||||
assert len(invite.code) == 8
|
||||
assert invite.is_valid() is True
|
||||
|
||||
def test_use_invite_code(self, auth_manager):
|
||||
"""Can use invite code."""
|
||||
admin = auth_manager.get_user_by_username("admin")
|
||||
invite = auth_manager.create_invite_code(created_by=admin.id, max_uses=1)
|
||||
|
||||
result = auth_manager.use_invite_code(invite.code)
|
||||
|
||||
assert result is True
|
||||
|
||||
# Check use count increased
|
||||
updated = auth_manager.get_invite_code(invite.code)
|
||||
assert updated.use_count == 1
|
||||
|
||||
def test_invite_code_max_uses(self, auth_manager):
|
||||
"""Invite code respects max uses."""
|
||||
admin = auth_manager.get_user_by_username("admin")
|
||||
invite = auth_manager.create_invite_code(created_by=admin.id, max_uses=1)
|
||||
|
||||
# First use should work
|
||||
auth_manager.use_invite_code(invite.code)
|
||||
|
||||
# Second use should fail (max_uses=1)
|
||||
updated = auth_manager.get_invite_code(invite.code)
|
||||
assert updated.is_valid() is False
|
||||
|
||||
def test_invite_code_case_insensitive(self, auth_manager):
|
||||
"""Invite code lookup is case insensitive."""
|
||||
admin = auth_manager.get_user_by_username("admin")
|
||||
invite = auth_manager.create_invite_code(created_by=admin.id)
|
||||
|
||||
retrieved_lower = auth_manager.get_invite_code(invite.code.lower())
|
||||
retrieved_upper = auth_manager.get_invite_code(invite.code.upper())
|
||||
|
||||
assert retrieved_lower is not None
|
||||
assert retrieved_upper is not None
|
||||
|
||||
def test_deactivate_invite_code(self, auth_manager):
|
||||
"""Can deactivate invite code."""
|
||||
admin = auth_manager.get_user_by_username("admin")
|
||||
invite = auth_manager.create_invite_code(created_by=admin.id)
|
||||
|
||||
auth_manager.deactivate_invite_code(invite.code)
|
||||
|
||||
updated = auth_manager.get_invite_code(invite.code)
|
||||
assert updated.is_valid() is False
|
||||
|
||||
|
||||
class TestAdminFunctions:
|
||||
"""Test admin-only functions."""
|
||||
|
||||
def test_list_users(self, auth_manager):
|
||||
"""Admin can list all users."""
|
||||
auth_manager.create_user(username="user1", password="pass1")
|
||||
auth_manager.create_user(username="user2", password="pass2")
|
||||
|
||||
users = auth_manager.list_users()
|
||||
|
||||
# Should include admin + 2 created users
|
||||
assert len(users) >= 3
|
||||
|
||||
def test_update_user_role(self, auth_manager):
|
||||
"""Admin can change user role."""
|
||||
user = auth_manager.create_user(username="testuser", password="pass")
|
||||
|
||||
updated = auth_manager.update_user(user.id, role=UserRole.ADMIN)
|
||||
|
||||
assert updated.is_admin() is True
|
||||
|
||||
def test_change_password(self, auth_manager):
|
||||
"""Admin can change user password."""
|
||||
user = auth_manager.create_user(username="testuser", password="oldpass")
|
||||
|
||||
auth_manager.change_password(user.id, "newpass")
|
||||
|
||||
# Old password should not work
|
||||
auth_fail = auth_manager.authenticate("testuser", "oldpass")
|
||||
assert auth_fail is None
|
||||
|
||||
# New password should work
|
||||
auth_ok = auth_manager.authenticate("testuser", "newpass")
|
||||
assert auth_ok is not None
|
||||
|
||||
def test_delete_user(self, auth_manager):
|
||||
"""Admin can deactivate user."""
|
||||
user = auth_manager.create_user(username="testuser", password="pass")
|
||||
|
||||
auth_manager.delete_user(user.id)
|
||||
|
||||
# User should be inactive
|
||||
updated = auth_manager.get_user_by_id(user.id)
|
||||
assert updated.is_active is False
|
||||
|
||||
# User should not be able to login
|
||||
auth_fail = auth_manager.authenticate("testuser", "pass")
|
||||
assert auth_fail is None
|
||||
|
||||
|
||||
class TestDefaultAdmin:
|
||||
"""Test default admin creation."""
|
||||
|
||||
def test_default_admin_created(self, auth_manager):
|
||||
"""Default admin is created if no admins exist."""
|
||||
admin = auth_manager.get_user_by_username("admin")
|
||||
|
||||
assert admin is not None
|
||||
assert admin.is_admin() is True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -148,15 +148,6 @@ class TestHouseRulesScoring:
|
||||
# K=-2, 3=3, columns 1&2 matched = 0
|
||||
assert score == 1
|
||||
|
||||
def test_lucky_sevens_zero(self):
|
||||
"""With lucky_sevens, 7s worth 0."""
|
||||
options = GameOptions(lucky_sevens=True)
|
||||
self.set_hand([Rank.SEVEN, Rank.ACE, Rank.ACE,
|
||||
Rank.THREE, Rank.ACE, Rank.ACE])
|
||||
score = self.player.calculate_score(options)
|
||||
# 7=0, 3=3, columns 1&2 matched = 0
|
||||
assert score == 3
|
||||
|
||||
def test_ten_penny(self):
|
||||
"""With ten_penny, 10s worth 1."""
|
||||
options = GameOptions(ten_penny=True)
|
||||
@@ -260,9 +251,13 @@ class TestDrawDiscardMechanics:
|
||||
player = self.game.get_player("p1")
|
||||
# Card is face down
|
||||
assert player.cards[0].face_up is False
|
||||
# to_dict doesn't reveal it
|
||||
card_dict = player.cards[0].to_dict(reveal=False)
|
||||
# to_client_dict hides face-down card details from clients
|
||||
card_dict = player.cards[0].to_client_dict()
|
||||
assert "rank" not in card_dict
|
||||
# But to_dict always includes full data for server-side caching
|
||||
full_dict = player.cards[0].to_dict()
|
||||
assert "rank" in full_dict
|
||||
assert "suit" in full_dict
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -191,25 +191,30 @@ def get_test_configs() -> list[tuple[str, GameOptions]]:
|
||||
# Baseline (no house rules)
|
||||
configs.append(("BASELINE", GameOptions(
|
||||
initial_flips=2,
|
||||
flip_on_discard=False,
|
||||
flip_mode="never",
|
||||
use_jokers=False,
|
||||
)))
|
||||
|
||||
# === Standard Options ===
|
||||
|
||||
configs.append(("flip_on_discard", GameOptions(
|
||||
configs.append(("flip_mode_always", GameOptions(
|
||||
initial_flips=2,
|
||||
flip_on_discard=True,
|
||||
flip_mode="always",
|
||||
)))
|
||||
|
||||
configs.append(("flip_mode_endgame", GameOptions(
|
||||
initial_flips=2,
|
||||
flip_mode="endgame",
|
||||
)))
|
||||
|
||||
configs.append(("initial_flips=0", GameOptions(
|
||||
initial_flips=0,
|
||||
flip_on_discard=False,
|
||||
flip_mode="never",
|
||||
)))
|
||||
|
||||
configs.append(("initial_flips=1", GameOptions(
|
||||
initial_flips=1,
|
||||
flip_on_discard=False,
|
||||
flip_mode="never",
|
||||
)))
|
||||
|
||||
configs.append(("knock_penalty", GameOptions(
|
||||
@@ -300,13 +305,13 @@ def get_test_configs() -> list[tuple[str, GameOptions]]:
|
||||
|
||||
configs.append(("CLASSIC+ (jokers + flip)", GameOptions(
|
||||
initial_flips=2,
|
||||
flip_on_discard=True,
|
||||
flip_mode="always",
|
||||
use_jokers=True,
|
||||
)))
|
||||
|
||||
configs.append(("EVERYTHING", GameOptions(
|
||||
initial_flips=2,
|
||||
flip_on_discard=True,
|
||||
flip_mode="always",
|
||||
knock_penalty=True,
|
||||
use_jokers=True,
|
||||
lucky_swing=True,
|
||||
@@ -472,8 +477,8 @@ def print_expected_effects(results: list[RuleTestResult]):
|
||||
status = "✓" if diff > 0 else "?"
|
||||
checks.append((r.name, expected, f"{actual} ({diff:+.1f})", status))
|
||||
|
||||
# flip_on_discard might slightly lower scores (more info)
|
||||
r = find("flip_on_discard")
|
||||
# flip_mode_always might slightly lower scores (more info)
|
||||
r = find("flip_mode_always")
|
||||
if r and r.scores:
|
||||
diff = r.mean_score - baseline.mean_score
|
||||
expected = "SIMILAR or lower"
|
||||
|
||||
@@ -315,5 +315,164 @@ class TestEdgeCases:
|
||||
)
|
||||
|
||||
|
||||
class TestAvoidBadPairs:
|
||||
"""Test that AI avoids creating wasteful pairs with negative cards."""
|
||||
|
||||
def test_filter_bad_pair_positions_with_visible_two(self):
|
||||
"""
|
||||
When placing a 2, avoid positions where column partner is a visible 2.
|
||||
|
||||
Setup: Visible 2 at position 0
|
||||
Placing: Another 2
|
||||
Expected: Position 3 should be filtered out (would pair with position 0)
|
||||
"""
|
||||
from ai import filter_bad_pair_positions
|
||||
|
||||
game = create_test_game()
|
||||
player = game.get_player("maya")
|
||||
|
||||
# Position 0 has a visible 2
|
||||
player.cards = [
|
||||
Card(Suit.HEARTS, Rank.TWO, face_up=True), # Pos 0: visible 2
|
||||
Card(Suit.HEARTS, Rank.FIVE, face_up=True), # Pos 1
|
||||
Card(Suit.HEARTS, Rank.SIX, face_up=True), # Pos 2
|
||||
Card(Suit.SPADES, Rank.SEVEN, face_up=False), # Pos 3: column partner of 0
|
||||
Card(Suit.SPADES, Rank.EIGHT, face_up=False), # Pos 4
|
||||
Card(Suit.SPADES, Rank.NINE, face_up=False), # Pos 5
|
||||
]
|
||||
|
||||
drawn_two = Card(Suit.CLUBS, Rank.TWO)
|
||||
face_down = [3, 4, 5]
|
||||
|
||||
safe_positions = filter_bad_pair_positions(face_down, drawn_two, player, game.options)
|
||||
|
||||
# Position 3 should be filtered out (would pair with visible 2 at position 0)
|
||||
assert 3 not in safe_positions, (
|
||||
"Position 3 should be filtered - would create wasteful 2-2 pair"
|
||||
)
|
||||
assert 4 in safe_positions
|
||||
assert 5 in safe_positions
|
||||
|
||||
def test_filter_allows_positive_card_pairs(self):
|
||||
"""
|
||||
Positive value cards can be paired - no filtering needed.
|
||||
|
||||
Pairing a 5 with another 5 is GOOD (saves 10 points).
|
||||
"""
|
||||
from ai import filter_bad_pair_positions
|
||||
|
||||
game = create_test_game()
|
||||
player = game.get_player("maya")
|
||||
|
||||
# Position 0 has a visible 5
|
||||
player.cards = [
|
||||
Card(Suit.HEARTS, Rank.FIVE, face_up=True), # Pos 0: visible 5
|
||||
Card(Suit.HEARTS, Rank.SIX, face_up=True),
|
||||
Card(Suit.HEARTS, Rank.SEVEN, face_up=True),
|
||||
Card(Suit.SPADES, Rank.EIGHT, face_up=False), # Pos 3: column partner
|
||||
Card(Suit.SPADES, Rank.NINE, face_up=False),
|
||||
Card(Suit.SPADES, Rank.TEN, face_up=False),
|
||||
]
|
||||
|
||||
drawn_five = Card(Suit.CLUBS, Rank.FIVE)
|
||||
face_down = [3, 4, 5]
|
||||
|
||||
safe_positions = filter_bad_pair_positions(face_down, drawn_five, player, game.options)
|
||||
|
||||
# All positions should be allowed - pairing 5s is good!
|
||||
assert safe_positions == face_down
|
||||
|
||||
def test_choose_swap_avoids_pairing_twos(self):
|
||||
"""
|
||||
The full choose_swap_or_discard flow should avoid placing 2s
|
||||
in positions that would pair them.
|
||||
|
||||
Run multiple times to verify randomness doesn't cause bad pairs.
|
||||
"""
|
||||
game = create_test_game()
|
||||
maya = game.get_player("maya")
|
||||
profile = get_maya_profile()
|
||||
|
||||
# Position 0 has a visible 2
|
||||
maya.cards = [
|
||||
Card(Suit.HEARTS, Rank.TWO, face_up=True), # Pos 0: visible 2
|
||||
Card(Suit.HEARTS, Rank.FIVE, face_up=True), # Pos 1
|
||||
Card(Suit.HEARTS, Rank.SIX, face_up=True), # Pos 2
|
||||
Card(Suit.SPADES, Rank.SEVEN, face_up=False), # Pos 3: BAD - would pair
|
||||
Card(Suit.SPADES, Rank.EIGHT, face_up=False), # Pos 4: OK
|
||||
Card(Suit.SPADES, Rank.NINE, face_up=False), # Pos 5: OK
|
||||
]
|
||||
|
||||
drawn_two = Card(Suit.CLUBS, Rank.TWO)
|
||||
|
||||
# Run 100 times - should NEVER pick position 3
|
||||
bad_pair_count = 0
|
||||
for _ in range(100):
|
||||
swap_pos = GolfAI.choose_swap_or_discard(drawn_two, maya, profile, game)
|
||||
if swap_pos == 3:
|
||||
bad_pair_count += 1
|
||||
|
||||
assert bad_pair_count == 0, (
|
||||
f"AI picked position 3 (creating 2-2 pair) {bad_pair_count}/100 times. "
|
||||
"Should avoid positions that waste negative card value."
|
||||
)
|
||||
|
||||
def test_forced_swap_avoids_pairing_twos(self):
|
||||
"""
|
||||
Even when forced to swap from discard, AI should avoid bad pairs.
|
||||
"""
|
||||
from ai import filter_bad_pair_positions
|
||||
|
||||
game = create_test_game()
|
||||
player = game.get_player("maya")
|
||||
|
||||
# Position 1 has a visible 2, only positions 3, 4 are face-down
|
||||
player.cards = [
|
||||
Card(Suit.HEARTS, Rank.FIVE, face_up=True),
|
||||
Card(Suit.HEARTS, Rank.TWO, face_up=True), # Pos 1: visible 2
|
||||
Card(Suit.HEARTS, Rank.SIX, face_up=True),
|
||||
Card(Suit.SPADES, Rank.SEVEN, face_up=True),
|
||||
Card(Suit.SPADES, Rank.EIGHT, face_up=False), # Pos 4: BAD - pairs with pos 1
|
||||
Card(Suit.SPADES, Rank.NINE, face_up=False), # Pos 5: OK
|
||||
]
|
||||
|
||||
drawn_two = Card(Suit.CLUBS, Rank.TWO)
|
||||
face_down = [4, 5]
|
||||
|
||||
safe_positions = filter_bad_pair_positions(face_down, drawn_two, player, game.options)
|
||||
|
||||
# Position 4 should be filtered out (would pair with visible 2 at position 1)
|
||||
assert 4 not in safe_positions
|
||||
assert 5 in safe_positions
|
||||
|
||||
def test_all_positions_bad_falls_back(self):
|
||||
"""
|
||||
If ALL positions would create bad pairs, fall back to original list.
|
||||
(Must place the card somewhere)
|
||||
"""
|
||||
from ai import filter_bad_pair_positions
|
||||
|
||||
game = create_test_game()
|
||||
player = game.get_player("maya")
|
||||
|
||||
# Only position 3 is face-down, and it would pair with visible 2 at position 0
|
||||
player.cards = [
|
||||
Card(Suit.HEARTS, Rank.TWO, face_up=True), # Pos 0: visible 2
|
||||
Card(Suit.HEARTS, Rank.FIVE, face_up=True),
|
||||
Card(Suit.HEARTS, Rank.SIX, face_up=True),
|
||||
Card(Suit.SPADES, Rank.SEVEN, face_up=False), # Pos 3: only option, but bad
|
||||
Card(Suit.SPADES, Rank.EIGHT, face_up=True),
|
||||
Card(Suit.SPADES, Rank.NINE, face_up=True),
|
||||
]
|
||||
|
||||
drawn_two = Card(Suit.CLUBS, Rank.TWO)
|
||||
face_down = [3] # Only option
|
||||
|
||||
safe_positions = filter_bad_pair_positions(face_down, drawn_two, player, game.options)
|
||||
|
||||
# Should return original list since there's no alternative
|
||||
assert safe_positions == face_down
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
|
||||
1
server/tests/__init__.py
Normal file
1
server/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tests package for Golf game."""
|
||||
431
server/tests/test_event_replay.py
Normal file
431
server/tests/test_event_replay.py
Normal 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
|
||||
564
server/tests/test_persistence.py
Normal file
564
server/tests/test_persistence.py
Normal 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
302
server/tests/test_replay.py
Normal 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"])
|
||||
5
tests/e2e/.gitignore
vendored
Normal file
5
tests/e2e/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
playwright-report/
|
||||
test-results/
|
||||
*.db
|
||||
*.db-journal
|
||||
255
tests/e2e/bot/actions.ts
Normal file
255
tests/e2e/bot/actions.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Game action executors with proper animation timing
|
||||
*/
|
||||
|
||||
import { Page } from '@playwright/test';
|
||||
import { SELECTORS } from '../utils/selectors';
|
||||
import { TIMING, waitForAnimations } from '../utils/timing';
|
||||
|
||||
/**
|
||||
* Result of a game action
|
||||
*/
|
||||
export interface ActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes game actions on the page
|
||||
*/
|
||||
export class Actions {
|
||||
constructor(private page: Page) {}
|
||||
|
||||
/**
|
||||
* Draw a card from the deck
|
||||
*/
|
||||
async drawFromDeck(): Promise<ActionResult> {
|
||||
try {
|
||||
// Wait for any ongoing animations first
|
||||
await waitForAnimations(this.page);
|
||||
|
||||
const deck = this.page.locator(SELECTORS.game.deck);
|
||||
await deck.waitFor({ state: 'visible', timeout: 5000 });
|
||||
|
||||
// Wait for deck to become clickable (may take a moment after turn starts)
|
||||
let isClickable = false;
|
||||
for (let i = 0; i < 20; i++) {
|
||||
isClickable = await deck.evaluate(el => el.classList.contains('clickable'));
|
||||
if (isClickable) break;
|
||||
await this.page.waitForTimeout(100);
|
||||
}
|
||||
|
||||
if (!isClickable) {
|
||||
return { success: false, error: 'Deck is not clickable' };
|
||||
}
|
||||
|
||||
// Use force:true because deck-area has a pulsing animation that makes it "unstable"
|
||||
await deck.click({ force: true, timeout: 5000 });
|
||||
await this.page.waitForTimeout(TIMING.drawComplete);
|
||||
await waitForAnimations(this.page);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a card from the discard pile
|
||||
*/
|
||||
async drawFromDiscard(): Promise<ActionResult> {
|
||||
try {
|
||||
// Wait for any ongoing animations first
|
||||
await waitForAnimations(this.page);
|
||||
|
||||
const discard = this.page.locator(SELECTORS.game.discard);
|
||||
await discard.waitFor({ state: 'visible', timeout: 5000 });
|
||||
// Use force:true because deck-area has a pulsing animation
|
||||
await discard.click({ force: true, timeout: 5000 });
|
||||
await this.page.waitForTimeout(TIMING.drawComplete);
|
||||
await waitForAnimations(this.page);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Swap drawn card with a card at position
|
||||
*/
|
||||
async swapCard(position: number): Promise<ActionResult> {
|
||||
try {
|
||||
const cardSelector = SELECTORS.cards.playerCard(position);
|
||||
const card = this.page.locator(cardSelector);
|
||||
await card.waitFor({ state: 'visible', timeout: 5000 });
|
||||
// Use force:true to handle any CSS animations
|
||||
await card.click({ force: true, timeout: 5000 });
|
||||
await this.page.waitForTimeout(TIMING.swapComplete);
|
||||
await waitForAnimations(this.page);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Discard the drawn card
|
||||
*/
|
||||
async discardDrawn(): Promise<ActionResult> {
|
||||
try {
|
||||
const discardBtn = this.page.locator(SELECTORS.game.discardBtn);
|
||||
await discardBtn.click();
|
||||
await this.page.waitForTimeout(TIMING.pauseAfterDiscard);
|
||||
await waitForAnimations(this.page);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flip a card at position
|
||||
*/
|
||||
async flipCard(position: number): Promise<ActionResult> {
|
||||
try {
|
||||
// Wait for animations before clicking
|
||||
await waitForAnimations(this.page);
|
||||
|
||||
const cardSelector = SELECTORS.cards.playerCard(position);
|
||||
const card = this.page.locator(cardSelector);
|
||||
await card.waitFor({ state: 'visible', timeout: 5000 });
|
||||
// Use force:true to handle any CSS animations
|
||||
await card.click({ force: true, timeout: 5000 });
|
||||
await this.page.waitForTimeout(TIMING.flipComplete);
|
||||
await waitForAnimations(this.page);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Skip the optional flip (endgame mode)
|
||||
*/
|
||||
async skipFlip(): Promise<ActionResult> {
|
||||
try {
|
||||
const skipBtn = this.page.locator(SELECTORS.game.skipFlipBtn);
|
||||
await skipBtn.click();
|
||||
await this.page.waitForTimeout(TIMING.turnTransition);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Knock early (flip all remaining cards)
|
||||
*/
|
||||
async knockEarly(): Promise<ActionResult> {
|
||||
try {
|
||||
const knockBtn = this.page.locator(SELECTORS.game.knockEarlyBtn);
|
||||
await knockBtn.click();
|
||||
await this.page.waitForTimeout(TIMING.swapComplete);
|
||||
await waitForAnimations(this.page);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for turn to start
|
||||
*/
|
||||
async waitForMyTurn(timeout: number = 30000): Promise<boolean> {
|
||||
try {
|
||||
await this.page.waitForFunction(
|
||||
(sel) => {
|
||||
const deckArea = document.querySelector(sel);
|
||||
return deckArea?.classList.contains('your-turn-to-draw');
|
||||
},
|
||||
SELECTORS.game.deckArea,
|
||||
{ timeout }
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for game phase change
|
||||
*/
|
||||
async waitForPhase(
|
||||
expectedPhases: string[],
|
||||
timeout: number = 30000
|
||||
): Promise<boolean> {
|
||||
const start = Date.now();
|
||||
|
||||
while (Date.now() - start < timeout) {
|
||||
// Check for round over
|
||||
const nextRoundBtn = this.page.locator(SELECTORS.game.nextRoundBtn);
|
||||
if (await nextRoundBtn.isVisible().catch(() => false)) {
|
||||
if (expectedPhases.includes('round_over')) return true;
|
||||
}
|
||||
|
||||
// Check for game over
|
||||
const newGameBtn = this.page.locator(SELECTORS.game.newGameBtn);
|
||||
if (await newGameBtn.isVisible().catch(() => false)) {
|
||||
if (expectedPhases.includes('game_over')) return true;
|
||||
}
|
||||
|
||||
// Check for final turn
|
||||
const finalTurnBadge = this.page.locator(SELECTORS.game.finalTurnBadge);
|
||||
if (await finalTurnBadge.isVisible().catch(() => false)) {
|
||||
if (expectedPhases.includes('final_turn')) return true;
|
||||
}
|
||||
|
||||
// Check for my turn (playing phase)
|
||||
const deckArea = this.page.locator(SELECTORS.game.deckArea);
|
||||
const isMyTurn = await deckArea.evaluate(el =>
|
||||
el.classList.contains('your-turn-to-draw')
|
||||
).catch(() => false);
|
||||
if (isMyTurn && expectedPhases.includes('playing')) return true;
|
||||
|
||||
await this.page.waitForTimeout(100);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the "Next Hole" button to start next round
|
||||
*/
|
||||
async nextRound(): Promise<ActionResult> {
|
||||
try {
|
||||
const btn = this.page.locator(SELECTORS.game.nextRoundBtn);
|
||||
await btn.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await btn.click();
|
||||
await this.page.waitForTimeout(TIMING.roundOverDelay);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the "New Game" button to return to waiting room
|
||||
*/
|
||||
async newGame(): Promise<ActionResult> {
|
||||
try {
|
||||
const btn = this.page.locator(SELECTORS.game.newGameBtn);
|
||||
await btn.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await btn.click();
|
||||
await this.page.waitForTimeout(TIMING.turnTransition);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for animations to complete
|
||||
*/
|
||||
async waitForAnimationComplete(timeout: number = 5000): Promise<void> {
|
||||
await waitForAnimations(this.page, timeout);
|
||||
}
|
||||
}
|
||||
334
tests/e2e/bot/ai-brain.ts
Normal file
334
tests/e2e/bot/ai-brain.ts
Normal file
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* AI decision-making logic for the test bot
|
||||
* Simplified port of server/ai.py for client-side decision making
|
||||
*/
|
||||
|
||||
import { CardState, PlayerState } from './state-parser';
|
||||
|
||||
/**
|
||||
* Card value mapping (standard rules)
|
||||
*/
|
||||
const CARD_VALUES: Record<string, number> = {
|
||||
'★': -2, // Joker
|
||||
'2': -2,
|
||||
'A': 1,
|
||||
'K': 0,
|
||||
'3': 3,
|
||||
'4': 4,
|
||||
'5': 5,
|
||||
'6': 6,
|
||||
'7': 7,
|
||||
'8': 8,
|
||||
'9': 9,
|
||||
'10': 10,
|
||||
'J': 10,
|
||||
'Q': 10,
|
||||
};
|
||||
|
||||
/**
|
||||
* Game options that affect card values
|
||||
*/
|
||||
export interface GameOptions {
|
||||
superKings?: boolean; // K = -2 instead of 0
|
||||
tenPenny?: boolean; // 10 = 1 instead of 10
|
||||
oneEyedJacks?: boolean; // J♥/J♠ = 0
|
||||
eagleEye?: boolean; // Jokers +2 unpaired, -4 paired
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the point value of a card
|
||||
*/
|
||||
export function getCardValue(card: { rank: string | null; suit?: string | null }, options: GameOptions = {}): number {
|
||||
if (!card.rank) return 5; // Unknown card estimated at average
|
||||
|
||||
let value = CARD_VALUES[card.rank] ?? 5;
|
||||
|
||||
// Super Kings rule
|
||||
if (options.superKings && card.rank === 'K') {
|
||||
value = -2;
|
||||
}
|
||||
|
||||
// Ten Penny rule
|
||||
if (options.tenPenny && card.rank === '10') {
|
||||
value = 1;
|
||||
}
|
||||
|
||||
// One-Eyed Jacks rule
|
||||
if (options.oneEyedJacks && card.rank === 'J') {
|
||||
if (card.suit === 'hearts' || card.suit === 'spades') {
|
||||
value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Eagle Eye rule (Jokers are +2 when unpaired)
|
||||
// Note: We can't know pairing status from just one card, so this is informational
|
||||
if (options.eagleEye && card.rank === '★') {
|
||||
value = 2; // Default to unpaired value
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get column partner position (cards that can form pairs)
|
||||
* Column pairs: (0,3), (1,4), (2,5)
|
||||
*/
|
||||
export function getColumnPartner(position: number): number {
|
||||
return position < 3 ? position + 3 : position - 3;
|
||||
}
|
||||
|
||||
/**
|
||||
* AI Brain - makes decisions for the test bot
|
||||
*/
|
||||
export class AIBrain {
|
||||
constructor(private options: GameOptions = {}) {}
|
||||
|
||||
/**
|
||||
* Choose 2 cards for initial flip
|
||||
* Prefer different columns for better pair information
|
||||
*/
|
||||
chooseInitialFlips(cards: CardState[]): number[] {
|
||||
const faceDown = cards.filter(c => !c.faceUp);
|
||||
if (faceDown.length === 0) return [];
|
||||
if (faceDown.length === 1) return [faceDown[0].position];
|
||||
|
||||
// Good initial flip patterns (different columns)
|
||||
const patterns = [
|
||||
[0, 4], [2, 4], [3, 1], [5, 1],
|
||||
[0, 5], [2, 3],
|
||||
];
|
||||
|
||||
// Find a valid pattern
|
||||
for (const pattern of patterns) {
|
||||
const valid = pattern.every(p =>
|
||||
faceDown.some(c => c.position === p)
|
||||
);
|
||||
if (valid) return pattern;
|
||||
}
|
||||
|
||||
// Fallback: pick any two face-down cards in different columns
|
||||
const result: number[] = [];
|
||||
const usedColumns = new Set<number>();
|
||||
|
||||
for (const card of faceDown) {
|
||||
const col = card.position % 3;
|
||||
if (!usedColumns.has(col)) {
|
||||
result.push(card.position);
|
||||
usedColumns.add(col);
|
||||
if (result.length === 2) break;
|
||||
}
|
||||
}
|
||||
|
||||
// If we couldn't get different columns, just take first two
|
||||
if (result.length < 2) {
|
||||
for (const card of faceDown) {
|
||||
if (!result.includes(card.position)) {
|
||||
result.push(card.position);
|
||||
if (result.length === 2) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether to take from discard pile
|
||||
*/
|
||||
shouldTakeDiscard(
|
||||
discardCard: { rank: string; suit: string } | null,
|
||||
myCards: CardState[]
|
||||
): boolean {
|
||||
if (!discardCard) return false;
|
||||
|
||||
const value = getCardValue(discardCard, this.options);
|
||||
|
||||
// Always take Jokers and Kings (excellent cards)
|
||||
if (discardCard.rank === '★' || discardCard.rank === 'K') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Always take negative/low value cards
|
||||
if (value <= 2) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if discard can form a pair with a visible card
|
||||
for (const card of myCards) {
|
||||
if (card.faceUp && card.rank === discardCard.rank) {
|
||||
const partnerPos = getColumnPartner(card.position);
|
||||
const partnerCard = myCards.find(c => c.position === partnerPos);
|
||||
|
||||
// Only pair if partner is face-down (unknown) - pairing negative cards is wasteful
|
||||
if (partnerCard && !partnerCard.faceUp && value > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Take medium cards if we have visible bad cards to replace
|
||||
if (value <= 5) {
|
||||
for (const card of myCards) {
|
||||
if (card.faceUp && card.rank) {
|
||||
const cardValue = getCardValue(card, this.options);
|
||||
if (cardValue > value + 1) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default: draw from deck
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose position to swap drawn card, or null to discard
|
||||
*/
|
||||
chooseSwapPosition(
|
||||
drawnCard: { rank: string; suit?: string | null },
|
||||
myCards: CardState[],
|
||||
mustSwap: boolean = false // True if drawn from discard
|
||||
): number | null {
|
||||
const drawnValue = getCardValue(drawnCard, this.options);
|
||||
|
||||
// Calculate score for each position
|
||||
const scores: { pos: number; score: number }[] = [];
|
||||
|
||||
for (let pos = 0; pos < 6; pos++) {
|
||||
const card = myCards.find(c => c.position === pos);
|
||||
if (!card) continue;
|
||||
|
||||
let score = 0;
|
||||
const partnerPos = getColumnPartner(pos);
|
||||
const partnerCard = myCards.find(c => c.position === partnerPos);
|
||||
|
||||
// Check for pair creation
|
||||
if (partnerCard?.faceUp && partnerCard.rank === drawnCard.rank) {
|
||||
const partnerValue = getCardValue(partnerCard, this.options);
|
||||
|
||||
if (drawnValue >= 0) {
|
||||
// Good pair! Both cards become 0
|
||||
score += drawnValue + partnerValue;
|
||||
} else {
|
||||
// Pairing negative cards is wasteful (unless special rules)
|
||||
score -= Math.abs(drawnValue) * 2;
|
||||
}
|
||||
}
|
||||
|
||||
// Point improvement
|
||||
if (card.faceUp && card.rank) {
|
||||
const currentValue = getCardValue(card, this.options);
|
||||
score += currentValue - drawnValue;
|
||||
} else {
|
||||
// Face-down card - expected value ~4.5
|
||||
const expectedHidden = 4.5;
|
||||
score += (expectedHidden - drawnValue) * 0.7; // Discount for uncertainty
|
||||
}
|
||||
|
||||
// Bonus for revealing hidden cards with good drawn cards
|
||||
if (!card.faceUp && drawnValue <= 3) {
|
||||
score += 2;
|
||||
}
|
||||
|
||||
scores.push({ pos, score });
|
||||
}
|
||||
|
||||
// Sort by score descending
|
||||
scores.sort((a, b) => b.score - a.score);
|
||||
|
||||
// If best score is positive, swap there
|
||||
if (scores.length > 0 && scores[0].score > 0) {
|
||||
return scores[0].pos;
|
||||
}
|
||||
|
||||
// Must swap if drawn from discard
|
||||
if (mustSwap && scores.length > 0) {
|
||||
// Find a face-down position if possible
|
||||
const faceDownScores = scores.filter(s => {
|
||||
const card = myCards.find(c => c.position === s.pos);
|
||||
return card && !card.faceUp;
|
||||
});
|
||||
|
||||
if (faceDownScores.length > 0) {
|
||||
return faceDownScores[0].pos;
|
||||
}
|
||||
|
||||
// Otherwise take the best score even if negative
|
||||
return scores[0].pos;
|
||||
}
|
||||
|
||||
// Discard the drawn card
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose which card to flip after discarding
|
||||
*/
|
||||
chooseFlipPosition(myCards: CardState[]): number {
|
||||
const faceDown = myCards.filter(c => !c.faceUp);
|
||||
if (faceDown.length === 0) return 0;
|
||||
|
||||
// Prefer flipping cards where the partner is visible (pair info)
|
||||
for (const card of faceDown) {
|
||||
const partnerPos = getColumnPartner(card.position);
|
||||
const partner = myCards.find(c => c.position === partnerPos);
|
||||
if (partner?.faceUp) {
|
||||
return card.position;
|
||||
}
|
||||
}
|
||||
|
||||
// Random face-down card
|
||||
return faceDown[Math.floor(Math.random() * faceDown.length)].position;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether to skip optional flip (endgame mode)
|
||||
*/
|
||||
shouldSkipFlip(myCards: CardState[]): boolean {
|
||||
const faceDown = myCards.filter(c => !c.faceUp);
|
||||
|
||||
// Always flip if we have many hidden cards
|
||||
if (faceDown.length >= 3) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Small chance to skip with 1-2 hidden cards
|
||||
return faceDown.length <= 2 && Math.random() < 0.15;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate estimated hand score
|
||||
*/
|
||||
estimateScore(cards: CardState[]): number {
|
||||
let score = 0;
|
||||
|
||||
// Group cards by column for pair detection
|
||||
const columns: (CardState | undefined)[][] = [
|
||||
[cards.find(c => c.position === 0), cards.find(c => c.position === 3)],
|
||||
[cards.find(c => c.position === 1), cards.find(c => c.position === 4)],
|
||||
[cards.find(c => c.position === 2), cards.find(c => c.position === 5)],
|
||||
];
|
||||
|
||||
for (const [top, bottom] of columns) {
|
||||
if (top?.faceUp && bottom?.faceUp) {
|
||||
if (top.rank === bottom.rank) {
|
||||
// Pair - contributes 0
|
||||
continue;
|
||||
}
|
||||
score += getCardValue(top, this.options);
|
||||
score += getCardValue(bottom, this.options);
|
||||
} else if (top?.faceUp) {
|
||||
score += getCardValue(top, this.options);
|
||||
score += 4.5; // Estimate for hidden bottom
|
||||
} else if (bottom?.faceUp) {
|
||||
score += 4.5; // Estimate for hidden top
|
||||
score += getCardValue(bottom, this.options);
|
||||
} else {
|
||||
score += 9; // Both hidden, estimate 4.5 each
|
||||
}
|
||||
}
|
||||
|
||||
return Math.round(score);
|
||||
}
|
||||
}
|
||||
599
tests/e2e/bot/golf-bot.ts
Normal file
599
tests/e2e/bot/golf-bot.ts
Normal file
@@ -0,0 +1,599 @@
|
||||
/**
|
||||
* GolfBot - Main orchestrator for the test bot
|
||||
* Controls browser and coordinates game actions
|
||||
*/
|
||||
|
||||
import { Page, expect } from '@playwright/test';
|
||||
import { StateParser, GamePhase, ParsedGameState } from './state-parser';
|
||||
import { AIBrain, GameOptions } from './ai-brain';
|
||||
import { Actions, ActionResult } from './actions';
|
||||
import { SELECTORS } from '../utils/selectors';
|
||||
import { TIMING, waitForAnimations } from '../utils/timing';
|
||||
|
||||
/**
|
||||
* Options for starting a game
|
||||
*/
|
||||
export interface StartGameOptions {
|
||||
holes?: number;
|
||||
decks?: number;
|
||||
initialFlips?: number;
|
||||
flipMode?: 'never' | 'always' | 'endgame';
|
||||
knockPenalty?: boolean;
|
||||
jokerMode?: 'none' | 'standard' | 'lucky-swing' | 'eagle-eye';
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a turn
|
||||
*/
|
||||
export interface TurnResult {
|
||||
success: boolean;
|
||||
action: string;
|
||||
details?: Record<string, unknown>;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* GolfBot - automated game player for testing
|
||||
*/
|
||||
export class GolfBot {
|
||||
private stateParser: StateParser;
|
||||
private actions: Actions;
|
||||
private brain: AIBrain;
|
||||
private screenshots: { label: string; buffer: Buffer }[] = [];
|
||||
private consoleErrors: string[] = [];
|
||||
private turnCount = 0;
|
||||
|
||||
constructor(
|
||||
private page: Page,
|
||||
aiOptions: GameOptions = {}
|
||||
) {
|
||||
this.stateParser = new StateParser(page);
|
||||
this.actions = new Actions(page);
|
||||
this.brain = new AIBrain(aiOptions);
|
||||
|
||||
// Capture console errors
|
||||
page.on('console', msg => {
|
||||
if (msg.type() === 'error') {
|
||||
this.consoleErrors.push(msg.text());
|
||||
}
|
||||
});
|
||||
|
||||
page.on('pageerror', err => {
|
||||
this.consoleErrors.push(err.message);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the game
|
||||
*/
|
||||
async goto(url?: string): Promise<void> {
|
||||
await this.page.goto(url || '/');
|
||||
await this.page.waitForLoadState('networkidle');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new game room
|
||||
*/
|
||||
async createGame(playerName: string): Promise<string> {
|
||||
// Enter name
|
||||
const nameInput = this.page.locator(SELECTORS.lobby.playerNameInput);
|
||||
await nameInput.fill(playerName);
|
||||
|
||||
// Click create room
|
||||
const createBtn = this.page.locator(SELECTORS.lobby.createRoomBtn);
|
||||
await createBtn.click();
|
||||
|
||||
// Wait for waiting room
|
||||
await this.page.waitForSelector(SELECTORS.screens.waiting, {
|
||||
state: 'visible',
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Get room code
|
||||
const roomCodeEl = this.page.locator(SELECTORS.waiting.roomCode);
|
||||
const roomCode = await roomCodeEl.textContent() || '';
|
||||
|
||||
return roomCode.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Join an existing game room
|
||||
*/
|
||||
async joinGame(roomCode: string, playerName: string): Promise<void> {
|
||||
// Enter name
|
||||
const nameInput = this.page.locator(SELECTORS.lobby.playerNameInput);
|
||||
await nameInput.fill(playerName);
|
||||
|
||||
// Enter room code
|
||||
const codeInput = this.page.locator(SELECTORS.lobby.roomCodeInput);
|
||||
await codeInput.fill(roomCode);
|
||||
|
||||
// Click join
|
||||
const joinBtn = this.page.locator(SELECTORS.lobby.joinRoomBtn);
|
||||
await joinBtn.click();
|
||||
|
||||
// Wait for waiting room
|
||||
await this.page.waitForSelector(SELECTORS.screens.waiting, {
|
||||
state: 'visible',
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a CPU player
|
||||
*/
|
||||
async addCPU(profileName?: string): Promise<void> {
|
||||
// Click add CPU button
|
||||
const addBtn = this.page.locator(SELECTORS.waiting.addCpuBtn);
|
||||
await addBtn.click();
|
||||
|
||||
// Wait for modal
|
||||
await this.page.waitForSelector(SELECTORS.waiting.cpuModal, {
|
||||
state: 'visible',
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
// Select profile if specified
|
||||
if (profileName) {
|
||||
const profileCard = this.page.locator(
|
||||
`${SELECTORS.waiting.cpuProfilesGrid} .profile-card:has-text("${profileName}")`
|
||||
);
|
||||
await profileCard.click();
|
||||
} else {
|
||||
// Select first available profile
|
||||
const firstProfile = this.page.locator(
|
||||
`${SELECTORS.waiting.cpuProfilesGrid} .profile-card:not(.unavailable)`
|
||||
).first();
|
||||
await firstProfile.click();
|
||||
}
|
||||
|
||||
// Click add button
|
||||
const addSelectedBtn = this.page.locator(SELECTORS.waiting.addSelectedCpusBtn);
|
||||
await addSelectedBtn.click();
|
||||
|
||||
// Wait for modal to close
|
||||
await this.page.waitForSelector(SELECTORS.waiting.cpuModal, {
|
||||
state: 'hidden',
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
await this.page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the game
|
||||
*/
|
||||
async startGame(options: StartGameOptions = {}): Promise<void> {
|
||||
// Set game options if host
|
||||
const hostSettings = this.page.locator(SELECTORS.waiting.hostSettings);
|
||||
|
||||
if (await hostSettings.isVisible()) {
|
||||
if (options.holes) {
|
||||
await this.page.selectOption(SELECTORS.waiting.numRounds, String(options.holes));
|
||||
}
|
||||
if (options.decks) {
|
||||
await this.page.selectOption(SELECTORS.waiting.numDecks, String(options.decks));
|
||||
}
|
||||
if (options.initialFlips !== undefined) {
|
||||
await this.page.selectOption(SELECTORS.waiting.initialFlips, String(options.initialFlips));
|
||||
}
|
||||
|
||||
// Advanced options require opening the details section first
|
||||
if (options.flipMode) {
|
||||
const advancedSection = this.page.locator('.advanced-options-section');
|
||||
if (await advancedSection.isVisible()) {
|
||||
// Check if it's already open
|
||||
const isOpen = await advancedSection.evaluate(el => el.hasAttribute('open'));
|
||||
if (!isOpen) {
|
||||
await advancedSection.locator('summary').click();
|
||||
await this.page.waitForTimeout(300);
|
||||
}
|
||||
}
|
||||
await this.page.selectOption(SELECTORS.waiting.flipMode, options.flipMode);
|
||||
}
|
||||
}
|
||||
|
||||
// Click start game
|
||||
const startBtn = this.page.locator(SELECTORS.waiting.startGameBtn);
|
||||
await startBtn.click();
|
||||
|
||||
// Wait for game screen
|
||||
await this.page.waitForSelector(SELECTORS.screens.game, {
|
||||
state: 'visible',
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
await waitForAnimations(this.page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current game phase
|
||||
*/
|
||||
async getGamePhase(): Promise<GamePhase> {
|
||||
return this.stateParser.getPhase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full game state
|
||||
*/
|
||||
async getGameState(): Promise<ParsedGameState> {
|
||||
return this.stateParser.getState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if it's bot's turn
|
||||
*/
|
||||
async isMyTurn(): Promise<boolean> {
|
||||
return this.stateParser.isMyTurn();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for bot's turn
|
||||
*/
|
||||
async waitForMyTurn(timeout: number = 30000): Promise<boolean> {
|
||||
return this.actions.waitForMyTurn(timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for any animation to complete
|
||||
*/
|
||||
async waitForAnimation(): Promise<void> {
|
||||
await waitForAnimations(this.page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a complete turn
|
||||
*/
|
||||
async playTurn(): Promise<TurnResult> {
|
||||
this.turnCount++;
|
||||
const state = await this.getGameState();
|
||||
|
||||
// Handle initial flip phase
|
||||
if (state.phase === 'initial_flip') {
|
||||
return this.handleInitialFlip(state);
|
||||
}
|
||||
|
||||
// Handle waiting for flip after discard
|
||||
if (state.phase === 'waiting_for_flip') {
|
||||
return this.handleWaitingForFlip(state);
|
||||
}
|
||||
|
||||
// Regular turn
|
||||
if (!state.heldCard.visible) {
|
||||
// Need to draw
|
||||
return this.handleDraw(state);
|
||||
} else {
|
||||
// Have a card, need to swap or discard
|
||||
return this.handleSwapOrDiscard(state);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle initial flip phase
|
||||
*/
|
||||
private async handleInitialFlip(state: ParsedGameState): Promise<TurnResult> {
|
||||
const myCards = state.myPlayer?.cards || [];
|
||||
const faceDownPositions = myCards.filter(c => !c.faceUp).map(c => c.position);
|
||||
|
||||
if (faceDownPositions.length === 0) {
|
||||
return { success: true, action: 'initial_flip_complete' };
|
||||
}
|
||||
|
||||
// Choose cards to flip
|
||||
const toFlip = this.brain.chooseInitialFlips(myCards);
|
||||
|
||||
for (const pos of toFlip) {
|
||||
if (faceDownPositions.includes(pos)) {
|
||||
const result = await this.actions.flipCard(pos);
|
||||
if (!result.success) {
|
||||
return { success: false, action: 'initial_flip', error: result.error };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
action: 'initial_flip',
|
||||
details: { positions: toFlip },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle draw phase
|
||||
*/
|
||||
private async handleDraw(state: ParsedGameState): Promise<TurnResult> {
|
||||
const myCards = state.myPlayer?.cards || [];
|
||||
const discardTop = state.discard.topCard;
|
||||
|
||||
// Decide: discard or deck
|
||||
const takeDiscard = this.brain.shouldTakeDiscard(discardTop, myCards);
|
||||
|
||||
let result: ActionResult;
|
||||
let source: string;
|
||||
|
||||
if (takeDiscard && state.discard.clickable) {
|
||||
result = await this.actions.drawFromDiscard();
|
||||
source = 'discard';
|
||||
} else {
|
||||
result = await this.actions.drawFromDeck();
|
||||
source = 'deck';
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, action: 'draw', error: result.error };
|
||||
}
|
||||
|
||||
// Wait for held card to be visible
|
||||
await this.page.waitForTimeout(500);
|
||||
|
||||
// Now handle swap or discard
|
||||
const newState = await this.getGameState();
|
||||
|
||||
// If held card still not visible, wait a bit more and retry
|
||||
if (!newState.heldCard.visible) {
|
||||
await this.page.waitForTimeout(500);
|
||||
const retryState = await this.getGameState();
|
||||
return this.handleSwapOrDiscard(retryState, source === 'discard');
|
||||
}
|
||||
|
||||
return this.handleSwapOrDiscard(newState, source === 'discard');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle swap or discard decision
|
||||
*/
|
||||
private async handleSwapOrDiscard(
|
||||
state: ParsedGameState,
|
||||
mustSwap: boolean = false
|
||||
): Promise<TurnResult> {
|
||||
const myCards = state.myPlayer?.cards || [];
|
||||
const heldCard = state.heldCard.card;
|
||||
|
||||
if (!heldCard) {
|
||||
return { success: false, action: 'swap_or_discard', error: 'No held card' };
|
||||
}
|
||||
|
||||
// Decide: swap position or discard
|
||||
const swapPos = this.brain.chooseSwapPosition(heldCard, myCards, mustSwap);
|
||||
|
||||
if (swapPos !== null) {
|
||||
// Swap
|
||||
const result = await this.actions.swapCard(swapPos);
|
||||
return {
|
||||
success: result.success,
|
||||
action: 'swap',
|
||||
details: { position: swapPos, card: heldCard },
|
||||
error: result.error,
|
||||
};
|
||||
} else {
|
||||
// Discard
|
||||
const result = await this.actions.discardDrawn();
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, action: 'discard', error: result.error };
|
||||
}
|
||||
|
||||
// Check if we need to flip
|
||||
await this.page.waitForTimeout(200);
|
||||
const afterState = await this.getGameState();
|
||||
|
||||
if (afterState.phase === 'waiting_for_flip') {
|
||||
return this.handleWaitingForFlip(afterState);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
action: 'discard',
|
||||
details: { card: heldCard },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle waiting for flip after discard
|
||||
*/
|
||||
private async handleWaitingForFlip(state: ParsedGameState): Promise<TurnResult> {
|
||||
const myCards = state.myPlayer?.cards || [];
|
||||
|
||||
// Check if flip is optional
|
||||
if (state.canSkipFlip) {
|
||||
if (this.brain.shouldSkipFlip(myCards)) {
|
||||
const result = await this.actions.skipFlip();
|
||||
return {
|
||||
success: result.success,
|
||||
action: 'skip_flip',
|
||||
error: result.error,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Choose a card to flip
|
||||
const pos = this.brain.chooseFlipPosition(myCards);
|
||||
const result = await this.actions.flipCard(pos);
|
||||
|
||||
return {
|
||||
success: result.success,
|
||||
action: 'flip',
|
||||
details: { position: pos },
|
||||
error: result.error,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a screenshot with label
|
||||
*/
|
||||
async takeScreenshot(label: string): Promise<Buffer> {
|
||||
const buffer = await this.page.screenshot();
|
||||
this.screenshots.push({ label, buffer });
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all collected screenshots
|
||||
*/
|
||||
getScreenshots(): { label: string; buffer: Buffer }[] {
|
||||
return this.screenshots;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get console errors collected
|
||||
*/
|
||||
getConsoleErrors(): string[] {
|
||||
return this.consoleErrors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear console errors
|
||||
*/
|
||||
clearConsoleErrors(): void {
|
||||
this.consoleErrors = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the UI appears frozen (animation stuck)
|
||||
*/
|
||||
async isFrozen(timeout: number = 3000): Promise<boolean> {
|
||||
try {
|
||||
await waitForAnimations(this.page, timeout);
|
||||
return false;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get turn count
|
||||
*/
|
||||
getTurnCount(): number {
|
||||
return this.turnCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Play through initial flip phase completely
|
||||
*/
|
||||
async completeInitialFlips(): Promise<void> {
|
||||
let phase = await this.getGamePhase();
|
||||
let attempts = 0;
|
||||
const maxAttempts = 10;
|
||||
|
||||
while (phase === 'initial_flip' && attempts < maxAttempts) {
|
||||
if (await this.isMyTurn()) {
|
||||
await this.playTurn();
|
||||
}
|
||||
await this.page.waitForTimeout(500);
|
||||
phase = await this.getGamePhase();
|
||||
attempts++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Play through entire round
|
||||
*/
|
||||
async playRound(maxTurns: number = 100): Promise<{ success: boolean; turns: number }> {
|
||||
let turns = 0;
|
||||
|
||||
while (turns < maxTurns) {
|
||||
const phase = await this.getGamePhase();
|
||||
|
||||
if (phase === 'round_over' || phase === 'game_over') {
|
||||
return { success: true, turns };
|
||||
}
|
||||
|
||||
if (await this.isMyTurn()) {
|
||||
const result = await this.playTurn();
|
||||
if (!result.success) {
|
||||
console.warn(`Turn ${turns} failed:`, result.error);
|
||||
}
|
||||
turns++;
|
||||
}
|
||||
|
||||
await this.page.waitForTimeout(200);
|
||||
|
||||
// Check for frozen state
|
||||
if (await this.isFrozen()) {
|
||||
return { success: false, turns };
|
||||
}
|
||||
}
|
||||
|
||||
return { success: false, turns };
|
||||
}
|
||||
|
||||
/**
|
||||
* Play through entire game (all rounds)
|
||||
*/
|
||||
async playGame(maxRounds: number = 18): Promise<{
|
||||
success: boolean;
|
||||
rounds: number;
|
||||
totalTurns: number;
|
||||
}> {
|
||||
let rounds = 0;
|
||||
let totalTurns = 0;
|
||||
|
||||
while (rounds < maxRounds) {
|
||||
const phase = await this.getGamePhase();
|
||||
|
||||
if (phase === 'game_over') {
|
||||
return { success: true, rounds, totalTurns };
|
||||
}
|
||||
|
||||
// Complete initial flips first
|
||||
await this.completeInitialFlips();
|
||||
|
||||
// Play the round
|
||||
const roundResult = await this.playRound();
|
||||
totalTurns += roundResult.turns;
|
||||
rounds++;
|
||||
|
||||
if (!roundResult.success) {
|
||||
return { success: false, rounds, totalTurns };
|
||||
}
|
||||
|
||||
// Check for game over
|
||||
let newPhase = await this.getGamePhase();
|
||||
if (newPhase === 'game_over') {
|
||||
return { success: true, rounds, totalTurns };
|
||||
}
|
||||
|
||||
// Check if this was the final round
|
||||
const state = await this.getGameState();
|
||||
const isLastRound = state.currentRound >= state.totalRounds;
|
||||
|
||||
// If last round just ended, wait for game_over or trigger it
|
||||
if (newPhase === 'round_over' && isLastRound) {
|
||||
// Wait a few seconds for auto-transition or countdown
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await this.page.waitForTimeout(1000);
|
||||
newPhase = await this.getGamePhase();
|
||||
if (newPhase === 'game_over') {
|
||||
return { success: true, rounds, totalTurns };
|
||||
}
|
||||
}
|
||||
|
||||
// Game might require clicking Next Hole to show Final Results
|
||||
// Try clicking the button to trigger the transition
|
||||
const nextResult = await this.actions.nextRound();
|
||||
|
||||
// Wait for Final Results modal to appear
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await this.page.waitForTimeout(1000);
|
||||
newPhase = await this.getGamePhase();
|
||||
if (newPhase === 'game_over') {
|
||||
return { success: true, rounds, totalTurns };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start next round if available
|
||||
if (newPhase === 'round_over') {
|
||||
await this.page.waitForTimeout(1000);
|
||||
const nextResult = await this.actions.nextRound();
|
||||
if (!nextResult.success) {
|
||||
// Maybe we're not the host, wait for host to start
|
||||
await this.page.waitForTimeout(5000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, rounds, totalTurns };
|
||||
}
|
||||
}
|
||||
10
tests/e2e/bot/index.ts
Normal file
10
tests/e2e/bot/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export { GolfBot, StartGameOptions, TurnResult } from './golf-bot';
|
||||
export { AIBrain, GameOptions, getCardValue, getColumnPartner } from './ai-brain';
|
||||
export { Actions, ActionResult } from './actions';
|
||||
export {
|
||||
StateParser,
|
||||
CardState,
|
||||
PlayerState,
|
||||
ParsedGameState,
|
||||
GamePhase,
|
||||
} from './state-parser';
|
||||
524
tests/e2e/bot/state-parser.ts
Normal file
524
tests/e2e/bot/state-parser.ts
Normal file
@@ -0,0 +1,524 @@
|
||||
import { Page, Locator } from '@playwright/test';
|
||||
import { SELECTORS } from '../utils/selectors';
|
||||
|
||||
/**
|
||||
* Represents a card's state as extracted from the DOM
|
||||
*/
|
||||
export interface CardState {
|
||||
position: number;
|
||||
faceUp: boolean;
|
||||
rank: string | null;
|
||||
suit: string | null;
|
||||
clickable: boolean;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a player's state as extracted from the DOM
|
||||
*/
|
||||
export interface PlayerState {
|
||||
name: string;
|
||||
cards: CardState[];
|
||||
isCurrentTurn: boolean;
|
||||
score: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the overall game state as extracted from the DOM
|
||||
*/
|
||||
export interface ParsedGameState {
|
||||
phase: GamePhase;
|
||||
currentRound: number;
|
||||
totalRounds: number;
|
||||
statusMessage: string;
|
||||
isFinalTurn: boolean;
|
||||
myPlayer: PlayerState | null;
|
||||
opponents: PlayerState[];
|
||||
deck: {
|
||||
clickable: boolean;
|
||||
};
|
||||
discard: {
|
||||
hasCard: boolean;
|
||||
clickable: boolean;
|
||||
pickedUp: boolean;
|
||||
topCard: { rank: string; suit: string } | null;
|
||||
};
|
||||
heldCard: {
|
||||
visible: boolean;
|
||||
card: { rank: string; suit: string } | null;
|
||||
};
|
||||
canDiscard: boolean;
|
||||
canSkipFlip: boolean;
|
||||
canKnockEarly: boolean;
|
||||
}
|
||||
|
||||
export type GamePhase =
|
||||
| 'lobby'
|
||||
| 'waiting'
|
||||
| 'initial_flip'
|
||||
| 'playing'
|
||||
| 'waiting_for_flip'
|
||||
| 'final_turn'
|
||||
| 'round_over'
|
||||
| 'game_over';
|
||||
|
||||
/**
|
||||
* Parses game state from the DOM
|
||||
* This allows visual validation - the DOM should reflect the internal game state
|
||||
*/
|
||||
export class StateParser {
|
||||
constructor(private page: Page) {}
|
||||
|
||||
/**
|
||||
* Get the current screen/phase
|
||||
*/
|
||||
async getPhase(): Promise<GamePhase> {
|
||||
// Check which screen is active
|
||||
const lobbyVisible = await this.isVisible(SELECTORS.screens.lobby);
|
||||
if (lobbyVisible) return 'lobby';
|
||||
|
||||
const waitingVisible = await this.isVisible(SELECTORS.screens.waiting);
|
||||
if (waitingVisible) return 'waiting';
|
||||
|
||||
const gameVisible = await this.isVisible(SELECTORS.screens.game);
|
||||
if (!gameVisible) return 'lobby';
|
||||
|
||||
// We're in the game screen - determine game phase
|
||||
const statusText = await this.getStatusMessage();
|
||||
const gameButtons = await this.isVisible(SELECTORS.game.gameButtons);
|
||||
|
||||
// Check for game over - Final Results modal or "New Game" button visible
|
||||
const finalResultsModal = this.page.locator('#final-results-modal');
|
||||
if (await finalResultsModal.isVisible().catch(() => false)) {
|
||||
return 'game_over';
|
||||
}
|
||||
const newGameBtn = this.page.locator(SELECTORS.game.newGameBtn);
|
||||
if (await newGameBtn.isVisible().catch(() => false)) {
|
||||
return 'game_over';
|
||||
}
|
||||
|
||||
// Check for round over (Next Hole button visible)
|
||||
const nextRoundBtn = this.page.locator(SELECTORS.game.nextRoundBtn);
|
||||
if (await nextRoundBtn.isVisible().catch(() => false)) {
|
||||
// Check if this is the last round - if so, might be transitioning to game_over
|
||||
const currentRound = await this.getCurrentRound();
|
||||
const totalRounds = await this.getTotalRounds();
|
||||
|
||||
// If on last round and all cards revealed, this is effectively game_over
|
||||
if (currentRound >= totalRounds) {
|
||||
// Check the button text - if it doesn't mention "Next", might be game over
|
||||
const btnText = await nextRoundBtn.textContent().catch(() => '');
|
||||
if (btnText && !btnText.toLowerCase().includes('next')) {
|
||||
return 'game_over';
|
||||
}
|
||||
// Still round_over but will transition to game_over soon
|
||||
}
|
||||
return 'round_over';
|
||||
}
|
||||
|
||||
// Check for final turn badge
|
||||
const finalTurnBadge = this.page.locator(SELECTORS.game.finalTurnBadge);
|
||||
if (await finalTurnBadge.isVisible().catch(() => false)) {
|
||||
return 'final_turn';
|
||||
}
|
||||
|
||||
// Check if waiting for initial flip
|
||||
if (statusText.toLowerCase().includes('flip') &&
|
||||
statusText.toLowerCase().includes('card')) {
|
||||
// Could be initial flip or flip after discard
|
||||
const skipFlipBtn = this.page.locator(SELECTORS.game.skipFlipBtn);
|
||||
if (await skipFlipBtn.isVisible().catch(() => false)) {
|
||||
return 'waiting_for_flip';
|
||||
}
|
||||
|
||||
// Check if we're in initial flip phase (multiple cards to flip)
|
||||
const myCards = await this.getMyCards();
|
||||
const faceUpCount = myCards.filter(c => c.faceUp).length;
|
||||
if (faceUpCount < 2) {
|
||||
return 'initial_flip';
|
||||
}
|
||||
return 'waiting_for_flip';
|
||||
}
|
||||
|
||||
return 'playing';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full parsed game state
|
||||
*/
|
||||
async getState(): Promise<ParsedGameState> {
|
||||
const phase = await this.getPhase();
|
||||
|
||||
return {
|
||||
phase,
|
||||
currentRound: await this.getCurrentRound(),
|
||||
totalRounds: await this.getTotalRounds(),
|
||||
statusMessage: await this.getStatusMessage(),
|
||||
isFinalTurn: await this.isFinalTurn(),
|
||||
myPlayer: await this.getMyPlayer(),
|
||||
opponents: await this.getOpponents(),
|
||||
deck: {
|
||||
clickable: await this.isDeckClickable(),
|
||||
},
|
||||
discard: {
|
||||
hasCard: await this.discardHasCard(),
|
||||
clickable: await this.isDiscardClickable(),
|
||||
pickedUp: await this.isDiscardPickedUp(),
|
||||
topCard: await this.getDiscardTop(),
|
||||
},
|
||||
heldCard: {
|
||||
visible: await this.isHeldCardVisible(),
|
||||
card: await this.getHeldCard(),
|
||||
},
|
||||
canDiscard: await this.isVisible(SELECTORS.game.discardBtn),
|
||||
canSkipFlip: await this.isVisible(SELECTORS.game.skipFlipBtn),
|
||||
canKnockEarly: await this.isVisible(SELECTORS.game.knockEarlyBtn),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current round number
|
||||
*/
|
||||
async getCurrentRound(): Promise<number> {
|
||||
const text = await this.getText(SELECTORS.game.currentRound);
|
||||
return parseInt(text) || 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total rounds
|
||||
*/
|
||||
async getTotalRounds(): Promise<number> {
|
||||
const text = await this.getText(SELECTORS.game.totalRounds);
|
||||
return parseInt(text) || 9;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status message text
|
||||
*/
|
||||
async getStatusMessage(): Promise<string> {
|
||||
return this.getText(SELECTORS.game.statusMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if final turn badge is visible
|
||||
*/
|
||||
async isFinalTurn(): Promise<boolean> {
|
||||
return this.isVisible(SELECTORS.game.finalTurnBadge);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get local player's state
|
||||
*/
|
||||
async getMyPlayer(): Promise<PlayerState | null> {
|
||||
const playerArea = this.page.locator(SELECTORS.game.playerArea).first();
|
||||
if (!await playerArea.isVisible().catch(() => false)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nameEl = playerArea.locator('.player-name');
|
||||
const name = await nameEl.textContent().catch(() => 'You') || 'You';
|
||||
|
||||
const scoreEl = playerArea.locator(SELECTORS.game.yourScore);
|
||||
const scoreText = await scoreEl.textContent().catch(() => '0') || '0';
|
||||
const score = parseInt(scoreText) || 0;
|
||||
|
||||
const cards = await this.getMyCards();
|
||||
const isCurrentTurn = await this.isMyTurn();
|
||||
|
||||
return { name, cards, isCurrentTurn, score };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cards for local player
|
||||
*/
|
||||
async getMyCards(): Promise<CardState[]> {
|
||||
const cards: CardState[] = [];
|
||||
const cardContainer = this.page.locator(SELECTORS.game.playerCards);
|
||||
|
||||
const cardEls = cardContainer.locator('.card, .card-slot .card');
|
||||
const count = await cardEls.count();
|
||||
|
||||
for (let i = 0; i < Math.min(count, 6); i++) {
|
||||
const cardEl = cardEls.nth(i);
|
||||
cards.push(await this.parseCard(cardEl, i));
|
||||
}
|
||||
|
||||
return cards;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get opponent players' states
|
||||
*/
|
||||
async getOpponents(): Promise<PlayerState[]> {
|
||||
const opponents: PlayerState[] = [];
|
||||
const opponentAreas = this.page.locator('.opponent-area');
|
||||
const count = await opponentAreas.count();
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const area = opponentAreas.nth(i);
|
||||
const nameEl = area.locator('.opponent-name');
|
||||
const name = await nameEl.textContent().catch(() => `Opponent ${i + 1}`) || `Opponent ${i + 1}`;
|
||||
|
||||
const scoreEl = area.locator('.opponent-showing');
|
||||
const scoreText = await scoreEl.textContent().catch(() => null);
|
||||
const score = scoreText ? parseInt(scoreText) : null;
|
||||
|
||||
const isCurrentTurn = await area.evaluate(el =>
|
||||
el.classList.contains('current-turn')
|
||||
);
|
||||
|
||||
const cards: CardState[] = [];
|
||||
const cardEls = area.locator('.card-grid .card');
|
||||
const cardCount = await cardEls.count();
|
||||
|
||||
for (let j = 0; j < Math.min(cardCount, 6); j++) {
|
||||
cards.push(await this.parseCard(cardEls.nth(j), j));
|
||||
}
|
||||
|
||||
opponents.push({ name, cards, isCurrentTurn, score });
|
||||
}
|
||||
|
||||
return opponents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single card element
|
||||
*/
|
||||
private async parseCard(cardEl: Locator, position: number): Promise<CardState> {
|
||||
const classList = await cardEl.evaluate(el => Array.from(el.classList));
|
||||
|
||||
// Face-down cards have 'card-back' class, face-up have 'card-front' class
|
||||
const faceUp = classList.includes('card-front');
|
||||
const clickable = classList.includes('clickable');
|
||||
const selected = classList.includes('selected');
|
||||
|
||||
let rank: string | null = null;
|
||||
let suit: string | null = null;
|
||||
|
||||
if (faceUp) {
|
||||
const content = await cardEl.textContent().catch(() => '') || '';
|
||||
|
||||
// Check for joker
|
||||
if (classList.includes('joker') || content.toLowerCase().includes('joker')) {
|
||||
rank = '★';
|
||||
// Determine suit from icon
|
||||
if (content.includes('🐉')) {
|
||||
suit = 'hearts';
|
||||
} else if (content.includes('👹')) {
|
||||
suit = 'spades';
|
||||
}
|
||||
} else {
|
||||
// Parse rank and suit from text
|
||||
const lines = content.split('\n').map(l => l.trim()).filter(l => l);
|
||||
if (lines.length >= 2) {
|
||||
rank = lines[0];
|
||||
suit = this.parseSuitSymbol(lines[1]);
|
||||
} else if (lines.length === 1) {
|
||||
// Try to extract rank from combined text
|
||||
const text = lines[0];
|
||||
const rankMatch = text.match(/^([AKQJ]|10|[2-9])/);
|
||||
if (rankMatch) {
|
||||
rank = rankMatch[1];
|
||||
const suitPart = text.slice(rank.length);
|
||||
suit = this.parseSuitSymbol(suitPart);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { position, faceUp, rank, suit, clickable, selected };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse suit symbol to suit name
|
||||
*/
|
||||
private parseSuitSymbol(symbol: string): string | null {
|
||||
const cleaned = symbol.trim();
|
||||
if (cleaned.includes('♥') || cleaned.includes('hearts')) return 'hearts';
|
||||
if (cleaned.includes('♦') || cleaned.includes('diamonds')) return 'diamonds';
|
||||
if (cleaned.includes('♣') || cleaned.includes('clubs')) return 'clubs';
|
||||
if (cleaned.includes('♠') || cleaned.includes('spades')) return 'spades';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if it's the local player's turn
|
||||
*/
|
||||
async isMyTurn(): Promise<boolean> {
|
||||
// Check if deck area has your-turn-to-draw class
|
||||
const deckArea = this.page.locator(SELECTORS.game.deckArea);
|
||||
const hasClass = await deckArea.evaluate(el =>
|
||||
el.classList.contains('your-turn-to-draw')
|
||||
).catch(() => false);
|
||||
|
||||
if (hasClass) return true;
|
||||
|
||||
// Check status message
|
||||
const status = await this.getStatusMessage();
|
||||
const statusLower = status.toLowerCase();
|
||||
|
||||
// Various indicators that it's our turn
|
||||
if (statusLower.includes('your turn')) return true;
|
||||
if (statusLower.includes('select') && statusLower.includes('card')) return true; // Initial flip
|
||||
if (statusLower.includes('flip a card')) return true;
|
||||
if (statusLower.includes('choose a card')) return true;
|
||||
|
||||
// Check if our cards are clickable (another indicator)
|
||||
const clickableCards = await this.getClickablePositions();
|
||||
if (clickableCards.length > 0) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if deck is clickable
|
||||
*/
|
||||
async isDeckClickable(): Promise<boolean> {
|
||||
const deck = this.page.locator(SELECTORS.game.deck);
|
||||
return deck.evaluate(el => el.classList.contains('clickable')).catch(() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if discard pile has a card
|
||||
*/
|
||||
async discardHasCard(): Promise<boolean> {
|
||||
const discard = this.page.locator(SELECTORS.game.discard);
|
||||
return discard.evaluate(el =>
|
||||
el.classList.contains('has-card') || el.classList.contains('card-front')
|
||||
).catch(() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if discard is clickable
|
||||
*/
|
||||
async isDiscardClickable(): Promise<boolean> {
|
||||
const discard = this.page.locator(SELECTORS.game.discard);
|
||||
return discard.evaluate(el =>
|
||||
el.classList.contains('clickable') && !el.classList.contains('disabled')
|
||||
).catch(() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if discard card is picked up (floating)
|
||||
*/
|
||||
async isDiscardPickedUp(): Promise<boolean> {
|
||||
const discard = this.page.locator(SELECTORS.game.discard);
|
||||
return discard.evaluate(el => el.classList.contains('picked-up')).catch(() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the top card of the discard pile
|
||||
*/
|
||||
async getDiscardTop(): Promise<{ rank: string; suit: string } | null> {
|
||||
const hasCard = await this.discardHasCard();
|
||||
if (!hasCard) return null;
|
||||
|
||||
const content = await this.page.locator(SELECTORS.game.discardContent).textContent()
|
||||
.catch(() => null);
|
||||
if (!content) return null;
|
||||
|
||||
return this.parseCardContent(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if held card is visible
|
||||
*/
|
||||
async isHeldCardVisible(): Promise<boolean> {
|
||||
const floating = this.page.locator(SELECTORS.game.heldCardFloating);
|
||||
return floating.isVisible().catch(() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get held card details
|
||||
*/
|
||||
async getHeldCard(): Promise<{ rank: string; suit: string } | null> {
|
||||
const visible = await this.isHeldCardVisible();
|
||||
if (!visible) return null;
|
||||
|
||||
const content = await this.page.locator(SELECTORS.game.heldCardFloatingContent)
|
||||
.textContent().catch(() => null);
|
||||
if (!content) return null;
|
||||
|
||||
return this.parseCardContent(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse card content text (from held card, discard, etc.)
|
||||
*/
|
||||
private parseCardContent(content: string): { rank: string; suit: string } | null {
|
||||
// Handle jokers
|
||||
if (content.toLowerCase().includes('joker')) {
|
||||
const suit = content.includes('🐉') ? 'hearts' : 'spades';
|
||||
return { rank: '★', suit };
|
||||
}
|
||||
|
||||
// Try to parse rank and suit
|
||||
// Content may be "7\n♥" (with newline) or "7♥" (combined)
|
||||
const lines = content.split('\n').map(l => l.trim()).filter(l => l);
|
||||
|
||||
if (lines.length >= 2) {
|
||||
// Two separate lines
|
||||
return {
|
||||
rank: lines[0],
|
||||
suit: this.parseSuitSymbol(lines[1]) || 'unknown',
|
||||
};
|
||||
} else if (lines.length === 1) {
|
||||
const text = lines[0];
|
||||
// Try to extract rank (A, K, Q, J, 10, or 2-9)
|
||||
const rankMatch = text.match(/^(10|[AKQJ2-9])/);
|
||||
if (rankMatch) {
|
||||
const rank = rankMatch[1];
|
||||
const suitPart = text.slice(rank.length);
|
||||
const suit = this.parseSuitSymbol(suitPart);
|
||||
if (suit) {
|
||||
return { rank, suit };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count face-up cards for local player
|
||||
*/
|
||||
async countFaceUpCards(): Promise<number> {
|
||||
const cards = await this.getMyCards();
|
||||
return cards.filter(c => c.faceUp).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count face-down cards for local player
|
||||
*/
|
||||
async countFaceDownCards(): Promise<number> {
|
||||
const cards = await this.getMyCards();
|
||||
return cards.filter(c => !c.faceUp).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get positions of clickable cards
|
||||
*/
|
||||
async getClickablePositions(): Promise<number[]> {
|
||||
const cards = await this.getMyCards();
|
||||
return cards.filter(c => c.clickable).map(c => c.position);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get positions of face-down cards
|
||||
*/
|
||||
async getFaceDownPositions(): Promise<number[]> {
|
||||
const cards = await this.getMyCards();
|
||||
return cards.filter(c => !c.faceUp).map(c => c.position);
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
private async isVisible(selector: string): Promise<boolean> {
|
||||
const el = this.page.locator(selector);
|
||||
return el.isVisible().catch(() => false);
|
||||
}
|
||||
|
||||
private async getText(selector: string): Promise<string> {
|
||||
const el = this.page.locator(selector);
|
||||
return (await el.textContent().catch(() => '')) || '';
|
||||
}
|
||||
}
|
||||
231
tests/e2e/health/animation-tracker.ts
Normal file
231
tests/e2e/health/animation-tracker.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* Animation tracker - monitors animation completion and timing
|
||||
*/
|
||||
|
||||
import { Page } from '@playwright/test';
|
||||
import { TIMING } from '../utils/timing';
|
||||
|
||||
/**
|
||||
* Animation event
|
||||
*/
|
||||
export interface AnimationEvent {
|
||||
type: 'start' | 'complete' | 'stall';
|
||||
animationType?: string;
|
||||
duration?: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* AnimationTracker - tracks animation states
|
||||
*/
|
||||
export class AnimationTracker {
|
||||
private events: AnimationEvent[] = [];
|
||||
private animationStartTime: number | null = null;
|
||||
|
||||
constructor(private page: Page) {}
|
||||
|
||||
/**
|
||||
* Record animation start
|
||||
*/
|
||||
recordStart(type?: string): void {
|
||||
this.animationStartTime = Date.now();
|
||||
this.events.push({
|
||||
type: 'start',
|
||||
animationType: type,
|
||||
timestamp: this.animationStartTime,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Record animation complete
|
||||
*/
|
||||
recordComplete(type?: string): void {
|
||||
const now = Date.now();
|
||||
const duration = this.animationStartTime
|
||||
? now - this.animationStartTime
|
||||
: undefined;
|
||||
|
||||
this.events.push({
|
||||
type: 'complete',
|
||||
animationType: type,
|
||||
duration,
|
||||
timestamp: now,
|
||||
});
|
||||
|
||||
this.animationStartTime = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record animation stall
|
||||
*/
|
||||
recordStall(type?: string): void {
|
||||
const now = Date.now();
|
||||
const duration = this.animationStartTime
|
||||
? now - this.animationStartTime
|
||||
: undefined;
|
||||
|
||||
this.events.push({
|
||||
type: 'stall',
|
||||
animationType: type,
|
||||
duration,
|
||||
timestamp: now,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if animation queue is animating
|
||||
*/
|
||||
async isAnimating(): Promise<boolean> {
|
||||
try {
|
||||
return await this.page.evaluate(() => {
|
||||
const game = (window as any).game;
|
||||
return game?.animationQueue?.isAnimating() ?? false;
|
||||
});
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get animation queue length
|
||||
*/
|
||||
async getQueueLength(): Promise<number> {
|
||||
try {
|
||||
return await this.page.evaluate(() => {
|
||||
const game = (window as any).game;
|
||||
return game?.animationQueue?.queue?.length ?? 0;
|
||||
});
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for animation to complete with tracking
|
||||
*/
|
||||
async waitForAnimation(
|
||||
type: string,
|
||||
timeoutMs: number = 5000
|
||||
): Promise<{ completed: boolean; duration: number }> {
|
||||
this.recordStart(type);
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
await this.page.waitForFunction(
|
||||
() => {
|
||||
const game = (window as any).game;
|
||||
if (!game?.animationQueue) return true;
|
||||
return !game.animationQueue.isAnimating();
|
||||
},
|
||||
{ timeout: timeoutMs }
|
||||
);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
this.recordComplete(type);
|
||||
return { completed: true, duration };
|
||||
} catch {
|
||||
const duration = Date.now() - startTime;
|
||||
this.recordStall(type);
|
||||
return { completed: false, duration };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for specific animation type by watching DOM changes
|
||||
*/
|
||||
async waitForFlipAnimation(timeoutMs: number = 2000): Promise<boolean> {
|
||||
return this.waitForAnimationClass('flipping', timeoutMs);
|
||||
}
|
||||
|
||||
async waitForSwapAnimation(timeoutMs: number = 3000): Promise<boolean> {
|
||||
return this.waitForAnimationClass('swap-animation', timeoutMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for animation class to appear and disappear
|
||||
*/
|
||||
private async waitForAnimationClass(
|
||||
className: string,
|
||||
timeoutMs: number
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
// Wait for class to appear
|
||||
await this.page.waitForSelector(`.${className}`, {
|
||||
state: 'attached',
|
||||
timeout: timeoutMs / 2,
|
||||
});
|
||||
|
||||
// Wait for class to disappear (animation complete)
|
||||
await this.page.waitForSelector(`.${className}`, {
|
||||
state: 'detached',
|
||||
timeout: timeoutMs / 2,
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get animation events
|
||||
*/
|
||||
getEvents(): AnimationEvent[] {
|
||||
return [...this.events];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stall events
|
||||
*/
|
||||
getStalls(): AnimationEvent[] {
|
||||
return this.events.filter(e => e.type === 'stall');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get average animation duration by type
|
||||
*/
|
||||
getAverageDuration(type?: string): number | null {
|
||||
const completed = this.events.filter(e =>
|
||||
e.type === 'complete' &&
|
||||
e.duration !== undefined &&
|
||||
(!type || e.animationType === type)
|
||||
);
|
||||
|
||||
if (completed.length === 0) return null;
|
||||
|
||||
const total = completed.reduce((sum, e) => sum + (e.duration || 0), 0);
|
||||
return total / completed.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if animations are within expected timing
|
||||
*/
|
||||
validateTiming(
|
||||
type: string,
|
||||
expectedMs: number,
|
||||
tolerancePercent: number = 50
|
||||
): { valid: boolean; actual: number | null } {
|
||||
const avgDuration = this.getAverageDuration(type);
|
||||
|
||||
if (avgDuration === null) {
|
||||
return { valid: true, actual: null };
|
||||
}
|
||||
|
||||
const tolerance = expectedMs * (tolerancePercent / 100);
|
||||
const minOk = expectedMs - tolerance;
|
||||
const maxOk = expectedMs + tolerance;
|
||||
|
||||
return {
|
||||
valid: avgDuration >= minOk && avgDuration <= maxOk,
|
||||
actual: avgDuration,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear tracked events
|
||||
*/
|
||||
clear(): void {
|
||||
this.events = [];
|
||||
this.animationStartTime = null;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user