Compare commits
106 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b071afdfb | ||
|
|
c7fb85d281 | ||
|
|
118912dd13 | ||
|
|
0e594a5e28 | ||
|
|
a6ec72d72c | ||
|
|
e2f353d4ab | ||
|
|
e601eb04c9 | ||
|
|
6c771810f7 | ||
|
|
dbad7037d1 | ||
|
|
21362ba125 | ||
|
|
2dcdaf2b49 | ||
|
|
1fa13bbe3b | ||
|
|
a76fd8da32 | ||
|
|
634d101f2c | ||
|
|
28c9882b17 | ||
|
|
a1d8a127dc | ||
|
|
65b4af9831 | ||
|
|
8942238f9c | ||
|
|
7dc27fe882 | ||
|
|
097f241c6f | ||
|
|
1c5d6b09e2 | ||
|
|
889f8ce1cd | ||
|
|
b4e9390f16 | ||
|
|
94e2bdaaa7 | ||
|
|
d322403764 | ||
|
|
9c6ce255bd | ||
|
|
06d52a9d2c | ||
|
|
76cbd4ae22 | ||
|
|
9b04bc85c2 | ||
|
|
2ccbfc8120 | ||
|
|
1678077c53 | ||
|
|
0dbb2d13ed | ||
|
|
82e5226acc | ||
|
|
b81874f5ba | ||
|
|
797d1e0280 | ||
|
|
538ca51ba5 | ||
|
|
9339abe19c | ||
|
|
ac2d53b404 | ||
|
|
7e108a71f9 | ||
|
|
7642d120e2 | ||
|
|
6ba0639d51 | ||
|
|
3b9522fec3 | ||
|
|
aa2093d6c8 | ||
|
|
3227c92d63 | ||
|
|
b7b21d8378 | ||
|
|
fb3bd53b0a | ||
|
|
4fcdf13f66 | ||
|
|
6673e63241 | ||
|
|
62e7d4e1dd | ||
|
|
bae5d8da3c | ||
|
|
62e3dc0395 | ||
|
|
bda88d8218 | ||
|
|
b5a8e1fe7b | ||
|
|
929ab0f320 | ||
|
|
7026d86081 | ||
|
|
b2ce6f5cf1 | ||
|
|
d4a39fe234 | ||
|
|
9966fd9470 | ||
|
|
050294754c | ||
|
|
1856019a95 | ||
|
|
f68d0bc26d | ||
|
|
c59c1e28e2 | ||
|
|
bfa94830a7 | ||
|
|
850b8d6abf | ||
|
|
e1cca98b8b | ||
|
|
df61d88ec6 | ||
|
|
9fc6b83bba | ||
|
|
13ab5b9017 | ||
|
|
9bb9d1e397 | ||
|
|
8431cd6fd1 | ||
|
|
49b2490c25 | ||
|
|
7d28e83a49 | ||
|
|
4ad508f84f | ||
|
|
9b53e51aa3 | ||
|
|
cd05930b69 | ||
|
|
c615c8b433 | ||
|
|
4664aae8aa | ||
|
|
a5d108f4f2 | ||
|
|
df422907b0 | ||
|
|
bc1b1b7725 | ||
|
|
7b64b8c17c | ||
|
|
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 |
121
.env.example
Normal file
121
.env.example
Normal file
@@ -0,0 +1,121 @@
|
||||
# =============================================================================
|
||||
# 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
|
||||
|
||||
# Environment name (development, staging, production)
|
||||
ENVIRONMENT=development
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Database
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# PostgreSQL connection URL (event sourcing, game logs, stats)
|
||||
# For development with Docker: postgresql://golf:devpassword@localhost:5432/golf
|
||||
DATABASE_URL=postgresql://golf:devpassword@localhost:5432/golf
|
||||
|
||||
# PostgreSQL URL for auth/stats features (can be same as DATABASE_URL)
|
||||
POSTGRES_URL=postgresql://golf:devpassword@localhost:5432/golf
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 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
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# 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=true
|
||||
|
||||
# Bootstrap admin account (for first-time setup with INVITE_ONLY=true)
|
||||
# Remove these after first login!
|
||||
# BOOTSTRAP_ADMIN_USERNAME=admin
|
||||
# BOOTSTRAP_ADMIN_PASSWORD=changeme12345
|
||||
|
||||
# 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
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Production Features (Optional)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Sentry error tracking
|
||||
# SENTRY_DSN=https://your-sentry-dsn
|
||||
|
||||
# Resend API for emails (required for user registration/password reset)
|
||||
# RESEND_API_KEY=your-api-key
|
||||
|
||||
# Enable rate limiting (recommended for production)
|
||||
# RATE_LIMIT_ENABLED=true
|
||||
|
||||
# Redis URL (required for matchmaking and rate limiting)
|
||||
# REDIS_URL=redis://localhost:6379
|
||||
|
||||
# Base URL for email links
|
||||
# BASE_URL=https://your-domain.com
|
||||
|
||||
# Matchmaking (skill-based public games)
|
||||
MATCHMAKING_ENABLED=true
|
||||
MATCHMAKING_MIN_PLAYERS=2
|
||||
MATCHMAKING_MAX_PLAYERS=4
|
||||
13
.gitignore
vendored
13
.gitignore
vendored
@@ -188,6 +188,19 @@ cython_debug/
|
||||
# you could uncomment the following to ignore the entire vscode folder
|
||||
# .vscode/
|
||||
|
||||
# Claude Code
|
||||
.claude/
|
||||
|
||||
# Virtualenv in project root
|
||||
bin/
|
||||
pyvenv.cfg
|
||||
|
||||
# Database files
|
||||
*.db
|
||||
|
||||
# Personal notes
|
||||
lookfah.md
|
||||
|
||||
# Ruff stuff:
|
||||
.ruff_cache/
|
||||
|
||||
|
||||
285
CLAUDE.md
Normal file
285
CLAUDE.md
Normal file
@@ -0,0 +1,285 @@
|
||||
# Golf Card Game - Project Context
|
||||
|
||||
A real-time multiplayer 6-card Golf card game with CPU opponents and smooth anime.js animations.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
pip install -r server/requirements.txt
|
||||
|
||||
# Run the server
|
||||
python server/main.py
|
||||
|
||||
# Visit http://localhost:8000
|
||||
```
|
||||
|
||||
For full installation (Docker, PostgreSQL, Redis, production), see [INSTALL.md](INSTALL.md).
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
golfgame/
|
||||
├── server/ # Python FastAPI backend
|
||||
│ ├── main.py # HTTP routes, WebSocket server, lifespan
|
||||
│ ├── game.py # Core game logic, state machine
|
||||
│ ├── ai.py # CPU opponent AI with timing/personality
|
||||
│ ├── handlers.py # WebSocket message handlers
|
||||
│ ├── room.py # Room/lobby management
|
||||
│ ├── config.py # Environment configuration (pydantic)
|
||||
│ ├── constants.py # Card values, game constants
|
||||
│ ├── auth.py # Authentication (JWT, passwords)
|
||||
│ ├── logging_config.py # Structured logging setup
|
||||
│ ├── simulate.py # AI simulation runner with stats
|
||||
│ ├── game_analyzer.py # Query tools for game analysis
|
||||
│ ├── score_analysis.py # Score distribution analysis
|
||||
│ ├── routers/ # FastAPI route modules
|
||||
│ │ ├── auth.py # Login, signup, verify endpoints
|
||||
│ │ ├── admin.py # Admin management endpoints
|
||||
│ │ ├── stats.py # Statistics & leaderboard endpoints
|
||||
│ │ ├── replay.py # Game replay endpoints
|
||||
│ │ └── health.py # Health check endpoints
|
||||
│ ├── services/ # Business logic layer
|
||||
│ │ ├── auth_service.py # User authentication
|
||||
│ │ ├── admin_service.py # Admin tools
|
||||
│ │ ├── stats_service.py # Player statistics & leaderboards
|
||||
│ │ ├── replay_service.py # Game replay functionality
|
||||
│ │ ├── game_logger.py # PostgreSQL game move logging
|
||||
│ │ ├── spectator.py # Spectator mode
|
||||
│ │ ├── email_service.py # Email notifications (Resend)
|
||||
│ │ ├── recovery_service.py # Account recovery
|
||||
│ │ └── ratelimit.py # Rate limiting
|
||||
│ ├── stores/ # Data persistence layer
|
||||
│ │ ├── event_store.py # PostgreSQL event sourcing
|
||||
│ │ ├── user_store.py # User persistence
|
||||
│ │ ├── state_cache.py # Redis state caching
|
||||
│ │ └── pubsub.py # Pub/sub messaging
|
||||
│ ├── models/ # Data models
|
||||
│ │ ├── events.py # Event types for event sourcing
|
||||
│ │ ├── game_state.py # Game state representation
|
||||
│ │ └── user.py # User data model
|
||||
│ └── middleware/ # Request middleware
|
||||
│ ├── security.py # CORS, CSP, security headers
|
||||
│ ├── request_id.py # Request ID tracking
|
||||
│ └── ratelimit.py # Rate limiting middleware
|
||||
│
|
||||
├── client/ # Vanilla JS frontend
|
||||
│ ├── index.html # Main game page
|
||||
│ ├── app.js # Main game controller
|
||||
│ ├── card-animations.js # Unified anime.js animation system
|
||||
│ ├── card-manager.js # DOM management for cards
|
||||
│ ├── animation-queue.js # Animation sequencing
|
||||
│ ├── timing-config.js # Centralized timing configuration
|
||||
│ ├── state-differ.js # Diff game state for animations
|
||||
│ ├── style.css # Styles (NO card transitions)
|
||||
│ ├── admin.html # Admin panel
|
||||
│ ├── admin.js # Admin panel interface
|
||||
│ ├── admin.css # Admin panel styles
|
||||
│ ├── replay.js # Game replay viewer
|
||||
│ ├── leaderboard.js # Leaderboard display
|
||||
│ └── ANIMATIONS.md # Animation system documentation
|
||||
│
|
||||
├── docs/
|
||||
│ ├── ANIMATION-FLOWS.md # Animation flow diagrams
|
||||
│ ├── v2/ # V2 architecture docs (event sourcing, auth, etc.)
|
||||
│ └── v3/ # V3 feature & refactoring docs
|
||||
│
|
||||
├── scripts/ # Helper scripts
|
||||
│ ├── install.sh # Interactive installer
|
||||
│ ├── dev-server.sh # Development server launcher
|
||||
│ └── docker-build.sh # Docker image builder
|
||||
│
|
||||
└── tests/e2e/ # End-to-end tests (Playwright)
|
||||
```
|
||||
|
||||
## Key Technical Decisions
|
||||
|
||||
### Animation System
|
||||
|
||||
**When to use anime.js vs CSS:**
|
||||
- **Anime.js (CardAnimations)**: Card movements, flips, swaps, draws - anything involving card elements
|
||||
- **CSS keyframes/transitions**: Simple UI feedback (button hover, badge entrance, status message fades) - non-card elements
|
||||
|
||||
**General rule:** If it moves a card, use anime.js. If it's UI chrome, CSS is fine.
|
||||
|
||||
- See `client/ANIMATIONS.md` for full documentation
|
||||
- See `docs/ANIMATION-FLOWS.md` for flow diagrams
|
||||
- `CardAnimations` class in `card-animations.js` handles everything
|
||||
- Timing configured in `timing-config.js`
|
||||
|
||||
### State Management
|
||||
|
||||
- Server is source of truth
|
||||
- Client receives full game state on each update
|
||||
- `state-differ.js` computes diffs to trigger appropriate animations
|
||||
|
||||
### Animation Race Condition Flags
|
||||
|
||||
Several flags in `app.js` prevent `renderGame()` from updating the discard pile during animations:
|
||||
|
||||
| Flag | Purpose |
|
||||
|------|---------|
|
||||
| `isDrawAnimating` | Local or opponent draw animation in progress |
|
||||
| `localDiscardAnimating` | Local player discarding drawn card |
|
||||
| `opponentDiscardAnimating` | Opponent discarding without swap |
|
||||
| `opponentSwapAnimation` | Opponent swap animation in progress |
|
||||
| `dealAnimationInProgress` | Deal animation running (suppresses flip prompts) |
|
||||
|
||||
**Critical:** These flags must be cleared in ALL code paths (success, error, fallback). Failure to clear causes UI to freeze.
|
||||
|
||||
**Clear flags when:**
|
||||
- Animation completes (callback)
|
||||
- New animation starts (clear stale flags)
|
||||
- `your_turn` message received (safety clear)
|
||||
- Error/fallback paths
|
||||
|
||||
### CPU Players
|
||||
|
||||
- AI logic in `server/ai.py`
|
||||
- Configurable timing delays for natural feel
|
||||
- Multiple personality types affect decision-making (pair hunters, aggressive, conservative, etc.)
|
||||
|
||||
**AI Decision Safety Checks:**
|
||||
- Never swap high cards (8+) into unknown positions (expected value ~4.5)
|
||||
- Unpredictability has value threshold (7) to prevent obviously bad random plays
|
||||
- Comeback bonus only applies to cards < 8
|
||||
- Denial logic skips hidden positions for 8+ cards
|
||||
|
||||
**Testing AI with simulations:**
|
||||
```bash
|
||||
# Run 500 games and check dumb move rate
|
||||
python server/simulate.py 500
|
||||
|
||||
# Detailed single game output
|
||||
python server/simulate.py 1 --detailed
|
||||
|
||||
# Compare rule presets
|
||||
python server/simulate.py 100 --compare
|
||||
```
|
||||
|
||||
### Server Architecture
|
||||
|
||||
- **Routers** (`server/routers/`): FastAPI route modules for auth, admin, stats, replay, health
|
||||
- **Services** (`server/services/`): Business logic layer (auth, admin, stats, replay, email, rate limiting)
|
||||
- **Stores** (`server/stores/`): Data persistence (PostgreSQL event store, user store, Redis state cache, pub/sub)
|
||||
- **Models** (`server/models/`): Data models (events, game state, user)
|
||||
- **Middleware** (`server/middleware/`): Security headers, request ID tracking, rate limiting
|
||||
- **Handlers** (`server/handlers.py`): WebSocket message dispatch (extracted from main.py)
|
||||
|
||||
## Common Development Tasks
|
||||
|
||||
### Adjusting Animation Speed
|
||||
|
||||
Edit `timing-config.js` - all timings are centralized there.
|
||||
|
||||
### Adding New Animations
|
||||
|
||||
1. Add method to `CardAnimations` class in `card-animations.js`
|
||||
2. Use anime.js, not CSS transitions
|
||||
3. Track in `activeAnimations` Map for cancellation support
|
||||
4. Add timing config to `timing-config.js` if needed
|
||||
|
||||
### Debugging Animations
|
||||
|
||||
```javascript
|
||||
// Check what's animating
|
||||
console.log(window.cardAnimations.activeAnimations);
|
||||
|
||||
// Force cleanup
|
||||
window.cardAnimations.cancelAll();
|
||||
|
||||
// Check timing config
|
||||
console.log(window.TIMING);
|
||||
```
|
||||
|
||||
### Testing CPU Behavior
|
||||
|
||||
Adjust delays in `server/ai.py` `CPU_TIMING` dict.
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# All server tests
|
||||
cd server && pytest -v
|
||||
|
||||
# AI simulation
|
||||
python server/simulate.py 500
|
||||
```
|
||||
|
||||
## Important Patterns
|
||||
|
||||
### No CSS Transitions on Cards
|
||||
|
||||
Cards animate via anime.js only. The following should NOT have `transition` (especially on `transform`):
|
||||
- `.card`, `.card-inner`
|
||||
- `.real-card`, `.swap-card`
|
||||
- `.held-card-floating`
|
||||
|
||||
Card hover effects are handled by `CardAnimations.hoverIn()/hoverOut()` methods.
|
||||
CSS may still use box-shadow transitions for hover glow effects.
|
||||
|
||||
### State Differ Logic (triggerAnimationsForStateChange)
|
||||
|
||||
The state differ in `app.js` detects what changed between game states:
|
||||
|
||||
**STEP 1: Draw Detection**
|
||||
- Detects when `drawn_card` goes from null to something
|
||||
- Triggers draw animation (from deck or discard)
|
||||
- Sets `isDrawAnimating` flag
|
||||
|
||||
**STEP 2: Discard/Swap Detection**
|
||||
- Detects when `discard_top` changes and it was another player's turn
|
||||
- Triggers swap or discard animation
|
||||
- **Important:** Skip STEP 2 if STEP 1 detected a draw from discard (the discard change was from REMOVING a card, not adding one)
|
||||
|
||||
### Animation Overlays
|
||||
|
||||
Complex animations create temporary overlay elements:
|
||||
1. Create `.draw-anim-card` positioned over source
|
||||
2. Hide original card (or set `opacity: 0` on discard pile during draw-from-discard)
|
||||
3. Animate overlay
|
||||
4. Remove overlay, reveal updated card, restore visibility
|
||||
|
||||
### Fire-and-Forget for Opponents
|
||||
|
||||
Opponent animations don't block - no callbacks needed:
|
||||
```javascript
|
||||
cardAnimations.animateOpponentFlip(cardElement, cardData);
|
||||
```
|
||||
|
||||
### Common Animation Pitfalls
|
||||
|
||||
**Card position before append:** Always set `left`/`top` styles BEFORE appending overlay cards to body, otherwise they flash at (0,0).
|
||||
|
||||
**Deal animation source:** Use `getDeckRect()` for deal animations, not `getDealerRect()`. The dealer rect returns the whole player area, causing cards to animate at wrong size.
|
||||
|
||||
**Element rects during hidden:** `visibility: hidden` still allows `getBoundingClientRect()` to work. `display: none` does not.
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Server
|
||||
- FastAPI + uvicorn (web framework & ASGI server)
|
||||
- websockets (WebSocket support)
|
||||
- asyncpg (PostgreSQL async driver)
|
||||
- redis (state caching, pub/sub)
|
||||
- bcrypt (password hashing)
|
||||
- resend (email service)
|
||||
- python-dotenv (environment management)
|
||||
- sentry-sdk (error tracking, optional)
|
||||
|
||||
### Client
|
||||
- anime.js (animations)
|
||||
- No other frameworks
|
||||
|
||||
### Infrastructure
|
||||
- PostgreSQL (event sourcing, auth, stats, game logs)
|
||||
- Redis (state caching, pub/sub)
|
||||
|
||||
## Game Rules Reference
|
||||
|
||||
- 6 cards per player in 2x3 grid
|
||||
- Lower score wins
|
||||
- Matching columns cancel out (0 points)
|
||||
- Jokers are -2 points
|
||||
- Kings are 0 points
|
||||
- Game ends when a player flips all cards
|
||||
38
Dockerfile
Normal file
38
Dockerfile
Normal file
@@ -0,0 +1,38 @@
|
||||
# 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 from the server directory (server uses relative imports)
|
||||
WORKDIR /app/server
|
||||
CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
573
INSTALL.md
Normal file
573
INSTALL.md
Normal file
@@ -0,0 +1,573 @@
|
||||
# Golf Game Installation Guide
|
||||
|
||||
Complete guide for installing and running the Golf card game server.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Quick Start](#quick-start)
|
||||
- [Requirements](#requirements)
|
||||
- [Development Setup](#development-setup)
|
||||
- [Production Installation](#production-installation)
|
||||
- [Docker Deployment](#docker-deployment)
|
||||
- [Configuration Reference](#configuration-reference)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
The fastest way to get started is using the interactive installer:
|
||||
|
||||
```bash
|
||||
./scripts/install.sh
|
||||
```
|
||||
|
||||
This provides a menu with options for:
|
||||
- Development setup (Docker services + virtualenv + dependencies)
|
||||
- Production installation to /opt/golfgame
|
||||
- Systemd service configuration
|
||||
- Status checks
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
### For Development
|
||||
|
||||
- **Python 3.11+** (3.12, 3.13, 3.14 also work)
|
||||
- **Docker** and **Docker Compose** (for PostgreSQL and Redis)
|
||||
- **Git**
|
||||
|
||||
### For Production
|
||||
|
||||
- **Python 3.11+**
|
||||
- **PostgreSQL 16+**
|
||||
- **Redis 7+**
|
||||
- **systemd** (for service management)
|
||||
- **nginx** (recommended, for reverse proxy)
|
||||
|
||||
---
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Option A: Using the Installer (Recommended)
|
||||
|
||||
```bash
|
||||
./scripts/install.sh
|
||||
# Select option 1: Development Setup
|
||||
```
|
||||
|
||||
This will:
|
||||
1. Start PostgreSQL and Redis in Docker containers
|
||||
2. Create a Python virtual environment
|
||||
3. Install all dependencies
|
||||
4. Generate a `.env` file configured for local development
|
||||
|
||||
### Option B: Manual Setup
|
||||
|
||||
#### 1. Start Docker Services
|
||||
|
||||
```bash
|
||||
docker-compose -f docker-compose.dev.yml up -d
|
||||
```
|
||||
|
||||
This starts:
|
||||
- **PostgreSQL** on `localhost:5432` (user: `golf`, password: `devpassword`, database: `golf`)
|
||||
- **Redis** on `localhost:6379`
|
||||
|
||||
Verify services are running:
|
||||
```bash
|
||||
docker-compose -f docker-compose.dev.yml ps
|
||||
```
|
||||
|
||||
#### 2. Create Python Virtual Environment
|
||||
|
||||
```bash
|
||||
# Create venv in project root
|
||||
python3 -m venv .
|
||||
|
||||
# Activate it
|
||||
source bin/activate
|
||||
|
||||
# Upgrade pip
|
||||
pip install --upgrade pip
|
||||
|
||||
# Install dependencies (including dev tools)
|
||||
pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
#### 3. Configure Environment
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env` for development:
|
||||
|
||||
```bash
|
||||
HOST=0.0.0.0
|
||||
PORT=8000
|
||||
DEBUG=true
|
||||
LOG_LEVEL=DEBUG
|
||||
ENVIRONMENT=development
|
||||
|
||||
DATABASE_URL=postgresql://golf:devpassword@localhost:5432/golf
|
||||
POSTGRES_URL=postgresql://golf:devpassword@localhost:5432/golf
|
||||
```
|
||||
|
||||
#### 4. Run the Development Server
|
||||
|
||||
```bash
|
||||
cd server
|
||||
../bin/uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
Or use the helper script:
|
||||
|
||||
```bash
|
||||
./scripts/dev-server.sh
|
||||
```
|
||||
|
||||
The server will be available at http://localhost:8000
|
||||
|
||||
#### 5. Verify Installation
|
||||
|
||||
```bash
|
||||
# Health check
|
||||
curl http://localhost:8000/health
|
||||
|
||||
# Should return: {"status":"ok","timestamp":"..."}
|
||||
```
|
||||
|
||||
### Stopping Development Services
|
||||
|
||||
```bash
|
||||
# Stop the server: Ctrl+C
|
||||
|
||||
# Stop Docker containers
|
||||
docker-compose -f docker-compose.dev.yml down
|
||||
|
||||
# Stop and remove volumes (clean slate)
|
||||
docker-compose -f docker-compose.dev.yml down -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Production Installation
|
||||
|
||||
### Option A: Using the Installer (Recommended)
|
||||
|
||||
```bash
|
||||
sudo ./scripts/install.sh
|
||||
# Select option 2: Production Install to /opt/golfgame
|
||||
```
|
||||
|
||||
### Option B: Manual Installation
|
||||
|
||||
#### 1. Install System Dependencies
|
||||
|
||||
```bash
|
||||
# Debian/Ubuntu
|
||||
sudo apt update
|
||||
sudo apt install -y python3 python3-venv python3-pip postgresql redis-server nginx
|
||||
|
||||
# Start and enable services
|
||||
sudo systemctl enable --now postgresql redis-server nginx
|
||||
```
|
||||
|
||||
#### 2. Create PostgreSQL Database
|
||||
|
||||
```bash
|
||||
sudo -u postgres psql << EOF
|
||||
CREATE USER golf WITH PASSWORD 'your_secure_password';
|
||||
CREATE DATABASE golf OWNER golf;
|
||||
GRANT ALL PRIVILEGES ON DATABASE golf TO golf;
|
||||
EOF
|
||||
```
|
||||
|
||||
#### 3. Create Installation Directory
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /opt/golfgame
|
||||
sudo chown $USER:$USER /opt/golfgame
|
||||
```
|
||||
|
||||
#### 4. Clone and Install Application
|
||||
|
||||
```bash
|
||||
cd /opt/golfgame
|
||||
git clone https://github.com/alee/golfgame.git .
|
||||
|
||||
# Create virtual environment
|
||||
python3 -m venv .
|
||||
source bin/activate
|
||||
|
||||
# Install application
|
||||
pip install --upgrade pip
|
||||
pip install .
|
||||
```
|
||||
|
||||
#### 5. Configure Production Environment
|
||||
|
||||
Create `/opt/golfgame/.env`:
|
||||
|
||||
```bash
|
||||
# Generate a secret key
|
||||
SECRET_KEY=$(python3 -c "import secrets; print(secrets.token_hex(32))")
|
||||
|
||||
cat > /opt/golfgame/.env << EOF
|
||||
HOST=0.0.0.0
|
||||
PORT=8000
|
||||
DEBUG=false
|
||||
LOG_LEVEL=INFO
|
||||
ENVIRONMENT=production
|
||||
|
||||
DATABASE_URL=postgresql://golf:your_secure_password@localhost:5432/golf
|
||||
POSTGRES_URL=postgresql://golf:your_secure_password@localhost:5432/golf
|
||||
|
||||
SECRET_KEY=$SECRET_KEY
|
||||
|
||||
MAX_PLAYERS_PER_ROOM=6
|
||||
ROOM_TIMEOUT_MINUTES=60
|
||||
|
||||
# Optional: Error tracking with Sentry
|
||||
# SENTRY_DSN=https://your-sentry-dsn
|
||||
|
||||
# Optional: Email via Resend
|
||||
# RESEND_API_KEY=your-api-key
|
||||
EOF
|
||||
|
||||
# Secure the file
|
||||
chmod 600 /opt/golfgame/.env
|
||||
```
|
||||
|
||||
#### 6. Set Ownership
|
||||
|
||||
```bash
|
||||
sudo chown -R www-data:www-data /opt/golfgame
|
||||
```
|
||||
|
||||
#### 7. Create Systemd Service
|
||||
|
||||
Create `/etc/systemd/system/golfgame.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Golf Card Game Server
|
||||
Documentation=https://github.com/alee/golfgame
|
||||
After=network.target postgresql.service redis.service
|
||||
Wants=postgresql.service redis.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=www-data
|
||||
Group=www-data
|
||||
WorkingDirectory=/opt/golfgame/server
|
||||
Environment="PATH=/opt/golfgame/bin:/usr/local/bin:/usr/bin:/bin"
|
||||
EnvironmentFile=/opt/golfgame/.env
|
||||
ExecStart=/opt/golfgame/bin/uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4
|
||||
ExecReload=/bin/kill -HUP $MAINPID
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
# Security hardening
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/opt/golfgame
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
#### 8. Enable and Start Service
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable golfgame
|
||||
sudo systemctl start golfgame
|
||||
|
||||
# Check status
|
||||
sudo systemctl status golfgame
|
||||
|
||||
# View logs
|
||||
journalctl -u golfgame -f
|
||||
```
|
||||
|
||||
#### 9. Configure Nginx Reverse Proxy
|
||||
|
||||
Create `/etc/nginx/sites-available/golfgame`:
|
||||
|
||||
```nginx
|
||||
upstream golfgame {
|
||||
server 127.0.0.1:8000;
|
||||
keepalive 64;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name your-domain.com;
|
||||
|
||||
# Redirect HTTP to HTTPS
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name your-domain.com;
|
||||
|
||||
# SSL configuration (use certbot for Let's Encrypt)
|
||||
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
|
||||
location / {
|
||||
proxy_pass http://golfgame;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
# WebSocket support
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
|
||||
# Standard proxy headers
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Timeouts for WebSocket
|
||||
proxy_read_timeout 86400;
|
||||
proxy_send_timeout 86400;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Enable the site:
|
||||
|
||||
```bash
|
||||
sudo ln -s /etc/nginx/sites-available/golfgame /etc/nginx/sites-enabled/
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
#### 10. SSL Certificate (Let's Encrypt)
|
||||
|
||||
```bash
|
||||
sudo apt install certbot python3-certbot-nginx
|
||||
sudo certbot --nginx -d your-domain.com
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Docker Deployment
|
||||
|
||||
### Build the Docker Image
|
||||
|
||||
```bash
|
||||
./scripts/docker-build.sh
|
||||
|
||||
# Or manually:
|
||||
docker build -t golfgame:latest .
|
||||
```
|
||||
|
||||
### Development with Docker
|
||||
|
||||
```bash
|
||||
# Start dev services only (PostgreSQL + Redis)
|
||||
docker-compose -f docker-compose.dev.yml up -d
|
||||
```
|
||||
|
||||
### Production with Docker Compose
|
||||
|
||||
```bash
|
||||
# Set required environment variables
|
||||
export DB_PASSWORD="your-secure-database-password"
|
||||
export SECRET_KEY=$(python3 -c "import secrets; print(secrets.token_hex(32))")
|
||||
export ACME_EMAIL="your-email@example.com"
|
||||
export DOMAIN="your-domain.com"
|
||||
|
||||
# Optional
|
||||
export RESEND_API_KEY="your-resend-key"
|
||||
export SENTRY_DSN="your-sentry-dsn"
|
||||
|
||||
# Start all 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=3
|
||||
```
|
||||
|
||||
The production compose file includes:
|
||||
- **app**: The Golf game server (scalable)
|
||||
- **postgres**: PostgreSQL database
|
||||
- **redis**: Redis for sessions
|
||||
- **traefik**: Reverse proxy with automatic HTTPS
|
||||
|
||||
---
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `HOST` | `0.0.0.0` | Server bind address |
|
||||
| `PORT` | `8000` | Server port |
|
||||
| `DEBUG` | `false` | Enable debug mode |
|
||||
| `LOG_LEVEL` | `INFO` | Logging level (DEBUG, INFO, WARNING, ERROR) |
|
||||
| `ENVIRONMENT` | `production` | Environment name |
|
||||
| `DATABASE_URL` | - | PostgreSQL URL (event sourcing, game logs, stats) |
|
||||
| `POSTGRES_URL` | - | PostgreSQL URL for auth/stats (can be same as DATABASE_URL) |
|
||||
| `SECRET_KEY` | - | Secret key for JWT tokens |
|
||||
| `MAX_PLAYERS_PER_ROOM` | `6` | Maximum players per game room |
|
||||
| `ROOM_TIMEOUT_MINUTES` | `60` | Inactive room cleanup timeout |
|
||||
| `ROOM_CODE_LENGTH` | `4` | Length of room codes |
|
||||
| `DEFAULT_ROUNDS` | `9` | Default holes per game |
|
||||
| `SENTRY_DSN` | - | Sentry error tracking DSN |
|
||||
| `RESEND_API_KEY` | - | Resend API key for emails |
|
||||
| `RATE_LIMIT_ENABLED` | `false` | Enable rate limiting |
|
||||
|
||||
### File Locations
|
||||
|
||||
| Path | Description |
|
||||
|------|-------------|
|
||||
| `/opt/golfgame/` | Production installation root |
|
||||
| `/opt/golfgame/.env` | Production environment config |
|
||||
| `/opt/golfgame/server/` | Server application code |
|
||||
| `/opt/golfgame/client/` | Static web client |
|
||||
| `/opt/golfgame/bin/` | Python virtualenv binaries |
|
||||
| `/etc/systemd/system/golfgame.service` | Systemd service file |
|
||||
| `/etc/nginx/sites-available/golfgame` | Nginx site config |
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Check Service Status
|
||||
|
||||
```bash
|
||||
# Systemd service
|
||||
sudo systemctl status golfgame
|
||||
journalctl -u golfgame -n 100
|
||||
|
||||
# Docker containers
|
||||
docker-compose -f docker-compose.dev.yml ps
|
||||
docker-compose -f docker-compose.dev.yml logs
|
||||
|
||||
# Using the installer
|
||||
./scripts/install.sh
|
||||
# Select option 6: Show Status
|
||||
```
|
||||
|
||||
### Health Check
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/health
|
||||
# Expected: {"status":"ok","timestamp":"..."}
|
||||
```
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### "No module named 'config'"
|
||||
|
||||
The server must be started from the `server/` directory:
|
||||
|
||||
```bash
|
||||
cd /opt/golfgame/server
|
||||
../bin/uvicorn main:app --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
#### "Connection refused" on PostgreSQL
|
||||
|
||||
1. Check PostgreSQL is running:
|
||||
```bash
|
||||
sudo systemctl status postgresql
|
||||
# Or for Docker:
|
||||
docker ps | grep postgres
|
||||
```
|
||||
|
||||
2. Verify connection settings in `.env`
|
||||
|
||||
3. Test connection:
|
||||
```bash
|
||||
psql -h localhost -U golf -d golf
|
||||
```
|
||||
|
||||
#### "POSTGRES_URL not configured" warning
|
||||
|
||||
Add `POSTGRES_URL` to your `.env` file. This is required for authentication and stats features.
|
||||
|
||||
#### Broken virtualenv symlinks
|
||||
|
||||
If Python was upgraded, the virtualenv symlinks may break. Recreate it:
|
||||
|
||||
```bash
|
||||
rm -rf bin lib lib64 pyvenv.cfg include share
|
||||
python3 -m venv .
|
||||
source bin/activate
|
||||
pip install -e ".[dev]" # or just: pip install .
|
||||
```
|
||||
|
||||
#### Permission denied on /opt/golfgame
|
||||
|
||||
```bash
|
||||
sudo chown -R www-data:www-data /opt/golfgame
|
||||
sudo chmod 600 /opt/golfgame/.env
|
||||
```
|
||||
|
||||
### Updating
|
||||
|
||||
#### Development
|
||||
|
||||
```bash
|
||||
git pull
|
||||
source bin/activate
|
||||
pip install -e ".[dev]"
|
||||
# Server auto-reloads with --reload flag
|
||||
```
|
||||
|
||||
#### Production
|
||||
|
||||
```bash
|
||||
cd /opt/golfgame
|
||||
sudo systemctl stop golfgame
|
||||
sudo -u www-data git pull
|
||||
sudo -u www-data ./bin/pip install .
|
||||
sudo systemctl start golfgame
|
||||
```
|
||||
|
||||
#### Docker
|
||||
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yml down
|
||||
git pull
|
||||
docker-compose -f docker-compose.prod.yml build
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Scripts Reference
|
||||
|
||||
| Script | Description |
|
||||
|--------|-------------|
|
||||
| `scripts/install.sh` | Interactive installer menu |
|
||||
| `scripts/dev-server.sh` | Start development server |
|
||||
| `scripts/docker-build.sh` | Build production Docker image |
|
||||
|
||||
---
|
||||
|
||||
## Support
|
||||
|
||||
- GitHub Issues: https://github.com/alee/golfgame/issues
|
||||
- Documentation: See `README.md` for game rules and API docs
|
||||
221
README.md
221
README.md
@@ -1,61 +1,45 @@
|
||||
# Golf Card Game
|
||||
|
||||
A multiplayer online 6-card Golf card game with AI opponents and extensive house rules support.
|
||||
A real-time multiplayer 6-card Golf card game with AI opponents, smooth anime.js animations, and extensive house rules support.
|
||||
|
||||
## Features
|
||||
|
||||
- **Multiplayer:** 2-6 players via WebSocket
|
||||
- **Real-time Multiplayer:** 2-6 players via WebSocket
|
||||
- **AI Opponents:** 8 unique CPU personalities with distinct play styles
|
||||
- **House Rules:** 15+ optional rule variants
|
||||
- **Game Logging:** SQLite logging for AI decision analysis
|
||||
- **Comprehensive Testing:** 80+ tests for rules and AI behavior
|
||||
- **Smooth Animations:** Anime.js-powered card dealing, drawing, swapping, and flipping
|
||||
- **User Accounts:** Registration, login, email verification
|
||||
- **Stats & Leaderboards:** Player statistics, win rates, and rankings
|
||||
- **Game Replay:** Review completed games with full playback
|
||||
- **Admin Tools:** User management, game moderation, system monitoring
|
||||
- **Event Sourcing:** Full game history stored for replay and analysis
|
||||
- **Production Ready:** Docker, systemd, nginx, rate limiting, Sentry integration
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Install Dependencies
|
||||
|
||||
```bash
|
||||
cd server
|
||||
pip install -r requirements.txt
|
||||
# Install dependencies
|
||||
pip install -r server/requirements.txt
|
||||
|
||||
# Run the server
|
||||
python server/main.py
|
||||
|
||||
# Visit http://localhost:8000
|
||||
```
|
||||
|
||||
### 2. Start the Server
|
||||
For full installation instructions (Docker, production deployment, etc.), see [INSTALL.md](INSTALL.md).
|
||||
|
||||
```bash
|
||||
cd server
|
||||
uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
||||
```
|
||||
## How to Play
|
||||
|
||||
### 3. Open the Game
|
||||
**6-Card Golf** is a card game where you try to get the **lowest score** across multiple rounds (holes).
|
||||
|
||||
Open `http://localhost:8000` in your browser.
|
||||
- Each player has 6 cards in a 2x3 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
|
||||
|
||||
## Game Rules
|
||||
|
||||
See [server/RULES.md](server/RULES.md) for complete rules documentation.
|
||||
|
||||
### Basic Scoring
|
||||
|
||||
| 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 +56,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
|
||||
|
||||
@@ -96,51 +71,117 @@ When a player reveals all 6 cards, others get one final turn. Lowest score wins.
|
||||
|
||||
```
|
||||
golfgame/
|
||||
├── server/
|
||||
│ ├── main.py # FastAPI WebSocket server
|
||||
│ ├── game.py # Core game logic
|
||||
│ ├── ai.py # AI decision making
|
||||
│ ├── room.py # Room/lobby management
|
||||
│ ├── game_log.py # SQLite logging
|
||||
│ ├── game_analyzer.py # Decision analysis CLI
|
||||
│ ├── simulate.py # AI-vs-AI simulation
|
||||
│ ├── score_analysis.py # Score distribution analysis
|
||||
│ ├── test_game.py # Game rules tests
|
||||
│ ├── test_analyzer.py # Analyzer tests
|
||||
│ ├── test_maya_bug.py # Bug regression tests
|
||||
│ ├── test_house_rules.py # House rules testing
|
||||
│ └── RULES.md # Rules documentation
|
||||
├── client/
|
||||
│ ├── index.html
|
||||
│ ├── style.css
|
||||
│ └── app.js
|
||||
├── server/ # Python FastAPI backend
|
||||
│ ├── main.py # HTTP routes, WebSocket server, lifespan
|
||||
│ ├── game.py # Core game logic, state machine
|
||||
│ ├── ai.py # CPU opponent AI with timing/personality
|
||||
│ ├── handlers.py # WebSocket message handlers
|
||||
│ ├── room.py # Room/lobby management
|
||||
│ ├── config.py # Environment configuration (pydantic)
|
||||
│ ├── constants.py # Card values, game constants
|
||||
│ ├── auth.py # Authentication (JWT, passwords)
|
||||
│ ├── logging_config.py # Structured logging setup
|
||||
│ ├── simulate.py # AI-vs-AI simulation runner
|
||||
│ ├── game_analyzer.py # Decision analysis CLI
|
||||
│ ├── score_analysis.py # Score distribution analysis
|
||||
│ ├── routers/ # FastAPI route modules
|
||||
│ │ ├── auth.py # Login, signup, verify endpoints
|
||||
│ │ ├── admin.py # Admin management endpoints
|
||||
│ │ ├── stats.py # Statistics & leaderboard endpoints
|
||||
│ │ ├── replay.py # Game replay endpoints
|
||||
│ │ └── health.py # Health check endpoints
|
||||
│ ├── services/ # Business logic layer
|
||||
│ │ ├── auth_service.py # User authentication
|
||||
│ │ ├── admin_service.py # Admin tools
|
||||
│ │ ├── stats_service.py # Player statistics & leaderboards
|
||||
│ │ ├── replay_service.py # Game replay functionality
|
||||
│ │ ├── game_logger.py # PostgreSQL game move logging
|
||||
│ │ ├── spectator.py # Spectator mode
|
||||
│ │ ├── email_service.py # Email notifications (Resend)
|
||||
│ │ ├── recovery_service.py # Account recovery
|
||||
│ │ └── ratelimit.py # Rate limiting
|
||||
│ ├── stores/ # Data persistence layer
|
||||
│ │ ├── event_store.py # PostgreSQL event sourcing
|
||||
│ │ ├── user_store.py # User persistence
|
||||
│ │ ├── state_cache.py # Redis state caching
|
||||
│ │ └── pubsub.py # Pub/sub messaging
|
||||
│ ├── models/ # Data models
|
||||
│ │ ├── events.py # Event types for event sourcing
|
||||
│ │ ├── game_state.py # Game state representation
|
||||
│ │ └── user.py # User data model
|
||||
│ ├── middleware/ # Request middleware
|
||||
│ │ ├── security.py # CORS, CSP, security headers
|
||||
│ │ ├── request_id.py # Request ID tracking
|
||||
│ │ └── ratelimit.py # Rate limiting middleware
|
||||
│ ├── RULES.md # Rules documentation
|
||||
│ └── test_*.py # Test files
|
||||
│
|
||||
├── client/ # Vanilla JS frontend
|
||||
│ ├── index.html # Main game page
|
||||
│ ├── app.js # Main game controller
|
||||
│ ├── card-animations.js # Unified anime.js animation system
|
||||
│ ├── card-manager.js # DOM management for cards
|
||||
│ ├── animation-queue.js # Animation sequencing
|
||||
│ ├── timing-config.js # Centralized timing configuration
|
||||
│ ├── state-differ.js # Diff game state for animations
|
||||
│ ├── style.css # Styles (NO card transitions)
|
||||
│ ├── admin.html # Admin panel
|
||||
│ ├── admin.js # Admin panel interface
|
||||
│ ├── admin.css # Admin panel styles
|
||||
│ ├── replay.js # Game replay viewer
|
||||
│ ├── leaderboard.js # Leaderboard display
|
||||
│ └── ANIMATIONS.md # Animation system documentation
|
||||
│
|
||||
├── scripts/ # Helper scripts
|
||||
│ ├── install.sh # Interactive installer
|
||||
│ ├── dev-server.sh # Development server launcher
|
||||
│ └── docker-build.sh # Docker image builder
|
||||
│
|
||||
├── docs/ # Architecture documentation
|
||||
│ ├── ANIMATION-FLOWS.md # Animation flow diagrams
|
||||
│ ├── v2/ # V2 architecture docs
|
||||
│ └── v3/ # V3 feature & refactoring docs
|
||||
│
|
||||
├── tests/e2e/ # End-to-end tests (Playwright)
|
||||
├── docker-compose.dev.yml # Dev Docker services (PostgreSQL + Redis)
|
||||
├── docker-compose.prod.yml # Production Docker setup
|
||||
├── Dockerfile # Container definition
|
||||
├── pyproject.toml # Python project metadata
|
||||
├── INSTALL.md # Installation & deployment guide
|
||||
├── CLAUDE.md # Project context for AI assistants
|
||||
└── README.md
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
cd server
|
||||
pytest test_game.py test_analyzer.py test_maya_bug.py -v
|
||||
# All server tests
|
||||
cd server && pytest -v
|
||||
|
||||
# Specific test files
|
||||
pytest test_game.py test_ai_decisions.py test_handlers.py test_room.py -v
|
||||
|
||||
# With coverage
|
||||
pytest --cov=. --cov-report=term-missing
|
||||
```
|
||||
|
||||
### AI Simulation
|
||||
|
||||
```bash
|
||||
# Run 50 games with 4 AI players
|
||||
python simulate.py 50 4
|
||||
# Run 500 games and check dumb move rate
|
||||
python server/simulate.py 500
|
||||
|
||||
# Run detailed single game
|
||||
python simulate.py detail 4
|
||||
# Detailed single game output
|
||||
python server/simulate.py 1 --detailed
|
||||
|
||||
# Compare rule presets
|
||||
python server/simulate.py 100 --compare
|
||||
|
||||
# Analyze AI decisions for blunders
|
||||
python game_analyzer.py blunders
|
||||
python server/game_analyzer.py blunders
|
||||
|
||||
# Score distribution analysis
|
||||
python score_analysis.py 100 4
|
||||
|
||||
# Test all house rules
|
||||
python test_house_rules.py 40
|
||||
python server/score_analysis.py 100
|
||||
```
|
||||
|
||||
### AI Performance
|
||||
@@ -153,10 +194,12 @@ From testing (1000+ games):
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- **Backend:** Python 3.12+, FastAPI, WebSockets
|
||||
- **Frontend:** Vanilla HTML/CSS/JavaScript
|
||||
- **Database:** SQLite (optional, for game logging)
|
||||
- **Testing:** pytest
|
||||
- **Backend:** Python 3.11+, FastAPI, WebSockets
|
||||
- **Frontend:** Vanilla HTML/CSS/JavaScript, anime.js (animations)
|
||||
- **Database:** PostgreSQL (event sourcing, auth, stats, game logs)
|
||||
- **Cache:** Redis (state caching, pub/sub)
|
||||
- **Testing:** pytest, Playwright (e2e)
|
||||
- **Deployment:** Docker, systemd, nginx
|
||||
|
||||
## License
|
||||
|
||||
|
||||
307
client/ANIMATIONS.md
Normal file
307
client/ANIMATIONS.md
Normal file
@@ -0,0 +1,307 @@
|
||||
# Card Animation System
|
||||
|
||||
This document describes the unified animation system for the Golf card game client.
|
||||
|
||||
For detailed animation flow diagrams (what triggers what, in what order, with what flags), see [`docs/ANIMATION-FLOWS.md`](../docs/ANIMATION-FLOWS.md).
|
||||
|
||||
## Architecture
|
||||
|
||||
**When to use anime.js vs CSS:**
|
||||
- **Anime.js (CardAnimations)**: Card movements, flips, swaps, draws - anything involving card elements
|
||||
- **CSS keyframes/transitions**: Simple UI feedback (button hover, badge entrance, status message fades) - non-card elements
|
||||
|
||||
**General rule:** If it moves a card, use anime.js. If it's UI chrome, CSS is fine.
|
||||
|
||||
| What | How |
|
||||
|------|-----|
|
||||
| Card movements | anime.js |
|
||||
| Card flips | anime.js |
|
||||
| Swap animations | anime.js |
|
||||
| Pulse/glow effects on cards | anime.js |
|
||||
| Button hover/active states | CSS transitions |
|
||||
| Badge entrance/exit | CSS transitions |
|
||||
| Status message fades | CSS transitions |
|
||||
| Card hover states | anime.js `hoverIn()`/`hoverOut()` |
|
||||
| Show/hide | CSS `.hidden` class only |
|
||||
|
||||
### Why anime.js?
|
||||
|
||||
- Consistent timing and easing across all animations
|
||||
- Coordinated multi-element sequences via timelines
|
||||
- Proper animation cancellation via `activeAnimations` tracking
|
||||
- No conflicts between CSS and JS animation systems
|
||||
|
||||
---
|
||||
|
||||
## Core Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `card-animations.js` | Unified `CardAnimations` class - all animation logic |
|
||||
| `timing-config.js` | Centralized timing/easing configuration |
|
||||
| `style.css` | Static styles only (no transitions on cards) |
|
||||
|
||||
---
|
||||
|
||||
## CardAnimations Class API
|
||||
|
||||
Global instance available at `window.cardAnimations`.
|
||||
|
||||
### Draw Animations
|
||||
|
||||
```javascript
|
||||
// Draw from deck - lift, move to hold area, flip to reveal
|
||||
cardAnimations.animateDrawDeck(cardData, onComplete)
|
||||
|
||||
// Draw from discard - quick grab, no flip
|
||||
cardAnimations.animateDrawDiscard(cardData, onComplete)
|
||||
|
||||
// For opponent draw-then-discard - deck to discard with flip
|
||||
cardAnimations.animateDeckToDiscard(card, onComplete)
|
||||
```
|
||||
|
||||
### Flip Animations
|
||||
|
||||
```javascript
|
||||
// Generic flip animation on any card element
|
||||
cardAnimations.animateFlip(element, cardData, onComplete)
|
||||
|
||||
// Initial flip at game start (local player)
|
||||
cardAnimations.animateInitialFlip(cardElement, cardData, onComplete)
|
||||
|
||||
// Opponent card flip (fire-and-forget)
|
||||
cardAnimations.animateOpponentFlip(cardElement, cardData, rotation)
|
||||
```
|
||||
|
||||
### Swap Animations
|
||||
|
||||
```javascript
|
||||
// Player swaps drawn card with hand card
|
||||
cardAnimations.animateSwap(position, oldCard, newCard, handCardElement, onComplete)
|
||||
|
||||
// Opponent swap (fire-and-forget)
|
||||
cardAnimations.animateOpponentSwap(playerId, position, discardCard, sourceCardElement, rotation, wasFaceUp)
|
||||
```
|
||||
|
||||
### Discard Animations
|
||||
|
||||
```javascript
|
||||
// Animate held card swooping to discard pile
|
||||
cardAnimations.animateDiscard(heldCardElement, targetCard, onComplete)
|
||||
```
|
||||
|
||||
### Ambient Effects (Looping)
|
||||
|
||||
```javascript
|
||||
// "Your turn to draw" shake effect
|
||||
cardAnimations.startTurnPulse(element)
|
||||
cardAnimations.stopTurnPulse(element)
|
||||
|
||||
// CPU thinking glow
|
||||
cardAnimations.startCpuThinking(element)
|
||||
cardAnimations.stopCpuThinking(element)
|
||||
|
||||
// Initial flip phase - clickable cards glow
|
||||
cardAnimations.startInitialFlipPulse(element)
|
||||
cardAnimations.stopInitialFlipPulse(element)
|
||||
cardAnimations.stopAllInitialFlipPulses()
|
||||
```
|
||||
|
||||
### One-Shot Effects
|
||||
|
||||
```javascript
|
||||
// Pulse when card lands on discard
|
||||
cardAnimations.pulseDiscard()
|
||||
|
||||
// Pulse effect on face-up swap
|
||||
cardAnimations.pulseSwap(element)
|
||||
|
||||
// Pop-in when element appears (use sparingly)
|
||||
cardAnimations.popIn(element)
|
||||
|
||||
// Gold ring expanding effect before draw
|
||||
cardAnimations.startDrawPulse(element)
|
||||
```
|
||||
|
||||
### Utility Methods
|
||||
|
||||
```javascript
|
||||
// Check if animation is in progress
|
||||
cardAnimations.isBusy()
|
||||
|
||||
// Cancel all running animations
|
||||
cardAnimations.cancel()
|
||||
cardAnimations.cancelAll()
|
||||
|
||||
// Clean up animation elements
|
||||
cardAnimations.cleanup()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Animation Coordination
|
||||
|
||||
### Server-Client Timing
|
||||
|
||||
Server CPU timing (in `server/ai.py` `CPU_TIMING`) must account for client animation durations:
|
||||
- `post_draw_settle`: Must be >= draw animation duration (~1.1s for deck draw)
|
||||
- `post_action_pause`: Must be >= swap/discard animation duration (~0.5s)
|
||||
|
||||
### Preventing Animation Overlap
|
||||
|
||||
Animation overlay cards are marked with `data-animating="true"` while active.
|
||||
Methods like `animateUnifiedSwap` and `animateOpponentDiscard` check for active
|
||||
animations and wait before starting new ones.
|
||||
|
||||
### Card Hover Initialization
|
||||
|
||||
Call `cardAnimations.initHoverListeners(container)` after dynamically creating cards.
|
||||
This is done automatically in `renderGame()` for player and opponent card areas.
|
||||
|
||||
---
|
||||
|
||||
## Animation Overlay Pattern
|
||||
|
||||
For complex animations (flips, swaps), the system:
|
||||
|
||||
1. Creates a temporary overlay element (`.draw-anim-card`)
|
||||
2. Positions it exactly over the source card
|
||||
3. Hides the original card (`opacity: 0` or `.swap-out`)
|
||||
4. Animates the overlay
|
||||
5. Removes overlay and reveals updated original card
|
||||
|
||||
This ensures smooth animations without modifying the DOM structure of game cards.
|
||||
|
||||
---
|
||||
|
||||
## Timing Configuration
|
||||
|
||||
All timing values are in `timing-config.js` and exposed as `window.TIMING`.
|
||||
|
||||
### Key Durations
|
||||
|
||||
All durations are configured in `timing-config.js` and read via `window.TIMING`.
|
||||
|
||||
| Animation | Duration | Config Key | Notes |
|
||||
|-----------|----------|------------|-------|
|
||||
| Flip | 320ms | `card.flip` | 3D rotateY with slight overshoot |
|
||||
| Deck lift | 120ms | `draw.deckLift` | Visible lift before travel |
|
||||
| Deck move | 250ms | `draw.deckMove` | Smooth travel to hold position |
|
||||
| Deck flip | 320ms | `draw.deckFlip` | Reveal drawn card |
|
||||
| Discard lift | 80ms | `draw.discardLift` | Quick decisive grab |
|
||||
| Discard move | 200ms | `draw.discardMove` | Travel to hold position |
|
||||
| Swap lift | 100ms | `swap.lift` | Pickup before arc travel |
|
||||
| Swap arc | 320ms | `swap.arc` | Arc travel between positions |
|
||||
| Swap settle | 100ms | `swap.settle` | Landing with gentle overshoot |
|
||||
| Swap pulse | 400ms | — | Scale + brightness (face-up swap) |
|
||||
| Turn shake | 400ms | — | Every 3 seconds |
|
||||
|
||||
### Easing Functions
|
||||
|
||||
Custom cubic bezier curves give cards natural weight and momentum:
|
||||
|
||||
```javascript
|
||||
window.TIMING.anime.easing = {
|
||||
flip: 'cubicBezier(0.34, 1.2, 0.64, 1)', // Slight overshoot snap
|
||||
move: 'cubicBezier(0.22, 0.68, 0.35, 1.0)', // Smooth deceleration
|
||||
lift: 'cubicBezier(0.0, 0.0, 0.2, 1)', // Quick out, soft stop
|
||||
settle: 'cubicBezier(0.34, 1.05, 0.64, 1)', // Tiny overshoot on landing
|
||||
arc: 'cubicBezier(0.45, 0, 0.15, 1)', // Smooth S-curve for arcs
|
||||
pulse: 'easeInOutSine', // Smooth oscillation (loops)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CSS Rules
|
||||
|
||||
### What CSS Does
|
||||
|
||||
- Static card appearance (colors, borders, sizing)
|
||||
- Layout and positioning
|
||||
- Card hover states (`:hover` scale/shadow - no movement)
|
||||
- Show/hide via `.hidden` class
|
||||
- **UI chrome animations** (buttons, badges, status messages):
|
||||
- Button hover/active transitions
|
||||
- Badge entrance/exit animations
|
||||
- Status message fade in/out
|
||||
- Modal transitions
|
||||
|
||||
### What CSS Does NOT Do (on card elements)
|
||||
|
||||
- No `transition` on any card element (`.card`, `.card-inner`, `.real-card`, `.swap-card`, `.held-card-floating`)
|
||||
- No `@keyframes` for card movements or flips
|
||||
- No `.flipped`, `.moving`, `.flipping` transition triggers for cards
|
||||
|
||||
### Important Classes
|
||||
|
||||
| Class | Purpose |
|
||||
|-------|---------|
|
||||
| `.draw-anim-card` | Temporary overlay during animation |
|
||||
| `.draw-anim-inner` | 3D flip container |
|
||||
| `.swap-out` | Hides original during swap animation |
|
||||
| `.hidden` | Opacity 0, no display change |
|
||||
| `.draw-pulse` | Gold ring expanding effect |
|
||||
|
||||
---
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Preventing Premature UI Updates
|
||||
|
||||
The `isDrawAnimating` flag in `app.js` prevents the held card from appearing before the draw animation completes:
|
||||
|
||||
```javascript
|
||||
// In renderGame()
|
||||
if (!this.isDrawAnimating && /* other conditions */) {
|
||||
// Show held card
|
||||
}
|
||||
```
|
||||
|
||||
### Animation Sequencing
|
||||
|
||||
Use anime.js timelines for coordinated sequences:
|
||||
|
||||
```javascript
|
||||
const T = window.TIMING;
|
||||
const timeline = anime.timeline({
|
||||
easing: T.anime.easing.move,
|
||||
complete: () => { /* cleanup */ }
|
||||
});
|
||||
|
||||
timeline.add({ targets: el, translateY: -15, duration: T.card.lift, easing: T.anime.easing.lift });
|
||||
timeline.add({ targets: el, left: x, top: y, duration: T.card.move });
|
||||
timeline.add({ targets: inner, rotateY: 0, duration: T.card.flip, easing: T.anime.easing.flip });
|
||||
```
|
||||
|
||||
### Fire-and-Forget Animations
|
||||
|
||||
For opponent/CPU animations that don't block game flow:
|
||||
|
||||
```javascript
|
||||
// No onComplete callback needed
|
||||
cardAnimations.animateOpponentFlip(cardElement, cardData);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Debugging
|
||||
|
||||
### Check Active Animations
|
||||
|
||||
```javascript
|
||||
console.log(window.cardAnimations.activeAnimations);
|
||||
```
|
||||
|
||||
### Force Cleanup
|
||||
|
||||
```javascript
|
||||
window.cardAnimations.cancelAll();
|
||||
```
|
||||
|
||||
### Animation Not Working?
|
||||
|
||||
1. Check that anime.js is loaded before card-animations.js
|
||||
2. Verify element exists and is visible
|
||||
3. Check for CSS transitions that might conflict
|
||||
4. Look for errors in console
|
||||
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>
|
||||
832
client/admin.js
Normal file
832
client/admin.js
Normal file
@@ -0,0 +1,832 @@
|
||||
/**
|
||||
* 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" data-action="view-user" data-id="${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" data-action="end-game" data-id="${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" data-action="copy-invite" data-code="${escapeHtml(invite.code)}">Copy Link</button>
|
||||
<button class="btn btn-small btn-danger" data-action="revoke-invite" data-code="${escapeHtml(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');
|
||||
}
|
||||
}
|
||||
|
||||
function copyInviteLink(code) {
|
||||
const link = `${window.location.origin}/?invite=${encodeURIComponent(code)}`;
|
||||
navigator.clipboard.writeText(link).then(() => {
|
||||
showToast('Invite link copied!', 'success');
|
||||
}).catch(() => {
|
||||
// Fallback: select text for manual copy
|
||||
prompt('Copy this link:', link);
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Delegated click handlers for dynamically-created buttons
|
||||
document.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('[data-action]');
|
||||
if (!btn) return;
|
||||
|
||||
const action = btn.dataset.action;
|
||||
if (action === 'view-user') viewUser(btn.dataset.id);
|
||||
else if (action === 'end-game') promptEndGame(btn.dataset.id);
|
||||
else if (action === 'copy-invite') copyInviteLink(btn.dataset.code);
|
||||
else if (action === 'revoke-invite') promptRevokeInvite(btn.dataset.code);
|
||||
});
|
||||
|
||||
// Check auth on load
|
||||
checkAuth();
|
||||
});
|
||||
395
client/animation-queue.js
Normal file
395
client/animation-queue.js
Normal file
@@ -0,0 +1,395 @@
|
||||
// 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) - use centralized TIMING config
|
||||
const T = window.TIMING || {};
|
||||
this.timing = {
|
||||
flipDuration: T.card?.flip || 540,
|
||||
moveDuration: T.card?.move || 270,
|
||||
cardLift: T.card?.lift || 100,
|
||||
pauseAfterFlip: T.pause?.afterFlip || 144,
|
||||
pauseAfterDiscard: T.pause?.afterDiscard || 550,
|
||||
pauseBeforeNewCard: T.pause?.beforeNewCard || 150,
|
||||
pauseAfterSwapComplete: T.pause?.afterSwapComplete || 400,
|
||||
pauseBetweenAnimations: T.pause?.betweenAnimations || 90,
|
||||
pauseBeforeFlip: T.pause?.beforeFlip || 50,
|
||||
// Beat timing
|
||||
beatBase: T.beat?.base || 1000,
|
||||
beatVariance: T.beat?.variance || 200,
|
||||
fadeOut: T.beat?.fadeOut || 300,
|
||||
fadeIn: T.beat?.fadeIn || 300,
|
||||
};
|
||||
}
|
||||
|
||||
// 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(this.timing.pauseBeforeFlip);
|
||||
|
||||
// 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 - smooth continuous motion
|
||||
async animateSwap(movement) {
|
||||
const { playerId, position, oldCard, newCard } = movement;
|
||||
|
||||
const slotRect = this.getSlotRect(playerId, position);
|
||||
const discardRect = this.getLocationRect('discard');
|
||||
const holdingRect = this.getLocationRect('holding');
|
||||
|
||||
if (!slotRect || !discardRect || slotRect.width === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create animation cards
|
||||
const handCard = this.createAnimCard();
|
||||
this.cardManager.cardLayer.appendChild(handCard);
|
||||
this.setCardPosition(handCard, slotRect);
|
||||
|
||||
const handInner = handCard.querySelector('.card-inner');
|
||||
const handFront = handCard.querySelector('.card-face-front');
|
||||
|
||||
const heldCard = this.createAnimCard();
|
||||
this.cardManager.cardLayer.appendChild(heldCard);
|
||||
this.setCardPosition(heldCard, holdingRect || discardRect);
|
||||
|
||||
const heldInner = heldCard.querySelector('.card-inner');
|
||||
const heldFront = heldCard.querySelector('.card-face-front');
|
||||
|
||||
// Set up initial state
|
||||
this.setCardFront(handFront, oldCard);
|
||||
if (!oldCard.face_up) {
|
||||
handInner.classList.add('flipped');
|
||||
}
|
||||
this.setCardFront(heldFront, newCard);
|
||||
heldInner.classList.remove('flipped');
|
||||
|
||||
// Step 1: If face-down, flip to reveal
|
||||
if (!oldCard.face_up) {
|
||||
this.playSound('flip');
|
||||
handInner.classList.remove('flipped');
|
||||
await this.delay(this.timing.flipDuration);
|
||||
}
|
||||
|
||||
// Step 2: Quick crossfade swap
|
||||
handCard.classList.add('fade-out');
|
||||
heldCard.classList.add('fade-out');
|
||||
await this.delay(150);
|
||||
|
||||
this.setCardPosition(handCard, discardRect);
|
||||
this.setCardPosition(heldCard, slotRect);
|
||||
|
||||
this.playSound('card');
|
||||
handCard.classList.remove('fade-out');
|
||||
heldCard.classList.remove('fade-out');
|
||||
handCard.classList.add('fade-in');
|
||||
heldCard.classList.add('fade-in');
|
||||
await this.delay(150);
|
||||
|
||||
// Clean up
|
||||
handCard.remove();
|
||||
heldCard.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 - show card lifting and moving to holding position
|
||||
async animateDrawDiscard(movement) {
|
||||
const { card } = movement;
|
||||
|
||||
const discardRect = this.getLocationRect('discard');
|
||||
const holdingRect = this.getLocationRect('holding');
|
||||
|
||||
if (!discardRect || !holdingRect) return;
|
||||
|
||||
// Create animation card at discard position (face UP - visible card)
|
||||
const animCard = this.createAnimCard();
|
||||
this.cardManager.cardLayer.appendChild(animCard);
|
||||
this.setCardPosition(animCard, discardRect);
|
||||
|
||||
const inner = animCard.querySelector('.card-inner');
|
||||
const front = animCard.querySelector('.card-face-front');
|
||||
|
||||
// Show the card face (discard is always visible)
|
||||
if (card) {
|
||||
this.setCardFront(front, card);
|
||||
}
|
||||
inner.classList.remove('flipped'); // Face up
|
||||
|
||||
// Lift effect before moving - card rises slightly
|
||||
animCard.style.transform = 'translateY(-8px) scale(1.05)';
|
||||
animCard.style.transition = `transform ${this.timing.cardLift}ms ease-out`;
|
||||
await this.delay(this.timing.cardLift);
|
||||
|
||||
// Move to holding position
|
||||
this.playSound('card');
|
||||
animCard.classList.add('moving');
|
||||
animCard.style.transform = '';
|
||||
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();
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
8
client/anime.min.js
vendored
Normal file
8
client/anime.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
4023
client/app.js
4023
client/app.js
File diff suppressed because it is too large
Load Diff
1735
client/card-animations.js
Normal file
1735
client/card-animations.js
Normal file
File diff suppressed because it is too large
Load Diff
300
client/card-manager.js
Normal file
300
client/card-manager.js
Normal file
@@ -0,0 +1,300 @@
|
||||
// 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"></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');
|
||||
const back = cardEl.querySelector('.card-face-back');
|
||||
|
||||
// Reset front classes
|
||||
front.className = 'card-face card-face-front';
|
||||
|
||||
// Apply deck color to card back
|
||||
if (back) {
|
||||
// Remove any existing deck color classes
|
||||
back.className = back.className.replace(/\bdeck-\w+/g, '').trim();
|
||||
back.className = 'card-face card-face-back';
|
||||
const deckColor = this.getDeckColorClass(cardData);
|
||||
if (deckColor) {
|
||||
back.classList.add(deckColor);
|
||||
}
|
||||
}
|
||||
|
||||
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)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get the deck color class for a card based on its deck_id
|
||||
getDeckColorClass(cardData) {
|
||||
if (!cardData || cardData.deck_id === undefined || cardData.deck_id === null) {
|
||||
return null;
|
||||
}
|
||||
// Get deck colors from game state (set by app.js)
|
||||
const deckColors = window.currentDeckColors || ['red', 'blue', 'gold'];
|
||||
const colorName = deckColors[cardData.deck_id] || deckColors[0] || 'red';
|
||||
return `deck-${colorName}`;
|
||||
}
|
||||
|
||||
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`;
|
||||
|
||||
// On mobile, scale font proportional to card width so rank/suit fit
|
||||
if (document.body.classList.contains('mobile-portrait')) {
|
||||
cardEl.style.fontSize = `${rect.width * 0.35}px`;
|
||||
} else {
|
||||
cardEl.style.fontSize = '';
|
||||
}
|
||||
|
||||
if (animate) {
|
||||
const moveDuration = window.TIMING?.card?.moving || 350;
|
||||
setTimeout(() => cardEl.classList.remove('moving'), moveDuration);
|
||||
}
|
||||
}
|
||||
|
||||
// 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 = null) {
|
||||
// Use centralized timing if not specified
|
||||
if (duration === null) {
|
||||
duration = window.TIMING?.cardManager?.flipDuration || 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 = null) {
|
||||
// Use centralized timing if not specified
|
||||
if (duration === null) {
|
||||
duration = window.TIMING?.cardManager?.moveDuration || 250;
|
||||
}
|
||||
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');
|
||||
const flipDuration = window.TIMING?.cardManager?.flipDuration || 400;
|
||||
await this.delay(flipDuration);
|
||||
}
|
||||
|
||||
// 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
|
||||
const pauseDuration = window.TIMING?.cardManager?.moveDuration || 250;
|
||||
await this.delay(pauseDuration);
|
||||
|
||||
// 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 |
@@ -2,46 +2,79 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<title>Golf Card Game</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</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>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="player-name">Your Name</label>
|
||||
<input type="text" id="player-name" placeholder="Enter your name" maxlength="12">
|
||||
<div class="alpha-banner">Alpha — Things may break. Stats may be wiped.</div>
|
||||
|
||||
<!-- Auth prompt for unauthenticated users -->
|
||||
<div id="auth-prompt" class="auth-prompt">
|
||||
<p>Log in or sign up to play.</p>
|
||||
<div class="button-group">
|
||||
<button id="login-btn" class="btn btn-primary">Login</button>
|
||||
<button id="signup-btn" class="btn btn-secondary">Sign Up</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="button-group">
|
||||
<button id="create-room-btn" class="btn btn-primary">Create Room</button>
|
||||
</div>
|
||||
<!-- Game controls (shown only when authenticated) -->
|
||||
<div id="lobby-game-controls" class="hidden">
|
||||
<div class="button-group">
|
||||
<button id="find-game-btn" class="btn btn-primary">Find Game</button>
|
||||
</div>
|
||||
|
||||
<div class="divider">or</div>
|
||||
<div class="divider">or</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="room-code">Room Code</label>
|
||||
<input type="text" id="room-code" placeholder="ABCD" maxlength="4">
|
||||
</div>
|
||||
<div class="button-group">
|
||||
<button id="create-room-btn" class="btn btn-secondary">Create Private Room</button>
|
||||
</div>
|
||||
|
||||
<div class="button-group">
|
||||
<button id="join-room-btn" class="btn btn-secondary">Join Room</button>
|
||||
<div class="form-group">
|
||||
<label for="room-code">Join Private Room</label>
|
||||
<input type="text" id="room-code" placeholder="ABCD" maxlength="4">
|
||||
</div>
|
||||
|
||||
<div class="button-group">
|
||||
<button id="join-room-btn" class="btn btn-secondary">Join Room</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p id="lobby-error" class="error"></p>
|
||||
</div>
|
||||
|
||||
<!-- Matchmaking Screen -->
|
||||
<div id="matchmaking-screen" class="screen">
|
||||
<h2>Finding Game...</h2>
|
||||
<div class="matchmaking-spinner"></div>
|
||||
<p id="matchmaking-status">Searching for opponents...</p>
|
||||
<p id="matchmaking-time" class="matchmaking-timer">0:00</p>
|
||||
<p id="matchmaking-queue-info" class="matchmaking-info"></p>
|
||||
<div class="button-group">
|
||||
<button id="cancel-matchmaking-btn" class="btn btn-danger">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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,6 +83,13 @@
|
||||
<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>
|
||||
|
||||
@@ -57,20 +97,14 @@
|
||||
<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>
|
||||
<label>Decks</label>
|
||||
<div class="stepper-control">
|
||||
<button type="button" id="decks-minus" class="stepper-btn">−</button>
|
||||
<span id="num-decks-display" class="stepper-value">1</span>
|
||||
<input type="hidden" id="num-decks" value="1">
|
||||
<button type="button" id="decks-plus" class="stepper-btn">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="num-decks">Decks</label>
|
||||
<select id="num-decks">
|
||||
<option value="1">1</option>
|
||||
<option value="2">2</option>
|
||||
<option value="3">3</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="num-rounds">Holes</label>
|
||||
<select id="num-rounds">
|
||||
@@ -80,13 +114,36 @@
|
||||
<option value="1">1</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="initial-flips">Cards Revealed</label>
|
||||
<select id="initial-flips">
|
||||
<option value="2" selected>2 cards</option>
|
||||
<option value="1">1 card</option>
|
||||
<option value="0">None</option>
|
||||
</select>
|
||||
<div id="deck-colors-group" class="form-group">
|
||||
<label for="deck-color-preset">Card Backs</label>
|
||||
<div class="deck-color-selector">
|
||||
<select id="deck-color-preset">
|
||||
<optgroup label="Themes">
|
||||
<option value="classic" selected>Classic</option>
|
||||
<option value="ninja">Ninja Turtles</option>
|
||||
<option value="ocean">Ocean</option>
|
||||
<option value="forest">Forest</option>
|
||||
<option value="sunset">Sunset</option>
|
||||
<option value="berry">Berry</option>
|
||||
<option value="neon">Neon</option>
|
||||
<option value="royal">Royal</option>
|
||||
<option value="earth">Earth</option>
|
||||
</optgroup>
|
||||
<optgroup label="Single Color">
|
||||
<option value="all-red">All Red</option>
|
||||
<option value="all-blue">All Blue</option>
|
||||
<option value="all-green">All Green</option>
|
||||
<option value="all-gold">All Gold</option>
|
||||
<option value="all-purple">All Purple</option>
|
||||
<option value="all-teal">All Teal</option>
|
||||
<option value="all-pink">All Pink</option>
|
||||
<option value="all-slate">All Slate</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
<div id="deck-color-preview" class="deck-color-preview">
|
||||
<div class="preview-card deck-red"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p id="deck-recommendation" class="recommendation hidden">Recommended: 2+ decks for 4+ players</p>
|
||||
@@ -94,20 +151,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 +193,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>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="options-category">
|
||||
<h4>Point Modifiers</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>
|
||||
</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>+2 / -4 paired</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Bonuses & Gameplay -->
|
||||
<!-- Right Column: Card Values & Bonuses -->
|
||||
<div class="options-column">
|
||||
<div class="options-category">
|
||||
<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"><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"><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 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>
|
||||
|
||||
@@ -191,6 +278,7 @@
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div id="unranked-notice" class="unranked-notice hidden">Games with house rules are unranked and won't affect leaderboard stats.</div>
|
||||
<button id="start-game-btn" class="btn btn-primary">Start Game</button>
|
||||
</div>
|
||||
|
||||
@@ -200,21 +288,31 @@
|
||||
|
||||
<!-- 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="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 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 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">
|
||||
<span class="final-turn-icon">⚡</span>
|
||||
<span class="final-turn-text">FINAL TURN</span>
|
||||
</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 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 class="game-table">
|
||||
@@ -223,25 +321,52 @@
|
||||
<div class="player-row">
|
||||
<div class="table-center">
|
||||
<div class="deck-area">
|
||||
<div id="deck" class="card card-back">
|
||||
<span>?</span>
|
||||
<!-- 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="discard" class="card">
|
||||
<span id="discard-content"></span>
|
||||
<div id="deck" class="card card-back"></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>
|
||||
<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>
|
||||
@@ -255,6 +380,11 @@
|
||||
|
||||
<!-- Right panel: Scores -->
|
||||
<div id="scoreboard" class="side-panel right-panel">
|
||||
<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>
|
||||
<hr class="scores-divider">
|
||||
</div>
|
||||
<h4>Scores</h4>
|
||||
<table id="score-table">
|
||||
<thead>
|
||||
@@ -267,12 +397,415 @@
|
||||
</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>
|
||||
|
||||
<!-- Mobile bottom bar (hidden on desktop) -->
|
||||
<div id="mobile-bottom-bar">
|
||||
<div class="mobile-round-info">Hole <span id="mobile-current-round">1</span>/<span id="mobile-total-rounds">9</span></div>
|
||||
<button class="mobile-bar-btn" data-drawer="standings-panel">Standings</button>
|
||||
<button class="mobile-bar-btn" data-drawer="scoreboard">Scores</button>
|
||||
<button id="mobile-leave-btn" class="mobile-bar-btn mobile-leave-btn">End Game</button>
|
||||
</div>
|
||||
|
||||
<!-- Drawer backdrop for mobile -->
|
||||
<div id="drawer-backdrop" class="drawer-backdrop"></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> Pairing Kings now has a real cost — two Kings in separate columns score -4 total, but paired they score 0. Makes you think twice before completing a King pair.</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> Drawing a 10 is no longer a crisis — Queens and Jacks become the only truly dangerous cards. Reduces the penalty spread between mid-range and high cards.</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 premium finds, but pairing them wastes their value (0 points instead of -4). Best placed in different columns.</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> With only one Joker in the deck, finding it is a major swing. Raises the stakes on every draw from the deck.</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> You need to be confident you have the lowest score before going out. Rewards patience and reading your opponents' likely hands.</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> Rewards racing to finish. The 5-point bonus can offset a slightly worse hand, creating a tension between improving your score and ending the round quickly.</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> Gives trailing players a way to close the gap — win a round and claw back 3 extra points. Over multiple holes, a player who's behind can mount a comeback by stringing together strong rounds.</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 break it — a last-turn swap you'd normally skip becomes worth considering.</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> Turns a bad round into a great one. If your score lands on exactly 21, you walk away with 0 instead. Worth keeping in mind before making that last swap.</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> Turns a potential disaster (40 points of Jacks) into a triumph. If you already have a pair of Jacks in one column and a third Jack appears, the -20 bonus makes it worth grabbing and hunting for the fourth.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rules-mode">
|
||||
<h3>Game 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 column pairs. Once you have a pair in one column, grabbing a third or fourth of that rank for another column becomes worthwhile. 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>
|
||||
<button class="leaderboard-tab" data-metric="rating">Rating</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 +821,91 @@
|
||||
</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>
|
||||
<p class="auth-switch"><a href="#" id="show-forgot">Forgot password?</a></p>
|
||||
</div>
|
||||
|
||||
<!-- Forgot Password Form -->
|
||||
<div id="forgot-form-container" class="hidden">
|
||||
<h3>Reset Password</h3>
|
||||
<p class="auth-hint">Enter your email and we'll send you a reset link.</p>
|
||||
<form id="forgot-form">
|
||||
<div class="form-group">
|
||||
<input type="email" id="forgot-email" placeholder="Email" required>
|
||||
</div>
|
||||
<p id="forgot-error" class="error"></p>
|
||||
<p id="forgot-success" class="success"></p>
|
||||
<button type="submit" class="btn btn-primary btn-full">Send Reset Link</button>
|
||||
</form>
|
||||
<p class="auth-switch"><a href="#" id="forgot-back-login">Back to login</a></p>
|
||||
</div>
|
||||
|
||||
<!-- Reset Password Form (from email link) -->
|
||||
<div id="reset-form-container" class="hidden">
|
||||
<h3>Set New Password</h3>
|
||||
<form id="reset-form">
|
||||
<div class="form-group">
|
||||
<input type="password" id="reset-password" placeholder="New password" required minlength="8">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="password" id="reset-password-confirm" placeholder="Confirm password" required minlength="8">
|
||||
</div>
|
||||
<p id="reset-error" class="error"></p>
|
||||
<p id="reset-success" class="success"></p>
|
||||
<button type="submit" class="btn btn-primary btn-full">Reset Password</button>
|
||||
</form>
|
||||
</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-invite-code" placeholder="Invite Code" required>
|
||||
</div>
|
||||
<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="anime.min.js"></script>
|
||||
<script src="timing-config.js"></script>
|
||||
<script src="card-animations.js"></script>
|
||||
<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>
|
||||
|
||||
316
client/leaderboard.js
Normal file
316
client/leaderboard.js
Normal file
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* 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',
|
||||
rating: 'Rating',
|
||||
};
|
||||
|
||||
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(),
|
||||
rating: (v) => Math.round(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 };
|
||||
}
|
||||
165
client/state-differ.js
Normal file
165
client/state-differ.js
Normal file
@@ -0,0 +1,165 @@
|
||||
// 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,
|
||||
card: drewFromDiscard ? oldState.discard_top : null // Include card for discard draw animation
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
3996
client/style.css
3996
client/style.css
File diff suppressed because it is too large
Load Diff
167
client/timing-config.js
Normal file
167
client/timing-config.js
Normal file
@@ -0,0 +1,167 @@
|
||||
// Centralized timing configuration for all animations and pauses
|
||||
// Edit these values to tune the feel of card animations and CPU gameplay
|
||||
|
||||
const TIMING = {
|
||||
// Card animations (milliseconds)
|
||||
card: {
|
||||
flip: 320, // Card flip duration — readable but snappy
|
||||
move: 300, // General card movement
|
||||
lift: 100, // Perceptible lift before travel
|
||||
settle: 80, // Gentle landing cushion
|
||||
},
|
||||
|
||||
// Pauses - minimal, let animations flow
|
||||
pause: {
|
||||
afterFlip: 0, // No pause - flow into next action
|
||||
afterDiscard: 100, // Brief settle
|
||||
beforeNewCard: 0, // No pause
|
||||
afterSwapComplete: 100, // Brief settle
|
||||
betweenAnimations: 0, // No gaps - continuous flow
|
||||
beforeFlip: 0, // No pause
|
||||
},
|
||||
|
||||
// Beat timing for animation phases (~1.2 sec with variance)
|
||||
beat: {
|
||||
base: 1200, // Base beat duration (longer to see results)
|
||||
variance: 200, // +/- variance for natural feel
|
||||
fadeOut: 300, // Fade out duration
|
||||
fadeIn: 300, // Fade in duration
|
||||
},
|
||||
|
||||
// UI feedback durations (milliseconds)
|
||||
feedback: {
|
||||
drawPulse: 375, // Draw pile highlight duration (25% slower for clear sequencing)
|
||||
discardLand: 375, // Discard land effect duration (25% slower)
|
||||
cardFlipIn: 300, // Card flip-in effect duration
|
||||
statusMessage: 2000, // Toast/status message duration
|
||||
copyConfirm: 2000, // Copy button confirmation duration
|
||||
discardPickup: 250, // Discard pickup animation duration
|
||||
},
|
||||
|
||||
// CSS animation timing (for reference - actual values in style.css)
|
||||
css: {
|
||||
cpuConsidering: 1500, // CPU considering pulse cycle
|
||||
},
|
||||
|
||||
// Anime.js animation configuration
|
||||
anime: {
|
||||
easing: {
|
||||
flip: 'cubicBezier(0.34, 1.2, 0.64, 1)', // Slight overshoot snap
|
||||
move: 'cubicBezier(0.22, 0.68, 0.35, 1.0)', // Smooth deceleration
|
||||
lift: 'cubicBezier(0.0, 0.0, 0.2, 1)', // Quick out, soft stop
|
||||
settle: 'cubicBezier(0.34, 1.05, 0.64, 1)', // Tiny overshoot on landing
|
||||
arc: 'cubicBezier(0.45, 0, 0.15, 1)', // Smooth S-curve for arcs
|
||||
pulse: 'easeInOutSine', // Keep for loops
|
||||
},
|
||||
loop: {
|
||||
turnPulse: { duration: 2000 },
|
||||
cpuThinking: { duration: 1500 },
|
||||
initialFlipGlow: { duration: 1500 },
|
||||
}
|
||||
},
|
||||
|
||||
// Card manager specific
|
||||
cardManager: {
|
||||
flipDuration: 320, // Card flip animation
|
||||
moveDuration: 300, // Card move animation
|
||||
},
|
||||
|
||||
// V3_02: Dealing animation
|
||||
dealing: {
|
||||
shufflePause: 400, // Pause after shuffle sound
|
||||
cardFlyTime: 150, // Time for card to fly to destination
|
||||
cardStagger: 80, // Delay between cards
|
||||
roundPause: 50, // Pause between deal rounds
|
||||
discardFlipDelay: 200, // Pause before flipping discard
|
||||
},
|
||||
|
||||
// V3_03: Round end reveal timing
|
||||
reveal: {
|
||||
voluntaryWindow: 2000, // Time for players to flip their own cards
|
||||
initialPause: 250, // Pause before auto-reveals start
|
||||
cardStagger: 50, // Between cards in same hand
|
||||
playerPause: 200, // Pause after each player's reveal
|
||||
highlightDuration: 100, // Player area highlight fade-in
|
||||
},
|
||||
|
||||
// V3_04: Pair celebration
|
||||
celebration: {
|
||||
pairDuration: 200, // Celebration animation length
|
||||
pairDelay: 25, // Slight delay before celebration
|
||||
},
|
||||
|
||||
// V3_07: Score tallying animation
|
||||
tally: {
|
||||
initialPause: 100, // After reveal, before tally
|
||||
cardHighlight: 70, // Duration to show each card value
|
||||
columnPause: 30, // Between columns
|
||||
pairCelebration: 200, // Pair cancel effect
|
||||
playerPause: 50, // Between players
|
||||
finalScoreReveal: 400, // Final score animation
|
||||
},
|
||||
|
||||
// Opponent initial flip stagger (after dealing)
|
||||
// All players flip concurrently within this window (not taking turns)
|
||||
initialFlips: {
|
||||
windowStart: 500, // Minimum delay before any opponent starts flipping
|
||||
windowEnd: 2500, // Maximum delay before opponent starts (random in range)
|
||||
cardStagger: 400, // Delay between an opponent's two card flips
|
||||
},
|
||||
|
||||
// V3_11: Physical swap animation
|
||||
swap: {
|
||||
lift: 100, // Time to lift cards — visible pickup
|
||||
arc: 320, // Time for arc travel
|
||||
settle: 100, // Time to settle into place — with overshoot easing
|
||||
},
|
||||
|
||||
// Draw animation durations (replaces hardcoded values in card-animations.js)
|
||||
draw: {
|
||||
deckLift: 120, // Lift off deck before travel
|
||||
deckMove: 250, // Travel to holding position
|
||||
deckRevealPause: 80, // Brief pause before flip (easing does the rest)
|
||||
deckFlip: 320, // Flip to reveal drawn card
|
||||
deckViewPause: 120, // Time to see revealed card
|
||||
discardLift: 80, // Quick grab from discard
|
||||
discardMove: 200, // Travel to holding position
|
||||
discardViewPause: 60, // Brief settle after arrival
|
||||
pulseDelay: 200, // Delay before card appears (pulse visible first)
|
||||
},
|
||||
|
||||
// V3_17: Knock notification
|
||||
knock: {
|
||||
statusDuration: 2500, // How long the knock status message persists
|
||||
},
|
||||
|
||||
// V3_17: Scoresheet modal
|
||||
scoresheet: {
|
||||
playerStagger: 150, // Delay between player row animations
|
||||
columnStagger: 80, // Delay between column animations within a row
|
||||
pairGlowDelay: 200, // Delay before paired columns glow
|
||||
},
|
||||
|
||||
// Player swap animation steps - smooth continuous motion
|
||||
playerSwap: {
|
||||
flipToReveal: 400, // Initial flip to show card
|
||||
pauseAfterReveal: 50, // Tiny beat to register the card
|
||||
moveToDiscard: 400, // Move old card to discard
|
||||
pulseBeforeSwap: 0, // No pulse - just flow
|
||||
completePause: 50, // Tiny settle
|
||||
},
|
||||
};
|
||||
|
||||
// Helper to get beat duration with variance
|
||||
function getBeatDuration() {
|
||||
const base = TIMING.beat.base;
|
||||
const variance = TIMING.beat.variance;
|
||||
return base + (Math.random() * variance * 2 - variance);
|
||||
}
|
||||
|
||||
// Export for module systems, also attach to window for direct use
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = TIMING;
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
window.TIMING = TIMING;
|
||||
window.getBeatDuration = getBeatDuration;
|
||||
}
|
||||
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
|
||||
160
docker-compose.prod.yml
Normal file
160
docker-compose.prod.yml
Normal file
@@ -0,0 +1,160 @@
|
||||
# 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
|
||||
- DATABASE_URL=postgresql://golf:${DB_PASSWORD}@postgres:5432/golf
|
||||
- REDIS_URL=redis://redis:6379
|
||||
- SECRET_KEY=${SECRET_KEY}
|
||||
- RESEND_API_KEY=${RESEND_API_KEY:-}
|
||||
- EMAIL_FROM=${EMAIL_FROM:-Golf Cards <noreply@contact.golfcards.club>}
|
||||
- SENTRY_DSN=${SENTRY_DSN:-}
|
||||
- ENVIRONMENT=production
|
||||
- LOG_LEVEL=INFO
|
||||
- BASE_URL=${BASE_URL:-https://golf.example.com}
|
||||
- RATE_LIMIT_ENABLED=true
|
||||
- INVITE_ONLY=true
|
||||
- BOOTSTRAP_ADMIN_USERNAME=${BOOTSTRAP_ADMIN_USERNAME:-}
|
||||
- BOOTSTRAP_ADMIN_PASSWORD=${BOOTSTRAP_ADMIN_PASSWORD:-}
|
||||
- MATCHMAKING_ENABLED=true
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
deploy:
|
||||
replicas: 1
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
max_attempts: 3
|
||||
resources:
|
||||
limits:
|
||||
memory: 256M
|
||||
reservations:
|
||||
memory: 64M
|
||||
networks:
|
||||
- internal
|
||||
- web
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=golfgame_web"
|
||||
- "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"
|
||||
# www -> bare domain redirect
|
||||
- "traefik.http.routers.golf-www.rule=Host(`www.${DOMAIN:-golf.example.com}`)"
|
||||
- "traefik.http.routers.golf-www.entrypoints=websecure"
|
||||
- "traefik.http.routers.golf-www.tls=true"
|
||||
- "traefik.http.routers.golf-www.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.routers.golf-www.middlewares=www-redirect"
|
||||
- "traefik.http.middlewares.www-redirect.redirectregex.regex=^https://www\\.(.+)"
|
||||
- "traefik.http.middlewares.www-redirect.redirectregex.replacement=https://$${1}"
|
||||
- "traefik.http.middlewares.www-redirect.redirectregex.permanent=true"
|
||||
- "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: 192M
|
||||
reservations:
|
||||
memory: 64M
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
command: redis-server --appendonly yes --maxmemory 32mb --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: 64M
|
||||
reservations:
|
||||
memory: 16M
|
||||
|
||||
traefik:
|
||||
image: traefik:v3.6
|
||||
environment:
|
||||
- DOCKER_API_VERSION=1.44
|
||||
command:
|
||||
- "--api.dashboard=true"
|
||||
- "--api.insecure=true"
|
||||
- "--accesslog=true"
|
||||
- "--log.level=WARN"
|
||||
- "--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: 64M
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
letsencrypt:
|
||||
|
||||
networks:
|
||||
internal:
|
||||
driver: bridge
|
||||
web:
|
||||
driver: bridge
|
||||
616
docs/ANIMATION-FLOWS.md
Normal file
616
docs/ANIMATION-FLOWS.md
Normal file
@@ -0,0 +1,616 @@
|
||||
# Animation Flow Reference
|
||||
|
||||
Complete reference for how card animations are triggered, sequenced, and cleaned up.
|
||||
All animations use anime.js via the `CardAnimations` class (`client/card-animations.js`).
|
||||
Timing is configured in `client/timing-config.js`.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Architecture Overview](#architecture-overview)
|
||||
2. [Animation Flags](#animation-flags)
|
||||
3. [Flow 1: Local Player Draws from Deck](#flow-1-local-player-draws-from-deck)
|
||||
4. [Flow 2: Local Player Draws from Discard](#flow-2-local-player-draws-from-discard)
|
||||
5. [Flow 3: Local Player Swaps](#flow-3-local-player-swaps)
|
||||
6. [Flow 4: Local Player Discards](#flow-4-local-player-discards)
|
||||
7. [Flow 5: Opponent Draws from Deck then Swaps](#flow-5-opponent-draws-from-deck-then-swaps)
|
||||
8. [Flow 6: Opponent Draws from Deck then Discards](#flow-6-opponent-draws-from-deck-then-discards)
|
||||
9. [Flow 7: Opponent Draws from Discard then Swaps](#flow-7-opponent-draws-from-discard-then-swaps)
|
||||
10. [Flow 8: Initial Card Flip](#flow-8-initial-card-flip)
|
||||
11. [Flow 9: Deal Animation](#flow-9-deal-animation)
|
||||
12. [Flow 10: Round End Reveal](#flow-10-round-end-reveal)
|
||||
13. [Flag Lifecycle Summary](#flag-lifecycle-summary)
|
||||
14. [Safety Clears](#safety-clears)
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ app.js │
|
||||
│ │
|
||||
│ User Click / WebSocket ──► triggerAnimationsForStateChange │
|
||||
│ │ │ │
|
||||
│ ▼ ▼ │
|
||||
│ Set flags ──────────────► CardAnimations method │
|
||||
│ │ │ │
|
||||
│ ▼ ▼ │
|
||||
│ renderGame() skips anime.js timeline runs │
|
||||
│ flagged elements │ │
|
||||
│ │ ▼ │
|
||||
│ │ Callback fires │
|
||||
│ │ │ │
|
||||
│ ▼ ▼ │
|
||||
│ Flags cleared ◄──────── renderGame() called │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ Normal rendering resumes │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Key principle:** Flags block `renderGame()` from updating the DOM while animations are in flight. The animation callback clears flags and triggers a fresh render.
|
||||
|
||||
---
|
||||
|
||||
## Animation Flags
|
||||
|
||||
Flags in `app.js` that prevent `renderGame()` from updating the discard pile or held card during animations:
|
||||
|
||||
| Flag | Type | Blocks | Purpose |
|
||||
|------|------|--------|---------|
|
||||
| `isDrawAnimating` | bool | Discard pile, held card | Draw animation in progress |
|
||||
| `localDiscardAnimating` | bool | Discard pile | Local player discarding drawn card |
|
||||
| `opponentDiscardAnimating` | bool | Discard pile | Opponent discarding without swap |
|
||||
| `opponentSwapAnimation` | object/null | Discard pile, turn indicator | Opponent swap `{ playerId, position }` |
|
||||
| `dealAnimationInProgress` | bool | Flip prompts | Deal animation running |
|
||||
| `swapAnimationInProgress` | bool | Game state application | Local swap — defers incoming state |
|
||||
|
||||
**renderGame() skip logic:**
|
||||
```
|
||||
if (localDiscardAnimating OR opponentSwapAnimation OR
|
||||
opponentDiscardAnimating OR isDrawAnimating):
|
||||
→ skip discard pile update
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Flow 1: Local Player Draws from Deck
|
||||
|
||||
**Trigger:** User clicks deck
|
||||
|
||||
```
|
||||
User clicks deck
|
||||
│
|
||||
▼
|
||||
drawFromDeck()
|
||||
├─ Validate: isMyTurn(), no drawnCard
|
||||
└─ Send: { type: 'draw', source: 'deck' }
|
||||
│
|
||||
▼
|
||||
Server responds: 'card_drawn'
|
||||
├─ Store drawnCard, drawnFromDiscard=false
|
||||
├─ Clear stale flags (opponentSwap, opponentDiscard)
|
||||
├─ SET isDrawAnimating = true
|
||||
└─ hideDrawnCard()
|
||||
│
|
||||
▼
|
||||
cardAnimations.animateDrawDeck(card, callback)
|
||||
│
|
||||
├─ Pulse deck (gold ring)
|
||||
├─ Wait pulseDelay (200ms)
|
||||
│
|
||||
▼
|
||||
_animateDrawDeckCard() timeline:
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 1. Lift off deck (120ms, lift ease) │
|
||||
│ translateY: -15, rotate wobble │
|
||||
│ │
|
||||
│ 2. Move to hold pos (250ms, move ease) │
|
||||
│ left/top to holdingRect │
|
||||
│ │
|
||||
│ 3. Brief pause (80ms) │
|
||||
│ │
|
||||
│ 4. Flip to reveal (320ms, flip ease) │
|
||||
│ rotateY: 180→0, play flip sound │
|
||||
│ │
|
||||
│ 5. View pause (120ms) │
|
||||
└─────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
Callback:
|
||||
├─ CLEAR isDrawAnimating = false
|
||||
├─ displayHeldCard(card) with popIn
|
||||
├─ renderGame()
|
||||
└─ Show toast: "Swap with a card or discard"
|
||||
```
|
||||
|
||||
**Total animation time:** ~200 + 120 + 250 + 80 + 320 + 120 = ~1090ms
|
||||
|
||||
---
|
||||
|
||||
## Flow 2: Local Player Draws from Discard
|
||||
|
||||
**Trigger:** User clicks discard pile
|
||||
|
||||
```
|
||||
User clicks discard
|
||||
│
|
||||
▼
|
||||
drawFromDiscard()
|
||||
├─ Validate: isMyTurn(), no drawnCard, discard_top exists
|
||||
└─ Send: { type: 'draw', source: 'discard' }
|
||||
│
|
||||
▼
|
||||
Server responds: 'card_drawn'
|
||||
├─ Store drawnCard, drawnFromDiscard=true
|
||||
├─ Clear stale flags
|
||||
├─ SET isDrawAnimating = true
|
||||
└─ hideDrawnCard()
|
||||
│
|
||||
▼
|
||||
cardAnimations.animateDrawDiscard(card, callback)
|
||||
│
|
||||
├─ Pulse discard (gold ring)
|
||||
├─ Wait pulseDelay (200ms)
|
||||
│
|
||||
▼
|
||||
_animateDrawDiscardCard() timeline:
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Hide actual discard pile (opacity: 0) │
|
||||
│ │
|
||||
│ 1. Quick lift (80ms, lift ease) │
|
||||
│ translateY: -12, scale: 1.05 │
|
||||
│ │
|
||||
│ 2. Move to hold pos (200ms, move ease) │
|
||||
│ left/top to holdingRect │
|
||||
│ │
|
||||
│ 3. Brief settle (60ms) │
|
||||
└─────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
Callback:
|
||||
├─ Restore discard pile opacity
|
||||
├─ CLEAR isDrawAnimating = false
|
||||
├─ displayHeldCard(card) with popIn
|
||||
├─ renderGame()
|
||||
└─ Show toast: "Swap with a card or discard"
|
||||
```
|
||||
|
||||
**Total animation time:** ~200 + 80 + 200 + 60 = ~540ms
|
||||
|
||||
---
|
||||
|
||||
## Flow 3: Local Player Swaps
|
||||
|
||||
**Trigger:** User clicks hand card while holding a drawn card
|
||||
|
||||
```
|
||||
User clicks hand card (position N)
|
||||
│
|
||||
▼
|
||||
handleCardClick(position)
|
||||
└─ drawnCard exists → animateSwap(position)
|
||||
│
|
||||
▼
|
||||
animateSwap(position)
|
||||
├─ SET swapAnimationInProgress = true
|
||||
├─ Hide originals (swap-out class, visibility:hidden)
|
||||
├─ Store drawnCard, clear this.drawnCard
|
||||
├─ SET skipNextDiscardFlip = true
|
||||
└─ Send: { type: 'swap', position }
|
||||
│
|
||||
├──────────────────────────────────┐
|
||||
│ Face-up card? │ Face-down card?
|
||||
▼ ▼
|
||||
Card data known Store pendingSwapData
|
||||
immediately Wait for server response
|
||||
│ │
|
||||
│ ▼
|
||||
│ Server: 'game_state'
|
||||
│ ├─ Detect swapAnimationInProgress
|
||||
│ ├─ Store pendingGameState
|
||||
│ └─ updateSwapAnimation(discard_top)
|
||||
│ │
|
||||
▼──────────────────────────────────▼
|
||||
cardAnimations.animateUnifiedSwap()
|
||||
│
|
||||
▼
|
||||
_doArcSwap() timeline:
|
||||
┌───────────────────────────────────────────┐
|
||||
│ (If face-down: flip first, 320ms) │
|
||||
│ │
|
||||
│ 1. Lift both cards (100ms, lift ease) │
|
||||
│ translateY: -10, scale: 1.03 │
|
||||
│ │
|
||||
│ 2a. Hand card arcs (320ms, arc ease) │
|
||||
│ → discard pile │
|
||||
│ │
|
||||
│ 2b. Held card arcs (320ms, arc ease) │ ← parallel
|
||||
│ → hand slot │ with 2a
|
||||
│ │
|
||||
│ 3. Settle (100ms, settle ease)│
|
||||
│ scale: 1.02→1 (gentle overshoot) │
|
||||
└───────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
Callback → completeSwapAnimation()
|
||||
├─ Clean up animation state, remove classes
|
||||
├─ CLEAR swapAnimationInProgress = false
|
||||
├─ Apply pendingGameState if exists
|
||||
└─ renderGame()
|
||||
```
|
||||
|
||||
**Total animation time:** ~100 + 320 + 100 = ~520ms (face-up), ~840ms (face-down)
|
||||
|
||||
---
|
||||
|
||||
## Flow 4: Local Player Discards
|
||||
|
||||
**Trigger:** User clicks discard button while holding a drawn card
|
||||
|
||||
```
|
||||
User clicks discard button
|
||||
│
|
||||
▼
|
||||
discardDrawn()
|
||||
├─ Store discardedCard
|
||||
├─ Send: { type: 'discard' }
|
||||
├─ Clear drawnCard, hide toast/button
|
||||
├─ Get heldRect (position of floating card)
|
||||
├─ Hide floating held card
|
||||
├─ SET skipNextDiscardFlip = true
|
||||
└─ SET localDiscardAnimating = true
|
||||
│
|
||||
▼
|
||||
cardAnimations.animateHeldToDiscard(card, heldRect, callback)
|
||||
│
|
||||
▼
|
||||
Timeline:
|
||||
┌───────────────────────────────────────────┐
|
||||
│ 1. Lift (100ms, lift ease) │
|
||||
│ translateY: -8, scale: 1.02 │
|
||||
│ │
|
||||
│ 2. Arc to discard (320ms, arc ease) │
|
||||
│ left/top with arc peak above │
|
||||
│ │
|
||||
│ 3. Settle (100ms, settle ease)│
|
||||
│ scale: 1.02→1 │
|
||||
└───────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
Callback:
|
||||
├─ updateDiscardPileDisplay(card)
|
||||
├─ pulseDiscardLand()
|
||||
├─ SET skipNextDiscardFlip = true
|
||||
└─ CLEAR localDiscardAnimating = false
|
||||
```
|
||||
|
||||
**Total animation time:** ~100 + 320 + 100 = ~520ms
|
||||
|
||||
---
|
||||
|
||||
## Flow 5: Opponent Draws from Deck then Swaps
|
||||
|
||||
**Trigger:** State change detected via WebSocket `game_state` update
|
||||
|
||||
```
|
||||
Server sends game_state (opponent drew + swapped)
|
||||
│
|
||||
▼
|
||||
triggerAnimationsForStateChange(old, new)
|
||||
│
|
||||
├─── STEP 1: Draw Detection ───────────────────────┐
|
||||
│ drawn_card: null → something │
|
||||
│ drawn_player_id != local player │
|
||||
│ Discard unchanged → drew from DECK │
|
||||
│ │
|
||||
│ ├─ Clear stale opponent flags │
|
||||
│ ├─ SET isDrawAnimating = true │
|
||||
│ └─ animateDrawDeck(null, callback) │
|
||||
│ │ │
|
||||
│ └─ Callback: CLEAR isDrawAnimating │
|
||||
│ │
|
||||
├─── STEP 2: Swap Detection ───────────────────────┐
|
||||
│ discard_top changed │
|
||||
│ Previous player's hand has different card │
|
||||
│ NOT justDetectedDraw (skip guard) │
|
||||
│ │
|
||||
│ └─ fireSwapAnimation(playerId, card, pos) │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ SET opponentSwapAnimation = { playerId, pos } │
|
||||
│ Hide source card (swap-out) │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ animateUnifiedSwap() → _doArcSwap() │
|
||||
│ (same timeline as Flow 3) │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ Callback: │
|
||||
│ ├─ Restore source card │
|
||||
│ ├─ CLEAR opponentSwapAnimation = null │
|
||||
│ └─ renderGame() │
|
||||
└───────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Note:** STEP 1 and STEP 2 are detected in the same `triggerAnimationsForStateChange` call. The draw animation fires first; the swap animation fires after (may overlap slightly depending on timing).
|
||||
|
||||
---
|
||||
|
||||
## Flow 6: Opponent Draws from Deck then Discards
|
||||
|
||||
**Trigger:** State change — opponent drew from deck but didn't swap (discarded drawn card)
|
||||
|
||||
```
|
||||
Server sends game_state (opponent drew + discarded)
|
||||
│
|
||||
▼
|
||||
triggerAnimationsForStateChange(old, new)
|
||||
│
|
||||
├─── STEP 1: Draw Detection ──────────────────┐
|
||||
│ (Same as Flow 5 — draw from deck) │
|
||||
│ SET isDrawAnimating = true │
|
||||
│ animateDrawDeck(null, callback) │
|
||||
│ │
|
||||
├─── STEP 2: Discard Detection ────────────────┐
|
||||
│ discard_top changed │
|
||||
│ No hand position changed (no swap) │
|
||||
│ │
|
||||
│ └─ fireDiscardAnimation(card, playerId) │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ SET opponentDiscardAnimating = true │
|
||||
│ SET skipNextDiscardFlip = true │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ animateOpponentDiscard(card, callback) │
|
||||
│ │
|
||||
│ Timeline: │
|
||||
│ ┌────────────────────────────────────────┐ │
|
||||
│ │ (Wait for draw overlay to clear) │ │
|
||||
│ │ │ │
|
||||
│ │ 1. Lift (100ms, lift ease) │ │
|
||||
│ │ 2. Arc→discard (320ms, arc ease) │ │
|
||||
│ │ 3. Settle (100ms, settle ease) │ │
|
||||
│ └────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ Callback: │
|
||||
│ ├─ CLEAR opponentDiscardAnimating = false │
|
||||
│ ├─ updateDiscardPileDisplay(card) │
|
||||
│ └─ pulseDiscardLand() │
|
||||
└───────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Flow 7: Opponent Draws from Discard then Swaps
|
||||
|
||||
**Trigger:** State change — opponent took from discard pile and swapped
|
||||
|
||||
```
|
||||
Server sends game_state (opponent drew from discard + swapped)
|
||||
│
|
||||
▼
|
||||
triggerAnimationsForStateChange(old, new)
|
||||
│
|
||||
├─── STEP 1: Draw Detection ──────────────────┐
|
||||
│ drawn_card: null → something │
|
||||
│ Discard top CHANGED → drew from DISCARD │
|
||||
│ │
|
||||
│ ├─ Clear stale opponent flags │
|
||||
│ ├─ SET isDrawAnimating = true │
|
||||
│ └─ animateDrawDiscard(card, callback) │
|
||||
│ │
|
||||
├─── STEP 2: Skip Guard ───────────────────────┐
|
||||
│ justDetectedDraw AND discard changed? │
|
||||
│ YES → SKIP STEP 2 │
|
||||
│ │
|
||||
│ The discard change was from REMOVING a │
|
||||
│ card (draw), not ADDING one (discard). │
|
||||
│ The swap detection comes from a LATER │
|
||||
│ state update when the turn completes. │
|
||||
└───────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
(Next state update detects the swap via STEP 2)
|
||||
└─ fireSwapAnimation() — same as Flow 5
|
||||
```
|
||||
|
||||
**Critical:** The skip guard (`!justDetectedDraw`) prevents double-animating when an opponent draws from the discard pile. Without it, the discard change would trigger both a draw animation AND a discard animation.
|
||||
|
||||
---
|
||||
|
||||
## Flow 8: Initial Card Flip
|
||||
|
||||
**Trigger:** User clicks face-down card during the initial flip phase (start of round)
|
||||
|
||||
```
|
||||
User clicks face-down card (position N)
|
||||
│
|
||||
▼
|
||||
handleCardClick(position)
|
||||
├─ Check: waiting_for_initial_flip
|
||||
├─ Validate: card is face-down, not already tracked
|
||||
├─ Add to locallyFlippedCards set
|
||||
├─ Add to selectedCards array
|
||||
└─ fireLocalFlipAnimation(position, card)
|
||||
│
|
||||
▼
|
||||
fireLocalFlipAnimation()
|
||||
├─ Add to animatingPositions set (prevent overlap)
|
||||
└─ cardAnimations.animateInitialFlip(cardEl, card, callback)
|
||||
│
|
||||
▼
|
||||
Timeline:
|
||||
┌──────────────────────────────────┐
|
||||
│ Create overlay at card position │
|
||||
│ Hide original (opacity: 0) │
|
||||
│ │
|
||||
│ 1. Flip (320ms, flip) │
|
||||
│ rotateY: 180→0 │
|
||||
│ Play flip sound │
|
||||
└──────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
Callback:
|
||||
├─ Remove overlay, restore original
|
||||
└─ Remove from animatingPositions
|
||||
│
|
||||
▼
|
||||
renderGame() (called after click)
|
||||
└─ Shows flipped state immediately (optimistic)
|
||||
│
|
||||
▼
|
||||
(If all required flips selected)
|
||||
└─ Send: { type: 'flip_cards', positions: [...] }
|
||||
│
|
||||
▼
|
||||
Server confirms → clear locallyFlippedCards
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Flow 9: Deal Animation
|
||||
|
||||
**Trigger:** `game_started` or `round_started` WebSocket message
|
||||
|
||||
```
|
||||
Server: 'game_started' / 'round_started'
|
||||
│
|
||||
▼
|
||||
Reset all state, cancel animations
|
||||
SET dealAnimationInProgress = true
|
||||
renderGame() — layout card slots
|
||||
Hide player/opponent cards (visibility: hidden)
|
||||
│
|
||||
▼
|
||||
cardAnimations.animateDealing(gameState, getPlayerRect, callback)
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Shuffle pause (400ms) │
|
||||
│ │
|
||||
│ For each deal round (6 total): │
|
||||
│ For each player (dealer's left first): │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ Create overlay at deck position │ │
|
||||
│ │ Fly to player card slot (150ms) │ │
|
||||
│ │ Play card sound │ │
|
||||
│ │ Stagger delay (80ms) │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ Round pause (50ms) │
|
||||
│ │
|
||||
│ Wait for last cards to land │
|
||||
│ Flip discard card (200ms delay + flip sound) │
|
||||
│ Clean up all overlays │
|
||||
└─────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
Callback:
|
||||
├─ CLEAR dealAnimationInProgress = false
|
||||
├─ Show real cards (visibility: visible)
|
||||
├─ renderGame()
|
||||
└─ animateOpponentInitialFlips()
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ For each opponent: │
|
||||
│ Random delay (500-2500ms window) │
|
||||
│ For each face-up card: │
|
||||
│ Temporarily show as face-down │
|
||||
│ animateOpponentFlip() (320ms) │
|
||||
│ Stagger (400ms between cards) │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Total deal time:** ~400 + (6 rounds x players x 230ms) + 350ms flip
|
||||
|
||||
---
|
||||
|
||||
## Flow 10: Round End Reveal
|
||||
|
||||
**Trigger:** `round_over` WebSocket message after round ends
|
||||
|
||||
```
|
||||
Server: 'game_state' (phase → 'round_over')
|
||||
├─ Detect roundJustEnded
|
||||
├─ Save pre/post reveal states
|
||||
└─ Update gameState but DON'T render
|
||||
│
|
||||
▼
|
||||
Server: 'round_over' (scores, rankings)
|
||||
│
|
||||
▼
|
||||
runRoundEndReveal(scores, rankings)
|
||||
├─ SET revealAnimationInProgress = true
|
||||
├─ renderGame() — show current layout
|
||||
├─ Compute cardsToReveal (face-down → face-up)
|
||||
└─ Get reveal order (knocker first, then clockwise)
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────┐
|
||||
│ For each player (in reveal order): │
|
||||
│ Highlight player area │
|
||||
│ Pause (200ms) │
|
||||
│ │
|
||||
│ For each face-down card: │
|
||||
│ animateRevealFlip(id, pos, card) │
|
||||
│ ├─ Local: animateInitialFlip (320ms) │
|
||||
│ └─ Opponent: animateOpponentFlip │
|
||||
│ Stagger (100ms) │
|
||||
│ │
|
||||
│ Wait for last flip + pause │
|
||||
│ Remove highlight │
|
||||
└──────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
CLEAR revealAnimationInProgress = false
|
||||
renderGame()
|
||||
│
|
||||
▼
|
||||
Run score tally animation
|
||||
Show scoreboard
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Flag Lifecycle Summary
|
||||
|
||||
Every flag follows the same pattern: **SET before animation, CLEAR in callback**.
|
||||
|
||||
```
|
||||
SET flag ──► Animation runs ──► Callback fires ──► CLEAR flag
|
||||
│
|
||||
▼
|
||||
renderGame()
|
||||
```
|
||||
|
||||
### Where each flag is cleared
|
||||
|
||||
| Flag | Normal Clear | Safety Clears |
|
||||
|------|-------------|---------------|
|
||||
| `isDrawAnimating` | Draw animation callback | — |
|
||||
| `localDiscardAnimating` | Discard animation callback | Fallback path |
|
||||
| `opponentDiscardAnimating` | Opponent discard callback | `your_turn`, `card_drawn`, before opponent draw |
|
||||
| `opponentSwapAnimation` | Swap animation callback | `your_turn`, `card_drawn`, before opponent draw, new round |
|
||||
| `dealAnimationInProgress` | Deal complete callback | — |
|
||||
| `swapAnimationInProgress` | `completeSwapAnimation()` | — |
|
||||
|
||||
---
|
||||
|
||||
## Safety Clears
|
||||
|
||||
Stale flags can freeze the UI. Multiple locations clear opponent flags as a safety net:
|
||||
|
||||
| Location | Clears | When |
|
||||
|----------|--------|------|
|
||||
| `your_turn` message handler | `opponentSwapAnimation`, `opponentDiscardAnimating` | Player's turn starts |
|
||||
| `card_drawn` handler (deck) | `opponentSwapAnimation`, `opponentDiscardAnimating` | Local player draws |
|
||||
| `card_drawn` handler (discard) | `opponentSwapAnimation`, `opponentDiscardAnimating` | Local player draws |
|
||||
| Before opponent draw animation | `opponentSwapAnimation`, `opponentDiscardAnimating` | New opponent animation starts |
|
||||
| `game_started`/`round_started` | All flags | New round resets everything |
|
||||
|
||||
**Rule:** If you add a new animation flag, add safety clears in the `your_turn` handler and at round start.
|
||||
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.
|
||||
317
docs/v2/V2_08_GAME_LOGGING.md
Normal file
317
docs/v2/V2_08_GAME_LOGGING.md
Normal file
@@ -0,0 +1,317 @@
|
||||
# V2-08: Unified Game Logging
|
||||
|
||||
## Overview
|
||||
|
||||
This document covers the unified PostgreSQL game logging system that replaces
|
||||
the legacy SQLite `game_log.py`. All game events and AI decisions are logged
|
||||
to PostgreSQL for analysis, replay, and cloud deployment.
|
||||
|
||||
**Dependencies:** V2-01 (Event Sourcing), V2-02 (Persistence)
|
||||
**Dependents:** Game Analyzer, Stats Dashboard
|
||||
|
||||
---
|
||||
|
||||
## Goals
|
||||
|
||||
1. Consolidate all game data in PostgreSQL (drop SQLite dependency)
|
||||
2. Preserve AI decision context for analysis
|
||||
3. Maintain compatibility with existing services (Stats, Replay, Recovery)
|
||||
4. Enable efficient queries for game analysis
|
||||
5. Support cloud deployment without local file dependencies
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ Game Server │
|
||||
│ (main.py) │
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌───────────────────┼───────────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
|
||||
│ GameLogger │ │ EventStore │ │ StatsService │
|
||||
│ Service │ │ (events) │ │ ReplayService │
|
||||
└───────┬───────┘ └───────────────┘ └───────────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────────────────────────────┐
|
||||
│ PostgreSQL │
|
||||
│ ┌─────────┐ ┌───────────┐ ┌──────────────┐ │
|
||||
│ │ games_v2│ │ events │ │ moves │ │
|
||||
│ │ metadata│ │ (actions) │ │ (AI context) │ │
|
||||
│ └─────────┘ └───────────┘ └──────────────┘ │
|
||||
└───────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Database Schema
|
||||
|
||||
### moves Table (New)
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS moves (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
game_id UUID NOT NULL,
|
||||
sequence_num INT NOT NULL,
|
||||
timestamp TIMESTAMPTZ DEFAULT NOW(),
|
||||
player_id VARCHAR(50) NOT NULL,
|
||||
player_name VARCHAR(100),
|
||||
is_cpu BOOLEAN DEFAULT FALSE,
|
||||
|
||||
-- Action details
|
||||
action VARCHAR(30) NOT NULL, -- draw_deck, take_discard, swap, discard, flip, etc.
|
||||
card_rank VARCHAR(5),
|
||||
card_suit VARCHAR(10),
|
||||
position INT,
|
||||
|
||||
-- AI context (JSONB for flexibility)
|
||||
hand_state JSONB, -- Player's hand at decision time
|
||||
discard_top JSONB, -- Top of discard pile
|
||||
visible_opponents JSONB, -- Face-up cards of opponents
|
||||
decision_reason TEXT, -- AI reasoning
|
||||
|
||||
UNIQUE(game_id, sequence_num)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_moves_game ON moves(game_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_moves_action ON moves(action);
|
||||
CREATE INDEX IF NOT EXISTS idx_moves_is_cpu ON moves(is_cpu);
|
||||
CREATE INDEX IF NOT EXISTS idx_moves_player ON moves(player_id);
|
||||
```
|
||||
|
||||
### Action Types
|
||||
|
||||
| Action | Description |
|
||||
|--------|-------------|
|
||||
| `draw_deck` | Player drew from deck |
|
||||
| `take_discard` | Player took top of discard pile |
|
||||
| `swap` | Player swapped drawn card with hand card |
|
||||
| `discard` | Player discarded drawn card |
|
||||
| `flip` | Player flipped a card after discarding |
|
||||
| `skip_flip` | Player skipped optional flip (endgame) |
|
||||
| `flip_as_action` | Player used flip-as-action house rule |
|
||||
| `knock_early` | Player knocked to end round early |
|
||||
|
||||
---
|
||||
|
||||
## GameLogger Service
|
||||
|
||||
**Location:** `/server/services/game_logger.py`
|
||||
|
||||
### API
|
||||
|
||||
```python
|
||||
class GameLogger:
|
||||
"""Logs game events and moves to PostgreSQL."""
|
||||
|
||||
def __init__(self, event_store: EventStore):
|
||||
"""Initialize with event store instance."""
|
||||
|
||||
def log_game_start(
|
||||
self,
|
||||
room_code: str,
|
||||
num_players: int,
|
||||
options: GameOptions,
|
||||
) -> str:
|
||||
"""Log game start, returns game_id."""
|
||||
|
||||
def log_move(
|
||||
self,
|
||||
game_id: str,
|
||||
player: Player,
|
||||
is_cpu: bool,
|
||||
action: str,
|
||||
card: Optional[Card] = None,
|
||||
position: Optional[int] = None,
|
||||
game: Optional[Game] = None,
|
||||
decision_reason: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Log a move with AI context."""
|
||||
|
||||
def log_game_end(self, game_id: str) -> None:
|
||||
"""Mark game as ended."""
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
```python
|
||||
# In main.py lifespan
|
||||
from services.game_logger import GameLogger, set_logger
|
||||
|
||||
_event_store = await get_event_store(config.POSTGRES_URL)
|
||||
_game_logger = GameLogger(_event_store)
|
||||
set_logger(_game_logger)
|
||||
|
||||
# In handlers
|
||||
from services.game_logger import get_logger
|
||||
|
||||
game_logger = get_logger()
|
||||
if game_logger:
|
||||
game_logger.log_move(
|
||||
game_id=room.game_log_id,
|
||||
player=player,
|
||||
is_cpu=False,
|
||||
action="swap",
|
||||
card=drawn_card,
|
||||
position=position,
|
||||
game=room.game,
|
||||
decision_reason="swapped 5 into position 2",
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Query Patterns
|
||||
|
||||
### Find Suspicious Discards
|
||||
|
||||
```python
|
||||
# Using EventStore
|
||||
blunders = await event_store.find_suspicious_discards(limit=50)
|
||||
```
|
||||
|
||||
```sql
|
||||
-- Direct SQL
|
||||
SELECT m.*, g.room_code
|
||||
FROM moves m
|
||||
JOIN games_v2 g ON m.game_id = g.id
|
||||
WHERE m.action = 'discard'
|
||||
AND m.card_rank IN ('A', '2', 'K')
|
||||
AND m.is_cpu = TRUE
|
||||
ORDER BY m.timestamp DESC
|
||||
LIMIT 50;
|
||||
```
|
||||
|
||||
### Get Player Decisions
|
||||
|
||||
```python
|
||||
moves = await event_store.get_player_decisions(game_id, player_name)
|
||||
```
|
||||
|
||||
```sql
|
||||
SELECT * FROM moves
|
||||
WHERE game_id = $1 AND player_name = $2
|
||||
ORDER BY sequence_num;
|
||||
```
|
||||
|
||||
### Recent Games with Stats
|
||||
|
||||
```python
|
||||
games = await event_store.get_recent_games_with_stats(limit=10)
|
||||
```
|
||||
|
||||
```sql
|
||||
SELECT g.*, COUNT(m.id) as total_moves
|
||||
FROM games_v2 g
|
||||
LEFT JOIN moves m ON g.id = m.game_id
|
||||
GROUP BY g.id
|
||||
ORDER BY g.created_at DESC
|
||||
LIMIT 10;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migration from SQLite
|
||||
|
||||
### Removed Files
|
||||
|
||||
- `/server/game_log.py` - Replaced by `/server/services/game_logger.py`
|
||||
- `/server/games.db` - Data now in PostgreSQL
|
||||
|
||||
### Updated Files
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `main.py` | Import from `services.game_logger`, init in lifespan |
|
||||
| `ai.py` | Import from `services.game_logger` |
|
||||
| `simulate.py` | Removed logging, uses in-memory SimulationStats only |
|
||||
| `game_analyzer.py` | CLI updated for PostgreSQL, class deprecated |
|
||||
| `stores/event_store.py` | Added `moves` table and query methods |
|
||||
|
||||
### Simulation Mode
|
||||
|
||||
Simulations (`simulate.py`) no longer write to the database. They use in-memory
|
||||
`SimulationStats` for analysis. This keeps simulations fast and avoids flooding
|
||||
the database with bulk test runs.
|
||||
|
||||
For simulation analysis:
|
||||
```bash
|
||||
python simulate.py 100 --preset baseline
|
||||
# Stats printed to console
|
||||
```
|
||||
|
||||
For production game analysis:
|
||||
```bash
|
||||
python game_analyzer.py blunders 20
|
||||
python game_analyzer.py recent 10
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. **PostgreSQL Integration**
|
||||
- [x] moves table created with proper indexes
|
||||
- [x] All game actions logged to PostgreSQL via GameLogger
|
||||
- [x] EventStore has append_move() and query methods
|
||||
|
||||
2. **Service Compatibility**
|
||||
- [x] StatsService still works (uses events table)
|
||||
- [x] ReplayService still works (uses events table)
|
||||
- [x] RecoveryService still works (uses events table)
|
||||
|
||||
3. **Simulation Mode**
|
||||
- [x] simulate.py works without PostgreSQL
|
||||
- [x] In-memory SimulationStats provides analysis
|
||||
|
||||
4. **SQLite Removal**
|
||||
- [x] game_log.py can be deleted
|
||||
- [x] games.db can be deleted
|
||||
- [x] No sqlite3 imports in main game code
|
||||
|
||||
---
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Async/Sync Bridging
|
||||
|
||||
The GameLogger provides sync methods (`log_move`, `log_game_start`) that
|
||||
internally fire async tasks. This allows existing sync code paths to call
|
||||
the logger without blocking:
|
||||
|
||||
```python
|
||||
def log_move(self, game_id, ...):
|
||||
if not game_id:
|
||||
return
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
asyncio.create_task(self.log_move_async(...))
|
||||
except RuntimeError:
|
||||
# Not in async context - skip (simulations)
|
||||
pass
|
||||
```
|
||||
|
||||
### Fire-and-Forget Logging
|
||||
|
||||
Move logging uses fire-and-forget async tasks to avoid blocking game logic.
|
||||
This means:
|
||||
- Logging failures don't crash the game
|
||||
- Slight delay between action and database write is acceptable
|
||||
- No acknowledgment that log succeeded
|
||||
|
||||
For critical data, use the events table which is the source of truth.
|
||||
|
||||
---
|
||||
|
||||
## Notes for Developers
|
||||
|
||||
- The `moves` table is denormalized for efficient queries
|
||||
- The `events` table remains the source of truth for game replay
|
||||
- GameLogger is None when PostgreSQL is not configured (no logging)
|
||||
- Always check `if game_logger:` before calling methods
|
||||
- For quick development testing, use simulate.py without database
|
||||
522
docs/v2/V2_BUILD_PLAN.md
Normal file
522
docs/v2/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.
|
||||
291
docs/v3/V3_00_MASTER_PLAN.md
Normal file
291
docs/v3/V3_00_MASTER_PLAN.md
Normal file
@@ -0,0 +1,291 @@
|
||||
# Golf Card Game - V3 Master Plan
|
||||
|
||||
## Overview
|
||||
|
||||
Transform the current Golf card game into a more natural, physical-feeling experience through enhanced animations, visual feedback, and gameplay flow improvements. The goal is to make the digital game feel as satisfying as playing with real cards.
|
||||
|
||||
**Theme:** "Make it feel like a real card game"
|
||||
|
||||
---
|
||||
|
||||
## Document Structure (VDD)
|
||||
|
||||
This plan is split into independent vertical slices ordered by priority and impact. Each document is self-contained and can be worked on by a separate agent.
|
||||
|
||||
| Document | Scope | Priority | Effort | Dependencies |
|
||||
|----------|-------|----------|--------|--------------|
|
||||
| `V3_01_DEALER_ROTATION.md` | Rotate dealer/first player each round | High | Low | None (server change) |
|
||||
| `V3_02_DEALING_ANIMATION.md` | Animated card dealing at round start | High | Medium | 01 |
|
||||
| `V3_03_ROUND_END_REVEAL.md` | Dramatic sequential card reveal | High | Medium | None |
|
||||
| `V3_04_COLUMN_PAIR_CELEBRATION.md` | Visual feedback for matching pairs | High | Low | None |
|
||||
| `V3_05_FINAL_TURN_URGENCY.md` | Enhanced final turn visual tension | High | Low | None |
|
||||
| `V3_06_OPPONENT_THINKING.md` | Visible opponent consideration phase | Medium | Low | None |
|
||||
| `V3_07_SCORE_TALLYING.md` | Animated score counting | Medium | Medium | 03 |
|
||||
| `V3_08_CARD_HOVER_SELECTION.md` | Enhanced card selection preview | Medium | Low | None |
|
||||
| `V3_09_KNOCK_EARLY_DRAMA.md` | Dramatic knock early presentation | Medium | Low | None |
|
||||
| `V3_10_COLUMN_PAIR_INDICATOR.md` | Visual connector for paired columns | Medium | Low | 04 |
|
||||
| `V3_11_SWAP_ANIMATION_IMPROVEMENTS.md` | More physical swap motion | Medium | Medium | None |
|
||||
| `V3_12_DRAW_SOURCE_DISTINCTION.md` | Visual distinction deck vs discard draw | Low | Low | None |
|
||||
| `V3_13_CARD_VALUE_TOOLTIPS.md` | Long-press card value display | Low | Medium | None |
|
||||
| `V3_14_ACTIVE_RULES_CONTEXT.md` | Contextual rule highlighting | Low | Low | None |
|
||||
| `V3_15_DISCARD_PILE_HISTORY.md` | Show recent discards fanned | Low | Medium | None |
|
||||
| `V3_16_REALISTIC_CARD_SOUNDS.md` | Improved audio feedback | Nice | Medium | None |
|
||||
| `V3_17_MOBILE_PORTRAIT_LAYOUT.md` | Full mobile portrait layout + animation fixes | High | High | 02, 11 |
|
||||
|
||||
---
|
||||
|
||||
## Current State (V2)
|
||||
|
||||
```
|
||||
Client (Vanilla JS)
|
||||
├── app.js - Main game logic (2500+ lines)
|
||||
├── card-manager.js - DOM card element management (3D flip structure)
|
||||
├── animation-queue.js - Sequential animation processing
|
||||
├── card-animations.js - Unified anime.js animation system (replaces draw-animations.js)
|
||||
├── state-differ.js - State change detection
|
||||
├── timing-config.js - Centralized animation timing + anime.js easing config
|
||||
├── anime.min.js - Anime.js library for all animations
|
||||
└── style.css - Minimal CSS, mostly layout
|
||||
```
|
||||
|
||||
**What works well:**
|
||||
- **Unified anime.js system** - All card animations use `window.cardAnimations` (CardAnimations class)
|
||||
- State diffing detects changes and triggers appropriate animations
|
||||
- Animation queue ensures sequential, non-overlapping animations
|
||||
- Centralized timing config with anime.js easing presets (`TIMING.anime.easing`)
|
||||
- Sound effects via Web Audio API
|
||||
- CardAnimations provides: draw, flip, swap, discard, ambient loops (turn pulse, CPU thinking)
|
||||
- Opponent turn visibility with CPU action announcements
|
||||
|
||||
**Limitations:**
|
||||
- Cards appear instantly at round start (no dealing animation)
|
||||
- Round end reveals all cards simultaneously
|
||||
- No visual celebration for column pairs
|
||||
- Final turn phase lacks urgency/tension
|
||||
- Swap animation uses crossfade rather than physical motion
|
||||
- Limited feedback during card selection
|
||||
- Discard pile shows only top card
|
||||
|
||||
---
|
||||
|
||||
## V3 Target Experience
|
||||
|
||||
### Physical Card Game Feel Checklist
|
||||
|
||||
| Aspect | Physical Game | Current Digital | V3 Target |
|
||||
|--------|---------------|-----------------|-----------|
|
||||
| **Dealer Rotation** | Deal passes clockwise each round | Always starts with host | Rotating dealer/first player |
|
||||
| **Dealing** | Cards dealt one at a time | Cards appear instantly | Animated dealing sequence |
|
||||
| **Drawing** | Card lifts, player considers | Card pops in | Source-appropriate pickup |
|
||||
| **Swapping** | Old card slides out, new slides in | Teleport swap | Cross-over motion |
|
||||
| **Pairing** | "Nice!" moment when match noticed | No feedback | Visual celebration |
|
||||
| **Round End** | Dramatic reveal, one player at a time | All cards flip at once | Staggered reveal |
|
||||
| **Scoring** | Count card by card | Score appears | Animated tally |
|
||||
| **Final Turn** | Tension in the room | Badge shows | Visual urgency |
|
||||
| **Sounds** | Shuffle, flip, slap | Synth beeps | Realistic card sounds |
|
||||
|
||||
---
|
||||
|
||||
## Tech Approach
|
||||
|
||||
### Animation Strategy
|
||||
|
||||
All **card animations** use the unified `CardAnimations` class (`card-animations.js`):
|
||||
- **Anime.js timelines** for all card animations (flip, swap, draw, discard)
|
||||
- **CardAnimations methods** - `animateDrawDeck()`, `animateFlip()`, `animateSwap()`, etc.
|
||||
- **Ambient loops** - `startTurnPulse()`, `startCpuThinking()`, `startInitialFlipPulse()`
|
||||
- **One-shot effects** - `pulseDiscard()`, `pulseSwap()`, `popIn()`
|
||||
- **Animation queue** for sequencing multi-step animations
|
||||
- **State differ** to trigger animations on state changes
|
||||
|
||||
**When to use CSS vs anime.js:**
|
||||
- **Anime.js (CardAnimations)**: Card movements, flips, swaps, draws - anything involving card elements
|
||||
- **CSS keyframes/transitions**: Simple UI feedback (button hover, badge entrance, status message fades) - non-card elements
|
||||
|
||||
**General rule:** If it moves a card, use anime.js. If it's UI chrome, CSS is fine.
|
||||
|
||||
### Timing Philosophy
|
||||
|
||||
From `timing-config.js`:
|
||||
```javascript
|
||||
// Current values - animations are smooth but quick
|
||||
card: {
|
||||
flip: 400, // Card flip duration
|
||||
move: 400, // Card movement
|
||||
},
|
||||
pause: {
|
||||
afterFlip: 0, // No pause - flow into next action
|
||||
betweenAnimations: 0, // No gaps
|
||||
},
|
||||
// Anime.js easing presets
|
||||
anime: {
|
||||
easing: {
|
||||
flip: 'easeInOutQuad',
|
||||
move: 'easeOutCubic',
|
||||
lift: 'easeOutQuad',
|
||||
pulse: 'easeInOutSine',
|
||||
},
|
||||
loop: {
|
||||
turnPulse: { duration: 2000 },
|
||||
cpuThinking: { duration: 1500 },
|
||||
initialFlipGlow: { duration: 1500 },
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
V3 will introduce **optional pauses for drama** without slowing normal gameplay:
|
||||
- Quick pauses at key moments (pair formed, round end)
|
||||
- Staggered timing for dealing/reveal (perceived faster than actual)
|
||||
- User preference for animation speed (future consideration)
|
||||
|
||||
### Sound Strategy
|
||||
|
||||
Current sounds are oscillator-based (Web Audio API synthesis). V3 options:
|
||||
1. **Enhanced synthesis** - More realistic waveforms, envelopes
|
||||
2. **Audio sprites** - Short recordings of real card sounds
|
||||
3. **Hybrid** - Synthesis for some, samples for others
|
||||
|
||||
Recommendation: Start with enhanced synthesis (no asset loading), consider audio sprites later.
|
||||
|
||||
---
|
||||
|
||||
## Phases & Milestones
|
||||
|
||||
### Phase 1: Core Feel (High Priority)
|
||||
**Goal:** Make the game feel noticeably more physical
|
||||
|
||||
| Item | Description | Document |
|
||||
|------|-------------|----------|
|
||||
| Dealer rotation | First player rotates each round (like real cards) | 01 |
|
||||
| Dealing animation | Cards dealt sequentially at round start | 02 |
|
||||
| Round end reveal | Dramatic staggered flip at round end | 03 |
|
||||
| Column pair celebration | Glow/pulse when pairs form | 04 |
|
||||
| Final turn urgency | Visual tension enhancement | 05 |
|
||||
|
||||
### Phase 2: Turn Polish (Medium Priority)
|
||||
**Goal:** Improve the feel of individual turns
|
||||
|
||||
| Item | Description | Document |
|
||||
|------|-------------|----------|
|
||||
| Opponent thinking | Visible consideration phase | 06 |
|
||||
| Score tallying | Animated counting | 07 |
|
||||
| Card hover/selection | Better swap preview | 08 |
|
||||
| Knock early drama | Dramatic knock presentation | 09 |
|
||||
| Column pair indicator | Visual pair connector | 10 |
|
||||
| Swap improvements | Physical swap motion | 11 |
|
||||
|
||||
### Phase 3: Polish & Extras (Low Priority)
|
||||
**Goal:** Nice-to-have improvements
|
||||
|
||||
| Item | Description | Document |
|
||||
|------|-------------|----------|
|
||||
| Draw distinction | Deck vs discard visual difference | 12 |
|
||||
| Card value tooltips | Long-press to see points | 13 |
|
||||
| Active rules context | Highlight relevant rules | 14 |
|
||||
| Discard history | Show fanned recent cards | 15 |
|
||||
| Realistic sounds | Better audio feedback | 16 |
|
||||
|
||||
---
|
||||
|
||||
## File Structure (Changes)
|
||||
|
||||
```
|
||||
server/
|
||||
├── game.py # Add dealer rotation logic (V3_01)
|
||||
|
||||
client/
|
||||
├── app.js # Enhance existing methods
|
||||
├── timing-config.js # Add new timing values + anime.js config
|
||||
├── card-animations.js # Extend with new animation methods
|
||||
├── animation-queue.js # Add new animation types
|
||||
├── style.css # Minimal additions (mostly layout)
|
||||
└── sounds/ # OPTIONAL: Audio sprites
|
||||
├── shuffle.mp3
|
||||
├── deal.mp3
|
||||
└── flip.mp3
|
||||
```
|
||||
|
||||
**Note:** All new animations should be added to `CardAnimations` class in `card-animations.js`. Do not add CSS keyframe animations for card movements.
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria (V3 Complete)
|
||||
|
||||
1. **Dealer rotates properly** - First player advances clockwise each round
|
||||
2. **Dealing feels physical** - Cards dealt one by one with shuffle sound
|
||||
3. **Round end is dramatic** - Staggered reveal with tension pause
|
||||
4. **Pairs are satisfying** - Visual celebration when columns match
|
||||
5. **Final turn has urgency** - Clear visual indication of tension
|
||||
6. **Swaps look natural** - Cards appear to exchange positions
|
||||
7. **No performance regression** - Animations run at 60fps on mobile
|
||||
8. **Timing is tunable** - All values in timing-config.js
|
||||
|
||||
---
|
||||
|
||||
## Design Principles
|
||||
|
||||
### 1. Enhance, Don't Slow Down
|
||||
Animations should make the game feel better without making it slower. Use perceived timing tricks:
|
||||
- Start next animation before previous fully completes
|
||||
- Stagger start times, not end times
|
||||
- Quick movements with slight ease-out
|
||||
|
||||
### 2. Respect the Player's Time
|
||||
- First-time experience: full animations
|
||||
- Repeat plays: consider faster mode option
|
||||
- Never block input unnecessarily
|
||||
|
||||
### 3. Clear Visual Hierarchy
|
||||
- Active player highlighted
|
||||
- Current action obvious
|
||||
- Next expected action hinted
|
||||
|
||||
### 4. Consistent Feedback
|
||||
- Same action = same animation
|
||||
- Similar duration for similar actions
|
||||
- Predictable timing helps player flow
|
||||
|
||||
### 5. Graceful Degradation
|
||||
- Animations enhance but aren't required
|
||||
- State updates should work without animations
|
||||
- Handle animation interruption gracefully
|
||||
|
||||
---
|
||||
|
||||
## How to Use These Documents
|
||||
|
||||
Each `V3_XX_*.md` document is designed to be:
|
||||
|
||||
1. **Self-contained** - Has all context needed to implement that feature
|
||||
2. **Agent-ready** - Can be given to a Claude agent as the primary context
|
||||
3. **Testable** - Includes visual verification criteria
|
||||
4. **Incremental** - Can be implemented and shipped independently
|
||||
|
||||
**Workflow:**
|
||||
1. Pick a document based on current priority
|
||||
2. Start a new Claude session with that document as context
|
||||
3. Implement the feature
|
||||
4. Verify against acceptance criteria
|
||||
5. Test on mobile and desktop
|
||||
6. Merge and move to next
|
||||
|
||||
---
|
||||
|
||||
## Notes for Implementation
|
||||
|
||||
- **Don't break existing functionality** - All current animations must still work
|
||||
- **Use existing infrastructure** - Build on animation-queue, timing-config
|
||||
- **Test on mobile** - Animations must run smoothly on phones
|
||||
- **Consider reduced motion** - Respect `prefers-reduced-motion` media query
|
||||
- **Keep it vanilla** - No new frameworks, Anime.js is sufficient
|
||||
|
||||
---
|
||||
|
||||
## Success Metrics
|
||||
|
||||
After V3 implementation, the game should:
|
||||
- Feel noticeably more satisfying to play
|
||||
- Get positive feedback on "polish" or "feel"
|
||||
- Not feel slower despite more animations
|
||||
- Work smoothly on all devices
|
||||
- Be easy to tune timing via config
|
||||
286
docs/v3/V3_01_DEALER_ROTATION.md
Normal file
286
docs/v3/V3_01_DEALER_ROTATION.md
Normal file
@@ -0,0 +1,286 @@
|
||||
# V3-01: Dealer/Starting Player Rotation
|
||||
|
||||
## Overview
|
||||
|
||||
In physical card games, the deal rotates clockwise after each hand. The player who deals also typically plays last (or the player to their left plays first). Currently, our game always starts with the host/first player each round.
|
||||
|
||||
**Dependencies:** None (server-side foundation)
|
||||
**Dependents:** V3_02 (Dealing Animation needs to know who is dealing)
|
||||
|
||||
---
|
||||
|
||||
## Goals
|
||||
|
||||
1. Track the current dealer position across rounds
|
||||
2. Rotate dealer clockwise after each round
|
||||
3. First player to act is to the left of the dealer (next in order)
|
||||
4. Communicate dealer position to clients
|
||||
5. Visual indicator of current dealer (client-side, prep for V3_02)
|
||||
|
||||
---
|
||||
|
||||
## Current State
|
||||
|
||||
From `server/game.py`, round start logic:
|
||||
|
||||
```python
|
||||
def start_next_round(self):
|
||||
"""Start the next round."""
|
||||
self.current_round += 1
|
||||
# ... deal cards ...
|
||||
# Current player is always index 0 (host/first joiner)
|
||||
self.current_player_idx = 0
|
||||
```
|
||||
|
||||
The `player_order` list is set once at game start and never changes. The first player is always `player_order[0]`.
|
||||
|
||||
---
|
||||
|
||||
## Design
|
||||
|
||||
### Server Changes
|
||||
|
||||
#### New State Fields
|
||||
|
||||
```python
|
||||
# In Game class __init__
|
||||
self.dealer_idx = 0 # Index into player_order of current dealer
|
||||
```
|
||||
|
||||
#### Round Start Logic
|
||||
|
||||
```python
|
||||
def start_next_round(self):
|
||||
"""Start the next round."""
|
||||
self.current_round += 1
|
||||
|
||||
# Rotate dealer clockwise (next player in order)
|
||||
if self.current_round > 1:
|
||||
self.dealer_idx = (self.dealer_idx + 1) % len(self.player_order)
|
||||
|
||||
# First player is to the LEFT of dealer (next after dealer)
|
||||
self.current_player_idx = (self.dealer_idx + 1) % len(self.player_order)
|
||||
|
||||
# ... rest of dealing logic ...
|
||||
```
|
||||
|
||||
#### Game State Response
|
||||
|
||||
Add dealer info to the game state sent to clients:
|
||||
|
||||
```python
|
||||
def get_state(self, for_player_id: str) -> dict:
|
||||
return {
|
||||
# ... existing fields ...
|
||||
"dealer_id": self.player_order[self.dealer_idx] if self.player_order else None,
|
||||
"dealer_idx": self.dealer_idx,
|
||||
# current_player_id already exists
|
||||
}
|
||||
```
|
||||
|
||||
### Client Changes
|
||||
|
||||
#### State Handling
|
||||
|
||||
In `app.js`, the `gameState` will now include:
|
||||
- `dealer_id` - The player ID of the current dealer
|
||||
- `dealer_idx` - Index for ordering
|
||||
|
||||
#### Visual Indicator
|
||||
|
||||
Add a dealer chip/badge to the current dealer's area:
|
||||
|
||||
```javascript
|
||||
// In renderGame() or opponent rendering
|
||||
const isDealer = player.id === this.gameState.dealer_id;
|
||||
if (isDealer) {
|
||||
div.classList.add('is-dealer');
|
||||
// Add dealer chip element
|
||||
}
|
||||
```
|
||||
|
||||
#### CSS
|
||||
|
||||
```css
|
||||
/* Dealer indicator */
|
||||
.is-dealer::before {
|
||||
content: "D";
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
left: -8px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: #f4a460;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
color: #1a1a2e;
|
||||
border: 2px solid #fff;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Or use a chip emoji/icon */
|
||||
.dealer-chip {
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
right: -10px;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Edge Cases
|
||||
|
||||
### Player Leaves Mid-Game
|
||||
|
||||
If the current dealer leaves:
|
||||
- Dealer position should stay at the same index
|
||||
- If that index is now out of bounds, wrap to 0
|
||||
- The show must go on
|
||||
|
||||
```python
|
||||
def remove_player(self, player_id: str):
|
||||
# ... existing removal logic ...
|
||||
|
||||
# Adjust dealer_idx if needed
|
||||
if self.dealer_idx >= len(self.player_order):
|
||||
self.dealer_idx = 0
|
||||
```
|
||||
|
||||
### 2-Player Game
|
||||
|
||||
With 2 players, dealer alternates each round:
|
||||
- Round 1: Player A deals, Player B plays first
|
||||
- Round 2: Player B deals, Player A plays first
|
||||
- This works naturally with the modulo logic
|
||||
|
||||
### Game Start (Round 1)
|
||||
|
||||
For round 1:
|
||||
- Dealer is the host (player_order[0])
|
||||
- First player is player_order[1] (or player_order[0] in solo/test)
|
||||
|
||||
Option: Could randomize initial dealer, but host-as-first-dealer is traditional.
|
||||
|
||||
---
|
||||
|
||||
## Test Cases
|
||||
|
||||
```python
|
||||
# server/tests/test_dealer_rotation.py
|
||||
|
||||
def test_dealer_starts_as_host():
|
||||
"""First round dealer is the host (first player)."""
|
||||
game = create_game_with_players(["Alice", "Bob", "Carol"])
|
||||
game.start_game()
|
||||
|
||||
assert game.dealer_idx == 0
|
||||
assert game.get_dealer_id() == "Alice"
|
||||
# First player is to dealer's left
|
||||
assert game.current_player_idx == 1
|
||||
assert game.get_current_player_id() == "Bob"
|
||||
|
||||
def test_dealer_rotates_each_round():
|
||||
"""Dealer advances clockwise after each round."""
|
||||
game = create_game_with_players(["Alice", "Bob", "Carol"])
|
||||
game.start_game()
|
||||
|
||||
# Round 1: Alice deals, Bob plays first
|
||||
assert game.dealer_idx == 0
|
||||
|
||||
complete_round(game)
|
||||
game.start_next_round()
|
||||
|
||||
# Round 2: Bob deals, Carol plays first
|
||||
assert game.dealer_idx == 1
|
||||
assert game.current_player_idx == 2
|
||||
|
||||
complete_round(game)
|
||||
game.start_next_round()
|
||||
|
||||
# Round 3: Carol deals, Alice plays first
|
||||
assert game.dealer_idx == 2
|
||||
assert game.current_player_idx == 0
|
||||
|
||||
def test_dealer_wraps_around():
|
||||
"""Dealer wraps to first player after last player deals."""
|
||||
game = create_game_with_players(["Alice", "Bob"])
|
||||
game.start_game()
|
||||
|
||||
# Round 1: Alice deals
|
||||
assert game.dealer_idx == 0
|
||||
|
||||
complete_round(game)
|
||||
game.start_next_round()
|
||||
|
||||
# Round 2: Bob deals
|
||||
assert game.dealer_idx == 1
|
||||
|
||||
complete_round(game)
|
||||
game.start_next_round()
|
||||
|
||||
# Round 3: Back to Alice
|
||||
assert game.dealer_idx == 0
|
||||
|
||||
def test_dealer_adjustment_on_player_leave():
|
||||
"""Dealer index adjusts when players leave."""
|
||||
game = create_game_with_players(["Alice", "Bob", "Carol"])
|
||||
game.start_game()
|
||||
|
||||
complete_round(game)
|
||||
game.start_next_round()
|
||||
# Bob is now dealer (idx 1)
|
||||
|
||||
game.remove_player("Carol") # Remove last player
|
||||
# Dealer idx should still be valid
|
||||
assert game.dealer_idx == 1
|
||||
assert game.dealer_idx < len(game.player_order)
|
||||
|
||||
def test_state_includes_dealer_info():
|
||||
"""Game state includes dealer information."""
|
||||
game = create_game_with_players(["Alice", "Bob"])
|
||||
game.start_game()
|
||||
|
||||
state = game.get_state("Alice")
|
||||
assert "dealer_id" in state
|
||||
assert state["dealer_id"] == "Alice"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. Add `dealer_idx` field to Game class
|
||||
2. Modify `start_game()` to set initial dealer
|
||||
3. Modify `start_next_round()` to rotate dealer
|
||||
4. Modify `get_state()` to include dealer info
|
||||
5. Handle edge case: player leaves
|
||||
6. Add tests for dealer rotation
|
||||
7. Client: Add dealer visual indicator
|
||||
8. Client: Style the dealer chip/badge
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Round 1 dealer is the host (first player in order)
|
||||
- [ ] Dealer rotates clockwise after each round
|
||||
- [ ] First player to act is always left of dealer
|
||||
- [ ] Dealer info included in game state sent to clients
|
||||
- [ ] Dealer position survives player departure
|
||||
- [ ] Visual indicator shows current dealer
|
||||
- [ ] All existing tests still pass
|
||||
|
||||
---
|
||||
|
||||
## Notes for Agent
|
||||
|
||||
- The `player_order` list is established at game start and defines clockwise order
|
||||
- Keep backward compatibility - games in progress shouldn't break
|
||||
- The dealer indicator is prep work for V3_02 (dealing animation)
|
||||
- Consider: Should dealer deal to themselves last? (Traditional, but not gameplay-affecting)
|
||||
- The visual dealer chip will become important when dealing animation shows cards coming FROM the dealer
|
||||
406
docs/v3/V3_02_DEALING_ANIMATION.md
Normal file
406
docs/v3/V3_02_DEALING_ANIMATION.md
Normal file
@@ -0,0 +1,406 @@
|
||||
# V3-02: Dealing Animation
|
||||
|
||||
## Overview
|
||||
|
||||
In physical card games, cards are dealt one at a time from the dealer to each player in turn. Currently, cards appear instantly when a round starts. This feature adds an animated dealing sequence that mimics the physical ritual.
|
||||
|
||||
**Dependencies:** V3_01 (Dealer Rotation - need to know who is dealing)
|
||||
**Dependents:** None
|
||||
|
||||
---
|
||||
|
||||
## Goals
|
||||
|
||||
1. Animate cards being dealt from a central deck position
|
||||
2. Deal one card at a time to each player in clockwise order
|
||||
3. Play shuffle sound before dealing begins
|
||||
4. Play card sound as each card lands
|
||||
5. Maintain quick perceived pace (stagger start times, not end times)
|
||||
6. Show dealing from dealer's position (or center as fallback)
|
||||
|
||||
---
|
||||
|
||||
## Current State
|
||||
|
||||
From `app.js`, when `game_started` or `round_started` message received:
|
||||
|
||||
```javascript
|
||||
case 'game_started':
|
||||
case 'round_started':
|
||||
this.gameState = data.game_state;
|
||||
this.playSound('shuffle');
|
||||
this.showGameScreen();
|
||||
this.renderGame(); // Cards appear instantly
|
||||
break;
|
||||
```
|
||||
|
||||
Cards are rendered immediately via `renderGame()` which populates the card grids.
|
||||
|
||||
---
|
||||
|
||||
## Design
|
||||
|
||||
### Animation Sequence
|
||||
|
||||
```
|
||||
1. Shuffle sound plays
|
||||
2. Brief pause (300ms) - deck appears to shuffle
|
||||
3. Deal round 1: One card to each player (clockwise from dealer's left)
|
||||
4. Deal round 2-6: Repeat until all 6 cards dealt to each player
|
||||
5. Flip discard pile top card
|
||||
6. Initial flip phase begins (or game starts if initial_flips=0)
|
||||
```
|
||||
|
||||
### Visual Flow
|
||||
|
||||
```
|
||||
[Deck]
|
||||
|
|
||||
┌─────────────────┼─────────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
[Opponent 1] [Opponent 2] [Opponent 3]
|
||||
|
|
||||
▼
|
||||
[Local Player]
|
||||
```
|
||||
|
||||
Cards fly from deck position to each player's card slot, face-down.
|
||||
|
||||
### Timing
|
||||
|
||||
```javascript
|
||||
// New timing values in timing-config.js
|
||||
dealing: {
|
||||
shufflePause: 400, // Pause after shuffle sound
|
||||
cardFlyTime: 150, // Time for card to fly to destination
|
||||
cardStagger: 80, // Delay between cards (overlap for speed)
|
||||
roundPause: 50, // Brief pause between deal rounds
|
||||
discardFlipDelay: 200, // Pause before flipping discard
|
||||
}
|
||||
```
|
||||
|
||||
Total time for 4-player game (24 cards):
|
||||
- 400ms shuffle + 24 cards × 80ms stagger + 200ms discard = ~2.5 seconds
|
||||
|
||||
This feels unhurried but not slow.
|
||||
|
||||
### Implementation Approach
|
||||
|
||||
#### Option A: Overlay Animation (Recommended)
|
||||
|
||||
Create temporary card elements that animate from deck to destinations, then remove them and show the real cards.
|
||||
|
||||
Pros:
|
||||
- Clean separation from game state
|
||||
- Easy to skip/interrupt
|
||||
- No complex state management
|
||||
|
||||
Cons:
|
||||
- Brief flash when swapping to real cards (mitigate with timing)
|
||||
|
||||
#### Option B: Animate Real Cards
|
||||
|
||||
Start with cards at deck position, animate to final positions.
|
||||
|
||||
Pros:
|
||||
- No element swap
|
||||
- More "real"
|
||||
|
||||
Cons:
|
||||
- Complex coordination with renderGame()
|
||||
- State management issues
|
||||
|
||||
**Recommendation:** Option A - overlay animation
|
||||
|
||||
---
|
||||
|
||||
## Implementation
|
||||
|
||||
### Add to `card-animations.js`
|
||||
|
||||
Add the dealing animation as a method on the existing `CardAnimations` class:
|
||||
|
||||
```javascript
|
||||
// Add to CardAnimations class in card-animations.js
|
||||
|
||||
/**
|
||||
* Run the dealing animation using anime.js timelines
|
||||
* @param {Object} gameState - The game state with players and their cards
|
||||
* @param {Function} getPlayerRect - Function(playerId, cardIdx) => {left, top, width, height}
|
||||
* @param {Function} onComplete - Callback when animation completes
|
||||
*/
|
||||
async animateDealing(gameState, getPlayerRect, onComplete) {
|
||||
const T = window.TIMING?.dealing || {
|
||||
shufflePause: 400,
|
||||
cardFlyTime: 150,
|
||||
cardStagger: 80,
|
||||
roundPause: 50,
|
||||
discardFlipDelay: 200,
|
||||
};
|
||||
|
||||
const deckRect = this.getDeckRect();
|
||||
const discardRect = this.getDiscardRect();
|
||||
if (!deckRect) {
|
||||
if (onComplete) onComplete();
|
||||
return;
|
||||
}
|
||||
|
||||
// Get player order starting from dealer's left
|
||||
const dealerIdx = gameState.dealer_idx || 0;
|
||||
const playerOrder = this.getDealOrder(gameState.players, dealerIdx);
|
||||
|
||||
// Create container for animation cards
|
||||
const container = document.createElement('div');
|
||||
container.className = 'deal-animation-container';
|
||||
container.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;pointer-events:none;z-index:1000;';
|
||||
document.body.appendChild(container);
|
||||
|
||||
// Shuffle sound and pause
|
||||
this.playSound('shuffle');
|
||||
await this.delay(T.shufflePause);
|
||||
|
||||
// Deal 6 rounds of cards using anime.js
|
||||
const allCards = [];
|
||||
for (let cardIdx = 0; cardIdx < 6; cardIdx++) {
|
||||
for (const player of playerOrder) {
|
||||
const targetRect = getPlayerRect(player.id, cardIdx);
|
||||
if (!targetRect) continue;
|
||||
|
||||
// Create card at deck position
|
||||
const deckColor = this.getDeckColor();
|
||||
const card = this.createAnimCard(deckRect, true, deckColor);
|
||||
card.classList.add('deal-anim-card');
|
||||
container.appendChild(card);
|
||||
allCards.push({ card, targetRect });
|
||||
|
||||
// Animate using anime.js
|
||||
anime({
|
||||
targets: card,
|
||||
left: targetRect.left,
|
||||
top: targetRect.top,
|
||||
width: targetRect.width,
|
||||
height: targetRect.height,
|
||||
duration: T.cardFlyTime,
|
||||
easing: this.getEasing('move'),
|
||||
});
|
||||
|
||||
this.playSound('card');
|
||||
await this.delay(T.cardStagger);
|
||||
}
|
||||
|
||||
// Brief pause between rounds
|
||||
if (cardIdx < 5) {
|
||||
await this.delay(T.roundPause);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for last cards to land
|
||||
await this.delay(T.cardFlyTime);
|
||||
|
||||
// Flip discard pile card
|
||||
if (discardRect && gameState.discard_top) {
|
||||
await this.delay(T.discardFlipDelay);
|
||||
this.playSound('flip');
|
||||
}
|
||||
|
||||
// Clean up
|
||||
container.remove();
|
||||
if (onComplete) onComplete();
|
||||
}
|
||||
|
||||
getDealOrder(players, dealerIdx) {
|
||||
// Rotate so dealing starts to dealer's left
|
||||
const order = [...players];
|
||||
const startIdx = (dealerIdx + 1) % order.length;
|
||||
return [...order.slice(startIdx), ...order.slice(0, startIdx)];
|
||||
}
|
||||
|
||||
delay(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
```
|
||||
|
||||
### CSS for Deal Animation
|
||||
|
||||
```css
|
||||
/* In style.css - minimal, anime.js handles all animation */
|
||||
|
||||
/* Deal animation container */
|
||||
.deal-animation-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
/* Deal cards inherit from .draw-anim-card (already exists in card-animations.js) */
|
||||
.deal-anim-card {
|
||||
/* Uses same structure as createAnimCard() */
|
||||
}
|
||||
```
|
||||
|
||||
### Integration in app.js
|
||||
|
||||
```javascript
|
||||
// In handleMessage, game_started/round_started case:
|
||||
|
||||
case 'game_started':
|
||||
case 'round_started':
|
||||
this.clearNextHoleCountdown();
|
||||
this.nextRoundBtn.classList.remove('waiting');
|
||||
this.roundWinnerNames = new Set();
|
||||
this.gameState = data.game_state;
|
||||
this.previousState = JSON.parse(JSON.stringify(data.game_state));
|
||||
this.locallyFlippedCards = new Set();
|
||||
this.selectedCards = [];
|
||||
this.animatingPositions = new Set();
|
||||
this.opponentSwapAnimation = null;
|
||||
|
||||
this.showGameScreen();
|
||||
|
||||
// NEW: Run deal animation using CardAnimations
|
||||
this.runDealAnimation(() => {
|
||||
this.renderGame();
|
||||
});
|
||||
break;
|
||||
|
||||
// New method using CardAnimations
|
||||
runDealAnimation(onComplete) {
|
||||
// Hide cards initially
|
||||
this.playerCards.style.visibility = 'hidden';
|
||||
this.opponentsRow.style.visibility = 'hidden';
|
||||
|
||||
// Use the global cardAnimations instance
|
||||
window.cardAnimations.animateDealing(
|
||||
this.gameState,
|
||||
(playerId, cardIdx) => this.getCardSlotRect(playerId, cardIdx),
|
||||
() => {
|
||||
// Show real cards
|
||||
this.playerCards.style.visibility = 'visible';
|
||||
this.opponentsRow.style.visibility = 'visible';
|
||||
onComplete();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Helper to get card slot position
|
||||
getCardSlotRect(playerId, cardIdx) {
|
||||
if (playerId === this.playerId) {
|
||||
// Local player
|
||||
const cards = this.playerCards.querySelectorAll('.card');
|
||||
return cards[cardIdx]?.getBoundingClientRect();
|
||||
} else {
|
||||
// Opponent
|
||||
const opponentAreas = this.opponentsRow.querySelectorAll('.opponent-area');
|
||||
for (const area of opponentAreas) {
|
||||
if (area.dataset.playerId === playerId) {
|
||||
const cards = area.querySelectorAll('.card');
|
||||
return cards[cardIdx]?.getBoundingClientRect();
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Timing Tuning
|
||||
|
||||
### Perceived Speed Tricks
|
||||
|
||||
1. **Overlap card flights** - Start next card before previous lands
|
||||
2. **Ease-out timing** - Cards decelerate into position (feels snappier)
|
||||
3. **Batch by round** - 6 deal rounds feels rhythmic
|
||||
4. **Quick stagger** - 80ms between cards feels like rapid dealing
|
||||
|
||||
### Accessibility
|
||||
|
||||
```javascript
|
||||
// Respect reduced motion preference
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
// Skip animation, just show cards
|
||||
this.renderGame();
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Edge Cases
|
||||
|
||||
### Animation Interrupted
|
||||
|
||||
If player disconnects or game state changes during dealing:
|
||||
- Cancel animation
|
||||
- Show cards immediately
|
||||
- Continue with normal game flow
|
||||
|
||||
### Varying Player Counts
|
||||
|
||||
2-6 players supported:
|
||||
- Fewer players = faster deal (fewer cards per round)
|
||||
- 2 players: 12 cards total, ~1.5 seconds
|
||||
- 6 players: 36 cards total, ~3.5 seconds
|
||||
|
||||
### Opponent Areas Not Ready
|
||||
|
||||
If opponent areas haven't rendered yet:
|
||||
- Fall back to animating to center positions
|
||||
- Or skip animation for that player
|
||||
|
||||
---
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
1. **2-player game** - Dealing alternates correctly
|
||||
2. **6-player game** - All players receive cards in order
|
||||
3. **Quick tap through** - Animation can be interrupted
|
||||
4. **Round 2+** - Dealing starts from correct dealer position
|
||||
5. **Mobile** - Animation runs smoothly at 60fps
|
||||
6. **Reduced motion** - Animation skipped appropriately
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Cards animate from deck to player positions
|
||||
- [ ] Deal order follows clockwise from dealer's left
|
||||
- [ ] Shuffle sound plays before dealing
|
||||
- [ ] Card sound plays as each card lands
|
||||
- [ ] Animation completes in < 4 seconds for 6 players
|
||||
- [ ] Real cards appear after animation (no flash)
|
||||
- [ ] Reduced motion preference respected
|
||||
- [ ] Works on mobile (60fps)
|
||||
- [ ] Can be interrupted without breaking game
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. Add timing values to `timing-config.js`
|
||||
2. Create `deal-animation.js` with DealAnimation class
|
||||
3. Add CSS for deal animation cards
|
||||
4. Add `data-player-id` to opponent areas for targeting
|
||||
5. Add `getCardSlotRect()` helper method
|
||||
6. Integrate animation in game_started/round_started handler
|
||||
7. Test with various player counts
|
||||
8. Add reduced motion support
|
||||
9. Tune timing for best feel
|
||||
|
||||
---
|
||||
|
||||
## Notes for Agent
|
||||
|
||||
- Add `animateDealing()` as a method on the existing `CardAnimations` class
|
||||
- Use `createAnimCard()` to create deal cards (already exists, handles 3D structure)
|
||||
- Use anime.js for all card movements, not CSS transitions
|
||||
- The existing `CardManager` handles persistent cards - don't modify it
|
||||
- Timing values should all be in `timing-config.js` under `dealing` key
|
||||
- Consider: Show dealer's hands actually dealing? (complex, skip for V3)
|
||||
- The shuffle sound already exists - reuse it via `playSound('shuffle')`
|
||||
- Cards should deal face-down (use `createAnimCard(rect, true, deckColor)`)
|
||||
532
docs/v3/V3_03_ROUND_END_REVEAL.md
Normal file
532
docs/v3/V3_03_ROUND_END_REVEAL.md
Normal file
@@ -0,0 +1,532 @@
|
||||
# V3-03: Round End Dramatic Reveal
|
||||
|
||||
## Overview
|
||||
|
||||
When a round ends, all face-down cards must be revealed for scoring. In physical games, this is a dramatic moment - each player flips their hidden cards one at a time while others watch. Currently, all cards flip simultaneously which lacks drama.
|
||||
|
||||
**Dependencies:** None
|
||||
**Dependents:** V3_07 (Score Tallying can follow the reveal)
|
||||
|
||||
---
|
||||
|
||||
## Goals
|
||||
|
||||
1. Reveal cards sequentially, one player at a time
|
||||
2. Within each player, reveal cards with slight stagger
|
||||
3. Pause briefly between players for dramatic effect
|
||||
4. Start with the player who triggered final turn (the "knocker")
|
||||
5. End with visible score tally moment
|
||||
6. Play flip sounds for each reveal
|
||||
|
||||
---
|
||||
|
||||
## Current State
|
||||
|
||||
When round ends, the server sends a `round_over` message and clients receive a `game_state` update where all cards are now `face_up: true`. The state differ detects the changes but doesn't sequence the animations - they happen together.
|
||||
|
||||
From `showScoreboard()` in app.js:
|
||||
```javascript
|
||||
showScoreboard(scores, isFinal, rankings) {
|
||||
// Cards are already revealed by state update
|
||||
// Scoreboard appears immediately
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Design
|
||||
|
||||
### Reveal Sequence
|
||||
|
||||
```
|
||||
1. Round ends - "Hole Complete!" message
|
||||
2. VOLUNTARY FLIP WINDOW (4 seconds):
|
||||
- Players can tap their own face-down cards to peek/flip
|
||||
- Countdown timer shows remaining time
|
||||
- "Tap to reveal your cards" prompt
|
||||
3. AUTO-REVEAL (after timeout or all flipped):
|
||||
- Knocker's cards reveal first (they went out)
|
||||
- For each other player (clockwise from knocker):
|
||||
a. Player area highlights
|
||||
b. Face-down cards flip with stagger (100ms between)
|
||||
c. Brief pause to see the reveal (400ms)
|
||||
4. Score tallying animation (see V3_07)
|
||||
5. Scoreboard appears
|
||||
```
|
||||
|
||||
### Voluntary Flip Window
|
||||
|
||||
Before the dramatic reveal sequence, players get a chance to flip their own hidden cards:
|
||||
- **Duration:** 4 seconds (configurable)
|
||||
- **Purpose:** Let players see their own cards before everyone else does
|
||||
- **UI:** Countdown timer, "Tap your cards to reveal" message
|
||||
- **Skip:** If all players flip their cards, proceed immediately
|
||||
|
||||
### Visual Flow
|
||||
|
||||
```
|
||||
Timeline:
|
||||
0ms - Round ends, pause
|
||||
500ms - Knocker highlight, first card flips
|
||||
600ms - Knocker second card flips (if any)
|
||||
700ms - Knocker third card flips (if any)
|
||||
1100ms - Pause to see knocker's hand
|
||||
1500ms - Player 2 highlight
|
||||
1600ms - Player 2 cards flip...
|
||||
...continue for all players...
|
||||
Final - Scoreboard appears
|
||||
```
|
||||
|
||||
### Timing Configuration
|
||||
|
||||
```javascript
|
||||
// In timing-config.js
|
||||
reveal: {
|
||||
voluntaryWindow: 4000, // Time for players to flip their own cards
|
||||
initialPause: 500, // Pause before auto-reveals start
|
||||
cardStagger: 100, // Between cards in same hand
|
||||
playerPause: 400, // Pause after each player's reveal
|
||||
highlightDuration: 200, // Player area highlight fade-in
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation
|
||||
|
||||
### Approach: Intercept State Update
|
||||
|
||||
Instead of letting `renderGame()` show all cards instantly, intercept the round_over state and run a reveal sequence.
|
||||
|
||||
```javascript
|
||||
// In handleMessage, game_state case:
|
||||
|
||||
case 'game_state':
|
||||
const oldState = this.gameState;
|
||||
const newState = data.game_state;
|
||||
|
||||
// Check for round end transition
|
||||
const roundJustEnded = oldState?.phase !== 'round_over' &&
|
||||
newState.phase === 'round_over';
|
||||
|
||||
if (roundJustEnded) {
|
||||
// Don't update state yet - run reveal animation first
|
||||
this.runRoundEndReveal(oldState, newState, () => {
|
||||
this.gameState = newState;
|
||||
this.renderGame();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal state update
|
||||
this.gameState = newState;
|
||||
this.renderGame();
|
||||
break;
|
||||
```
|
||||
|
||||
### Voluntary Flip Window Implementation
|
||||
|
||||
```javascript
|
||||
async runVoluntaryFlipWindow(oldState, newState) {
|
||||
const T = window.TIMING?.reveal || {};
|
||||
const windowDuration = T.voluntaryWindow || 4000;
|
||||
|
||||
// Find which of MY cards need flipping
|
||||
const myOldCards = oldState?.players?.find(p => p.id === this.playerId)?.cards || [];
|
||||
const myNewCards = newState?.players?.find(p => p.id === this.playerId)?.cards || [];
|
||||
const myHiddenPositions = [];
|
||||
|
||||
for (let i = 0; i < 6; i++) {
|
||||
if (!myOldCards[i]?.face_up && myNewCards[i]?.face_up) {
|
||||
myHiddenPositions.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
// If I have no hidden cards, skip window
|
||||
if (myHiddenPositions.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Show prompt and countdown
|
||||
this.showRevealPrompt(windowDuration);
|
||||
|
||||
// Enable clicking on my hidden cards
|
||||
this.voluntaryFlipMode = true;
|
||||
this.voluntaryFlipPositions = new Set(myHiddenPositions);
|
||||
this.renderGame(); // Re-render to make cards clickable
|
||||
|
||||
// Wait for timeout or all cards flipped
|
||||
return new Promise(resolve => {
|
||||
const checkComplete = () => {
|
||||
if (this.voluntaryFlipPositions.size === 0) {
|
||||
this.hideRevealPrompt();
|
||||
this.voluntaryFlipMode = false;
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
// Set up interval to check completion
|
||||
const checkInterval = setInterval(checkComplete, 100);
|
||||
|
||||
// Timeout after window duration
|
||||
setTimeout(() => {
|
||||
clearInterval(checkInterval);
|
||||
this.hideRevealPrompt();
|
||||
this.voluntaryFlipMode = false;
|
||||
resolve();
|
||||
}, windowDuration);
|
||||
});
|
||||
}
|
||||
|
||||
showRevealPrompt(duration) {
|
||||
// Create countdown overlay
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'reveal-prompt';
|
||||
overlay.className = 'reveal-prompt';
|
||||
overlay.innerHTML = `
|
||||
<div class="reveal-prompt-text">Tap your cards to reveal</div>
|
||||
<div class="reveal-prompt-countdown">${Math.ceil(duration / 1000)}</div>
|
||||
`;
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
// Countdown timer
|
||||
const countdownEl = overlay.querySelector('.reveal-prompt-countdown');
|
||||
let remaining = duration;
|
||||
this.countdownInterval = setInterval(() => {
|
||||
remaining -= 100;
|
||||
countdownEl.textContent = Math.ceil(remaining / 1000);
|
||||
if (remaining <= 0) {
|
||||
clearInterval(this.countdownInterval);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
hideRevealPrompt() {
|
||||
clearInterval(this.countdownInterval);
|
||||
const overlay = document.getElementById('reveal-prompt');
|
||||
if (overlay) {
|
||||
overlay.classList.add('fading');
|
||||
setTimeout(() => overlay.remove(), 300);
|
||||
}
|
||||
}
|
||||
|
||||
// Modify handleCardClick to handle voluntary flips
|
||||
handleCardClick(position) {
|
||||
// ... existing code ...
|
||||
|
||||
// Voluntary flip during reveal window
|
||||
if (this.voluntaryFlipMode && this.voluntaryFlipPositions?.has(position)) {
|
||||
const myData = this.getMyPlayerData();
|
||||
const card = myData?.cards[position];
|
||||
if (card) {
|
||||
this.playSound('flip');
|
||||
this.fireLocalFlipAnimation(position, card);
|
||||
this.voluntaryFlipPositions.delete(position);
|
||||
// Update local state to show card flipped
|
||||
this.locallyFlippedCards.add(position);
|
||||
this.renderGame();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ... rest of existing code ...
|
||||
}
|
||||
```
|
||||
|
||||
### Reveal Animation Method
|
||||
|
||||
```javascript
|
||||
async runRoundEndReveal(oldState, newState, onComplete) {
|
||||
const T = window.TIMING?.reveal || {};
|
||||
|
||||
// STEP 1: Voluntary flip window - let players peek at their own cards
|
||||
this.setStatus('Reveal your hidden cards!', 'reveal-window');
|
||||
await this.runVoluntaryFlipWindow(oldState, newState);
|
||||
|
||||
// STEP 2: Auto-reveal remaining hidden cards
|
||||
// Recalculate what needs flipping (some may have been voluntarily revealed)
|
||||
const revealsByPlayer = this.getCardsToReveal(oldState, newState);
|
||||
|
||||
// Get reveal order: knocker first, then clockwise
|
||||
const knockerId = newState.finisher_id;
|
||||
const revealOrder = this.getRevealOrder(newState.players, knockerId);
|
||||
|
||||
// Initial dramatic pause before auto-reveals
|
||||
this.setStatus('Revealing cards...', 'reveal');
|
||||
await this.delay(T.initialPause || 500);
|
||||
|
||||
// Reveal each player's cards
|
||||
for (const player of revealOrder) {
|
||||
const cardsToFlip = revealsByPlayer.get(player.id) || [];
|
||||
if (cardsToFlip.length === 0) continue;
|
||||
|
||||
// Highlight player area
|
||||
this.highlightPlayerArea(player.id, true);
|
||||
await this.delay(T.highlightDuration || 200);
|
||||
|
||||
// Flip each card with stagger
|
||||
for (const { position, card } of cardsToFlip) {
|
||||
this.animateRevealFlip(player.id, position, card);
|
||||
await this.delay(T.cardStagger || 100);
|
||||
}
|
||||
|
||||
// Wait for last flip to complete + pause
|
||||
await this.delay(400 + (T.playerPause || 400));
|
||||
|
||||
// Remove highlight
|
||||
this.highlightPlayerArea(player.id, false);
|
||||
}
|
||||
|
||||
// All revealed
|
||||
onComplete();
|
||||
}
|
||||
|
||||
getCardsToReveal(oldState, newState) {
|
||||
const reveals = new Map();
|
||||
|
||||
for (const newPlayer of newState.players) {
|
||||
const oldPlayer = oldState.players.find(p => p.id === newPlayer.id);
|
||||
if (!oldPlayer) continue;
|
||||
|
||||
const cardsToFlip = [];
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const wasHidden = !oldPlayer.cards[i]?.face_up;
|
||||
const nowVisible = newPlayer.cards[i]?.face_up;
|
||||
|
||||
if (wasHidden && nowVisible) {
|
||||
cardsToFlip.push({
|
||||
position: i,
|
||||
card: newPlayer.cards[i]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (cardsToFlip.length > 0) {
|
||||
reveals.set(newPlayer.id, cardsToFlip);
|
||||
}
|
||||
}
|
||||
|
||||
return reveals;
|
||||
}
|
||||
|
||||
getRevealOrder(players, knockerId) {
|
||||
// Knocker first
|
||||
const knocker = players.find(p => p.id === knockerId);
|
||||
const others = players.filter(p => p.id !== knockerId);
|
||||
|
||||
// Others in clockwise order (already sorted by player_order)
|
||||
if (knocker) {
|
||||
return [knocker, ...others];
|
||||
}
|
||||
return others;
|
||||
}
|
||||
|
||||
highlightPlayerArea(playerId, highlight) {
|
||||
if (playerId === this.playerId) {
|
||||
this.playerArea.classList.toggle('revealing', highlight);
|
||||
} else {
|
||||
const area = this.opponentsRow.querySelector(
|
||||
`.opponent-area[data-player-id="${playerId}"]`
|
||||
);
|
||||
if (area) {
|
||||
area.classList.toggle('revealing', highlight);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
animateRevealFlip(playerId, position, cardData) {
|
||||
// Reuse existing flip animation
|
||||
if (playerId === this.playerId) {
|
||||
this.fireLocalFlipAnimation(position, cardData);
|
||||
} else {
|
||||
this.fireFlipAnimation(playerId, position, cardData);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### CSS for Reveal Prompt and Highlights
|
||||
|
||||
```css
|
||||
/* Voluntary reveal prompt */
|
||||
.reveal-prompt {
|
||||
position: fixed;
|
||||
top: 20%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: linear-gradient(135deg, #f4a460 0%, #d4845a 100%);
|
||||
color: white;
|
||||
padding: 15px 30px;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
z-index: 200;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
animation: prompt-entrance 0.3s ease-out;
|
||||
}
|
||||
|
||||
.reveal-prompt.fading {
|
||||
animation: prompt-fade 0.3s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes prompt-entrance {
|
||||
0% { transform: translateX(-50%) translateY(-20px); opacity: 0; }
|
||||
100% { transform: translateX(-50%) translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes prompt-fade {
|
||||
0% { opacity: 1; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
|
||||
.reveal-prompt-text {
|
||||
font-size: 1.1em;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.reveal-prompt-countdown {
|
||||
font-size: 2em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Cards clickable during voluntary reveal */
|
||||
.player-area.voluntary-flip .card.can-flip {
|
||||
cursor: pointer;
|
||||
animation: flip-hint 0.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes flip-hint {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.03); }
|
||||
}
|
||||
|
||||
/* Player area highlight during reveal */
|
||||
.player-area.revealing,
|
||||
.opponent-area.revealing {
|
||||
animation: reveal-highlight 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes reveal-highlight {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(244, 164, 96, 0);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 20px 10px rgba(244, 164, 96, 0.4);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 10px 5px rgba(244, 164, 96, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
/* Keep highlight while revealing */
|
||||
.player-area.revealing,
|
||||
.opponent-area.revealing {
|
||||
box-shadow: 0 0 10px 5px rgba(244, 164, 96, 0.2);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Special Cases
|
||||
|
||||
### All Cards Already Face-Up
|
||||
|
||||
If a player has no face-down cards (they knocked or flipped everything):
|
||||
- Skip their reveal in the sequence
|
||||
- Don't highlight their area
|
||||
|
||||
### Player Disconnected
|
||||
|
||||
If a player left before round end:
|
||||
- Their cards still need to reveal for scoring
|
||||
- Handle missing player areas gracefully
|
||||
|
||||
### Single Player (Debug/Test)
|
||||
|
||||
If only one player remains:
|
||||
- Still do the reveal animation for their cards
|
||||
- Feels consistent
|
||||
|
||||
### Quick Mode (Future)
|
||||
|
||||
Consider a setting to skip reveal animation:
|
||||
```javascript
|
||||
if (this.settings.quickMode) {
|
||||
this.gameState = newState;
|
||||
this.renderGame();
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Timing Tuning
|
||||
|
||||
The reveal should feel dramatic but not tedious:
|
||||
|
||||
| Scenario | Cards to Reveal | Approximate Duration |
|
||||
|----------|----------------|---------------------|
|
||||
| 2 players, 2 hidden each | 4 cards | ~2 seconds |
|
||||
| 4 players, 3 hidden each | 12 cards | ~4 seconds |
|
||||
| 6 players, 4 hidden each | 24 cards | ~7 seconds |
|
||||
|
||||
If too slow, reduce:
|
||||
- `cardStagger`: 100ms → 60ms
|
||||
- `playerPause`: 400ms → 250ms
|
||||
|
||||
---
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
1. **Normal round end** - Knocker reveals first, others follow
|
||||
2. **Knocker has no hidden cards** - Skip knocker, start with next player
|
||||
3. **All players have hidden cards** - Full reveal sequence
|
||||
4. **Some players have no hidden cards** - Skip them gracefully
|
||||
5. **Player disconnected** - Handle gracefully
|
||||
6. **2-player game** - Both players reveal in order
|
||||
7. **Quick succession** - Multiple round ends don't overlap
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] **Voluntary flip window:** 4-second window for players to flip their own cards
|
||||
- [ ] Countdown timer shows remaining time
|
||||
- [ ] Players can tap their face-down cards to reveal early
|
||||
- [ ] Auto-reveal starts after timeout (or if all cards flipped)
|
||||
- [ ] Cards reveal sequentially during auto-reveal, not all at once
|
||||
- [ ] Knocker (finisher) reveals first
|
||||
- [ ] Other players reveal clockwise after knocker
|
||||
- [ ] Cards within a hand have slight stagger
|
||||
- [ ] Pause between players for drama
|
||||
- [ ] Player area highlights during their reveal
|
||||
- [ ] Flip sound plays for each card
|
||||
- [ ] Reveal completes before scoreboard appears
|
||||
- [ ] Handles players with no hidden cards
|
||||
- [ ] Animation can be interrupted if needed
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. Add reveal timing to `timing-config.js`
|
||||
2. Add `data-player-id` to opponent areas (if not done in V3_02)
|
||||
3. Implement `getCardsToReveal()` method
|
||||
4. Implement `getRevealOrder()` method
|
||||
5. Implement `highlightPlayerArea()` method
|
||||
6. Implement `runRoundEndReveal()` method
|
||||
7. Intercept round_over state transition
|
||||
8. Add reveal highlight CSS
|
||||
9. Test with various player counts and card states
|
||||
10. Tune timing for best dramatic effect
|
||||
|
||||
---
|
||||
|
||||
## Notes for Agent
|
||||
|
||||
- Use `window.cardAnimations.animateFlip()` or `animateOpponentFlip()` for reveals
|
||||
- The existing CardAnimations class has all flip animation methods ready
|
||||
- Don't forget to set `finisher_id` in game state (server may already do this)
|
||||
- The reveal order should match the physical clockwise order
|
||||
- Consider: Add a "drum roll" sound before reveals? (Nice to have)
|
||||
- The scoreboard should NOT appear until all reveals complete
|
||||
- State update is deferred until animation completes - ensure no race conditions
|
||||
- All animations use anime.js timelines internally - no CSS keyframes needed
|
||||
354
docs/v3/V3_04_COLUMN_PAIR_CELEBRATION.md
Normal file
354
docs/v3/V3_04_COLUMN_PAIR_CELEBRATION.md
Normal file
@@ -0,0 +1,354 @@
|
||||
# V3-04: Column Pair Celebration
|
||||
|
||||
## Overview
|
||||
|
||||
Matching cards in a column (positions 0+3, 1+4, or 2+5) score 0 points - a key strategic mechanic. In physical games, players often exclaim when they make a pair. Currently, there's no visual feedback when a pair is formed, missing a satisfying moment.
|
||||
|
||||
**Dependencies:** None
|
||||
**Dependents:** V3_10 (Column Pair Indicator builds on this)
|
||||
|
||||
---
|
||||
|
||||
## Goals
|
||||
|
||||
1. Detect when a swap creates a new column pair
|
||||
2. Play satisfying visual celebration on both cards
|
||||
3. Play a distinct "pair matched" sound
|
||||
4. Brief but noticeable - shouldn't slow gameplay
|
||||
5. Works for both local player and opponent swaps
|
||||
|
||||
---
|
||||
|
||||
## Current State
|
||||
|
||||
Column pairs are calculated during scoring but there's no visual indication when a pair forms during play.
|
||||
|
||||
From the rules (RULES.md):
|
||||
```
|
||||
Column 0: positions (0, 3)
|
||||
Column 1: positions (1, 4)
|
||||
Column 2: positions (2, 5)
|
||||
```
|
||||
|
||||
A pair is formed when both cards in a column are face-up and have the same rank.
|
||||
|
||||
---
|
||||
|
||||
## Design
|
||||
|
||||
### Detection
|
||||
|
||||
After any swap or flip, check if a new pair was formed:
|
||||
|
||||
```javascript
|
||||
function detectNewPair(oldCards, newCards) {
|
||||
const columns = [[0, 3], [1, 4], [2, 5]];
|
||||
|
||||
for (const [top, bottom] of columns) {
|
||||
const wasPaired = isPaired(oldCards, top, bottom);
|
||||
const nowPaired = isPaired(newCards, top, bottom);
|
||||
|
||||
if (!wasPaired && nowPaired) {
|
||||
return { column: columns.indexOf([top, bottom]), positions: [top, bottom] };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isPaired(cards, pos1, pos2) {
|
||||
const card1 = cards[pos1];
|
||||
const card2 = cards[pos2];
|
||||
return card1?.face_up && card2?.face_up &&
|
||||
card1?.rank && card2?.rank &&
|
||||
card1.rank === card2.rank;
|
||||
}
|
||||
```
|
||||
|
||||
### Celebration Animation
|
||||
|
||||
When a pair forms:
|
||||
|
||||
```
|
||||
1. Both cards pulse/glow simultaneously
|
||||
2. Brief sparkle effect (optional)
|
||||
3. "Pair!" sound plays
|
||||
4. Animation lasts ~400ms
|
||||
5. Cards return to normal
|
||||
```
|
||||
|
||||
### Visual Effect Options
|
||||
|
||||
**Option A: Anime.js Glow Pulse** (Recommended - matches existing animation system)
|
||||
```javascript
|
||||
// Add to CardAnimations class
|
||||
celebratePair(cardElement1, cardElement2) {
|
||||
this.playSound('pair');
|
||||
|
||||
const duration = window.TIMING?.celebration?.pairDuration || 400;
|
||||
|
||||
[cardElement1, cardElement2].forEach(el => {
|
||||
anime({
|
||||
targets: el,
|
||||
boxShadow: [
|
||||
'0 0 0 0 rgba(255, 215, 0, 0)',
|
||||
'0 0 15px 8px rgba(255, 215, 0, 0.5)',
|
||||
'0 0 0 0 rgba(255, 215, 0, 0)'
|
||||
],
|
||||
scale: [1, 1.05, 1],
|
||||
duration: duration,
|
||||
easing: 'easeOutQuad'
|
||||
});
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Option B: Scale Bounce**
|
||||
```javascript
|
||||
anime({
|
||||
targets: [cardElement1, cardElement2],
|
||||
scale: [1, 1.1, 1],
|
||||
duration: 400,
|
||||
easing: 'easeOutQuad'
|
||||
});
|
||||
```
|
||||
|
||||
**Option C: Connecting Line**
|
||||
Draw a brief line connecting the paired cards (more complex).
|
||||
|
||||
**Recommendation:** Option A - anime.js glow pulse matches the existing animation system.
|
||||
|
||||
---
|
||||
|
||||
## Implementation
|
||||
|
||||
### Timing Configuration
|
||||
|
||||
```javascript
|
||||
// In timing-config.js
|
||||
celebration: {
|
||||
pairDuration: 400, // Celebration animation length
|
||||
pairDelay: 50, // Slight delay before celebration (let swap settle)
|
||||
}
|
||||
```
|
||||
|
||||
### Sound
|
||||
|
||||
Add a new sound type for pairs:
|
||||
|
||||
```javascript
|
||||
// In playSound() method
|
||||
} else if (type === 'pair') {
|
||||
// Two-tone "ding-ding" for pair match
|
||||
const osc1 = ctx.createOscillator();
|
||||
const osc2 = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
|
||||
osc1.connect(gain);
|
||||
osc2.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
|
||||
osc1.frequency.setValueAtTime(880, ctx.currentTime); // A5
|
||||
osc2.frequency.setValueAtTime(1108, ctx.currentTime); // C#6
|
||||
|
||||
gain.gain.setValueAtTime(0.1, ctx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.3);
|
||||
|
||||
osc1.start(ctx.currentTime);
|
||||
osc2.start(ctx.currentTime);
|
||||
osc1.stop(ctx.currentTime + 0.3);
|
||||
osc2.stop(ctx.currentTime + 0.3);
|
||||
}
|
||||
```
|
||||
|
||||
### Detection Integration
|
||||
|
||||
In the state differ or after swap animations:
|
||||
|
||||
```javascript
|
||||
// In triggerAnimationsForStateChange() or after swap completes
|
||||
|
||||
checkForNewPairs(oldState, newState, playerId) {
|
||||
const oldPlayer = oldState?.players?.find(p => p.id === playerId);
|
||||
const newPlayer = newState?.players?.find(p => p.id === playerId);
|
||||
|
||||
if (!oldPlayer || !newPlayer) return;
|
||||
|
||||
const columns = [[0, 3], [1, 4], [2, 5]];
|
||||
|
||||
for (const [top, bottom] of columns) {
|
||||
const wasPaired = this.isPaired(oldPlayer.cards, top, bottom);
|
||||
const nowPaired = this.isPaired(newPlayer.cards, top, bottom);
|
||||
|
||||
if (!wasPaired && nowPaired) {
|
||||
// New pair formed!
|
||||
setTimeout(() => {
|
||||
this.celebratePair(playerId, top, bottom);
|
||||
}, window.TIMING?.celebration?.pairDelay || 50);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isPaired(cards, pos1, pos2) {
|
||||
const c1 = cards[pos1];
|
||||
const c2 = cards[pos2];
|
||||
return c1?.face_up && c2?.face_up && c1?.rank === c2?.rank;
|
||||
}
|
||||
|
||||
celebratePair(playerId, pos1, pos2) {
|
||||
const cards = this.getCardElements(playerId, pos1, pos2);
|
||||
if (cards.length === 0) return;
|
||||
|
||||
// Use CardAnimations to animate (or add method to CardAnimations)
|
||||
window.cardAnimations.celebratePair(cards[0], cards[1]);
|
||||
}
|
||||
|
||||
// Add to CardAnimations class in card-animations.js:
|
||||
celebratePair(cardElement1, cardElement2) {
|
||||
this.playSound('pair');
|
||||
|
||||
const duration = window.TIMING?.celebration?.pairDuration || 400;
|
||||
|
||||
[cardElement1, cardElement2].forEach(el => {
|
||||
if (!el) return;
|
||||
|
||||
// Temporarily raise z-index so glow shows above adjacent cards
|
||||
el.style.zIndex = '10';
|
||||
|
||||
anime({
|
||||
targets: el,
|
||||
boxShadow: [
|
||||
'0 0 0 0 rgba(255, 215, 0, 0)',
|
||||
'0 0 15px 8px rgba(255, 215, 0, 0.5)',
|
||||
'0 0 0 0 rgba(255, 215, 0, 0)'
|
||||
],
|
||||
scale: [1, 1.05, 1],
|
||||
duration: duration,
|
||||
easing: 'easeOutQuad',
|
||||
complete: () => {
|
||||
el.style.zIndex = '';
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getCardElements(playerId, ...positions) {
|
||||
const elements = [];
|
||||
|
||||
if (playerId === this.playerId) {
|
||||
const cards = this.playerCards.querySelectorAll('.card');
|
||||
for (const pos of positions) {
|
||||
if (cards[pos]) elements.push(cards[pos]);
|
||||
}
|
||||
} else {
|
||||
const area = this.opponentsRow.querySelector(
|
||||
`.opponent-area[data-player-id="${playerId}"]`
|
||||
);
|
||||
if (area) {
|
||||
const cards = area.querySelectorAll('.card');
|
||||
for (const pos of positions) {
|
||||
if (cards[pos]) elements.push(cards[pos]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return elements;
|
||||
}
|
||||
```
|
||||
|
||||
### CSS
|
||||
|
||||
No CSS keyframes needed - all animation is handled by anime.js in `CardAnimations.celebratePair()`.
|
||||
|
||||
The animation temporarily sets `z-index: 10` on cards during celebration to ensure the glow shows above adjacent cards. For opponent pairs, you can pass a different color parameter:
|
||||
|
||||
```javascript
|
||||
// Optional: Different color for opponent pairs
|
||||
celebratePair(cardElement1, cardElement2, isOpponent = false) {
|
||||
const color = isOpponent
|
||||
? 'rgba(100, 200, 255, 0.4)' // Blue for opponents
|
||||
: 'rgba(255, 215, 0, 0.5)'; // Gold for local player
|
||||
|
||||
// ... anime.js animation with color ...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Edge Cases
|
||||
|
||||
### Pair Broken Then Reformed
|
||||
|
||||
If a swap breaks one pair and creates another:
|
||||
- Only celebrate the new pair
|
||||
- Don't mourn the broken pair (no negative feedback)
|
||||
|
||||
### Multiple Pairs in One Move
|
||||
|
||||
Theoretically possible (swap creates pairs in adjacent columns):
|
||||
- Celebrate all new pairs simultaneously
|
||||
- Same sound, same animation on all involved cards
|
||||
|
||||
### Pair at Round Start (Initial Flip)
|
||||
|
||||
If initial flip creates a pair:
|
||||
- Yes, celebrate it! Early luck deserves recognition
|
||||
|
||||
### Negative Card Pairs (2s, Jokers)
|
||||
|
||||
Pairing 2s or Jokers is strategically bad (wastes -2 value), but:
|
||||
- Still celebrate the pair (it's mechanically correct)
|
||||
- Player will learn the strategy over time
|
||||
- Consider: different sound/color for "bad" pairs? (Too complex for V3)
|
||||
|
||||
---
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
1. **Local player creates pair** - Both cards glow, sound plays
|
||||
2. **Opponent creates pair** - Their cards glow, sound plays
|
||||
3. **Initial flip creates pair** - Celebration after flip animation
|
||||
4. **Swap breaks one pair, creates another** - Only new pair celebrates
|
||||
5. **No pair formed** - No celebration
|
||||
6. **Face-down card in column** - No false celebration
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Swap that creates a pair triggers celebration
|
||||
- [ ] Flip that creates a pair triggers celebration
|
||||
- [ ] Both paired cards animate simultaneously
|
||||
- [ ] Distinct "pair" sound plays
|
||||
- [ ] Animation is brief (~400ms)
|
||||
- [ ] Works for local player and opponents
|
||||
- [ ] No celebration when pair isn't formed
|
||||
- [ ] No celebration for already-existing pairs
|
||||
- [ ] Animation doesn't block gameplay
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. Add `pair` sound to `playSound()` method
|
||||
2. Add celebration timing to `timing-config.js`
|
||||
3. Implement `isPaired()` helper method
|
||||
4. Implement `checkForNewPairs()` method
|
||||
5. Implement `celebratePair()` method
|
||||
6. Implement `getCardElements()` helper
|
||||
7. Add CSS animation for pair celebration
|
||||
8. Integrate into state change detection
|
||||
9. Test all pair formation scenarios
|
||||
10. Tune sound and timing for satisfaction
|
||||
|
||||
---
|
||||
|
||||
## Notes for Agent
|
||||
|
||||
- Add `celebratePair()` method to the existing `CardAnimations` class
|
||||
- Use anime.js for all animation - no CSS keyframes
|
||||
- Keep the celebration brief - shouldn't slow down fast players
|
||||
- The glow color (gold) suggests "success" - matches golf scoring concept
|
||||
- Consider accessibility: animation should be visible but not overwhelming
|
||||
- The existing swap animation completes before pair check runs
|
||||
- Don't celebrate pairs that already existed before the action
|
||||
- Opponent celebration can use slightly different color (optional parameter)
|
||||
411
docs/v3/V3_05_FINAL_TURN_URGENCY.md
Normal file
411
docs/v3/V3_05_FINAL_TURN_URGENCY.md
Normal file
@@ -0,0 +1,411 @@
|
||||
# V3-05: Final Turn Urgency
|
||||
|
||||
## Overview
|
||||
|
||||
When a player reveals all their cards, the round enters "final turn" phase - each other player gets one last turn. This is a tense moment in physical games. Currently, only a small badge shows "Final Turn" which lacks urgency.
|
||||
|
||||
**Dependencies:** None
|
||||
**Dependents:** None
|
||||
|
||||
---
|
||||
|
||||
## Goals
|
||||
|
||||
1. Create visual tension when final turn begins
|
||||
2. Show who triggered final turn (the knocker)
|
||||
3. Indicate how many players still need to act
|
||||
4. Make each remaining turn feel consequential
|
||||
5. Countdown feeling as players take their last turns
|
||||
|
||||
---
|
||||
|
||||
## Current State
|
||||
|
||||
From `app.js`:
|
||||
```javascript
|
||||
// Final turn badge exists but is minimal
|
||||
if (isFinalTurn) {
|
||||
this.finalTurnBadge.classList.remove('hidden');
|
||||
} else {
|
||||
this.finalTurnBadge.classList.add('hidden');
|
||||
}
|
||||
```
|
||||
|
||||
The badge just shows "FINAL TURN" text - no countdown, no urgency indicator.
|
||||
|
||||
---
|
||||
|
||||
## Design
|
||||
|
||||
### Visual Elements
|
||||
|
||||
1. **Pulsing Border** - Game area gets subtle pulsing red/orange border
|
||||
2. **Enhanced Badge** - Larger badge with countdown
|
||||
3. **Knocker Indicator** - Show who triggered final turn
|
||||
4. **Turn Counter** - "2 players remaining" style indicator
|
||||
|
||||
### Badge Enhancement
|
||||
|
||||
```
|
||||
Current: [FINAL TURN]
|
||||
|
||||
Enhanced: [⚠️ FINAL TURN]
|
||||
[Player 2 of 3]
|
||||
```
|
||||
|
||||
Or more dramatic:
|
||||
```
|
||||
[🔔 LAST CHANCE!]
|
||||
[2 turns left]
|
||||
```
|
||||
|
||||
### Color Scheme
|
||||
|
||||
- Normal play: Green felt background
|
||||
- Final turn: Subtle warm/orange tint or border pulse
|
||||
- Not overwhelming, but noticeable shift
|
||||
|
||||
---
|
||||
|
||||
## Implementation
|
||||
|
||||
### Enhanced Final Turn Badge
|
||||
|
||||
```html
|
||||
<!-- Enhanced badge structure -->
|
||||
<div id="final-turn-badge" class="hidden">
|
||||
<div class="final-turn-icon">⚡</div>
|
||||
<div class="final-turn-text">FINAL TURN</div>
|
||||
<div class="final-turn-remaining">2 turns left</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### CSS Enhancements
|
||||
|
||||
```css
|
||||
/* Enhanced final turn badge */
|
||||
#final-turn-badge {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: linear-gradient(135deg, #ff6b35 0%, #d63031 100%);
|
||||
color: white;
|
||||
padding: 12px 24px;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
z-index: 100;
|
||||
box-shadow: 0 4px 20px rgba(214, 48, 49, 0.4);
|
||||
animation: final-turn-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
#final-turn-badge.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.final-turn-icon {
|
||||
font-size: 1.5em;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.final-turn-text {
|
||||
font-weight: bold;
|
||||
font-size: 1.2em;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.final-turn-remaining {
|
||||
font-size: 0.9em;
|
||||
opacity: 0.9;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
@keyframes final-turn-pulse {
|
||||
0%, 100% {
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
box-shadow: 0 4px 20px rgba(214, 48, 49, 0.4);
|
||||
}
|
||||
50% {
|
||||
transform: translate(-50%, -50%) scale(1.02);
|
||||
box-shadow: 0 4px 30px rgba(214, 48, 49, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
/* Game area border pulse during final turn */
|
||||
#game-screen.final-turn-active {
|
||||
animation: game-area-urgency 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes game-area-urgency {
|
||||
0%, 100% {
|
||||
box-shadow: inset 0 0 0 0 rgba(255, 107, 53, 0);
|
||||
}
|
||||
50% {
|
||||
box-shadow: inset 0 0 30px 0 rgba(255, 107, 53, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
/* Knocker highlight */
|
||||
.player-area.is-knocker,
|
||||
.opponent-area.is-knocker {
|
||||
border: 2px solid #ff6b35;
|
||||
}
|
||||
|
||||
.knocker-badge {
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
right: -10px;
|
||||
background: #ff6b35;
|
||||
color: white;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 0.7em;
|
||||
font-weight: bold;
|
||||
}
|
||||
```
|
||||
|
||||
### JavaScript Updates
|
||||
|
||||
```javascript
|
||||
// In renderGame() or dedicated method
|
||||
|
||||
updateFinalTurnDisplay() {
|
||||
const isFinalTurn = this.gameState?.phase === 'final_turn';
|
||||
const finisherId = this.gameState?.finisher_id;
|
||||
|
||||
// Toggle game area class
|
||||
this.gameScreen.classList.toggle('final-turn-active', isFinalTurn);
|
||||
|
||||
if (isFinalTurn) {
|
||||
// Calculate remaining turns
|
||||
const remaining = this.countRemainingTurns();
|
||||
|
||||
// Update badge content
|
||||
this.finalTurnBadge.querySelector('.final-turn-remaining').textContent =
|
||||
remaining === 1 ? '1 turn left' : `${remaining} turns left`;
|
||||
|
||||
// Show badge with entrance animation
|
||||
this.finalTurnBadge.classList.remove('hidden');
|
||||
this.finalTurnBadge.classList.add('entering');
|
||||
setTimeout(() => {
|
||||
this.finalTurnBadge.classList.remove('entering');
|
||||
}, 300);
|
||||
|
||||
// Mark knocker
|
||||
this.markKnocker(finisherId);
|
||||
|
||||
// Play alert sound on first appearance
|
||||
if (!this.finalTurnAnnounced) {
|
||||
this.playSound('alert');
|
||||
this.finalTurnAnnounced = true;
|
||||
}
|
||||
} else {
|
||||
this.finalTurnBadge.classList.add('hidden');
|
||||
this.gameScreen.classList.remove('final-turn-active');
|
||||
this.finalTurnAnnounced = false;
|
||||
this.clearKnockerMark();
|
||||
}
|
||||
}
|
||||
|
||||
countRemainingTurns() {
|
||||
if (!this.gameState || this.gameState.phase !== 'final_turn') return 0;
|
||||
|
||||
const finisherId = this.gameState.finisher_id;
|
||||
const currentIdx = this.gameState.players.findIndex(
|
||||
p => p.id === this.gameState.current_player_id
|
||||
);
|
||||
const finisherIdx = this.gameState.players.findIndex(
|
||||
p => p.id === finisherId
|
||||
);
|
||||
|
||||
if (currentIdx === -1 || finisherIdx === -1) return 0;
|
||||
|
||||
// Count players between current and finisher (not including finisher)
|
||||
let count = 0;
|
||||
let idx = currentIdx;
|
||||
const numPlayers = this.gameState.players.length;
|
||||
|
||||
while (idx !== finisherIdx) {
|
||||
count++;
|
||||
idx = (idx + 1) % numPlayers;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
markKnocker(knockerId) {
|
||||
// Add knocker badge to the player who triggered final turn
|
||||
this.clearKnockerMark();
|
||||
|
||||
if (!knockerId) return;
|
||||
|
||||
if (knockerId === this.playerId) {
|
||||
this.playerArea.classList.add('is-knocker');
|
||||
// Add badge element
|
||||
const badge = document.createElement('div');
|
||||
badge.className = 'knocker-badge';
|
||||
badge.textContent = 'OUT';
|
||||
this.playerArea.appendChild(badge);
|
||||
} else {
|
||||
const area = this.opponentsRow.querySelector(
|
||||
`.opponent-area[data-player-id="${knockerId}"]`
|
||||
);
|
||||
if (area) {
|
||||
area.classList.add('is-knocker');
|
||||
const badge = document.createElement('div');
|
||||
badge.className = 'knocker-badge';
|
||||
badge.textContent = 'OUT';
|
||||
area.appendChild(badge);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clearKnockerMark() {
|
||||
// Remove all knocker indicators
|
||||
document.querySelectorAll('.is-knocker').forEach(el => {
|
||||
el.classList.remove('is-knocker');
|
||||
});
|
||||
document.querySelectorAll('.knocker-badge').forEach(el => {
|
||||
el.remove();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Alert Sound
|
||||
|
||||
```javascript
|
||||
// In playSound() method
|
||||
} else if (type === 'alert') {
|
||||
// Attention-getting sound for final turn
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
|
||||
osc.type = 'triangle';
|
||||
osc.frequency.setValueAtTime(523, ctx.currentTime); // C5
|
||||
osc.frequency.setValueAtTime(659, ctx.currentTime + 0.1); // E5
|
||||
osc.frequency.setValueAtTime(784, ctx.currentTime + 0.2); // G5
|
||||
|
||||
gain.gain.setValueAtTime(0.15, ctx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.4);
|
||||
|
||||
osc.start(ctx.currentTime);
|
||||
osc.stop(ctx.currentTime + 0.4);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Entrance Animation
|
||||
|
||||
When final turn starts, badge should appear dramatically:
|
||||
|
||||
```css
|
||||
#final-turn-badge.entering {
|
||||
animation: badge-entrance 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes badge-entrance {
|
||||
0% {
|
||||
transform: translate(-50%, -50%) scale(0.5);
|
||||
opacity: 0;
|
||||
}
|
||||
70% {
|
||||
transform: translate(-50%, -50%) scale(1.1);
|
||||
}
|
||||
100% {
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Turn Countdown Update
|
||||
|
||||
Each time a player takes their final turn, update the counter:
|
||||
|
||||
```javascript
|
||||
// In state change detection
|
||||
if (newState.phase === 'final_turn') {
|
||||
const oldRemaining = this.lastRemainingTurns;
|
||||
const newRemaining = this.countRemainingTurns();
|
||||
|
||||
if (oldRemaining !== newRemaining) {
|
||||
this.updateFinalTurnCounter(newRemaining);
|
||||
this.lastRemainingTurns = newRemaining;
|
||||
|
||||
// Pulse the badge on update
|
||||
this.finalTurnBadge.classList.add('counter-updated');
|
||||
setTimeout(() => {
|
||||
this.finalTurnBadge.classList.remove('counter-updated');
|
||||
}, 200);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```css
|
||||
#final-turn-badge.counter-updated {
|
||||
animation: counter-pulse 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes counter-pulse {
|
||||
0% { transform: translate(-50%, -50%) scale(1); }
|
||||
50% { transform: translate(-50%, -50%) scale(1.05); }
|
||||
100% { transform: translate(-50%, -50%) scale(1); }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
1. **Enter final turn** - Badge appears with animation, sound plays
|
||||
2. **Turn counter decrements** - Shows "2 turns left" → "1 turn left"
|
||||
3. **Last turn** - Shows "1 turn left", extra urgency
|
||||
4. **Round ends** - Badge disappears, border pulse stops
|
||||
5. **Knocker marked** - OUT badge on player who triggered
|
||||
6. **Multiple rounds** - Badge resets between rounds
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Final turn badge appears when phase is `final_turn`
|
||||
- [ ] Badge shows remaining turns count
|
||||
- [ ] Count updates as players take turns
|
||||
- [ ] Game area has subtle urgency visual
|
||||
- [ ] Knocker is marked with badge
|
||||
- [ ] Alert sound plays when final turn starts
|
||||
- [ ] Badge has entrance animation
|
||||
- [ ] All visuals reset when round ends
|
||||
- [ ] Not overwhelming - tension without annoyance
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. Update HTML structure for enhanced badge
|
||||
2. Add CSS for badge, urgency border, knocker indicator
|
||||
3. Implement `countRemainingTurns()` method
|
||||
4. Implement `updateFinalTurnDisplay()` method
|
||||
5. Implement `markKnocker()` and `clearKnockerMark()`
|
||||
6. Add alert sound to `playSound()`
|
||||
7. Integrate into `renderGame()` or state change handler
|
||||
8. Add entrance animation
|
||||
9. Add counter update pulse
|
||||
10. Test all scenarios
|
||||
|
||||
---
|
||||
|
||||
## Notes for Agent
|
||||
|
||||
- The urgency should enhance tension, not frustrate players
|
||||
- Keep the pulsing subtle - not distracting during play
|
||||
- The knocker badge helps players understand game state
|
||||
- Consider mobile: badge should fit on small screens
|
||||
- The remaining turns count helps players plan their last move
|
||||
- Reset all state between rounds (finalTurnAnnounced flag)
|
||||
376
docs/v3/V3_06_OPPONENT_THINKING.md
Normal file
376
docs/v3/V3_06_OPPONENT_THINKING.md
Normal file
@@ -0,0 +1,376 @@
|
||||
# V3-06: Opponent Thinking Phase
|
||||
|
||||
## Overview
|
||||
|
||||
In physical card games, you watch opponents pick up a card, consider it, and decide. Currently, CPU turns happen quickly with minimal visual indication that they're "thinking." This feature adds visible consideration time.
|
||||
|
||||
**Dependencies:** None
|
||||
**Dependents:** None
|
||||
|
||||
---
|
||||
|
||||
## Goals
|
||||
|
||||
1. Show when an opponent is considering their move
|
||||
2. Highlight which pile they're considering (deck vs discard)
|
||||
3. Add brief thinking pause before CPU actions
|
||||
4. Make CPU feel more like a real player
|
||||
5. Human opponents should also show consideration state
|
||||
|
||||
---
|
||||
|
||||
## Current State
|
||||
|
||||
From `app.js` and `card-animations.js`:
|
||||
```javascript
|
||||
// In app.js
|
||||
updateCpuConsideringState() {
|
||||
const currentPlayer = this.gameState.players.find(
|
||||
p => p.id === this.gameState.current_player_id
|
||||
);
|
||||
const isCpuTurn = currentPlayer && currentPlayer.is_cpu;
|
||||
const hasNotDrawn = !this.gameState.has_drawn_card;
|
||||
|
||||
if (isCpuTurn && hasNotDrawn) {
|
||||
this.discard.classList.add('cpu-considering');
|
||||
} else {
|
||||
this.discard.classList.remove('cpu-considering');
|
||||
}
|
||||
}
|
||||
|
||||
// CardAnimations already has CPU thinking glow:
|
||||
startCpuThinking(element) {
|
||||
anime({
|
||||
targets: element,
|
||||
boxShadow: [
|
||||
'0 4px 12px rgba(0,0,0,0.3)',
|
||||
'0 4px 12px rgba(0,0,0,0.3), 0 0 18px rgba(59, 130, 246, 0.5)',
|
||||
'0 4px 12px rgba(0,0,0,0.3)'
|
||||
],
|
||||
duration: 1500,
|
||||
easing: 'easeInOutSine',
|
||||
loop: true
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
The existing `startCpuThinking()` method in CardAnimations provides a looping glow animation. This feature enhances visibility further.
|
||||
|
||||
---
|
||||
|
||||
## Design
|
||||
|
||||
### Enhanced Consideration Display
|
||||
|
||||
1. **Opponent area highlight** - Active player's area glows
|
||||
2. **"Thinking" indicator** - Small animation near their name
|
||||
3. **Deck/discard highlight** - Show which pile they're eyeing
|
||||
4. **Held card consideration** - After draw, show they're deciding
|
||||
|
||||
### States
|
||||
|
||||
```
|
||||
1. WAITING_TO_DRAW
|
||||
- Player area highlighted
|
||||
- Deck and discard both subtly available
|
||||
- Brief pause before action (CPU)
|
||||
|
||||
2. CONSIDERING_DISCARD
|
||||
- Player looks at discard pile
|
||||
- Discard pile pulses brighter
|
||||
- "Eye" indicator on discard
|
||||
|
||||
3. DREW_CARD
|
||||
- Held card visible (existing)
|
||||
- Player area still highlighted
|
||||
|
||||
4. CONSIDERING_SWAP
|
||||
- Player deciding which card to swap
|
||||
- Their hand cards subtly indicate options
|
||||
```
|
||||
|
||||
### Timing (CPU only)
|
||||
|
||||
```javascript
|
||||
// In timing-config.js
|
||||
cpuThinking: {
|
||||
beforeDraw: 800, // Pause before CPU draws
|
||||
discardConsider: 400, // Extra pause when looking at discard
|
||||
beforeSwap: 500, // Pause before CPU swaps
|
||||
beforeDiscard: 300, // Pause before CPU discards drawn card
|
||||
}
|
||||
```
|
||||
|
||||
Human players don't need artificial pauses - their actual thinking provides the delay.
|
||||
|
||||
---
|
||||
|
||||
## Implementation
|
||||
|
||||
### Thinking Indicator
|
||||
|
||||
Add a small animated indicator near the current player's name:
|
||||
|
||||
```html
|
||||
<!-- In opponent area -->
|
||||
<div class="opponent-area" data-player-id="...">
|
||||
<h4>
|
||||
<span class="thinking-indicator hidden">🤔</span>
|
||||
<span class="opponent-name">Sofia</span>
|
||||
...
|
||||
</h4>
|
||||
</div>
|
||||
```
|
||||
|
||||
### CSS and Animations
|
||||
|
||||
Most animations should use anime.js via CardAnimations class for consistency:
|
||||
|
||||
```javascript
|
||||
// In CardAnimations class - the startCpuThinking method already exists
|
||||
// Add similar methods for other thinking states:
|
||||
|
||||
startOpponentThinking(opponentArea) {
|
||||
const id = `opponentThinking-${opponentArea.dataset.playerId}`;
|
||||
this.stopOpponentThinking(opponentArea);
|
||||
|
||||
anime({
|
||||
targets: opponentArea,
|
||||
boxShadow: [
|
||||
'0 0 15px rgba(244, 164, 96, 0.4)',
|
||||
'0 0 25px rgba(244, 164, 96, 0.6)',
|
||||
'0 0 15px rgba(244, 164, 96, 0.4)'
|
||||
],
|
||||
duration: 1500,
|
||||
easing: 'easeInOutSine',
|
||||
loop: true
|
||||
});
|
||||
}
|
||||
|
||||
stopOpponentThinking(opponentArea) {
|
||||
anime.remove(opponentArea);
|
||||
opponentArea.style.boxShadow = '';
|
||||
}
|
||||
```
|
||||
|
||||
Minimal CSS for layout only:
|
||||
|
||||
```css
|
||||
/* Thinking indicator - simple show/hide */
|
||||
.thinking-indicator {
|
||||
display: inline-block;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.thinking-indicator.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Current turn highlight base (animation handled by anime.js) */
|
||||
.opponent-area.current-turn {
|
||||
border-color: #f4a460;
|
||||
}
|
||||
|
||||
/* Eye indicator positioning */
|
||||
.pile-eye-indicator {
|
||||
position: absolute;
|
||||
top: -15px;
|
||||
right: -10px;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
```
|
||||
|
||||
For the thinking indicator bobbing, use anime.js:
|
||||
|
||||
```javascript
|
||||
// Animate emoji indicator
|
||||
startThinkingIndicator(element) {
|
||||
anime({
|
||||
targets: element,
|
||||
translateY: [0, -3, 0],
|
||||
duration: 800,
|
||||
easing: 'easeInOutSine',
|
||||
loop: true
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### JavaScript Updates
|
||||
|
||||
```javascript
|
||||
// Enhanced consideration state management
|
||||
|
||||
updateConsiderationState() {
|
||||
const currentPlayer = this.gameState?.players?.find(
|
||||
p => p.id === this.gameState.current_player_id
|
||||
);
|
||||
|
||||
if (!currentPlayer || currentPlayer.id === this.playerId) {
|
||||
this.clearConsiderationState();
|
||||
return;
|
||||
}
|
||||
|
||||
const hasDrawn = this.gameState.has_drawn_card;
|
||||
const isCpu = currentPlayer.is_cpu;
|
||||
|
||||
// Find opponent area
|
||||
const area = this.opponentsRow.querySelector(
|
||||
`.opponent-area[data-player-id="${currentPlayer.id}"]`
|
||||
);
|
||||
|
||||
if (!area) return;
|
||||
|
||||
// Show thinking indicator for CPUs
|
||||
const indicator = area.querySelector('.thinking-indicator');
|
||||
if (indicator) {
|
||||
indicator.classList.toggle('hidden', !isCpu || hasDrawn);
|
||||
}
|
||||
|
||||
// Add thinking class to area
|
||||
area.classList.toggle('thinking', !hasDrawn);
|
||||
|
||||
// Show which pile they might be considering
|
||||
if (!hasDrawn && isCpu) {
|
||||
// CPU AI hint: check if discard is attractive
|
||||
const discardValue = this.getDiscardValue();
|
||||
if (discardValue !== null && discardValue <= 4) {
|
||||
this.discard.classList.add('being-considered');
|
||||
this.deck.classList.remove('being-considered');
|
||||
} else {
|
||||
this.deck.classList.add('being-considered');
|
||||
this.discard.classList.remove('being-considered');
|
||||
}
|
||||
} else {
|
||||
this.deck.classList.remove('being-considered');
|
||||
this.discard.classList.remove('being-considered');
|
||||
}
|
||||
}
|
||||
|
||||
clearConsiderationState() {
|
||||
// Remove all consideration indicators
|
||||
this.opponentsRow.querySelectorAll('.thinking-indicator').forEach(el => {
|
||||
el.classList.add('hidden');
|
||||
});
|
||||
this.opponentsRow.querySelectorAll('.opponent-area').forEach(el => {
|
||||
el.classList.remove('thinking');
|
||||
});
|
||||
this.deck.classList.remove('being-considered');
|
||||
this.discard.classList.remove('being-considered');
|
||||
}
|
||||
|
||||
getDiscardValue() {
|
||||
const card = this.gameState?.discard_top;
|
||||
if (!card) return null;
|
||||
|
||||
const values = this.gameState?.card_values || {
|
||||
'A': 1, '2': -2, '3': 3, '4': 4, '5': 5, '6': 6, '7': 7,
|
||||
'8': 8, '9': 9, '10': 10, 'J': 10, 'Q': 10, 'K': 0, '★': -2
|
||||
};
|
||||
|
||||
return values[card.rank] ?? 10;
|
||||
}
|
||||
```
|
||||
|
||||
### Server-Side CPU Thinking Delay
|
||||
|
||||
The server should add pauses for CPU thinking (or the client can delay rendering):
|
||||
|
||||
```python
|
||||
# In ai.py or game.py, after CPU makes decision
|
||||
|
||||
async def cpu_take_turn(self, game, player_id):
|
||||
thinking_time = self.profile.get_thinking_time() # 500-1500ms based on profile
|
||||
|
||||
# Pre-draw consideration
|
||||
await asyncio.sleep(thinking_time * 0.5)
|
||||
|
||||
# Make draw decision
|
||||
source = self.decide_draw_source(game, player_id)
|
||||
|
||||
# Broadcast "considering" state
|
||||
await self.broadcast_cpu_considering(game, player_id, source)
|
||||
await asyncio.sleep(thinking_time * 0.3)
|
||||
|
||||
# Execute draw
|
||||
game.draw_card(player_id, source)
|
||||
|
||||
# Post-draw consideration
|
||||
await asyncio.sleep(thinking_time * 0.4)
|
||||
|
||||
# Make swap/discard decision
|
||||
...
|
||||
```
|
||||
|
||||
Alternatively, handle all delays on the client side by adding pauses before rendering CPU actions.
|
||||
|
||||
---
|
||||
|
||||
## CPU Personality Integration
|
||||
|
||||
Different AI profiles could have different thinking patterns:
|
||||
|
||||
```javascript
|
||||
// Thinking time variance by personality (from ai.py profiles)
|
||||
const thinkingProfiles = {
|
||||
'Sofia': { baseTime: 1200, variance: 200 }, // Calculated & Patient
|
||||
'Maya': { baseTime: 600, variance: 100 }, // Aggressive Closer
|
||||
'Priya': { baseTime: 1000, variance: 300 }, // Pair Hunter (considers more)
|
||||
'Marcus': { baseTime: 800, variance: 150 }, // Steady Eddie
|
||||
'Kenji': { baseTime: 500, variance: 200 }, // Risk Taker (quick)
|
||||
'Diego': { baseTime: 700, variance: 400 }, // Chaotic Gambler (variable)
|
||||
'River': { baseTime: 900, variance: 250 }, // Adaptive Strategist
|
||||
'Sage': { baseTime: 1100, variance: 150 }, // Sneaky Finisher
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
1. **CPU turn starts** - Area highlights, thinking indicator shows
|
||||
2. **CPU considering discard** - Discard pile glows if valuable card
|
||||
3. **CPU draws** - Thinking indicator changes to held card state
|
||||
4. **CPU swaps** - Brief consideration before swap
|
||||
5. **Human opponent turn** - Area highlights but no thinking indicator
|
||||
6. **Local player turn** - No consideration UI (they know what they're doing)
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Current opponent's area highlights during their turn
|
||||
- [ ] CPU players show thinking indicator (emoji)
|
||||
- [ ] Deck/discard shows which pile CPU is considering
|
||||
- [ ] Brief pause before CPU actions (feels like thinking)
|
||||
- [ ] Different CPU personalities have different timing
|
||||
- [ ] Human opponents highlight without thinking indicator
|
||||
- [ ] All indicators clear when turn ends
|
||||
- [ ] Doesn't slow down the game significantly
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. Add thinking indicator element to opponent areas
|
||||
2. Add CSS for thinking animations
|
||||
3. Implement `updateConsiderationState()` method
|
||||
4. Implement `clearConsiderationState()` method
|
||||
5. Add pile consideration highlighting
|
||||
6. Integrate CPU thinking delays (server or client)
|
||||
7. Test with various CPU profiles
|
||||
8. Tune timing for natural feel
|
||||
|
||||
---
|
||||
|
||||
## Notes for Agent
|
||||
|
||||
- Use existing CardAnimations methods: `startCpuThinking()`, `stopCpuThinking()`
|
||||
- Add new methods to CardAnimations for opponent area glow
|
||||
- Use anime.js for all looping animations, not CSS keyframes
|
||||
- Keep thinking pauses short enough to not frustrate players
|
||||
- The goal is to make CPUs feel more human, not slow
|
||||
- Different profiles should feel distinct in their play speed
|
||||
- Human players don't need artificial delays
|
||||
- Consider: Option to speed up CPU thinking? (Future setting)
|
||||
- The "being considered" pile indicator is a subtle hint at AI logic
|
||||
- Track animations in `activeAnimations` for proper cleanup
|
||||
484
docs/v3/V3_07_SCORE_TALLYING.md
Normal file
484
docs/v3/V3_07_SCORE_TALLYING.md
Normal file
@@ -0,0 +1,484 @@
|
||||
# V3-07: Animated Score Tallying
|
||||
|
||||
## Overview
|
||||
|
||||
In physical card games, scoring involves counting cards one by one, noting pairs, and calculating the total. Currently, scores just appear in the scoreboard. This feature adds animated score counting that highlights each card's contribution.
|
||||
|
||||
**Dependencies:** V3_03 (Round End Reveal should complete before tallying)
|
||||
**Dependents:** None
|
||||
|
||||
---
|
||||
|
||||
## Goals
|
||||
|
||||
1. Animate score counting card-by-card
|
||||
2. Highlight each card as its value is added
|
||||
3. Show column pairs canceling to zero
|
||||
4. Running total builds up visibly
|
||||
5. Special effect for negative cards and pairs
|
||||
6. Satisfying "final score" reveal
|
||||
|
||||
---
|
||||
|
||||
## Current State
|
||||
|
||||
From `showScoreboard()` in app.js:
|
||||
```javascript
|
||||
showScoreboard(scores, isFinal, rankings) {
|
||||
// Scores appear instantly in table
|
||||
// No animation of how score was calculated
|
||||
}
|
||||
```
|
||||
|
||||
The server calculates scores and sends them. The client just displays them.
|
||||
|
||||
---
|
||||
|
||||
## Design
|
||||
|
||||
### Tally Sequence
|
||||
|
||||
```
|
||||
1. Round end reveal completes (V3_03)
|
||||
2. Brief pause (300ms)
|
||||
3. For each player (starting with knocker):
|
||||
a. Highlight player area
|
||||
b. Count through each column:
|
||||
- Highlight top card, show value
|
||||
- Highlight bottom card, show value
|
||||
- If pair: show "PAIR! +0" effect
|
||||
- If not pair: add values to running total
|
||||
c. Show final score with flourish
|
||||
d. Move to next player
|
||||
4. Scoreboard slides in with all scores
|
||||
```
|
||||
|
||||
### Visual Elements
|
||||
|
||||
- **Card value overlay** - Temporary badge showing card's point value
|
||||
- **Running total** - Animated counter near player area
|
||||
- **Pair effect** - Special animation when column pair cancels
|
||||
- **Final score** - Large number with celebration effect
|
||||
|
||||
### Timing
|
||||
|
||||
```javascript
|
||||
// In timing-config.js
|
||||
tally: {
|
||||
initialPause: 300, // After reveal, before tally
|
||||
cardHighlight: 200, // Duration to show each card value
|
||||
columnPause: 150, // Between columns
|
||||
pairCelebration: 400, // Pair cancel effect
|
||||
playerPause: 500, // Between players
|
||||
finalScoreReveal: 600, // Final score animation
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation
|
||||
|
||||
### Card Value Overlay
|
||||
|
||||
```javascript
|
||||
// Create temporary overlay showing card value
|
||||
showCardValue(cardElement, value, isNegative) {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'card-value-overlay';
|
||||
if (isNegative) overlay.classList.add('negative');
|
||||
if (value === 0) overlay.classList.add('zero');
|
||||
|
||||
const sign = value > 0 ? '+' : '';
|
||||
overlay.textContent = `${sign}${value}`;
|
||||
|
||||
// Position over the card
|
||||
const rect = cardElement.getBoundingClientRect();
|
||||
overlay.style.left = `${rect.left + rect.width / 2}px`;
|
||||
overlay.style.top = `${rect.top + rect.height / 2}px`;
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
// Animate in
|
||||
overlay.classList.add('visible');
|
||||
|
||||
return overlay;
|
||||
}
|
||||
|
||||
hideCardValue(overlay) {
|
||||
overlay.classList.remove('visible');
|
||||
setTimeout(() => overlay.remove(), 200);
|
||||
}
|
||||
```
|
||||
|
||||
### CSS for Overlays
|
||||
|
||||
```css
|
||||
/* Card value overlay */
|
||||
.card-value-overlay {
|
||||
position: fixed;
|
||||
transform: translate(-50%, -50%) scale(0.5);
|
||||
background: rgba(30, 30, 46, 0.9);
|
||||
color: white;
|
||||
padding: 8px 14px;
|
||||
border-radius: 8px;
|
||||
font-size: 1.4em;
|
||||
font-weight: bold;
|
||||
opacity: 0;
|
||||
transition: transform 0.2s ease-out, opacity 0.2s ease-out;
|
||||
z-index: 200;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.card-value-overlay.visible {
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.card-value-overlay.negative {
|
||||
background: linear-gradient(135deg, #27ae60 0%, #1e8449 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.card-value-overlay.zero {
|
||||
background: linear-gradient(135deg, #f4a460 0%, #d4845a 100%);
|
||||
}
|
||||
|
||||
/* Running total */
|
||||
.running-total {
|
||||
position: absolute;
|
||||
bottom: -30px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
padding: 4px 12px;
|
||||
border-radius: 15px;
|
||||
font-size: 1.2em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.running-total.updating {
|
||||
animation: total-bounce 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes total-bounce {
|
||||
0% { transform: translateX(-50%) scale(1); }
|
||||
50% { transform: translateX(-50%) scale(1.1); }
|
||||
100% { transform: translateX(-50%) scale(1); }
|
||||
}
|
||||
|
||||
/* Pair cancel effect */
|
||||
.pair-cancel-overlay {
|
||||
position: fixed;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 1.2em;
|
||||
font-weight: bold;
|
||||
color: #f4a460;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
animation: pair-cancel 0.6s ease-out forwards;
|
||||
z-index: 200;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes pair-cancel {
|
||||
0% {
|
||||
transform: translate(-50%, -50%) scale(0.5);
|
||||
opacity: 0;
|
||||
}
|
||||
30% {
|
||||
transform: translate(-50%, -50%) scale(1.2);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translate(-50%, -60%) scale(1);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Card highlight during tally */
|
||||
.card.tallying {
|
||||
box-shadow: 0 0 15px rgba(244, 164, 96, 0.6);
|
||||
transform: scale(1.05);
|
||||
transition: box-shadow 0.1s, transform 0.1s;
|
||||
}
|
||||
|
||||
/* Final score reveal */
|
||||
.final-score-overlay {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%) scale(0);
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||
color: white;
|
||||
padding: 20px 40px;
|
||||
border-radius: 15px;
|
||||
text-align: center;
|
||||
z-index: 250;
|
||||
animation: final-score-reveal 0.6s ease-out forwards;
|
||||
}
|
||||
|
||||
.final-score-overlay .player-name {
|
||||
font-size: 1em;
|
||||
opacity: 0.8;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.final-score-overlay .score-value {
|
||||
font-size: 3em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.final-score-overlay .score-value.negative {
|
||||
color: #27ae60;
|
||||
}
|
||||
|
||||
@keyframes final-score-reveal {
|
||||
0% {
|
||||
transform: translate(-50%, -50%) scale(0);
|
||||
}
|
||||
60% {
|
||||
transform: translate(-50%, -50%) scale(1.1);
|
||||
}
|
||||
100% {
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Main Tally Logic
|
||||
|
||||
```javascript
|
||||
async runScoreTally(players, onComplete) {
|
||||
const T = window.TIMING?.tally || {};
|
||||
|
||||
// Initial pause after reveal
|
||||
await this.delay(T.initialPause || 300);
|
||||
|
||||
// Get card values from game state
|
||||
const cardValues = this.gameState?.card_values || this.getDefaultCardValues();
|
||||
|
||||
// Tally each player
|
||||
for (const player of players) {
|
||||
const area = this.getPlayerArea(player.id);
|
||||
if (!area) continue;
|
||||
|
||||
// Highlight player area
|
||||
area.classList.add('tallying-player');
|
||||
|
||||
// Create running total display
|
||||
const runningTotal = document.createElement('div');
|
||||
runningTotal.className = 'running-total';
|
||||
runningTotal.textContent = '0';
|
||||
area.appendChild(runningTotal);
|
||||
|
||||
let total = 0;
|
||||
const cards = area.querySelectorAll('.card');
|
||||
|
||||
// Process each column
|
||||
const columns = [[0, 3], [1, 4], [2, 5]];
|
||||
|
||||
for (const [topIdx, bottomIdx] of columns) {
|
||||
const topCard = cards[topIdx];
|
||||
const bottomCard = cards[bottomIdx];
|
||||
const topData = player.cards[topIdx];
|
||||
const bottomData = player.cards[bottomIdx];
|
||||
|
||||
// Highlight top card
|
||||
topCard.classList.add('tallying');
|
||||
const topValue = cardValues[topData.rank] ?? 0;
|
||||
const topOverlay = this.showCardValue(topCard, topValue, topValue < 0);
|
||||
await this.delay(T.cardHighlight || 200);
|
||||
|
||||
// Highlight bottom card
|
||||
bottomCard.classList.add('tallying');
|
||||
const bottomValue = cardValues[bottomData.rank] ?? 0;
|
||||
const bottomOverlay = this.showCardValue(bottomCard, bottomValue, bottomValue < 0);
|
||||
await this.delay(T.cardHighlight || 200);
|
||||
|
||||
// Check for pair
|
||||
if (topData.rank === bottomData.rank) {
|
||||
// Pair! Show cancel effect
|
||||
this.hideCardValue(topOverlay);
|
||||
this.hideCardValue(bottomOverlay);
|
||||
this.showPairCancel(topCard, bottomCard);
|
||||
await this.delay(T.pairCelebration || 400);
|
||||
} else {
|
||||
// Add values to total
|
||||
total += topValue + bottomValue;
|
||||
this.updateRunningTotal(runningTotal, total);
|
||||
this.hideCardValue(topOverlay);
|
||||
this.hideCardValue(bottomOverlay);
|
||||
}
|
||||
|
||||
// Clear card highlights
|
||||
topCard.classList.remove('tallying');
|
||||
bottomCard.classList.remove('tallying');
|
||||
|
||||
await this.delay(T.columnPause || 150);
|
||||
}
|
||||
|
||||
// Show final score for this player
|
||||
await this.showFinalScore(player.name, total);
|
||||
await this.delay(T.finalScoreReveal || 600);
|
||||
|
||||
// Clean up
|
||||
runningTotal.remove();
|
||||
area.classList.remove('tallying-player');
|
||||
|
||||
await this.delay(T.playerPause || 500);
|
||||
}
|
||||
|
||||
onComplete();
|
||||
}
|
||||
|
||||
showPairCancel(card1, card2) {
|
||||
// Position between the two cards
|
||||
const rect1 = card1.getBoundingClientRect();
|
||||
const rect2 = card2.getBoundingClientRect();
|
||||
const centerX = (rect1.left + rect1.right + rect2.left + rect2.right) / 4;
|
||||
const centerY = (rect1.top + rect1.bottom + rect2.top + rect2.bottom) / 4;
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'pair-cancel-overlay';
|
||||
overlay.textContent = 'PAIR! +0';
|
||||
overlay.style.left = `${centerX}px`;
|
||||
overlay.style.top = `${centerY}px`;
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
// Pulse both cards
|
||||
card1.classList.add('pair-matched');
|
||||
card2.classList.add('pair-matched');
|
||||
|
||||
setTimeout(() => {
|
||||
overlay.remove();
|
||||
card1.classList.remove('pair-matched');
|
||||
card2.classList.remove('pair-matched');
|
||||
}, 600);
|
||||
|
||||
this.playSound('pair');
|
||||
}
|
||||
|
||||
updateRunningTotal(element, value) {
|
||||
element.textContent = value >= 0 ? value : value;
|
||||
element.classList.add('updating');
|
||||
setTimeout(() => element.classList.remove('updating'), 200);
|
||||
}
|
||||
|
||||
async showFinalScore(playerName, score) {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'final-score-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="player-name">${playerName}</div>
|
||||
<div class="score-value ${score < 0 ? 'negative' : ''}">${score}</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
this.playSound(score < 0 ? 'success' : 'card');
|
||||
|
||||
await this.delay(800);
|
||||
|
||||
overlay.style.opacity = '0';
|
||||
overlay.style.transition = 'opacity 0.3s';
|
||||
await this.delay(300);
|
||||
overlay.remove();
|
||||
}
|
||||
|
||||
getDefaultCardValues() {
|
||||
return {
|
||||
'A': 1, '2': -2, '3': 3, '4': 4, '5': 5, '6': 6, '7': 7,
|
||||
'8': 8, '9': 9, '10': 10, 'J': 10, 'Q': 10, 'K': 0, '★': -2
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Integration with Round End
|
||||
|
||||
```javascript
|
||||
// In runRoundEndReveal completion callback
|
||||
|
||||
async runRoundEndReveal(oldState, newState, onComplete) {
|
||||
// ... existing reveal logic ...
|
||||
|
||||
// After all reveals complete
|
||||
await this.runScoreTally(newState.players, () => {
|
||||
// Now show the scoreboard
|
||||
onComplete();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Simplified Mode
|
||||
|
||||
For faster games, offer a simplified tally that just shows final scores:
|
||||
|
||||
```javascript
|
||||
if (this.settings.quickTally) {
|
||||
// Just flash the final scores, skip card-by-card
|
||||
for (const player of players) {
|
||||
const score = this.calculateScore(player.cards);
|
||||
await this.showFinalScore(player.name, score);
|
||||
await this.delay(400);
|
||||
}
|
||||
onComplete();
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
1. **Normal hand** - Values add up correctly
|
||||
2. **Paired column** - Shows "PAIR! +0" effect
|
||||
3. **All pairs** - Total is 0, multiple pair celebrations
|
||||
4. **Negative cards** - Green highlight, reduces total
|
||||
5. **Multiple players** - Tallies sequentially
|
||||
6. **Various scores** - Positive, negative, zero
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Cards highlight as they're counted
|
||||
- [ ] Point values show as temporary overlays
|
||||
- [ ] Running total updates with each card
|
||||
- [ ] Paired columns show cancel effect
|
||||
- [ ] Final score has celebration animation
|
||||
- [ ] Tally order: knocker first, then clockwise
|
||||
- [ ] Sound effects enhance the experience
|
||||
- [ ] Total time under 10 seconds for 4 players
|
||||
- [ ] Scoreboard appears after tally completes
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. Add tally timing to `timing-config.js`
|
||||
2. Create CSS for all overlays and animations
|
||||
3. Implement `showCardValue()` and `hideCardValue()`
|
||||
4. Implement `showPairCancel()`
|
||||
5. Implement `updateRunningTotal()`
|
||||
6. Implement `showFinalScore()`
|
||||
7. Implement main `runScoreTally()` method
|
||||
8. Integrate with round end reveal
|
||||
9. Test various scoring scenarios
|
||||
10. Add quick tally option
|
||||
|
||||
---
|
||||
|
||||
## Notes for Agent
|
||||
|
||||
- **CSS vs anime.js**: Use CSS for UI overlays (value badges, running total). Use anime.js for card highlight effects.
|
||||
- Card highlighting can use `window.cardAnimations` methods or simple anime.js calls
|
||||
- The tally should feel satisfying, not tedious
|
||||
- Keep individual card highlight times short
|
||||
- Pair cancellation is a highlight moment - give it emphasis
|
||||
- Consider accessibility: values should be readable
|
||||
- The running total helps players follow the math
|
||||
- Don't forget to handle house rules affecting card values (use `gameState.card_values`)
|
||||
343
docs/v3/V3_08_CARD_HOVER_SELECTION.md
Normal file
343
docs/v3/V3_08_CARD_HOVER_SELECTION.md
Normal file
@@ -0,0 +1,343 @@
|
||||
# V3-08: Card Hover/Selection Enhancement
|
||||
|
||||
## Overview
|
||||
|
||||
When holding a drawn card, players must choose which card to swap with. Currently, clicking a card immediately swaps. This feature adds better hover feedback showing the potential swap before committing.
|
||||
|
||||
**Dependencies:** None
|
||||
**Dependents:** None
|
||||
|
||||
---
|
||||
|
||||
## Goals
|
||||
|
||||
1. Clear visual preview of the swap before clicking
|
||||
2. Show where the held card will go
|
||||
3. Show where the hand card will go (discard)
|
||||
4. Distinct hover states for face-up vs face-down cards
|
||||
5. Mobile-friendly (no hover, but clear tap targets)
|
||||
|
||||
---
|
||||
|
||||
## Current State
|
||||
|
||||
From `app.js`:
|
||||
```javascript
|
||||
handleCardClick(position) {
|
||||
// ... if holding drawn card ...
|
||||
if (this.drawnCard) {
|
||||
this.animateSwap(position); // Immediately swaps
|
||||
return;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Cards have basic hover effects in CSS but no swap preview.
|
||||
|
||||
---
|
||||
|
||||
## Design
|
||||
|
||||
### Desktop Hover Preview
|
||||
|
||||
When hovering over a hand card while holding a drawn card:
|
||||
|
||||
```
|
||||
1. Hovered card lifts slightly and dims
|
||||
2. Ghost of held card appears in that slot (semi-transparent)
|
||||
3. Arrow or line hints at the swap direction
|
||||
4. "Click to swap" tooltip (optional)
|
||||
```
|
||||
|
||||
### Mobile Tap Preview
|
||||
|
||||
Since mobile has no hover:
|
||||
- First tap = select/highlight the card
|
||||
- Second tap = confirm swap
|
||||
- Or: long-press shows preview, release to swap
|
||||
|
||||
**Recommendation:** Immediate swap on tap (current behavior) is fine for mobile. Focus on desktop hover preview.
|
||||
|
||||
---
|
||||
|
||||
## Implementation
|
||||
|
||||
### CSS Hover Enhancements
|
||||
|
||||
```css
|
||||
/* Card hover when holding drawn card */
|
||||
.player-area.can-swap .card {
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s, box-shadow 0.15s, opacity 0.15s;
|
||||
}
|
||||
|
||||
.player-area.can-swap .card:hover {
|
||||
transform: translateY(-5px) scale(1.02);
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Dimmed state showing "this will be replaced" */
|
||||
.player-area.can-swap .card:hover::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Ghost preview of incoming card */
|
||||
.card-ghost-preview {
|
||||
position: absolute;
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
transform: scale(0.95);
|
||||
z-index: 5;
|
||||
border: 2px dashed rgba(244, 164, 96, 0.8);
|
||||
}
|
||||
|
||||
/* Swap indicator arrow */
|
||||
.swap-indicator {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.player-area.can-swap .card:hover ~ .swap-indicator {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Different highlight for face-down cards */
|
||||
.player-area.can-swap .card.card-back:hover {
|
||||
box-shadow: 0 8px 20px rgba(244, 164, 96, 0.4);
|
||||
}
|
||||
|
||||
/* "Unknown" indicator for face-down hover */
|
||||
.card.card-back:hover::before {
|
||||
content: '?';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 2em;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
```
|
||||
|
||||
### JavaScript Implementation
|
||||
|
||||
```javascript
|
||||
// Add swap preview functionality
|
||||
setupSwapPreview() {
|
||||
this.ghostPreview = document.createElement('div');
|
||||
this.ghostPreview.className = 'card-ghost-preview hidden';
|
||||
this.playerCards.appendChild(this.ghostPreview);
|
||||
}
|
||||
|
||||
// Call during render when player is holding a card
|
||||
updateSwapPreviewState() {
|
||||
const canSwap = this.drawnCard && this.isMyTurn();
|
||||
|
||||
this.playerArea.classList.toggle('can-swap', canSwap);
|
||||
|
||||
if (!canSwap) {
|
||||
this.ghostPreview?.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
// Set up ghost preview content
|
||||
if (this.drawnCard && this.ghostPreview) {
|
||||
this.ghostPreview.className = 'card-ghost-preview card card-front hidden';
|
||||
|
||||
if (this.drawnCard.rank === '★') {
|
||||
this.ghostPreview.classList.add('joker');
|
||||
} else if (this.isRedSuit(this.drawnCard.suit)) {
|
||||
this.ghostPreview.classList.add('red');
|
||||
} else {
|
||||
this.ghostPreview.classList.add('black');
|
||||
}
|
||||
|
||||
this.ghostPreview.innerHTML = this.renderCardContent(this.drawnCard);
|
||||
}
|
||||
}
|
||||
|
||||
// Bind hover events to cards
|
||||
bindCardHoverEvents() {
|
||||
const cards = this.playerCards.querySelectorAll('.card');
|
||||
|
||||
cards.forEach((card, index) => {
|
||||
card.addEventListener('mouseenter', () => {
|
||||
if (!this.drawnCard || !this.isMyTurn()) return;
|
||||
this.showSwapPreview(card, index);
|
||||
});
|
||||
|
||||
card.addEventListener('mouseleave', () => {
|
||||
this.hideSwapPreview();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
showSwapPreview(targetCard, position) {
|
||||
if (!this.ghostPreview) return;
|
||||
|
||||
// Position ghost at target card location
|
||||
const rect = targetCard.getBoundingClientRect();
|
||||
const containerRect = this.playerCards.getBoundingClientRect();
|
||||
|
||||
this.ghostPreview.style.left = `${rect.left - containerRect.left}px`;
|
||||
this.ghostPreview.style.top = `${rect.top - containerRect.top}px`;
|
||||
this.ghostPreview.style.width = `${rect.width}px`;
|
||||
this.ghostPreview.style.height = `${rect.height}px`;
|
||||
|
||||
this.ghostPreview.classList.remove('hidden');
|
||||
|
||||
// Highlight target card
|
||||
targetCard.classList.add('swap-target');
|
||||
|
||||
// Show what will happen
|
||||
this.setStatus(`Swap with position ${position + 1}`, 'swap-preview');
|
||||
}
|
||||
|
||||
hideSwapPreview() {
|
||||
this.ghostPreview?.classList.add('hidden');
|
||||
|
||||
// Remove target highlight
|
||||
this.playerCards.querySelectorAll('.card').forEach(card => {
|
||||
card.classList.remove('swap-target');
|
||||
});
|
||||
|
||||
// Restore normal status
|
||||
this.updateStatusFromGameState();
|
||||
}
|
||||
```
|
||||
|
||||
### Card Position Labels (Optional Enhancement)
|
||||
|
||||
Show position numbers on cards during swap selection:
|
||||
|
||||
```css
|
||||
.player-area.can-swap .card::before {
|
||||
content: attr(data-position);
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
left: -8px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
font-size: 11px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
// In renderGame, add data-position to cards
|
||||
const cards = this.playerCards.querySelectorAll('.card');
|
||||
cards.forEach((card, i) => {
|
||||
card.dataset.position = i + 1;
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Visual Preview Options
|
||||
|
||||
### Option A: Ghost Card (Recommended)
|
||||
|
||||
Semi-transparent copy of the held card appears over the target slot.
|
||||
|
||||
### Option B: Arrow Indicator
|
||||
|
||||
Arrow from held card to target slot, and from target to discard.
|
||||
|
||||
### Option C: Split Preview
|
||||
|
||||
Show both cards side-by-side with swap arrows.
|
||||
|
||||
**Recommendation:** Option A is simplest and most intuitive.
|
||||
|
||||
---
|
||||
|
||||
## Face-Down Card Interaction
|
||||
|
||||
When swapping with a face-down card, player is taking a risk:
|
||||
|
||||
- Show "?" indicator to emphasize unknown
|
||||
- Maybe show estimated value range? (Too complex for V3)
|
||||
- Different hover color (orange = warning)
|
||||
|
||||
```css
|
||||
.player-area.can-swap .card.card-back:hover {
|
||||
border: 2px solid #f4a460;
|
||||
}
|
||||
|
||||
.player-area.can-swap .card.card-back:hover::after {
|
||||
content: 'Unknown';
|
||||
position: absolute;
|
||||
bottom: -20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 0.7em;
|
||||
color: #f4a460;
|
||||
white-space: nowrap;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
1. **Hover over face-up card** - Shows preview, card lifts
|
||||
2. **Hover over face-down card** - Shows warning styling
|
||||
3. **Move between cards** - Preview updates smoothly
|
||||
4. **Mouse leaves card area** - Preview disappears
|
||||
5. **Not holding card** - No special hover effects
|
||||
6. **Not my turn** - No hover effects
|
||||
7. **Mobile tap** - Works without preview (existing behavior)
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Cards lift on hover when holding drawn card
|
||||
- [ ] Ghost preview shows incoming card
|
||||
- [ ] Face-down cards have distinct hover (unknown warning)
|
||||
- [ ] Preview disappears on mouse leave
|
||||
- [ ] No effects when not holding card
|
||||
- [ ] No effects when not your turn
|
||||
- [ ] Mobile tap still works normally
|
||||
- [ ] Smooth transitions, no jank
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. Add `can-swap` class toggle to player area
|
||||
2. Add CSS for hover lift effect
|
||||
3. Create ghost preview element
|
||||
4. Implement `showSwapPreview()` method
|
||||
5. Implement `hideSwapPreview()` method
|
||||
6. Bind mouseenter/mouseleave events
|
||||
7. Add face-down card distinct styling
|
||||
8. Test on desktop and mobile
|
||||
9. Optional: Add position labels
|
||||
|
||||
---
|
||||
|
||||
## Notes for Agent
|
||||
|
||||
- **CSS vs anime.js**: CSS is appropriate for simple hover effects (performant, no JS overhead)
|
||||
- Keep hover effects performant (CSS transforms preferred)
|
||||
- Don't break existing click-to-swap behavior
|
||||
- Mobile should work exactly as before (immediate swap)
|
||||
- Consider reduced motion preferences
|
||||
- The ghost preview should match the actual card appearance
|
||||
- Position labels help new players understand the grid
|
||||
451
docs/v3/V3_09_KNOCK_EARLY_DRAMA.md
Normal file
451
docs/v3/V3_09_KNOCK_EARLY_DRAMA.md
Normal file
@@ -0,0 +1,451 @@
|
||||
# V3-09: Knock Early Drama
|
||||
|
||||
## Overview
|
||||
|
||||
The "Knock Early" house rule lets players flip all remaining face-down cards (if 2 or fewer) to immediately trigger final turn. This is a high-risk, high-reward move that deserves dramatic presentation.
|
||||
|
||||
**Dependencies:** None
|
||||
**Dependents:** None
|
||||
|
||||
---
|
||||
|
||||
## Goals
|
||||
|
||||
1. Make knock early feel dramatic and consequential
|
||||
2. Show confirmation dialog (optional - it's risky!)
|
||||
3. Dramatic animation when knock happens
|
||||
4. Clear feedback showing the decision
|
||||
5. Other players see "Player X knocked early!"
|
||||
|
||||
---
|
||||
|
||||
## Current State
|
||||
|
||||
From `app.js`:
|
||||
```javascript
|
||||
knockEarly() {
|
||||
if (!this.gameState || !this.gameState.knock_early) return;
|
||||
this.send({ type: 'knock_early' });
|
||||
this.hideToast();
|
||||
}
|
||||
```
|
||||
|
||||
The knock early button exists but there's no special visual treatment.
|
||||
|
||||
---
|
||||
|
||||
## Design
|
||||
|
||||
### Knock Early Flow
|
||||
|
||||
```
|
||||
1. Player clicks "Knock Early" button
|
||||
2. Confirmation prompt: "Reveal your hidden cards and go out?"
|
||||
3. If confirmed:
|
||||
a. Dramatic sound effect
|
||||
b. Player's hidden cards flip rapidly in sequence
|
||||
c. "KNOCK!" banner appears
|
||||
d. Final turn badge triggers
|
||||
4. Other players see announcement
|
||||
```
|
||||
|
||||
### Visual Elements
|
||||
|
||||
- **Confirmation dialog** - "Are you sure?" with preview
|
||||
- **Rapid flip animation** - Cards flip faster than normal
|
||||
- **"KNOCK!" banner** - Large dramatic announcement
|
||||
- **Screen shake** (subtle) - Impact feeling
|
||||
|
||||
---
|
||||
|
||||
## Implementation
|
||||
|
||||
### Confirmation Dialog
|
||||
|
||||
```javascript
|
||||
knockEarly() {
|
||||
if (!this.gameState || !this.gameState.knock_early) return;
|
||||
|
||||
// Count hidden cards
|
||||
const myData = this.getMyPlayerData();
|
||||
const hiddenCards = myData.cards.filter(c => !c.face_up);
|
||||
|
||||
if (hiddenCards.length === 0 || hiddenCards.length > 2) {
|
||||
return; // Can't knock
|
||||
}
|
||||
|
||||
// Show confirmation
|
||||
this.showKnockConfirmation(hiddenCards.length, () => {
|
||||
this.executeKnockEarly();
|
||||
});
|
||||
}
|
||||
|
||||
showKnockConfirmation(hiddenCount, onConfirm) {
|
||||
// Create modal
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'knock-confirm-modal';
|
||||
modal.innerHTML = `
|
||||
<div class="knock-confirm-content">
|
||||
<div class="knock-confirm-icon">⚡</div>
|
||||
<h3>Knock Early?</h3>
|
||||
<p>You'll reveal ${hiddenCount} hidden card${hiddenCount > 1 ? 's' : ''} and trigger final turn.</p>
|
||||
<p class="knock-warning">This cannot be undone!</p>
|
||||
<div class="knock-confirm-buttons">
|
||||
<button class="btn btn-secondary knock-cancel">Cancel</button>
|
||||
<button class="btn btn-primary knock-confirm">Knock!</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
|
||||
// Bind events
|
||||
modal.querySelector('.knock-cancel').addEventListener('click', () => {
|
||||
this.playSound('click');
|
||||
modal.remove();
|
||||
});
|
||||
|
||||
modal.querySelector('.knock-confirm').addEventListener('click', () => {
|
||||
this.playSound('click');
|
||||
modal.remove();
|
||||
onConfirm();
|
||||
});
|
||||
|
||||
// Click outside to cancel
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) {
|
||||
modal.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async executeKnockEarly() {
|
||||
// Play dramatic sound
|
||||
this.playSound('knock');
|
||||
|
||||
// Get positions of hidden cards
|
||||
const myData = this.getMyPlayerData();
|
||||
const hiddenPositions = myData.cards
|
||||
.map((card, i) => ({ card, position: i }))
|
||||
.filter(({ card }) => !card.face_up)
|
||||
.map(({ position }) => position);
|
||||
|
||||
// Start rapid flip animation
|
||||
await this.animateKnockFlips(hiddenPositions);
|
||||
|
||||
// Show KNOCK banner
|
||||
this.showKnockBanner();
|
||||
|
||||
// Send to server
|
||||
this.send({ type: 'knock_early' });
|
||||
this.hideToast();
|
||||
}
|
||||
|
||||
async animateKnockFlips(positions) {
|
||||
// Rapid sequential flips
|
||||
const flipDelay = 150; // Faster than normal
|
||||
|
||||
for (const position of positions) {
|
||||
const myData = this.getMyPlayerData();
|
||||
const card = myData.cards[position];
|
||||
this.fireLocalFlipAnimation(position, card);
|
||||
this.playSound('flip');
|
||||
await this.delay(flipDelay);
|
||||
}
|
||||
|
||||
// Wait for last flip
|
||||
await this.delay(300);
|
||||
}
|
||||
|
||||
showKnockBanner() {
|
||||
const banner = document.createElement('div');
|
||||
banner.className = 'knock-banner';
|
||||
banner.innerHTML = '<span>KNOCK!</span>';
|
||||
document.body.appendChild(banner);
|
||||
|
||||
// Screen shake effect
|
||||
document.body.classList.add('screen-shake');
|
||||
|
||||
// Remove after animation
|
||||
setTimeout(() => {
|
||||
banner.classList.add('fading');
|
||||
document.body.classList.remove('screen-shake');
|
||||
}, 800);
|
||||
|
||||
setTimeout(() => {
|
||||
banner.remove();
|
||||
}, 1100);
|
||||
}
|
||||
```
|
||||
|
||||
### CSS
|
||||
|
||||
```css
|
||||
/* Knock confirmation modal */
|
||||
.knock-confirm-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 300;
|
||||
animation: modal-fade-in 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes modal-fade-in {
|
||||
0% { opacity: 0; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
.knock-confirm-content {
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||
padding: 30px;
|
||||
border-radius: 15px;
|
||||
text-align: center;
|
||||
max-width: 320px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
|
||||
animation: modal-scale-in 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes modal-scale-in {
|
||||
0% { transform: scale(0.9); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
.knock-confirm-icon {
|
||||
font-size: 3em;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.knock-confirm-content h3 {
|
||||
margin: 0 0 15px;
|
||||
color: #f4a460;
|
||||
}
|
||||
|
||||
.knock-confirm-content p {
|
||||
margin: 0 0 10px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.knock-warning {
|
||||
color: #e74c3c !important;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.knock-confirm-buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.knock-confirm-buttons .btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* KNOCK banner */
|
||||
.knock-banner {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%) scale(0);
|
||||
z-index: 400;
|
||||
pointer-events: none;
|
||||
animation: knock-banner-in 0.3s ease-out forwards;
|
||||
}
|
||||
|
||||
.knock-banner span {
|
||||
display: block;
|
||||
font-size: 4em;
|
||||
font-weight: 900;
|
||||
color: #f4a460;
|
||||
text-shadow:
|
||||
0 0 20px rgba(244, 164, 96, 0.8),
|
||||
0 0 40px rgba(244, 164, 96, 0.4),
|
||||
2px 2px 0 #1a1a2e;
|
||||
letter-spacing: 0.2em;
|
||||
}
|
||||
|
||||
@keyframes knock-banner-in {
|
||||
0% {
|
||||
transform: translate(-50%, -50%) scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
transform: translate(-50%, -50%) scale(1.2);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.knock-banner.fading {
|
||||
animation: knock-banner-out 0.3s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes knock-banner-out {
|
||||
0% {
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translate(-50%, -50%) scale(0.8);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Screen shake effect */
|
||||
@keyframes screen-shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
20% { transform: translateX(-3px); }
|
||||
40% { transform: translateX(3px); }
|
||||
60% { transform: translateX(-2px); }
|
||||
80% { transform: translateX(2px); }
|
||||
}
|
||||
|
||||
body.screen-shake {
|
||||
animation: screen-shake 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Enhanced knock early button */
|
||||
#knock-early-btn {
|
||||
background: linear-gradient(135deg, #ff6b35 0%, #d63031 100%);
|
||||
animation: knock-btn-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes knock-btn-pulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 2px 10px rgba(214, 48, 49, 0.3);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 2px 20px rgba(214, 48, 49, 0.5);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Knock Sound
|
||||
|
||||
```javascript
|
||||
// In playSound() method
|
||||
} else if (type === 'knock') {
|
||||
// Dramatic "knock" sound - low thud
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
|
||||
osc.type = 'sine';
|
||||
osc.frequency.setValueAtTime(80, ctx.currentTime);
|
||||
osc.frequency.exponentialRampToValueAtTime(40, ctx.currentTime + 0.15);
|
||||
|
||||
gain.gain.setValueAtTime(0.4, ctx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.2);
|
||||
|
||||
osc.start(ctx.currentTime);
|
||||
osc.stop(ctx.currentTime + 0.2);
|
||||
|
||||
// Secondary impact
|
||||
setTimeout(() => {
|
||||
const osc2 = ctx.createOscillator();
|
||||
const gain2 = ctx.createGain();
|
||||
osc2.connect(gain2);
|
||||
gain2.connect(ctx.destination);
|
||||
osc2.type = 'sine';
|
||||
osc2.frequency.setValueAtTime(60, ctx.currentTime);
|
||||
gain2.gain.setValueAtTime(0.2, ctx.currentTime);
|
||||
gain2.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.1);
|
||||
osc2.start(ctx.currentTime);
|
||||
osc2.stop(ctx.currentTime + 0.1);
|
||||
}, 100);
|
||||
}
|
||||
```
|
||||
|
||||
### Opponent Sees Knock
|
||||
|
||||
When another player knocks, show announcement:
|
||||
|
||||
```javascript
|
||||
// In state change detection or game_state handler
|
||||
|
||||
if (newState.phase === 'final_turn' && oldState?.phase !== 'final_turn') {
|
||||
const knocker = newState.players.find(p => p.id === newState.finisher_id);
|
||||
if (knocker && knocker.id !== this.playerId) {
|
||||
// Someone else knocked
|
||||
this.showOpponentKnockAnnouncement(knocker.name);
|
||||
}
|
||||
}
|
||||
|
||||
showOpponentKnockAnnouncement(playerName) {
|
||||
this.playSound('alert');
|
||||
|
||||
const banner = document.createElement('div');
|
||||
banner.className = 'opponent-knock-banner';
|
||||
banner.innerHTML = `<span>${playerName} knocked!</span>`;
|
||||
document.body.appendChild(banner);
|
||||
|
||||
setTimeout(() => {
|
||||
banner.classList.add('fading');
|
||||
}, 1500);
|
||||
|
||||
setTimeout(() => {
|
||||
banner.remove();
|
||||
}, 1800);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
1. **Knock with 1 hidden card** - Single flip, then knock banner
|
||||
2. **Knock with 2 hidden cards** - Rapid double flip
|
||||
3. **Cancel confirmation** - Modal closes, no action
|
||||
4. **Opponent knocks** - See announcement
|
||||
5. **Can't knock (3+ hidden)** - Button disabled
|
||||
6. **Can't knock (all face-up)** - Button disabled
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Confirmation dialog appears before knock
|
||||
- [ ] Dialog shows number of cards to reveal
|
||||
- [ ] Cancel button works
|
||||
- [ ] Knock triggers rapid flip animation
|
||||
- [ ] "KNOCK!" banner appears with fanfare
|
||||
- [ ] Subtle screen shake effect
|
||||
- [ ] Other players see announcement
|
||||
- [ ] Final turn triggers after knock
|
||||
- [ ] Sound effects enhance the drama
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. Add knock sound to `playSound()`
|
||||
2. Implement `showKnockConfirmation()` method
|
||||
3. Implement `executeKnockEarly()` method
|
||||
4. Implement `animateKnockFlips()` method
|
||||
5. Implement `showKnockBanner()` method
|
||||
6. Add CSS for modal and banner
|
||||
7. Implement opponent knock announcement
|
||||
8. Add screen shake effect
|
||||
9. Test all scenarios
|
||||
10. Tune timing for maximum drama
|
||||
|
||||
---
|
||||
|
||||
## Notes for Agent
|
||||
|
||||
- **CSS vs anime.js**: CSS is fine for modal/button animations (UI chrome). Screen shake can use anime.js for precision.
|
||||
- The confirmation prevents accidental knocks (it's irreversible)
|
||||
- Keep animation fast - drama without delay
|
||||
- The screen shake should be subtle (accessibility)
|
||||
- Consider: skip confirmation option for experienced players?
|
||||
- Make sure knock works even if animations fail
|
||||
394
docs/v3/V3_10_COLUMN_PAIR_INDICATOR.md
Normal file
394
docs/v3/V3_10_COLUMN_PAIR_INDICATOR.md
Normal file
@@ -0,0 +1,394 @@
|
||||
# V3-10: Column Pair Indicator
|
||||
|
||||
## Overview
|
||||
|
||||
When two cards in a column match (forming a pair that scores 0), there's currently no persistent visual indicator. This feature adds a subtle connector showing paired columns at a glance.
|
||||
|
||||
**Dependencies:** V3_04 (Column Pair Celebration - this builds on that)
|
||||
**Dependents:** None
|
||||
|
||||
---
|
||||
|
||||
## Goals
|
||||
|
||||
1. Show which columns are currently paired
|
||||
2. Visual connector between paired cards
|
||||
3. Score indicator showing "+0" or "locked"
|
||||
4. Don't clutter the interface
|
||||
5. Help new players understand pairing
|
||||
|
||||
---
|
||||
|
||||
## Current State
|
||||
|
||||
After V3_04 (celebration), pairs get a brief animation when formed. But after that animation, there's no indication which columns are paired. Players must remember or scan visually.
|
||||
|
||||
---
|
||||
|
||||
## Design
|
||||
|
||||
### Visual Options
|
||||
|
||||
**Option A: Connecting Line**
|
||||
Draw a subtle line or bracket connecting paired cards.
|
||||
|
||||
**Option B: Shared Glow**
|
||||
Both cards have a subtle shared glow color.
|
||||
|
||||
**Option C: Zero Badge**
|
||||
Small "0" badge on the column.
|
||||
|
||||
**Option D: Lock Icon**
|
||||
Small lock icon indicating "locked in" pair.
|
||||
|
||||
**Recommendation:** Option A (line) + Option C (badge) - clear and informative.
|
||||
|
||||
### Visual Treatment
|
||||
|
||||
```
|
||||
Normal columns: Paired column:
|
||||
┌───┐ ┌───┐ ┌───┐ ─┐
|
||||
│ K │ │ 7 │ │ 5 │ │ [0]
|
||||
└───┘ └───┘ └───┘ │
|
||||
│
|
||||
┌───┐ ┌───┐ ┌───┐ ─┘
|
||||
│ Q │ │ 3 │ │ 5 │
|
||||
└───┘ └───┘ └───┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation
|
||||
|
||||
### Detecting Pairs
|
||||
|
||||
```javascript
|
||||
getColumnPairs(cards) {
|
||||
const pairs = [];
|
||||
const columns = [[0, 3], [1, 4], [2, 5]];
|
||||
|
||||
for (let i = 0; i < columns.length; i++) {
|
||||
const [top, bottom] = columns[i];
|
||||
const topCard = cards[top];
|
||||
const bottomCard = cards[bottom];
|
||||
|
||||
if (topCard?.face_up && bottomCard?.face_up &&
|
||||
topCard?.rank && topCard.rank === bottomCard?.rank) {
|
||||
pairs.push({
|
||||
column: i,
|
||||
topPosition: top,
|
||||
bottomPosition: bottom,
|
||||
rank: topCard.rank
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return pairs;
|
||||
}
|
||||
```
|
||||
|
||||
### Rendering Pair Indicators
|
||||
|
||||
```javascript
|
||||
renderPairIndicators(playerId, cards) {
|
||||
const pairs = this.getColumnPairs(cards);
|
||||
const container = this.getPairIndicatorContainer(playerId);
|
||||
|
||||
// Clear existing indicators
|
||||
container.innerHTML = '';
|
||||
|
||||
if (pairs.length === 0) return;
|
||||
|
||||
const cardElements = this.getCardElements(playerId);
|
||||
|
||||
for (const pair of pairs) {
|
||||
const topCard = cardElements[pair.topPosition];
|
||||
const bottomCard = cardElements[pair.bottomPosition];
|
||||
|
||||
if (!topCard || !bottomCard) continue;
|
||||
|
||||
// Create connector line
|
||||
const connector = this.createPairConnector(topCard, bottomCard, pair.column);
|
||||
container.appendChild(connector);
|
||||
|
||||
// Add paired class to cards
|
||||
topCard.classList.add('paired');
|
||||
bottomCard.classList.add('paired');
|
||||
}
|
||||
}
|
||||
|
||||
createPairConnector(topCard, bottomCard, columnIndex) {
|
||||
const connector = document.createElement('div');
|
||||
connector.className = 'pair-connector';
|
||||
connector.dataset.column = columnIndex;
|
||||
|
||||
// Calculate position
|
||||
const topRect = topCard.getBoundingClientRect();
|
||||
const bottomRect = bottomCard.getBoundingClientRect();
|
||||
const containerRect = topCard.closest('.card-grid').getBoundingClientRect();
|
||||
|
||||
// Position connector to the right of the column
|
||||
const x = topRect.right - containerRect.left + 5;
|
||||
const y = topRect.top - containerRect.top;
|
||||
const height = bottomRect.bottom - topRect.top;
|
||||
|
||||
connector.style.cssText = `
|
||||
left: ${x}px;
|
||||
top: ${y}px;
|
||||
height: ${height}px;
|
||||
`;
|
||||
|
||||
// Add zero badge
|
||||
const badge = document.createElement('div');
|
||||
badge.className = 'pair-badge';
|
||||
badge.textContent = '0';
|
||||
connector.appendChild(badge);
|
||||
|
||||
return connector;
|
||||
}
|
||||
|
||||
getPairIndicatorContainer(playerId) {
|
||||
// Get or create indicator container
|
||||
const area = playerId === this.playerId
|
||||
? this.playerCards
|
||||
: this.opponentsRow.querySelector(`[data-player-id="${playerId}"] .card-grid`);
|
||||
|
||||
if (!area) return document.createElement('div'); // Fallback
|
||||
|
||||
let container = area.querySelector('.pair-indicators');
|
||||
if (!container) {
|
||||
container = document.createElement('div');
|
||||
container.className = 'pair-indicators';
|
||||
area.style.position = 'relative';
|
||||
area.appendChild(container);
|
||||
}
|
||||
|
||||
return container;
|
||||
}
|
||||
```
|
||||
|
||||
### CSS
|
||||
|
||||
```css
|
||||
/* Pair indicators container */
|
||||
.pair-indicators {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
/* Connector line */
|
||||
.pair-connector {
|
||||
position: absolute;
|
||||
width: 3px;
|
||||
background: linear-gradient(180deg,
|
||||
rgba(244, 164, 96, 0.6) 0%,
|
||||
rgba(244, 164, 96, 0.8) 50%,
|
||||
rgba(244, 164, 96, 0.6) 100%
|
||||
);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Bracket style alternative */
|
||||
.pair-connector::before,
|
||||
.pair-connector::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
width: 8px;
|
||||
height: 3px;
|
||||
background: rgba(244, 164, 96, 0.6);
|
||||
}
|
||||
|
||||
.pair-connector::before {
|
||||
top: 0;
|
||||
border-radius: 2px 0 0 0;
|
||||
}
|
||||
|
||||
.pair-connector::after {
|
||||
bottom: 0;
|
||||
border-radius: 0 0 0 2px;
|
||||
}
|
||||
|
||||
/* Zero badge */
|
||||
.pair-badge {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: #f4a460;
|
||||
color: #1a1a2e;
|
||||
font-size: 0.7em;
|
||||
font-weight: bold;
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Paired card subtle highlight */
|
||||
.card.paired {
|
||||
box-shadow: 0 0 8px rgba(244, 164, 96, 0.3);
|
||||
}
|
||||
|
||||
/* Opponent paired cards - smaller/subtler */
|
||||
.opponent-area .pair-connector {
|
||||
width: 2px;
|
||||
}
|
||||
|
||||
.opponent-area .pair-badge {
|
||||
font-size: 0.6em;
|
||||
padding: 1px 4px;
|
||||
}
|
||||
|
||||
.opponent-area .card.paired {
|
||||
box-shadow: 0 0 5px rgba(244, 164, 96, 0.2);
|
||||
}
|
||||
```
|
||||
|
||||
### Integration with renderGame
|
||||
|
||||
```javascript
|
||||
// In renderGame(), after rendering cards
|
||||
renderGame() {
|
||||
// ... existing rendering ...
|
||||
|
||||
// Update pair indicators for all players
|
||||
for (const player of this.gameState.players) {
|
||||
this.renderPairIndicators(player.id, player.cards);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Handling Window Resize
|
||||
|
||||
Pair connectors are positioned absolutely, so they need updating on resize:
|
||||
|
||||
```javascript
|
||||
constructor() {
|
||||
// ... existing constructor ...
|
||||
|
||||
// Debounced resize handler for pair indicators
|
||||
window.addEventListener('resize', this.debounce(() => {
|
||||
if (this.gameState) {
|
||||
for (const player of this.gameState.players) {
|
||||
this.renderPairIndicators(player.id, player.cards);
|
||||
}
|
||||
}
|
||||
}, 100));
|
||||
}
|
||||
|
||||
debounce(fn, delay) {
|
||||
let timeout;
|
||||
return (...args) => {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => fn.apply(this, args), delay);
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Alternative: CSS-Only Approach
|
||||
|
||||
Simpler approach using only CSS classes:
|
||||
|
||||
```javascript
|
||||
// In renderGame, just add classes
|
||||
for (const player of this.gameState.players) {
|
||||
const pairs = this.getColumnPairs(player.cards);
|
||||
const cards = this.getCardElements(player.id);
|
||||
|
||||
// Clear previous
|
||||
cards.forEach(c => c.classList.remove('paired', 'pair-top', 'pair-bottom'));
|
||||
|
||||
for (const pair of pairs) {
|
||||
cards[pair.topPosition]?.classList.add('paired', 'pair-top');
|
||||
cards[pair.bottomPosition]?.classList.add('paired', 'pair-bottom');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```css
|
||||
/* CSS-only pair indication */
|
||||
.card.pair-top {
|
||||
border-bottom: 3px solid #f4a460;
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
.card.pair-bottom {
|
||||
border-top: 3px solid #f4a460;
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
|
||||
.card.paired::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: -10px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
background: rgba(244, 164, 96, 0.5);
|
||||
}
|
||||
|
||||
.card.pair-bottom::after {
|
||||
top: -100%; /* Extend up to connect */
|
||||
}
|
||||
```
|
||||
|
||||
**Recommendation:** Start with CSS-only approach. Add connector elements if more visual clarity needed.
|
||||
|
||||
---
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
1. **Single pair** - One column shows indicator
|
||||
2. **Multiple pairs** - Multiple indicators (rare but possible)
|
||||
3. **No pairs** - No indicators
|
||||
4. **Pair broken** - Indicator disappears
|
||||
5. **Pair formed** - Indicator appears (after celebration)
|
||||
6. **Face-down card in column** - No indicator
|
||||
7. **Opponent pairs** - Smaller indicators visible
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Paired columns show visual connector
|
||||
- [ ] "0" badge indicates the score contribution
|
||||
- [ ] Indicators update when cards change
|
||||
- [ ] Works for local player and opponents
|
||||
- [ ] Smaller/subtler for opponents
|
||||
- [ ] Handles window resize
|
||||
- [ ] Doesn't clutter interface
|
||||
- [ ] Helps new players understand pairing
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. Implement `getColumnPairs()` method
|
||||
2. Choose approach: CSS-only or connector elements
|
||||
3. If connector: implement `createPairConnector()`
|
||||
4. Add CSS for indicators
|
||||
5. Integrate into `renderGame()`
|
||||
6. Add resize handling
|
||||
7. Test various pair scenarios
|
||||
8. Adjust styling for opponents
|
||||
|
||||
---
|
||||
|
||||
## Notes for Agent
|
||||
|
||||
- **CSS vs anime.js**: CSS is appropriate for static indicators (not animated elements)
|
||||
- Keep indicators subtle - informative not distracting
|
||||
- Opponent indicators should be smaller/lighter
|
||||
- CSS-only approach is simpler to maintain
|
||||
- The badge helps players learning the scoring system
|
||||
- Consider: toggle option to hide indicators? (For experienced players)
|
||||
- Make sure indicators don't overlap cards on mobile
|
||||
317
docs/v3/V3_11_SWAP_ANIMATION_IMPROVEMENTS.md
Normal file
317
docs/v3/V3_11_SWAP_ANIMATION_IMPROVEMENTS.md
Normal file
@@ -0,0 +1,317 @@
|
||||
# V3-11: Swap Animation Improvements
|
||||
|
||||
## Overview
|
||||
|
||||
When swapping a drawn card with a hand card, the current animation uses a "flip in place + teleport" approach. Physical card games have cards that slide past each other. This feature improves the swap animation to feel more physical.
|
||||
|
||||
**Dependencies:** None
|
||||
**Dependents:** None
|
||||
|
||||
---
|
||||
|
||||
## Goals
|
||||
|
||||
1. Cards visibly exchange positions (not teleport)
|
||||
2. Old card slides toward discard
|
||||
3. New card slides into hand slot
|
||||
4. Brief "crossing" moment visible
|
||||
5. Smooth, performant animation
|
||||
6. Works for both face-up and face-down swaps
|
||||
|
||||
---
|
||||
|
||||
## Current State
|
||||
|
||||
From `card-animations.js` (CardAnimations class):
|
||||
```javascript
|
||||
// Current swap uses anime.js with pulse effect for face-up swaps
|
||||
// and flip animation for face-down swaps
|
||||
|
||||
animateSwap(position, oldCard, newCard, handCardElement, onComplete) {
|
||||
if (isAlreadyFaceUp) {
|
||||
// Face-up swap: subtle pulse, no flip needed
|
||||
this._animateFaceUpSwap(handCardElement, onComplete);
|
||||
} else {
|
||||
// Face-down swap: flip reveal then swap
|
||||
this._animateFaceDownSwap(position, oldCard, handCardElement, onComplete);
|
||||
}
|
||||
}
|
||||
|
||||
_animateFaceUpSwap(handCardElement, onComplete) {
|
||||
anime({
|
||||
targets: handCardElement,
|
||||
scale: [1, 0.92, 1.08, 1],
|
||||
filter: ['brightness(1)', 'brightness(0.85)', 'brightness(1.15)', 'brightness(1)'],
|
||||
duration: 400,
|
||||
easing: 'easeOutQuad'
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
The current animation uses a pulse effect for face-up swaps and a flip reveal for face-down swaps. It works but lacks the physical feeling of cards moving past each other.
|
||||
|
||||
---
|
||||
|
||||
## Design
|
||||
|
||||
### Animation Sequence
|
||||
|
||||
```
|
||||
1. If face-down: Flip hand card to reveal (existing)
|
||||
2. Lift both cards slightly (z-index, shadow)
|
||||
3. Hand card arcs toward discard pile
|
||||
4. Held card arcs toward hand slot
|
||||
5. Cards cross paths visually (middle of arc)
|
||||
6. Cards land at destinations
|
||||
7. Landing pulse effect
|
||||
```
|
||||
|
||||
### Arc Paths
|
||||
|
||||
Instead of straight lines, cards follow curved paths:
|
||||
|
||||
```
|
||||
Hand card path
|
||||
╭─────────────────╮
|
||||
│ │
|
||||
[Hand] [Discard]
|
||||
│ │
|
||||
╰─────────────────╯
|
||||
Held card path
|
||||
```
|
||||
|
||||
The curves create a visual "exchange" moment.
|
||||
|
||||
---
|
||||
|
||||
## Implementation
|
||||
|
||||
### Enhanced Swap Animation (Add to CardAnimations class)
|
||||
|
||||
```javascript
|
||||
// In card-animations.js - enhance the existing animateSwap method
|
||||
|
||||
async animatePhysicalSwap(handCardEl, heldCardEl, handRect, discardRect, holdingRect, onComplete) {
|
||||
const T = window.TIMING?.swap || {
|
||||
lift: 80,
|
||||
arc: 280,
|
||||
settle: 60,
|
||||
};
|
||||
|
||||
// Create animation elements that will travel
|
||||
const travelingHandCard = this.createTravelingCard(handCardEl);
|
||||
const travelingHeldCard = this.createTravelingCard(heldCardEl);
|
||||
|
||||
document.body.appendChild(travelingHandCard);
|
||||
document.body.appendChild(travelingHeldCard);
|
||||
|
||||
// Position at start
|
||||
this.positionAt(travelingHandCard, handRect);
|
||||
this.positionAt(travelingHeldCard, holdingRect || discardRect);
|
||||
|
||||
// Hide originals
|
||||
handCardEl.style.visibility = 'hidden';
|
||||
heldCardEl.style.visibility = 'hidden';
|
||||
|
||||
this.playSound('card');
|
||||
|
||||
// Use anime.js timeline for coordinated arc movement
|
||||
const timeline = anime.timeline({
|
||||
easing: this.getEasing('move'),
|
||||
complete: () => {
|
||||
travelingHandCard.remove();
|
||||
travelingHeldCard.remove();
|
||||
handCardEl.style.visibility = 'visible';
|
||||
heldCardEl.style.visibility = 'visible';
|
||||
this.pulseDiscard();
|
||||
if (onComplete) onComplete();
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate arc midpoints
|
||||
const midY1 = (handRect.top + discardRect.top) / 2 - 40; // Arc up
|
||||
const midY2 = ((holdingRect || discardRect).top + handRect.top) / 2 + 40; // Arc down
|
||||
|
||||
// Step 1: Lift both cards with shadow increase
|
||||
timeline.add({
|
||||
targets: [travelingHandCard, travelingHeldCard],
|
||||
translateY: -10,
|
||||
boxShadow: '0 8px 30px rgba(0, 0, 0, 0.5)',
|
||||
scale: 1.02,
|
||||
duration: T.lift,
|
||||
easing: this.getEasing('lift')
|
||||
});
|
||||
|
||||
// Step 2: Hand card arcs to discard
|
||||
timeline.add({
|
||||
targets: travelingHandCard,
|
||||
left: discardRect.left,
|
||||
top: [
|
||||
{ value: midY1, duration: T.arc / 2 },
|
||||
{ value: discardRect.top, duration: T.arc / 2 }
|
||||
],
|
||||
rotate: [0, -5, 0],
|
||||
duration: T.arc,
|
||||
}, `-=${T.lift / 2}`);
|
||||
|
||||
// Held card arcs to hand (in parallel)
|
||||
timeline.add({
|
||||
targets: travelingHeldCard,
|
||||
left: handRect.left,
|
||||
top: [
|
||||
{ value: midY2, duration: T.arc / 2 },
|
||||
{ value: handRect.top, duration: T.arc / 2 }
|
||||
],
|
||||
rotate: [0, 5, 0],
|
||||
duration: T.arc,
|
||||
}, `-=${T.arc + T.lift / 2}`);
|
||||
|
||||
// Step 3: Settle
|
||||
timeline.add({
|
||||
targets: [travelingHandCard, travelingHeldCard],
|
||||
translateY: 0,
|
||||
boxShadow: '0 2px 10px rgba(0, 0, 0, 0.3)',
|
||||
scale: 1,
|
||||
duration: T.settle,
|
||||
});
|
||||
|
||||
this.activeAnimations.set('physicalSwap', timeline);
|
||||
}
|
||||
|
||||
createTravelingCard(sourceCard) {
|
||||
const clone = sourceCard.cloneNode(true);
|
||||
clone.className = 'traveling-card';
|
||||
clone.style.position = 'fixed';
|
||||
clone.style.pointerEvents = 'none';
|
||||
clone.style.zIndex = '1000';
|
||||
clone.style.borderRadius = '6px';
|
||||
return clone;
|
||||
}
|
||||
|
||||
positionAt(element, rect) {
|
||||
element.style.left = `${rect.left}px`;
|
||||
element.style.top = `${rect.top}px`;
|
||||
element.style.width = `${rect.width}px`;
|
||||
element.style.height = `${rect.height}px`;
|
||||
}
|
||||
```
|
||||
|
||||
### CSS for Traveling Cards
|
||||
|
||||
Minimal CSS needed - anime.js handles all animation properties including box-shadow and scale:
|
||||
|
||||
```css
|
||||
/* Traveling card during swap - base styles only */
|
||||
.traveling-card {
|
||||
position: fixed;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
|
||||
/* All animation handled by anime.js */
|
||||
}
|
||||
```
|
||||
|
||||
### Timing Configuration
|
||||
|
||||
```javascript
|
||||
// In timing-config.js
|
||||
swap: {
|
||||
lift: 80, // Time to lift cards
|
||||
arc: 280, // Time for arc travel
|
||||
settle: 60, // Time to settle into place
|
||||
// Total: ~420ms (similar to current)
|
||||
}
|
||||
```
|
||||
|
||||
### Note on Animation Approach
|
||||
|
||||
All swap animations use anime.js timelines, not CSS transitions or Web Animations API. This provides:
|
||||
- Better coordination between multiple elements
|
||||
- Consistent with rest of animation system
|
||||
- Easier timing control via `window.TIMING`
|
||||
- Proper animation cancellation via `activeAnimations` tracking
|
||||
|
||||
---
|
||||
|
||||
## Integration Points
|
||||
|
||||
### For Local Player Swap
|
||||
|
||||
```javascript
|
||||
// In animateSwap() method
|
||||
animateSwap(position) {
|
||||
const cardElements = this.playerCards.querySelectorAll('.card');
|
||||
const handCardEl = cardElements[position];
|
||||
|
||||
// Get positions
|
||||
const handRect = handCardEl.getBoundingClientRect();
|
||||
const discardRect = this.discard.getBoundingClientRect();
|
||||
const holdingRect = this.getHoldingRect();
|
||||
|
||||
// If face-down, flip first (existing logic)
|
||||
// ...
|
||||
|
||||
// Then do physical swap
|
||||
this.animatePhysicalSwap(
|
||||
handCardEl,
|
||||
this.heldCardFloating,
|
||||
handRect,
|
||||
discardRect,
|
||||
holdingRect
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### For Opponent Swap
|
||||
|
||||
The opponent swap animation in `fireSwapAnimation()` can use similar arc logic for the visible card traveling to discard.
|
||||
|
||||
---
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
1. **Swap face-up card** - Direct arc exchange
|
||||
2. **Swap face-down card** - Flip first, then arc
|
||||
3. **Fast repeated swaps** - No animation overlap
|
||||
4. **Mobile** - Animation performs at 60fps
|
||||
5. **Different screen sizes** - Arcs scale appropriately
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Cards visibly travel to new positions (not teleport)
|
||||
- [ ] Arc paths create "crossing" visual
|
||||
- [ ] Lift and settle effects enhance physicality
|
||||
- [ ] Animation total time ~400ms (not slower than current)
|
||||
- [ ] Works for face-up and face-down cards
|
||||
- [ ] Performant on mobile (60fps)
|
||||
- [ ] Landing effect on discard pile
|
||||
- [ ] Opponent swaps also improved
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. Add swap timing to `timing-config.js`
|
||||
2. Implement `createTravelingCard()` helper
|
||||
3. Implement `animateArc()` with Web Animations API
|
||||
4. Implement `animatePhysicalSwap()` method
|
||||
5. Add CSS for traveling cards
|
||||
6. Integrate with local player swap
|
||||
7. Integrate with opponent swap animation
|
||||
8. Test on various devices
|
||||
9. Tune arc height and timing
|
||||
|
||||
---
|
||||
|
||||
## Notes for Agent
|
||||
|
||||
- Add `animatePhysicalSwap()` to the existing CardAnimations class
|
||||
- Use anime.js timelines for coordinated multi-element animation
|
||||
- Arc height should scale with card distance
|
||||
- The "crossing" moment is the key visual improvement
|
||||
- Keep total animation time similar to current (~400ms)
|
||||
- Track animation in `activeAnimations` for proper cancellation
|
||||
- Consider: option for "fast mode" with simpler animations?
|
||||
- Make sure sound timing aligns with visual (card leaving hand)
|
||||
- Existing `animateSwap()` can call this new method internally
|
||||
279
docs/v3/V3_12_DRAW_SOURCE_DISTINCTION.md
Normal file
279
docs/v3/V3_12_DRAW_SOURCE_DISTINCTION.md
Normal file
@@ -0,0 +1,279 @@
|
||||
# V3-12: Draw Source Distinction
|
||||
|
||||
## Overview
|
||||
|
||||
Drawing from the deck (face-down, unknown) vs discard (face-up, known) should feel different. Currently both animations are similar. This feature enhances the visual distinction.
|
||||
|
||||
**Dependencies:** None
|
||||
**Dependents:** None
|
||||
|
||||
---
|
||||
|
||||
## Goals
|
||||
|
||||
1. Deck draw: Card emerges face-down, then flips
|
||||
2. Discard draw: Card lifts straight up (already visible)
|
||||
3. Different sound for each source
|
||||
4. Visual hint about the strategic difference
|
||||
5. Help new players understand the two options
|
||||
|
||||
---
|
||||
|
||||
## Current State
|
||||
|
||||
From `card-animations.js` (CardAnimations class):
|
||||
```javascript
|
||||
// Deck draw: suspenseful pause + flip reveal
|
||||
animateDrawDeck(cardData, onComplete) {
|
||||
// Pulse deck, lift card face-down, move to holding, suspense pause, flip
|
||||
timeline.add({ targets: inner, rotateY: 0, duration: 245 });
|
||||
}
|
||||
|
||||
// Discard draw: quick decisive grab
|
||||
animateDrawDiscard(cardData, onComplete) {
|
||||
// Pulse discard, quick lift, direct move to holding (no flip needed)
|
||||
timeline.add({ targets: animCard, translateY: -12, scale: 1.05, duration: 42 });
|
||||
}
|
||||
```
|
||||
|
||||
The distinction exists and is already fairly pronounced. This feature enhances it further with:
|
||||
- More distinct sounds for each source
|
||||
- Visual "shuffleDeckVisual" effect when drawing from deck
|
||||
- Better timing contrast
|
||||
|
||||
---
|
||||
|
||||
## Design
|
||||
|
||||
### Deck Draw (Unknown)
|
||||
|
||||
```
|
||||
1. Deck "shuffles" slightly (optional)
|
||||
2. Top card lifts off deck
|
||||
3. Card floats to holding position (face-down)
|
||||
4. Brief suspense pause
|
||||
5. Card flips to reveal
|
||||
6. Sound: "mysterious" flip sound
|
||||
```
|
||||
|
||||
### Discard Draw (Known)
|
||||
|
||||
```
|
||||
1. Card lifts directly (quick)
|
||||
2. No flip needed - already visible
|
||||
3. Moves to holding position
|
||||
4. "Picked up" visual on discard pile
|
||||
5. Sound: quick "pick" sound
|
||||
```
|
||||
|
||||
### Visual Distinction
|
||||
|
||||
| Aspect | Deck Draw | Discard Draw |
|
||||
|--------|-----------|--------------|
|
||||
| Card state | Face-down → Face-up | Face-up entire time |
|
||||
| Motion | Float + flip | Direct lift |
|
||||
| Sound | Suspenseful flip | Quick pick |
|
||||
| Duration | Longer (suspense) | Shorter (decisive) |
|
||||
| Deck visual | Cards shuffle | N/A |
|
||||
| Discard visual | N/A | "Picked up" state |
|
||||
|
||||
---
|
||||
|
||||
## Implementation
|
||||
|
||||
### Enhanced Deck Draw
|
||||
|
||||
The existing `animateDrawDeck()` in `card-animations.js` already has most of this functionality. Enhancements to add:
|
||||
|
||||
```javascript
|
||||
// In card-animations.js - enhance existing animateDrawDeck
|
||||
|
||||
// The current implementation already:
|
||||
// - Pulses deck before drawing (startDrawPulse)
|
||||
// - Lifts card with wobble
|
||||
// - Adds suspense pause before flip
|
||||
// - Flips to reveal with sound
|
||||
|
||||
// Add distinct sound for deck draws:
|
||||
animateDrawDeck(cardData, onComplete) {
|
||||
// ... existing code ...
|
||||
|
||||
// Change sound from 'card' to 'draw-deck' for more mysterious feel
|
||||
this.playSound('draw-deck'); // Instead of 'card'
|
||||
|
||||
// ... rest of existing code ...
|
||||
}
|
||||
|
||||
// The shuffleDeckVisual already exists as startDrawPulse:
|
||||
startDrawPulse(element) {
|
||||
if (!element) return;
|
||||
element.classList.add('draw-pulse');
|
||||
setTimeout(() => {
|
||||
element.classList.remove('draw-pulse');
|
||||
}, 450);
|
||||
}
|
||||
```
|
||||
|
||||
**Key existing features:**
|
||||
- `startDrawPulse()` - gold ring pulse effect
|
||||
- Suspense pause of 200ms before flip
|
||||
- Flip duration 245ms with `easeInOutQuad` easing
|
||||
|
||||
### Enhanced Discard Draw
|
||||
|
||||
The existing `animateDrawDiscard()` in `card-animations.js` already has quick, decisive animation:
|
||||
|
||||
```javascript
|
||||
// Current implementation already does:
|
||||
// - Pulses discard before picking up (startDrawPulse)
|
||||
// - Quick lift (42ms) with scale
|
||||
// - Direct move (126ms) - much faster than deck draw
|
||||
// - No flip needed (card already face-up)
|
||||
|
||||
// Enhancement: Add distinct sound for discard draws
|
||||
_animateDrawDiscardCard(cardData, discardRect, holdingRect, onComplete) {
|
||||
// ... existing code ...
|
||||
|
||||
// Change sound from 'card' to 'draw-discard' for decisive feel
|
||||
this.playSound('draw-discard'); // Instead of 'card'
|
||||
|
||||
// ... rest of existing code ...
|
||||
}
|
||||
```
|
||||
|
||||
**Current timing comparison (already implemented):**
|
||||
|
||||
| Phase | Deck Draw | Discard Draw |
|
||||
|-------|-----------|--------------|
|
||||
| Pulse delay | 250ms | 200ms |
|
||||
| Lift | 105ms | 42ms |
|
||||
| Travel | 175ms | 126ms |
|
||||
| Suspense | 200ms | 0ms |
|
||||
| Flip | 245ms | 0ms |
|
||||
| Settle | 150ms | 80ms |
|
||||
| **Total** | **~1125ms** | **~448ms** |
|
||||
|
||||
The distinction is already pronounced - discard draw is ~2.5x faster.
|
||||
|
||||
### Deck Visual Effects
|
||||
|
||||
The `draw-pulse` class already exists with a CSS animation (gold ring expanding). For additional deck depth effect, use CSS only:
|
||||
|
||||
```css
|
||||
/* Deck "depth" visual - multiple card shadows */
|
||||
#deck {
|
||||
box-shadow:
|
||||
1px 1px 0 0 rgba(0, 0, 0, 0.1),
|
||||
2px 2px 0 0 rgba(0, 0, 0, 0.1),
|
||||
3px 3px 0 0 rgba(0, 0, 0, 0.1),
|
||||
4px 4px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Existing draw-pulse animation handles the visual feedback */
|
||||
.draw-pulse {
|
||||
/* Already defined in style.css */
|
||||
}
|
||||
```
|
||||
|
||||
### Distinct Sounds
|
||||
|
||||
```javascript
|
||||
// In playSound() method
|
||||
|
||||
} else if (type === 'draw-deck') {
|
||||
// Mysterious "what's this?" sound
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
|
||||
osc.type = 'triangle';
|
||||
osc.frequency.setValueAtTime(300, ctx.currentTime);
|
||||
osc.frequency.exponentialRampToValueAtTime(500, ctx.currentTime + 0.1);
|
||||
osc.frequency.exponentialRampToValueAtTime(350, ctx.currentTime + 0.15);
|
||||
|
||||
gain.gain.setValueAtTime(0.08, ctx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.2);
|
||||
|
||||
osc.start(ctx.currentTime);
|
||||
osc.stop(ctx.currentTime + 0.2);
|
||||
|
||||
} else if (type === 'draw-discard') {
|
||||
// Quick decisive "grab" sound
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
|
||||
osc.type = 'square';
|
||||
osc.frequency.setValueAtTime(600, ctx.currentTime);
|
||||
osc.frequency.exponentialRampToValueAtTime(300, ctx.currentTime + 0.05);
|
||||
|
||||
gain.gain.setValueAtTime(0.08, ctx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.06);
|
||||
|
||||
osc.start(ctx.currentTime);
|
||||
osc.stop(ctx.currentTime + 0.06);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Timing Comparison
|
||||
|
||||
| Phase | Deck Draw | Discard Draw |
|
||||
|-------|-----------|--------------|
|
||||
| Lift | 150ms | 80ms |
|
||||
| Travel | 250ms | 200ms |
|
||||
| Suspense | 200ms | 0ms |
|
||||
| Flip | 350ms | 0ms |
|
||||
| Settle | 150ms | 80ms |
|
||||
| **Total** | **~1100ms** | **~360ms** |
|
||||
|
||||
Deck draw is intentionally longer to build suspense.
|
||||
|
||||
---
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
1. **Draw from deck** - Longer animation with flip
|
||||
2. **Draw from discard** - Quick decisive grab
|
||||
3. **Rapid alternating draws** - Animations don't conflict
|
||||
4. **CPU draws** - Same visual distinction
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Deck draw has suspenseful pause before flip
|
||||
- [ ] Discard draw is quick and direct
|
||||
- [ ] Different sounds for each source
|
||||
- [ ] Deck shows visual "dealing" effect
|
||||
- [ ] Timing difference is noticeable but not tedious
|
||||
- [ ] Both animations complete cleanly
|
||||
- [ ] Works for both local player and opponents
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. Add distinct sounds to `playSound()`
|
||||
2. Enhance `animateDrawDeck()` with suspense
|
||||
3. Enhance `animateDrawDiscard()` for quick grab
|
||||
4. Add deck visual effects (CSS)
|
||||
5. Add `shuffleDeckVisual()` method
|
||||
6. Test both draw types
|
||||
7. Tune timing for feel
|
||||
|
||||
---
|
||||
|
||||
## Notes for Agent
|
||||
|
||||
- Most of this is already implemented in `card-animations.js`
|
||||
- Main enhancement is adding distinct sounds (`draw-deck` vs `draw-discard`)
|
||||
- The existing timing difference (1125ms vs 448ms) is already significant
|
||||
- Deck draw suspense shouldn't be annoying, just noticeable
|
||||
- Discard draw being faster reflects the strategic advantage (you know what you're getting)
|
||||
- Consider: Show deck count visual changing? (Nice to have)
|
||||
- Sound design matters here - different tones communicate different meanings
|
||||
- Mobile performance should still be smooth
|
||||
399
docs/v3/V3_13_CARD_VALUE_TOOLTIPS.md
Normal file
399
docs/v3/V3_13_CARD_VALUE_TOOLTIPS.md
Normal file
@@ -0,0 +1,399 @@
|
||||
# V3-13: Card Value Tooltips
|
||||
|
||||
## Overview
|
||||
|
||||
New players often forget card values, especially special cards (2=-2, K=0, Joker=-2). This feature adds tooltips showing card point values on long-press or hover.
|
||||
|
||||
**Dependencies:** None
|
||||
**Dependents:** None
|
||||
|
||||
---
|
||||
|
||||
## Goals
|
||||
|
||||
1. Show card point value on long-press (mobile) or hover (desktop)
|
||||
2. Especially helpful for special value cards
|
||||
3. Show house rule modified values if active
|
||||
4. Don't interfere with normal gameplay
|
||||
5. Optional: disable for experienced players
|
||||
|
||||
---
|
||||
|
||||
## Current State
|
||||
|
||||
No card value tooltips exist. Players must remember:
|
||||
- Standard values: A=1, 2-10=face, J/Q=10, K=0
|
||||
- Special values: 2=-2, Joker=-2
|
||||
- House rules: super_kings=-2, ten_penny=1, etc.
|
||||
|
||||
---
|
||||
|
||||
## Design
|
||||
|
||||
### Tooltip Content
|
||||
|
||||
```
|
||||
┌─────────┐
|
||||
│ K │ ← Normal card display
|
||||
│ ♠ │
|
||||
└─────────┘
|
||||
│
|
||||
▼
|
||||
┌───────┐
|
||||
│ 0 pts │ ← Tooltip on hover/long-press
|
||||
└───────┘
|
||||
```
|
||||
|
||||
For special cards:
|
||||
```
|
||||
┌────────────┐
|
||||
│ -2 pts │
|
||||
│ (negative!)│
|
||||
└────────────┘
|
||||
```
|
||||
|
||||
### Activation
|
||||
|
||||
- **Desktop:** Hover for 500ms (not instant to avoid cluttering)
|
||||
- **Mobile:** Long-press (300ms threshold)
|
||||
- **Dismiss:** Mouse leave / touch release
|
||||
|
||||
---
|
||||
|
||||
## Implementation
|
||||
|
||||
### JavaScript
|
||||
|
||||
```javascript
|
||||
// Card tooltip system
|
||||
|
||||
initCardTooltips() {
|
||||
this.tooltip = document.createElement('div');
|
||||
this.tooltip.className = 'card-value-tooltip hidden';
|
||||
document.body.appendChild(this.tooltip);
|
||||
|
||||
this.tooltipTimeout = null;
|
||||
this.currentTooltipTarget = null;
|
||||
}
|
||||
|
||||
bindCardTooltipEvents(cardElement, cardData) {
|
||||
// Desktop hover
|
||||
cardElement.addEventListener('mouseenter', () => {
|
||||
this.scheduleTooltip(cardElement, cardData);
|
||||
});
|
||||
|
||||
cardElement.addEventListener('mouseleave', () => {
|
||||
this.hideCardTooltip();
|
||||
});
|
||||
|
||||
// Mobile long-press
|
||||
let pressTimer = null;
|
||||
|
||||
cardElement.addEventListener('touchstart', (e) => {
|
||||
pressTimer = setTimeout(() => {
|
||||
this.showCardTooltip(cardElement, cardData);
|
||||
// Prevent triggering card click
|
||||
e.preventDefault();
|
||||
}, 300);
|
||||
});
|
||||
|
||||
cardElement.addEventListener('touchend', () => {
|
||||
clearTimeout(pressTimer);
|
||||
this.hideCardTooltip();
|
||||
});
|
||||
|
||||
cardElement.addEventListener('touchmove', () => {
|
||||
clearTimeout(pressTimer);
|
||||
this.hideCardTooltip();
|
||||
});
|
||||
}
|
||||
|
||||
scheduleTooltip(cardElement, cardData) {
|
||||
this.hideCardTooltip();
|
||||
|
||||
if (!cardData?.face_up || !cardData?.rank) return;
|
||||
|
||||
this.tooltipTimeout = setTimeout(() => {
|
||||
this.showCardTooltip(cardElement, cardData);
|
||||
}, 500); // 500ms delay on desktop
|
||||
}
|
||||
|
||||
showCardTooltip(cardElement, cardData) {
|
||||
if (!cardData?.face_up || !cardData?.rank) return;
|
||||
|
||||
const value = this.getCardPointValue(cardData);
|
||||
const special = this.getCardSpecialNote(cardData);
|
||||
|
||||
// Build tooltip content
|
||||
let content = `<span class="tooltip-value ${value < 0 ? 'negative' : ''}">${value} pts</span>`;
|
||||
if (special) {
|
||||
content += `<span class="tooltip-note">${special}</span>`;
|
||||
}
|
||||
|
||||
this.tooltip.innerHTML = content;
|
||||
|
||||
// Position tooltip
|
||||
const rect = cardElement.getBoundingClientRect();
|
||||
const tooltipRect = this.tooltip.getBoundingClientRect();
|
||||
|
||||
let left = rect.left + rect.width / 2;
|
||||
let top = rect.bottom + 8;
|
||||
|
||||
// Keep on screen
|
||||
if (left + tooltipRect.width / 2 > window.innerWidth) {
|
||||
left = window.innerWidth - tooltipRect.width / 2 - 10;
|
||||
}
|
||||
if (left - tooltipRect.width / 2 < 0) {
|
||||
left = tooltipRect.width / 2 + 10;
|
||||
}
|
||||
if (top + tooltipRect.height > window.innerHeight) {
|
||||
top = rect.top - tooltipRect.height - 8;
|
||||
}
|
||||
|
||||
this.tooltip.style.left = `${left}px`;
|
||||
this.tooltip.style.top = `${top}px`;
|
||||
this.tooltip.classList.remove('hidden');
|
||||
|
||||
this.currentTooltipTarget = cardElement;
|
||||
}
|
||||
|
||||
hideCardTooltip() {
|
||||
clearTimeout(this.tooltipTimeout);
|
||||
this.tooltip.classList.add('hidden');
|
||||
this.currentTooltipTarget = null;
|
||||
}
|
||||
|
||||
getCardPointValue(cardData) {
|
||||
const values = this.gameState?.card_values || {
|
||||
'A': 1, '2': -2, '3': 3, '4': 4, '5': 5, '6': 6, '7': 7,
|
||||
'8': 8, '9': 9, '10': 10, 'J': 10, 'Q': 10, 'K': 0, '★': -2
|
||||
};
|
||||
|
||||
return values[cardData.rank] ?? 0;
|
||||
}
|
||||
|
||||
getCardSpecialNote(cardData) {
|
||||
const rank = cardData.rank;
|
||||
const value = this.getCardPointValue(cardData);
|
||||
|
||||
// Special notes for notable cards
|
||||
if (value < 0) {
|
||||
return 'Negative - keep it!';
|
||||
}
|
||||
if (rank === 'K' && value === 0) {
|
||||
return 'Safe card';
|
||||
}
|
||||
if (rank === 'K' && value === -2) {
|
||||
return 'Super King!';
|
||||
}
|
||||
if (rank === '10' && value === 1) {
|
||||
return 'Ten Penny rule';
|
||||
}
|
||||
if (rank === 'J' || rank === 'Q') {
|
||||
return 'High - replace if possible';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
### CSS
|
||||
|
||||
```css
|
||||
/* Card value tooltip */
|
||||
.card-value-tooltip {
|
||||
position: fixed;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(26, 26, 46, 0.95);
|
||||
color: white;
|
||||
padding: 6px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85em;
|
||||
text-align: center;
|
||||
z-index: 500;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.card-value-tooltip.hidden {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.card-value-tooltip::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 6px solid transparent;
|
||||
border-bottom-color: rgba(26, 26, 46, 0.95);
|
||||
}
|
||||
|
||||
.tooltip-value {
|
||||
display: block;
|
||||
font-size: 1.2em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.tooltip-value.negative {
|
||||
color: #27ae60;
|
||||
}
|
||||
|
||||
.tooltip-note {
|
||||
display: block;
|
||||
font-size: 0.85em;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Visual indicator that tooltip is available */
|
||||
.card[data-has-tooltip]:hover {
|
||||
cursor: help;
|
||||
}
|
||||
```
|
||||
|
||||
### Integration with renderGame
|
||||
|
||||
```javascript
|
||||
// In renderGame, after creating card elements
|
||||
renderPlayerCards() {
|
||||
// ... existing card rendering ...
|
||||
|
||||
const cards = this.playerCards.querySelectorAll('.card');
|
||||
const myData = this.getMyPlayerData();
|
||||
|
||||
cards.forEach((cardEl, i) => {
|
||||
const cardData = myData?.cards[i];
|
||||
if (cardData?.face_up) {
|
||||
cardEl.dataset.hasTooltip = 'true';
|
||||
this.bindCardTooltipEvents(cardEl, cardData);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Similar for opponent cards
|
||||
renderOpponentCards(player, container) {
|
||||
// ... existing card rendering ...
|
||||
|
||||
const cards = container.querySelectorAll('.card');
|
||||
player.cards.forEach((cardData, i) => {
|
||||
if (cardData?.face_up && cards[i]) {
|
||||
cards[i].dataset.hasTooltip = 'true';
|
||||
this.bindCardTooltipEvents(cards[i], cardData);
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## House Rule Awareness
|
||||
|
||||
Tooltip values should reflect active house rules:
|
||||
|
||||
```javascript
|
||||
getCardPointValue(cardData) {
|
||||
// Use server-provided values which include house rules
|
||||
if (this.gameState?.card_values) {
|
||||
return this.gameState.card_values[cardData.rank] ?? 0;
|
||||
}
|
||||
|
||||
// Fallback to defaults
|
||||
return DEFAULT_CARD_VALUES[cardData.rank] ?? 0;
|
||||
}
|
||||
```
|
||||
|
||||
The server already provides `card_values` in game state that accounts for:
|
||||
- `super_kings` (K = -2)
|
||||
- `ten_penny` (10 = 1)
|
||||
- `lucky_swing` (Joker = -5)
|
||||
- etc.
|
||||
|
||||
---
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- Only bind tooltip events to face-up cards
|
||||
- Remove tooltip events when cards re-render
|
||||
- Use event delegation if performance becomes an issue
|
||||
|
||||
```javascript
|
||||
// Event delegation approach
|
||||
this.playerCards.addEventListener('mouseenter', (e) => {
|
||||
const card = e.target.closest('.card');
|
||||
if (card && card.dataset.hasTooltip) {
|
||||
const cardData = this.getCardDataForElement(card);
|
||||
this.scheduleTooltip(card, cardData);
|
||||
}
|
||||
}, true);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Settings Option (Optional)
|
||||
|
||||
Let players disable tooltips:
|
||||
|
||||
```javascript
|
||||
// In settings
|
||||
this.showCardTooltips = localStorage.getItem('showCardTooltips') !== 'false';
|
||||
|
||||
// Check before showing
|
||||
showCardTooltip(cardElement, cardData) {
|
||||
if (!this.showCardTooltips) return;
|
||||
// ... rest of method
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
1. **Hover on face-up card** - Tooltip appears after delay
|
||||
2. **Long-press on mobile** - Tooltip appears
|
||||
3. **Move mouse away** - Tooltip disappears
|
||||
4. **Face-down card** - No tooltip
|
||||
5. **Special cards (K, 2, Joker)** - Show special note
|
||||
6. **House rules active** - Modified values shown
|
||||
7. **Rapid card changes** - No stale tooltips
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Hover (500ms delay) shows tooltip on desktop
|
||||
- [ ] Long-press (300ms) shows tooltip on mobile
|
||||
- [ ] Tooltip shows point value
|
||||
- [ ] Negative values highlighted green
|
||||
- [ ] Special notes for notable cards
|
||||
- [ ] House rule modified values displayed
|
||||
- [ ] Tooltips don't interfere with gameplay
|
||||
- [ ] Tooltips position correctly (stay on screen)
|
||||
- [ ] Face-down cards have no tooltip
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. Create tooltip element and basic CSS
|
||||
2. Implement `showCardTooltip()` method
|
||||
3. Implement `hideCardTooltip()` method
|
||||
4. Add desktop hover events
|
||||
5. Add mobile long-press events
|
||||
6. Integrate with `renderGame()`
|
||||
7. Add house rule awareness
|
||||
8. Test on mobile and desktop
|
||||
9. Optional: Add settings toggle
|
||||
|
||||
---
|
||||
|
||||
## Notes for Agent
|
||||
|
||||
- **CSS vs anime.js**: CSS is appropriate for tooltip show/hide transitions (simple UI)
|
||||
- The 500ms delay prevents tooltips appearing during normal play
|
||||
- Mobile long-press should be discoverable but not intrusive
|
||||
- Use server-provided `card_values` for house rule accuracy
|
||||
- Consider: Quick reference card in rules screen? (Separate feature)
|
||||
- Don't show tooltip during swap animation
|
||||
332
docs/v3/V3_14_ACTIVE_RULES_CONTEXT.md
Normal file
332
docs/v3/V3_14_ACTIVE_RULES_CONTEXT.md
Normal file
@@ -0,0 +1,332 @@
|
||||
# V3-14: Active Rules Context
|
||||
|
||||
## Overview
|
||||
|
||||
The active rules bar shows which house rules are in effect, but doesn't highlight when a rule is relevant to the current action. This feature adds contextual highlighting to help players understand rule effects.
|
||||
|
||||
**Dependencies:** None
|
||||
**Dependents:** None
|
||||
|
||||
---
|
||||
|
||||
## Goals
|
||||
|
||||
1. Highlight relevant rules during specific actions
|
||||
2. Brief explanatory tooltip when rule affects play
|
||||
3. Help players learn how rules work
|
||||
4. Don't clutter the interface
|
||||
5. Fade after the moment passes
|
||||
|
||||
---
|
||||
|
||||
## Current State
|
||||
|
||||
From `app.js`:
|
||||
```javascript
|
||||
updateActiveRulesBar() {
|
||||
const rules = this.gameState.active_rules || [];
|
||||
if (rules.length === 0) {
|
||||
this.activeRulesList.innerHTML = '<span class="rule-tag standard">Standard</span>';
|
||||
} else {
|
||||
// Show rule tags
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Rules are listed but never highlighted contextually.
|
||||
|
||||
---
|
||||
|
||||
## Design
|
||||
|
||||
### Contextual Highlighting Moments
|
||||
|
||||
| Moment | Relevant Rule(s) | Highlight Text |
|
||||
|--------|------------------|----------------|
|
||||
| Discard from deck | flip_mode: always | "Must flip a card!" |
|
||||
| Player knocks | knock_penalty | "+10 if not lowest!" |
|
||||
| Player knocks | knock_bonus | "-5 for going out first" |
|
||||
| Pair negative cards | negative_pairs_keep_value | "Pairs keep -4!" |
|
||||
| Draw Joker | lucky_swing | "Worth -5!" |
|
||||
| Round end | underdog_bonus | "-3 for lowest score" |
|
||||
| Score = 21 | blackjack | "Blackjack! Score → 0" |
|
||||
| Four Jacks | wolfpack | "-20 Wolfpack bonus!" |
|
||||
|
||||
### Visual Treatment
|
||||
|
||||
```
|
||||
Normal: [Speed Golf] [Knock Penalty]
|
||||
|
||||
Highlighted: [Speed Golf ← Must flip!] [Knock Penalty]
|
||||
↑
|
||||
Pulsing, expanded
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation
|
||||
|
||||
### Rule Highlight Method
|
||||
|
||||
```javascript
|
||||
highlightRule(ruleKey, message, duration = 3000) {
|
||||
const ruleTag = this.activeRulesList.querySelector(
|
||||
`[data-rule="${ruleKey}"]`
|
||||
);
|
||||
|
||||
if (!ruleTag) return;
|
||||
|
||||
// Add highlight class
|
||||
ruleTag.classList.add('rule-highlighted');
|
||||
|
||||
// Add message
|
||||
const messageEl = document.createElement('span');
|
||||
messageEl.className = 'rule-message';
|
||||
messageEl.textContent = message;
|
||||
ruleTag.appendChild(messageEl);
|
||||
|
||||
// Remove after duration
|
||||
setTimeout(() => {
|
||||
ruleTag.classList.remove('rule-highlighted');
|
||||
messageEl.remove();
|
||||
}, duration);
|
||||
}
|
||||
```
|
||||
|
||||
### Integration Points
|
||||
|
||||
```javascript
|
||||
// In handleMessage or state change handlers
|
||||
|
||||
// 1. Speed Golf - must flip after discard
|
||||
case 'can_flip':
|
||||
if (!data.optional && this.gameState.flip_mode === 'always') {
|
||||
this.highlightRule('flip_mode', 'Must flip a card!');
|
||||
}
|
||||
break;
|
||||
|
||||
// 2. Knock penalty warning
|
||||
knockEarly() {
|
||||
if (this.gameState.knock_penalty) {
|
||||
this.highlightRule('knock_penalty', '+10 if not lowest!', 4000);
|
||||
}
|
||||
// ... rest of knock logic
|
||||
}
|
||||
|
||||
// 3. Lucky swing Joker
|
||||
case 'card_drawn':
|
||||
if (data.card.rank === '★' && this.gameState.lucky_swing) {
|
||||
this.highlightRule('lucky_swing', 'Worth -5!');
|
||||
}
|
||||
break;
|
||||
|
||||
// 4. Blackjack at round end
|
||||
showScoreboard(scores, isFinal, rankings) {
|
||||
// Check for blackjack
|
||||
for (const [playerId, score] of Object.entries(scores)) {
|
||||
if (score === 0 && this.wasOriginallyBlackjack(playerId)) {
|
||||
this.highlightRule('blackjack', 'Blackjack! 21 → 0');
|
||||
}
|
||||
}
|
||||
// ... rest of scoreboard logic
|
||||
}
|
||||
```
|
||||
|
||||
### Update Rule Rendering
|
||||
|
||||
Add data attributes for targeting:
|
||||
|
||||
```javascript
|
||||
updateActiveRulesBar() {
|
||||
const rules = this.gameState.active_rules || [];
|
||||
|
||||
if (rules.length === 0) {
|
||||
this.activeRulesList.innerHTML = '<span class="rule-tag standard">Standard</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeRulesList.innerHTML = rules
|
||||
.map(rule => {
|
||||
const key = this.getRuleKey(rule);
|
||||
return `<span class="rule-tag" data-rule="${key}">${rule}</span>`;
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
getRuleKey(ruleName) {
|
||||
// Convert display name to key
|
||||
const mapping = {
|
||||
'Speed Golf': 'flip_mode',
|
||||
'Endgame Flip': 'flip_mode',
|
||||
'Knock Penalty': 'knock_penalty',
|
||||
'Knock Bonus': 'knock_bonus',
|
||||
'Super Kings': 'super_kings',
|
||||
'Ten Penny': 'ten_penny',
|
||||
'Lucky Swing': 'lucky_swing',
|
||||
'Eagle Eye': 'eagle_eye',
|
||||
'Underdog': 'underdog_bonus',
|
||||
'Tied Shame': 'tied_shame',
|
||||
'Blackjack': 'blackjack',
|
||||
'Wolfpack': 'wolfpack',
|
||||
'Flip Action': 'flip_as_action',
|
||||
'4 of a Kind': 'four_of_a_kind',
|
||||
'Negative Pairs': 'negative_pairs_keep_value',
|
||||
'One-Eyed Jacks': 'one_eyed_jacks',
|
||||
'Knock Early': 'knock_early',
|
||||
};
|
||||
return mapping[ruleName] || ruleName.toLowerCase().replace(/\s+/g, '_');
|
||||
}
|
||||
```
|
||||
|
||||
### CSS
|
||||
|
||||
```css
|
||||
/* Rule tag base */
|
||||
.rule-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 10px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 12px;
|
||||
font-size: 0.8em;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* Highlighted rule */
|
||||
.rule-tag.rule-highlighted {
|
||||
background: rgba(244, 164, 96, 0.3);
|
||||
box-shadow: 0 0 10px rgba(244, 164, 96, 0.4);
|
||||
animation: rule-pulse 0.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes rule-pulse {
|
||||
0% { transform: scale(1); }
|
||||
50% { transform: scale(1.05); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
/* Message that appears */
|
||||
.rule-message {
|
||||
margin-left: 8px;
|
||||
padding-left: 8px;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.3);
|
||||
font-weight: bold;
|
||||
color: #f4a460;
|
||||
animation: message-fade-in 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes message-fade-in {
|
||||
0% { opacity: 0; transform: translateX(-5px); }
|
||||
100% { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
/* Ensure bar is visible when highlighted */
|
||||
#active-rules-bar:has(.rule-highlighted) {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rule-Specific Triggers
|
||||
|
||||
### Flip Mode (Speed Golf/Endgame)
|
||||
|
||||
```javascript
|
||||
// When player must flip
|
||||
if (this.waitingForFlip && !this.flipIsOptional) {
|
||||
this.highlightRule('flip_mode', 'Flip a face-down card!');
|
||||
}
|
||||
```
|
||||
|
||||
### Knock Penalty/Bonus
|
||||
|
||||
```javascript
|
||||
// When someone triggers final turn
|
||||
if (newState.phase === 'final_turn' && oldState?.phase !== 'final_turn') {
|
||||
if (this.gameState.knock_penalty) {
|
||||
this.highlightRule('knock_penalty', '+10 if beaten!');
|
||||
}
|
||||
if (this.gameState.knock_bonus) {
|
||||
this.highlightRule('knock_bonus', '-5 for going out!');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Negative Pairs
|
||||
|
||||
```javascript
|
||||
// When pair of 2s or Jokers is formed
|
||||
checkForNewPairs(oldState, newState, playerId) {
|
||||
// ... pair detection ...
|
||||
if (nowPaired && this.gameState.negative_pairs_keep_value) {
|
||||
const isNegativePair = cardRank === '2' || cardRank === '★';
|
||||
if (isNegativePair) {
|
||||
this.highlightRule('negative_pairs_keep_value', 'Keeps -4!');
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Score Bonuses (Round End)
|
||||
|
||||
```javascript
|
||||
// In showScoreboard
|
||||
if (this.gameState.underdog_bonus) {
|
||||
const lowestPlayer = findLowest(scores);
|
||||
this.highlightRule('underdog_bonus', `${lowestPlayer} gets -3!`);
|
||||
}
|
||||
|
||||
if (this.gameState.tied_shame) {
|
||||
const ties = findTies(scores);
|
||||
if (ties.length > 0) {
|
||||
this.highlightRule('tied_shame', '+5 for ties!');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
1. **Speed Golf mode** - "Must flip" highlighted when discarding
|
||||
2. **Knock with penalty** - Warning shown
|
||||
3. **Draw Lucky Swing Joker** - "-5" highlighted
|
||||
4. **Blackjack score** - Celebration when 21 → 0
|
||||
5. **No active rules** - No highlights
|
||||
6. **Multiple rules trigger** - All relevant ones highlight
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Rules have data attributes for targeting
|
||||
- [ ] Relevant rule highlights during specific actions
|
||||
- [ ] Highlight message explains the effect
|
||||
- [ ] Highlight auto-fades after duration
|
||||
- [ ] Multiple rules can highlight simultaneously
|
||||
- [ ] Works for all major house rules
|
||||
- [ ] Doesn't interfere with gameplay flow
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. Add `data-rule` attributes to rule tags
|
||||
2. Implement `getRuleKey()` mapping
|
||||
3. Implement `highlightRule()` method
|
||||
4. Add CSS for highlight animation
|
||||
5. Add trigger points for each major rule
|
||||
6. Test with various rule combinations
|
||||
7. Tune timing and messaging
|
||||
|
||||
---
|
||||
|
||||
## Notes for Agent
|
||||
|
||||
- **CSS vs anime.js**: CSS is appropriate for rule tag highlights (simple UI feedback)
|
||||
- Keep highlight messages very short (3-5 words)
|
||||
- Don't highlight on every single action, just key moments
|
||||
- The goal is education, not distraction
|
||||
- Consider: First-time highlight only? (Too complex for V3)
|
||||
- Make sure the bar is visible when highlighting (expand if collapsed)
|
||||
384
docs/v3/V3_15_DISCARD_PILE_HISTORY.md
Normal file
384
docs/v3/V3_15_DISCARD_PILE_HISTORY.md
Normal file
@@ -0,0 +1,384 @@
|
||||
# V3-15: Discard Pile History
|
||||
|
||||
## Overview
|
||||
|
||||
In physical card games, you can see the top few cards of the discard pile fanned out slightly. This provides memory aid and context for recent play. Currently our discard pile shows only the top card.
|
||||
|
||||
**Dependencies:** None
|
||||
**Dependents:** None
|
||||
|
||||
---
|
||||
|
||||
## Goals
|
||||
|
||||
1. Show 2-3 recent discards visually fanned
|
||||
2. Help players track what's been discarded recently
|
||||
3. Subtle visual depth without cluttering
|
||||
4. Optional: expandable full discard view
|
||||
5. Authentic card game feel
|
||||
|
||||
---
|
||||
|
||||
## Current State
|
||||
|
||||
From `app.js` and CSS:
|
||||
```javascript
|
||||
// Only shows the top card
|
||||
updateDiscard(cardData) {
|
||||
this.discard.innerHTML = this.createCardHTML(cardData);
|
||||
}
|
||||
```
|
||||
|
||||
The discard pile is a single card element with no history visualization.
|
||||
|
||||
---
|
||||
|
||||
## Design
|
||||
|
||||
### Visual Treatment
|
||||
|
||||
```
|
||||
Current: With history:
|
||||
┌─────┐ ┌─────┐
|
||||
│ 7 │ │ 7 │ ← Top card (clickable)
|
||||
│ ♥ │ ╱└─────┘
|
||||
└─────┘ ╱ └─────┘ ← Previous (faded, offset)
|
||||
└─────┘ ← Older (more faded)
|
||||
```
|
||||
|
||||
### Fan Layout
|
||||
|
||||
- Top card: Full visibility, normal position
|
||||
- Previous card: Offset 3-4px left and up, 50% opacity
|
||||
- Older card: Offset 6-8px left and up, 25% opacity
|
||||
- Maximum 3 visible cards (performance + clarity)
|
||||
|
||||
---
|
||||
|
||||
## Implementation
|
||||
|
||||
### Track Discard History
|
||||
|
||||
```javascript
|
||||
// In app.js constructor
|
||||
this.discardHistory = [];
|
||||
this.maxVisibleHistory = 3;
|
||||
|
||||
// Update when discard changes
|
||||
updateDiscardHistory(newCard) {
|
||||
if (!newCard) {
|
||||
this.discardHistory = [];
|
||||
return;
|
||||
}
|
||||
|
||||
// Add new card to front
|
||||
this.discardHistory.unshift(newCard);
|
||||
|
||||
// Keep only recent cards
|
||||
if (this.discardHistory.length > this.maxVisibleHistory) {
|
||||
this.discardHistory = this.discardHistory.slice(0, this.maxVisibleHistory);
|
||||
}
|
||||
}
|
||||
|
||||
// Called from state differ or handleMessage
|
||||
onDiscardChange(newCard, oldCard) {
|
||||
// Only add if it's a new card (not initial state)
|
||||
if (oldCard && newCard && oldCard.rank !== newCard.rank) {
|
||||
this.updateDiscardHistory(newCard);
|
||||
} else if (newCard && !oldCard) {
|
||||
this.updateDiscardHistory(newCard);
|
||||
}
|
||||
|
||||
this.renderDiscardPile();
|
||||
}
|
||||
```
|
||||
|
||||
### Render Fanned Pile
|
||||
|
||||
```javascript
|
||||
renderDiscardPile() {
|
||||
const container = this.discard;
|
||||
container.innerHTML = '';
|
||||
|
||||
if (this.discardHistory.length === 0) {
|
||||
container.innerHTML = '<div class="card empty">Empty</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Render from oldest to newest (back to front)
|
||||
const cards = [...this.discardHistory].reverse();
|
||||
|
||||
cards.forEach((cardData, index) => {
|
||||
const reverseIndex = cards.length - 1 - index;
|
||||
const card = this.createDiscardCard(cardData, reverseIndex);
|
||||
container.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
createDiscardCard(cardData, depthIndex) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card discard-card';
|
||||
card.dataset.depth = depthIndex;
|
||||
|
||||
// Only top card is interactive
|
||||
if (depthIndex === 0) {
|
||||
card.classList.add('top-card');
|
||||
card.addEventListener('click', () => this.handleDiscardClick());
|
||||
}
|
||||
|
||||
// Set card content
|
||||
card.innerHTML = this.createCardContentHTML(cardData);
|
||||
|
||||
// Apply offset based on depth
|
||||
const offset = depthIndex * 4;
|
||||
card.style.setProperty('--depth-offset', `${offset}px`);
|
||||
|
||||
return card;
|
||||
}
|
||||
```
|
||||
|
||||
### CSS Styling
|
||||
|
||||
```css
|
||||
/* Discard pile container */
|
||||
#discard {
|
||||
position: relative;
|
||||
width: var(--card-width);
|
||||
height: var(--card-height);
|
||||
}
|
||||
|
||||
/* Stacked discard cards */
|
||||
.discard-card {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transition: transform 0.2s, opacity 0.2s;
|
||||
}
|
||||
|
||||
/* Depth-based styling */
|
||||
.discard-card[data-depth="0"] {
|
||||
z-index: 3;
|
||||
opacity: 1;
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
|
||||
.discard-card[data-depth="1"] {
|
||||
z-index: 2;
|
||||
opacity: 0.5;
|
||||
transform: translate(-4px, -4px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.discard-card[data-depth="2"] {
|
||||
z-index: 1;
|
||||
opacity: 0.25;
|
||||
transform: translate(-8px, -8px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Using CSS variable for dynamic offset */
|
||||
.discard-card:not(.top-card) {
|
||||
transform: translate(
|
||||
calc(var(--depth-offset, 0px) * -1),
|
||||
calc(var(--depth-offset, 0px) * -1)
|
||||
);
|
||||
}
|
||||
|
||||
/* Hover to expand history slightly */
|
||||
#discard:hover .discard-card[data-depth="1"] {
|
||||
opacity: 0.7;
|
||||
transform: translate(-8px, -8px);
|
||||
}
|
||||
|
||||
#discard:hover .discard-card[data-depth="2"] {
|
||||
opacity: 0.4;
|
||||
transform: translate(-16px, -16px);
|
||||
}
|
||||
|
||||
/* Animation when new card is discarded */
|
||||
@keyframes discard-land {
|
||||
0% {
|
||||
transform: translate(0, -20px) scale(1.05);
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.discard-card.top-card.just-landed {
|
||||
animation: discard-land 0.2s ease-out;
|
||||
}
|
||||
|
||||
/* Shift animation for cards moving back */
|
||||
@keyframes shift-back {
|
||||
0% { transform: translate(0, 0); }
|
||||
100% { transform: translate(var(--depth-offset) * -1, var(--depth-offset) * -1); }
|
||||
}
|
||||
```
|
||||
|
||||
### Integration with State Changes
|
||||
|
||||
```javascript
|
||||
// In state-differ.js or wherever discard changes are detected
|
||||
detectDiscardChange(oldState, newState) {
|
||||
const oldDiscard = oldState?.discard_pile?.[oldState.discard_pile.length - 1];
|
||||
const newDiscard = newState?.discard_pile?.[newState.discard_pile.length - 1];
|
||||
|
||||
if (this.cardsDifferent(oldDiscard, newDiscard)) {
|
||||
return {
|
||||
type: 'discard_change',
|
||||
oldCard: oldDiscard,
|
||||
newCard: newDiscard
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle the change
|
||||
handleDiscardChange(change) {
|
||||
this.onDiscardChange(change.newCard, change.oldCard);
|
||||
}
|
||||
```
|
||||
|
||||
### Round/Game Reset
|
||||
|
||||
```javascript
|
||||
// Clear history at start of new round
|
||||
onNewRound() {
|
||||
this.discardHistory = [];
|
||||
this.renderDiscardPile();
|
||||
}
|
||||
|
||||
// Or when deck is reshuffled (if that's a game mechanic)
|
||||
onDeckReshuffle() {
|
||||
this.discardHistory = [];
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Optional: Expandable Full History
|
||||
|
||||
For players who want to see all discards:
|
||||
|
||||
```javascript
|
||||
// Toggle full discard view
|
||||
showDiscardHistory() {
|
||||
const modal = document.getElementById('discard-history-modal');
|
||||
modal.innerHTML = this.buildFullDiscardView();
|
||||
modal.classList.add('visible');
|
||||
}
|
||||
|
||||
buildFullDiscardView() {
|
||||
// Show all cards in discard pile from game state
|
||||
const discards = this.gameState.discard_pile || [];
|
||||
return discards.map(card =>
|
||||
`<div class="card mini">${this.createCardContentHTML(card)}</div>`
|
||||
).join('');
|
||||
}
|
||||
```
|
||||
|
||||
```css
|
||||
#discard-history-modal {
|
||||
position: fixed;
|
||||
bottom: 80px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(26, 26, 46, 0.95);
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
display: none;
|
||||
max-width: 90vw;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
#discard-history-modal.visible {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
#discard-history-modal .card.mini {
|
||||
width: 40px;
|
||||
height: 56px;
|
||||
font-size: 0.7em;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Mobile Considerations
|
||||
|
||||
On smaller screens, reduce the fan offset:
|
||||
|
||||
```css
|
||||
@media (max-width: 600px) {
|
||||
.discard-card[data-depth="1"] {
|
||||
transform: translate(-2px, -2px);
|
||||
}
|
||||
.discard-card[data-depth="2"] {
|
||||
transform: translate(-4px, -4px);
|
||||
}
|
||||
|
||||
/* Skip hover expansion on touch */
|
||||
#discard:hover .discard-card {
|
||||
transform: translate(
|
||||
calc(var(--depth-offset, 0px) * -0.5),
|
||||
calc(var(--depth-offset, 0px) * -0.5)
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
1. **First discard** - Single card shows
|
||||
2. **Second discard** - Two cards fanned
|
||||
3. **Third+ discards** - Three cards max, oldest drops off
|
||||
4. **New round** - History clears
|
||||
5. **Draw from discard** - Top card removed, others shift forward
|
||||
6. **Hover interaction** - Cards fan out slightly more
|
||||
7. **Mobile view** - Smaller offset, still visible
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Recent 2-3 discards visible in fanned pile
|
||||
- [ ] Older cards progressively more faded
|
||||
- [ ] Only top card is interactive
|
||||
- [ ] History updates smoothly when cards change
|
||||
- [ ] History clears on new round
|
||||
- [ ] Hover expands fan slightly (desktop)
|
||||
- [ ] Works on mobile with smaller offsets
|
||||
- [ ] Optional: expandable full history view
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. Add `discardHistory` array tracking
|
||||
2. Implement `renderDiscardPile()` method
|
||||
3. Add CSS for fanned stack
|
||||
4. Integrate with state change detection
|
||||
5. Add round reset handling
|
||||
6. Add hover expansion effect
|
||||
7. Test on various screen sizes
|
||||
8. Optional: Add full history modal
|
||||
|
||||
---
|
||||
|
||||
## Notes for Agent
|
||||
|
||||
- **CSS vs anime.js**: CSS is appropriate for static fan layout. If adding "landing" animation for new discards, use anime.js.
|
||||
- Keep visible history small (3 cards max) for clarity
|
||||
- The fan offset should be subtle, not dramatic
|
||||
- History helps players remember what was recently played
|
||||
- Consider: Should drawing from discard affect history display?
|
||||
- Mobile: smaller offset but still visible
|
||||
- Don't overcomplicate - this is a nice-to-have feature
|
||||
632
docs/v3/V3_16_REALISTIC_CARD_SOUNDS.md
Normal file
632
docs/v3/V3_16_REALISTIC_CARD_SOUNDS.md
Normal file
@@ -0,0 +1,632 @@
|
||||
# V3-16: Realistic Card Sounds
|
||||
|
||||
## Overview
|
||||
|
||||
Current sounds use simple Web Audio oscillator beeps. Real card games have distinct sounds: shuffling, dealing, flipping, placing. This feature improves audio feedback to feel more physical.
|
||||
|
||||
**Dependencies:** None
|
||||
**Dependents:** None
|
||||
|
||||
---
|
||||
|
||||
## Goals
|
||||
|
||||
1. Distinct sounds for each card action
|
||||
2. Variation to avoid repetition fatigue
|
||||
3. Physical "card" quality (paper, snap, thunk)
|
||||
4. Volume control and mute option
|
||||
5. Performant (Web Audio API synthesis or small samples)
|
||||
|
||||
---
|
||||
|
||||
## Current State
|
||||
|
||||
From `app.js` and `card-animations.js`:
|
||||
```javascript
|
||||
// app.js has the main playSound method
|
||||
playSound(type) {
|
||||
const ctx = new AudioContext();
|
||||
const osc = ctx.createOscillator();
|
||||
// Simple beep tones for different actions
|
||||
}
|
||||
|
||||
// CardAnimations routes to app.js via window.game.playSound()
|
||||
playSound(type) {
|
||||
if (window.game && typeof window.game.playSound === 'function') {
|
||||
window.game.playSound(type);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Sounds are functional but feel digital/arcade rather than physical. The existing sound types include:
|
||||
- `card` - general card movement
|
||||
- `flip` - card flip
|
||||
- `shuffle` - deck shuffle
|
||||
|
||||
---
|
||||
|
||||
## Design
|
||||
|
||||
### Sound Palette
|
||||
|
||||
| Action | Sound Character | Notes |
|
||||
|--------|-----------------|-------|
|
||||
| Card flip | Sharp snap | Paper/cardboard flip |
|
||||
| Card place | Soft thunk | Card landing on table |
|
||||
| Card draw | Slide + lift | Taking from pile |
|
||||
| Card shuffle | Multiple snaps | Riffle texture |
|
||||
| Pair formed | Satisfying click | Success feedback |
|
||||
| Knock | Table tap | Knuckle on table |
|
||||
| Deal | Quick sequence | Multiple snaps |
|
||||
| Turn notification | Subtle chime | Alert without jarring |
|
||||
| Round end | Flourish | Resolution feel |
|
||||
|
||||
### Synthesis vs Samples
|
||||
|
||||
**Option A: Synthesized sounds (current approach, enhanced)**
|
||||
- No external files needed
|
||||
- Smaller bundle size
|
||||
- More control over variations
|
||||
- Can sound artificial
|
||||
|
||||
**Option B: Audio samples**
|
||||
- More realistic
|
||||
- Larger file size (small samples ~5-10KB each)
|
||||
- Need to handle loading
|
||||
- Can use Web Audio for variations
|
||||
|
||||
**Recommendation:** Hybrid - synthesized base with sample layering for key sounds.
|
||||
|
||||
---
|
||||
|
||||
## Implementation
|
||||
|
||||
### Enhanced Sound System
|
||||
|
||||
```javascript
|
||||
// sound-system.js
|
||||
|
||||
class SoundSystem {
|
||||
constructor() {
|
||||
this.ctx = null;
|
||||
this.enabled = true;
|
||||
this.volume = 0.5;
|
||||
this.samples = {};
|
||||
this.initialized = false;
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (this.initialized) return;
|
||||
|
||||
this.ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
this.masterGain = this.ctx.createGain();
|
||||
this.masterGain.connect(this.ctx.destination);
|
||||
this.masterGain.gain.value = this.volume;
|
||||
|
||||
// Load settings
|
||||
this.enabled = localStorage.getItem('soundEnabled') !== 'false';
|
||||
this.volume = parseFloat(localStorage.getItem('soundVolume') || '0.5');
|
||||
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
setVolume(value) {
|
||||
this.volume = Math.max(0, Math.min(1, value));
|
||||
if (this.masterGain) {
|
||||
this.masterGain.gain.value = this.volume;
|
||||
}
|
||||
localStorage.setItem('soundVolume', this.volume.toString());
|
||||
}
|
||||
|
||||
setEnabled(enabled) {
|
||||
this.enabled = enabled;
|
||||
localStorage.setItem('soundEnabled', enabled.toString());
|
||||
}
|
||||
|
||||
async play(type) {
|
||||
if (!this.enabled) return;
|
||||
if (!this.ctx || this.ctx.state === 'suspended') {
|
||||
await this.ctx?.resume();
|
||||
}
|
||||
|
||||
const now = this.ctx.currentTime;
|
||||
|
||||
switch (type) {
|
||||
case 'flip':
|
||||
this.playFlip(now);
|
||||
break;
|
||||
case 'place':
|
||||
case 'discard':
|
||||
this.playPlace(now);
|
||||
break;
|
||||
case 'draw-deck':
|
||||
this.playDrawDeck(now);
|
||||
break;
|
||||
case 'draw-discard':
|
||||
this.playDrawDiscard(now);
|
||||
break;
|
||||
case 'pair':
|
||||
this.playPair(now);
|
||||
break;
|
||||
case 'knock':
|
||||
this.playKnock(now);
|
||||
break;
|
||||
case 'deal':
|
||||
this.playDeal(now);
|
||||
break;
|
||||
case 'shuffle':
|
||||
this.playShuffle(now);
|
||||
break;
|
||||
case 'turn':
|
||||
this.playTurn(now);
|
||||
break;
|
||||
case 'round-end':
|
||||
this.playRoundEnd(now);
|
||||
break;
|
||||
case 'win':
|
||||
this.playWin(now);
|
||||
break;
|
||||
default:
|
||||
this.playGeneric(now);
|
||||
}
|
||||
}
|
||||
|
||||
// Card flip - sharp snap
|
||||
playFlip(now) {
|
||||
// White noise burst for paper snap
|
||||
const noise = this.createNoiseBurst(0.03, 0.02);
|
||||
|
||||
// High frequency click
|
||||
const click = this.ctx.createOscillator();
|
||||
const clickGain = this.ctx.createGain();
|
||||
click.connect(clickGain);
|
||||
clickGain.connect(this.masterGain);
|
||||
|
||||
click.type = 'square';
|
||||
click.frequency.setValueAtTime(2000 + Math.random() * 500, now);
|
||||
click.frequency.exponentialRampToValueAtTime(800, now + 0.02);
|
||||
|
||||
clickGain.gain.setValueAtTime(0.15, now);
|
||||
clickGain.gain.exponentialRampToValueAtTime(0.001, now + 0.05);
|
||||
|
||||
click.start(now);
|
||||
click.stop(now + 0.05);
|
||||
}
|
||||
|
||||
// Card place - soft thunk
|
||||
playPlace(now) {
|
||||
// Low thump
|
||||
const thump = this.ctx.createOscillator();
|
||||
const thumpGain = this.ctx.createGain();
|
||||
thump.connect(thumpGain);
|
||||
thumpGain.connect(this.masterGain);
|
||||
|
||||
thump.type = 'sine';
|
||||
thump.frequency.setValueAtTime(150 + Math.random() * 30, now);
|
||||
thump.frequency.exponentialRampToValueAtTime(80, now + 0.08);
|
||||
|
||||
thumpGain.gain.setValueAtTime(0.2, now);
|
||||
thumpGain.gain.exponentialRampToValueAtTime(0.001, now + 0.1);
|
||||
|
||||
thump.start(now);
|
||||
thump.stop(now + 0.1);
|
||||
|
||||
// Soft noise
|
||||
this.createNoiseBurst(0.02, 0.04);
|
||||
}
|
||||
|
||||
// Draw from deck - mysterious slide + flip
|
||||
playDrawDeck(now) {
|
||||
// Slide sound
|
||||
const slide = this.ctx.createOscillator();
|
||||
const slideGain = this.ctx.createGain();
|
||||
slide.connect(slideGain);
|
||||
slideGain.connect(this.masterGain);
|
||||
|
||||
slide.type = 'triangle';
|
||||
slide.frequency.setValueAtTime(200, now);
|
||||
slide.frequency.exponentialRampToValueAtTime(400, now + 0.1);
|
||||
|
||||
slideGain.gain.setValueAtTime(0.08, now);
|
||||
slideGain.gain.exponentialRampToValueAtTime(0.001, now + 0.12);
|
||||
|
||||
slide.start(now);
|
||||
slide.stop(now + 0.12);
|
||||
|
||||
// Delayed flip
|
||||
setTimeout(() => this.playFlip(this.ctx.currentTime), 150);
|
||||
}
|
||||
|
||||
// Draw from discard - quick grab
|
||||
playDrawDiscard(now) {
|
||||
const grab = this.ctx.createOscillator();
|
||||
const grabGain = this.ctx.createGain();
|
||||
grab.connect(grabGain);
|
||||
grabGain.connect(this.masterGain);
|
||||
|
||||
grab.type = 'square';
|
||||
grab.frequency.setValueAtTime(600, now);
|
||||
grab.frequency.exponentialRampToValueAtTime(300, now + 0.04);
|
||||
|
||||
grabGain.gain.setValueAtTime(0.1, now);
|
||||
grabGain.gain.exponentialRampToValueAtTime(0.001, now + 0.05);
|
||||
|
||||
grab.start(now);
|
||||
grab.stop(now + 0.05);
|
||||
}
|
||||
|
||||
// Pair formed - satisfying double click
|
||||
playPair(now) {
|
||||
// Two quick clicks
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const click = this.ctx.createOscillator();
|
||||
const gain = this.ctx.createGain();
|
||||
click.connect(gain);
|
||||
gain.connect(this.masterGain);
|
||||
|
||||
click.type = 'triangle';
|
||||
click.frequency.setValueAtTime(800 + i * 200, now + i * 0.08);
|
||||
|
||||
gain.gain.setValueAtTime(0.15, now + i * 0.08);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + i * 0.08 + 0.06);
|
||||
|
||||
click.start(now + i * 0.08);
|
||||
click.stop(now + i * 0.08 + 0.06);
|
||||
}
|
||||
}
|
||||
|
||||
// Knock - table tap
|
||||
playKnock(now) {
|
||||
// Low woody thunk
|
||||
const knock = this.ctx.createOscillator();
|
||||
const knockGain = this.ctx.createGain();
|
||||
knock.connect(knockGain);
|
||||
knockGain.connect(this.masterGain);
|
||||
|
||||
knock.type = 'sine';
|
||||
knock.frequency.setValueAtTime(120, now);
|
||||
knock.frequency.exponentialRampToValueAtTime(60, now + 0.1);
|
||||
|
||||
knockGain.gain.setValueAtTime(0.3, now);
|
||||
knockGain.gain.exponentialRampToValueAtTime(0.001, now + 0.15);
|
||||
|
||||
knock.start(now);
|
||||
knock.stop(now + 0.15);
|
||||
|
||||
// Resonance
|
||||
const resonance = this.ctx.createOscillator();
|
||||
const resGain = this.ctx.createGain();
|
||||
resonance.connect(resGain);
|
||||
resGain.connect(this.masterGain);
|
||||
|
||||
resonance.type = 'triangle';
|
||||
resonance.frequency.setValueAtTime(180, now);
|
||||
|
||||
resGain.gain.setValueAtTime(0.1, now);
|
||||
resGain.gain.exponentialRampToValueAtTime(0.001, now + 0.2);
|
||||
|
||||
resonance.start(now);
|
||||
resonance.stop(now + 0.2);
|
||||
}
|
||||
|
||||
// Deal - rapid card sequence
|
||||
playDeal(now) {
|
||||
// Multiple quick snaps
|
||||
for (let i = 0; i < 4; i++) {
|
||||
setTimeout(() => {
|
||||
const snap = this.ctx.createOscillator();
|
||||
const gain = this.ctx.createGain();
|
||||
snap.connect(gain);
|
||||
gain.connect(this.masterGain);
|
||||
|
||||
snap.type = 'square';
|
||||
snap.frequency.setValueAtTime(1500 + Math.random() * 300, this.ctx.currentTime);
|
||||
|
||||
gain.gain.setValueAtTime(0.08, this.ctx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.03);
|
||||
|
||||
snap.start(this.ctx.currentTime);
|
||||
snap.stop(this.ctx.currentTime + 0.03);
|
||||
}, i * 80);
|
||||
}
|
||||
}
|
||||
|
||||
// Shuffle - riffle texture
|
||||
playShuffle(now) {
|
||||
// Many tiny clicks with frequency variation
|
||||
for (let i = 0; i < 12; i++) {
|
||||
setTimeout(() => {
|
||||
this.createNoiseBurst(0.01, 0.01 + Math.random() * 0.02);
|
||||
}, i * 40 + Math.random() * 20);
|
||||
}
|
||||
}
|
||||
|
||||
// Turn notification - gentle chime
|
||||
playTurn(now) {
|
||||
const freqs = [523, 659]; // C5, E5
|
||||
|
||||
freqs.forEach((freq, i) => {
|
||||
const osc = this.ctx.createOscillator();
|
||||
const gain = this.ctx.createGain();
|
||||
osc.connect(gain);
|
||||
gain.connect(this.masterGain);
|
||||
|
||||
osc.type = 'sine';
|
||||
osc.frequency.setValueAtTime(freq, now + i * 0.1);
|
||||
|
||||
gain.gain.setValueAtTime(0.1, now + i * 0.1);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + i * 0.1 + 0.3);
|
||||
|
||||
osc.start(now + i * 0.1);
|
||||
osc.stop(now + i * 0.1 + 0.3);
|
||||
});
|
||||
}
|
||||
|
||||
// Round end - resolution flourish
|
||||
playRoundEnd(now) {
|
||||
const freqs = [392, 494, 587, 784]; // G4, B4, D5, G5
|
||||
|
||||
freqs.forEach((freq, i) => {
|
||||
const osc = this.ctx.createOscillator();
|
||||
const gain = this.ctx.createGain();
|
||||
osc.connect(gain);
|
||||
gain.connect(this.masterGain);
|
||||
|
||||
osc.type = 'triangle';
|
||||
osc.frequency.setValueAtTime(freq, now + i * 0.08);
|
||||
|
||||
gain.gain.setValueAtTime(0.12, now + i * 0.08);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + i * 0.08 + 0.4);
|
||||
|
||||
osc.start(now + i * 0.08);
|
||||
osc.stop(now + i * 0.08 + 0.4);
|
||||
});
|
||||
}
|
||||
|
||||
// Win celebration
|
||||
playWin(now) {
|
||||
const freqs = [523, 659, 784, 1047]; // C5, E5, G5, C6
|
||||
|
||||
freqs.forEach((freq, i) => {
|
||||
const osc = this.ctx.createOscillator();
|
||||
const gain = this.ctx.createGain();
|
||||
osc.connect(gain);
|
||||
gain.connect(this.masterGain);
|
||||
|
||||
osc.type = 'sine';
|
||||
osc.frequency.setValueAtTime(freq, now + i * 0.12);
|
||||
|
||||
gain.gain.setValueAtTime(0.15, now + i * 0.12);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + i * 0.12 + 0.5);
|
||||
|
||||
osc.start(now + i * 0.12);
|
||||
osc.stop(now + i * 0.12 + 0.5);
|
||||
});
|
||||
}
|
||||
|
||||
// Generic click
|
||||
playGeneric(now) {
|
||||
const osc = this.ctx.createOscillator();
|
||||
const gain = this.ctx.createGain();
|
||||
osc.connect(gain);
|
||||
gain.connect(this.masterGain);
|
||||
|
||||
osc.type = 'triangle';
|
||||
osc.frequency.setValueAtTime(440, now);
|
||||
|
||||
gain.gain.setValueAtTime(0.1, now);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.1);
|
||||
|
||||
osc.start(now);
|
||||
osc.stop(now + 0.1);
|
||||
}
|
||||
|
||||
// Helper: Create white noise burst for paper/snap sounds
|
||||
createNoiseBurst(volume, duration) {
|
||||
const bufferSize = this.ctx.sampleRate * duration;
|
||||
const buffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate);
|
||||
const output = buffer.getChannelData(0);
|
||||
|
||||
for (let i = 0; i < bufferSize; i++) {
|
||||
output[i] = Math.random() * 2 - 1;
|
||||
}
|
||||
|
||||
const noise = this.ctx.createBufferSource();
|
||||
noise.buffer = buffer;
|
||||
|
||||
const noiseGain = this.ctx.createGain();
|
||||
noise.connect(noiseGain);
|
||||
noiseGain.connect(this.masterGain);
|
||||
|
||||
const now = this.ctx.currentTime;
|
||||
noiseGain.gain.setValueAtTime(volume, now);
|
||||
noiseGain.gain.exponentialRampToValueAtTime(0.001, now + duration);
|
||||
|
||||
noise.start(now);
|
||||
noise.stop(now + duration);
|
||||
|
||||
return noise;
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton
|
||||
const soundSystem = new SoundSystem();
|
||||
export default soundSystem;
|
||||
```
|
||||
|
||||
### Integration with App
|
||||
|
||||
The SoundSystem can replace the existing `playSound()` method in `app.js`:
|
||||
|
||||
```javascript
|
||||
// In app.js - replace the existing playSound method
|
||||
// Option 1: Direct integration (no import needed for non-module setup)
|
||||
|
||||
// Create global instance
|
||||
window.soundSystem = new SoundSystem();
|
||||
|
||||
// Initialize on first interaction
|
||||
document.addEventListener('click', async () => {
|
||||
await window.soundSystem.init();
|
||||
}, { once: true });
|
||||
|
||||
// Replace existing playSound calls
|
||||
playSound(type) {
|
||||
window.soundSystem.play(type);
|
||||
}
|
||||
|
||||
// CardAnimations already routes through window.game.playSound()
|
||||
// so no changes needed in card-animations.js
|
||||
```
|
||||
|
||||
### Sound Variation
|
||||
|
||||
Add slight randomization to prevent repetitive sounds:
|
||||
|
||||
```javascript
|
||||
playFlip(now) {
|
||||
// Random variation
|
||||
const pitchVariation = 1 + (Math.random() - 0.5) * 0.1;
|
||||
const volumeVariation = 1 + (Math.random() - 0.5) * 0.2;
|
||||
|
||||
// Apply to sound...
|
||||
click.frequency.setValueAtTime(2000 * pitchVariation, now);
|
||||
clickGain.gain.setValueAtTime(0.15 * volumeVariation, now);
|
||||
}
|
||||
```
|
||||
|
||||
### Settings UI
|
||||
|
||||
```javascript
|
||||
// In settings panel
|
||||
renderSoundSettings() {
|
||||
return `
|
||||
<div class="setting-group">
|
||||
<label class="setting-toggle">
|
||||
<input type="checkbox" id="sound-enabled"
|
||||
${soundSystem.enabled ? 'checked' : ''}>
|
||||
<span>Sound Effects</span>
|
||||
</label>
|
||||
|
||||
<label class="setting-slider" ${!soundSystem.enabled ? 'style="opacity: 0.5"' : ''}>
|
||||
<span>Volume</span>
|
||||
<input type="range" id="sound-volume"
|
||||
min="0" max="1" step="0.1"
|
||||
value="${soundSystem.volume}"
|
||||
${!soundSystem.enabled ? 'disabled' : ''}>
|
||||
</label>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Event handlers
|
||||
document.getElementById('sound-enabled').addEventListener('change', (e) => {
|
||||
soundSystem.setEnabled(e.target.checked);
|
||||
});
|
||||
|
||||
document.getElementById('sound-volume').addEventListener('input', (e) => {
|
||||
soundSystem.setVolume(parseFloat(e.target.value));
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CSS for Settings
|
||||
|
||||
```css
|
||||
.setting-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.setting-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.setting-slider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.setting-slider input[type="range"] {
|
||||
flex: 1;
|
||||
-webkit-appearance: none;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.setting-slider input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: #f4a460;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
1. **Card flip** - Sharp snap sound
|
||||
2. **Card place/discard** - Soft thunk
|
||||
3. **Draw from deck** - Slide + flip sequence
|
||||
4. **Draw from discard** - Quick grab
|
||||
5. **Pair formed** - Double click satisfaction
|
||||
6. **Knock** - Table tap
|
||||
7. **Deal sequence** - Rapid snaps
|
||||
8. **Volume control** - Adjusts all sounds
|
||||
9. **Mute toggle** - Silences all sounds
|
||||
10. **Settings persist** - Reload maintains preferences
|
||||
11. **First interaction** - AudioContext initializes
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Distinct sounds for each card action
|
||||
- [ ] Sounds feel physical (not arcade beeps)
|
||||
- [ ] Variation prevents repetition fatigue
|
||||
- [ ] Volume slider works
|
||||
- [ ] Mute toggle works
|
||||
- [ ] Settings persist in localStorage
|
||||
- [ ] AudioContext handles browser restrictions
|
||||
- [ ] No sound glitches or overlaps
|
||||
- [ ] Performant (no audio lag)
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. Create SoundSystem class with basic structure
|
||||
2. Implement individual sound methods
|
||||
3. Add noise burst helper for paper sounds
|
||||
4. Add volume/enabled controls
|
||||
5. Integrate with existing playSound calls
|
||||
6. Add variation to prevent repetition
|
||||
7. Add settings UI
|
||||
8. Test on various browsers
|
||||
9. Fine-tune sound character
|
||||
|
||||
---
|
||||
|
||||
## Notes for Agent
|
||||
|
||||
- Replaces existing `playSound()` method in `app.js`
|
||||
- CardAnimations already routes through `window.game.playSound()` - no changes needed there
|
||||
- Web Audio API has good browser support
|
||||
- AudioContext must be created after user interaction
|
||||
- Noise bursts add realistic texture to card sounds
|
||||
- Keep sounds short (<200ms) to stay responsive
|
||||
- Volume variation and pitch variation prevent fatigue
|
||||
- Test with headphones - sounds should be pleasant, not jarring
|
||||
- Consider: different sound "themes"? (Classic, Minimal, Fun)
|
||||
- Mobile: test performance impact of audio synthesis
|
||||
- Settings should persist in localStorage
|
||||
117
docs/v3/V3_17_MOBILE_PORTRAIT_LAYOUT.md
Normal file
117
docs/v3/V3_17_MOBILE_PORTRAIT_LAYOUT.md
Normal file
@@ -0,0 +1,117 @@
|
||||
# V3.17: Mobile Portrait Layout
|
||||
|
||||
**Version:** 3.1.1
|
||||
**Commits:** `4fcdf13`, `fb3bd53`
|
||||
|
||||
## Overview
|
||||
|
||||
Full mobile portrait layout for phones, triggered by JS `matchMedia` on narrow portrait screens (`max-width: 500px`, `orientation: portrait`). The desktop layout is completely untouched — all mobile rules are scoped under `body.mobile-portrait`.
|
||||
|
||||
## Key Features
|
||||
|
||||
### Responsive Game Layout
|
||||
- Viewport fills 100dvh with no scroll; `overscroll-behavior: contain` prevents pull-to-refresh
|
||||
- Game screen uses flexbox column: compact header → opponents row → player row → bottom bar
|
||||
- Safe-area insets respected for notched devices (`env(safe-area-inset-top/bottom)`)
|
||||
|
||||
### Compact Header
|
||||
- Single-row header with reduced font sizes (0.75rem) and tight gaps
|
||||
- Non-essential items hidden on mobile: username display, logout button, active rules bar
|
||||
- Status message, round info, final turn badge, and leave button all use `white-space: nowrap` with ellipsis overflow
|
||||
|
||||
### Opponent Cards
|
||||
- Flat horizontal strip (no arch rotation) with horizontal scroll for 4+ opponents
|
||||
- Cards scaled to 32x45px with 0.6rem font (26x36px on short screens)
|
||||
- Dealer chip scaled from 38px to 20px diameter to fit compact opponent areas
|
||||
- Showing score badge sized proportionally
|
||||
|
||||
### Deck/Discard Area
|
||||
- Deck and discard cards match player card size (72x101px) for visual consistency
|
||||
- Held card floating matches player card size with proportional font scaling
|
||||
|
||||
### Player Cards
|
||||
- Fixed 72x101px cards with 1.5rem font in 3-column grid
|
||||
- 60x84px with 1.3rem font on short screens (max-height: 600px)
|
||||
- Font size set inline by `card-manager.js` proportional to card width (0.35x ratio on mobile)
|
||||
|
||||
### Side Panels as Bottom Drawers
|
||||
- Standings and scoreboard panels slide up as bottom drawers from a mobile bottom bar
|
||||
- Drawer backdrop overlay with tap-to-dismiss
|
||||
- Drag handle visual indicator on each drawer
|
||||
- Drawers auto-close on screen change or layout change back to desktop
|
||||
|
||||
### Short Screen Fallback
|
||||
- `@media (max-height: 600px)` reduces all card sizes, gaps, and padding
|
||||
- Opponent cards: 26x36px, deck/discard: 60x84px, player cards: 60x84px
|
||||
|
||||
## Animation Fixes
|
||||
|
||||
### Deal Animation Guard
|
||||
- `renderGame()` returns early when `dealAnimationInProgress` is true
|
||||
- Prevents WebSocket state updates from destroying card slot DOM elements mid-deal animation
|
||||
- Cards were piling up at (0,0) because `getCardSlotRect()` read stale/null positions after `innerHTML = ''`
|
||||
|
||||
### Animation Overlay Card Sizing
|
||||
- **Root cause:** Base `.card` CSS (`width: clamp(65px, 5.5vw, 100px)`) was leaking into animation overlay elements (`.draw-anim-front.card`), overriding the intended `width: 100%` inherited from the overlay container
|
||||
- **Effect:** Opponent flip overlays appeared at 65px instead of 32px (too big); deck/discard draw overlays appeared at 65px instead of 72px (too small)
|
||||
- **Fix:** Added `!important` to `.draw-anim-front/.draw-anim-back` `width` and `height` rules to ensure animation overlays always match their parent container's inline dimensions from JavaScript
|
||||
|
||||
### Opponent Swap Held Card Sizing
|
||||
- `fireSwapAnimation()` now passes a `heldRect` sized to match the opponent card (32px) positioned at the holding location, instead of defaulting to deck dimensions (72px)
|
||||
- The traveling held card no longer appears oversized relative to opponent cards during the swap arc
|
||||
|
||||
### Font Size Consistency
|
||||
- `cardFontSize()` helper in `CardAnimations` uses 0.35x width ratio on mobile (vs 0.5x desktop)
|
||||
- Applied consistently across all animation paths: `createAnimCard`, `createCardFromData`, and arc swap font transitions
|
||||
- Held card floating gets inline font-size scaled to card width on mobile
|
||||
|
||||
## CSS Architecture
|
||||
|
||||
All mobile rules use the `body.mobile-portrait` scope:
|
||||
|
||||
```css
|
||||
/* Applied by JS matchMedia, not CSS media query */
|
||||
body.mobile-portrait .selector { ... }
|
||||
|
||||
/* Short screen fallback uses both */
|
||||
@media (max-height: 600px) {
|
||||
body.mobile-portrait .selector { ... }
|
||||
}
|
||||
```
|
||||
|
||||
Card sizing uses `!important` to override base `.card` clamp values:
|
||||
```css
|
||||
body.mobile-portrait .opponent-area .card {
|
||||
width: 32px !important;
|
||||
height: 45px !important;
|
||||
}
|
||||
```
|
||||
|
||||
Animation overlays use `!important` to override base `.card` leaking:
|
||||
```css
|
||||
.draw-anim-front,
|
||||
.draw-anim-back {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
```
|
||||
|
||||
## Files Modified
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `client/style.css` | ~470 lines of mobile portrait CSS added at end of file |
|
||||
| `client/app.js` | Mobile detection, drawer management, `renderGame()` guard, swap heldRect sizing, held card font scaling |
|
||||
| `client/card-animations.js` | `cardFontSize()` helper, consistent font scaling across all animation paths |
|
||||
| `client/card-manager.js` | Inline font-size on mobile for `updateCardElement()` |
|
||||
| `client/index.html` | Mobile bottom bar, drawer backdrop, viewport-fit=cover |
|
||||
|
||||
## Testing
|
||||
|
||||
- **Desktop:** No visual changes — all rules scoped under `body.mobile-portrait`
|
||||
- **Mobile portrait:** Verify game fits 100dvh, no scroll, cards properly sized
|
||||
- **Deal animation:** Cards fly to correct grid positions (not piling up)
|
||||
- **Draw/discard:** Animation overlay matches source card size
|
||||
- **Opponent swap:** Flip and arc animations use opponent card dimensions
|
||||
- **Short screens (iPhone SE):** All elements fit with reduced sizes
|
||||
- **Orientation change:** Layout switches cleanly between mobile and desktop
|
||||
276
docs/v3/refactor-ai.md
Normal file
276
docs/v3/refactor-ai.md
Normal file
@@ -0,0 +1,276 @@
|
||||
# Plan 2: ai.py Refactor
|
||||
|
||||
## Overview
|
||||
|
||||
`ai.py` is 1,978 lines with a single function (`choose_swap_or_discard`) at **666 lines** and cyclomatic complexity 50+. The goal is to decompose it into testable, understandable pieces without changing any AI behavior.
|
||||
|
||||
Key constraint: **AI behavior must remain identical.** This is pure structural refactoring. We can validate with `python server/simulate.py 500` before and after - stats should match within normal variance.
|
||||
|
||||
---
|
||||
|
||||
## The Problem Functions
|
||||
|
||||
| Function | Lines | What It Does |
|
||||
|----------|-------|-------------|
|
||||
| `choose_swap_or_discard()` | ~666 | Decides which position (0-5) to swap drawn card into, or None to discard |
|
||||
| `calculate_swap_score()` | ~240 | Scores a single position for swapping |
|
||||
| `should_take_discard()` | ~160 | Decides whether to take from discard pile |
|
||||
| `process_cpu_turn()` | ~240 | Orchestrates a full CPU turn with timing |
|
||||
|
||||
---
|
||||
|
||||
## Refactoring Plan
|
||||
|
||||
### Step 1: Extract Named Constants
|
||||
|
||||
Create section at top of `ai.py` (or a separate `ai_constants.py` if preferred):
|
||||
|
||||
```python
|
||||
# =============================================================================
|
||||
# AI Decision Constants
|
||||
# =============================================================================
|
||||
|
||||
# Expected value of an unknown (face-down) card, based on deck distribution
|
||||
EXPECTED_HIDDEN_VALUE = 4.5
|
||||
|
||||
# Pessimistic estimate for hidden cards (used in go-out safety checks)
|
||||
PESSIMISTIC_HIDDEN_VALUE = 6.0
|
||||
|
||||
# Conservative estimate (used by conservative personality)
|
||||
CONSERVATIVE_HIDDEN_VALUE = 2.5
|
||||
|
||||
# Cards at or above this value should never be swapped into unknown positions
|
||||
HIGH_CARD_THRESHOLD = 8
|
||||
|
||||
# Maximum card value for unpredictability swaps
|
||||
UNPREDICTABLE_MAX_VALUE = 7
|
||||
|
||||
# Pair potential discount when adjacent card matches
|
||||
PAIR_POTENTIAL_DISCOUNT = 0.25
|
||||
|
||||
# Blackjack target score
|
||||
BLACKJACK_TARGET = 21
|
||||
|
||||
# Base acceptable score range for go-out decisions
|
||||
GO_OUT_SCORE_BASE = 12
|
||||
GO_OUT_SCORE_MAX = 20
|
||||
```
|
||||
|
||||
**Locations to update:** ~30 magic number sites across the file. Each becomes a named reference.
|
||||
|
||||
### Step 2: Extract Column/Pair Utility Functions
|
||||
|
||||
The "iterate columns, check pairs" pattern appears 8+ times. Create shared utilities:
|
||||
|
||||
```python
|
||||
def iter_columns(player: Player):
|
||||
"""Yield (col_index, top_idx, bot_idx, top_card, bot_card) for each column."""
|
||||
for col in range(3):
|
||||
top_idx = col
|
||||
bot_idx = col + 3
|
||||
yield col, top_idx, bot_idx, player.cards[top_idx], player.cards[bot_idx]
|
||||
|
||||
|
||||
def project_score(player: Player, swap_pos: int, new_card: Card, options: GameOptions) -> int:
|
||||
"""Calculate what the player's score would be if new_card were swapped into swap_pos.
|
||||
|
||||
Handles pair cancellation correctly. Used by multiple decision paths.
|
||||
"""
|
||||
total = 0
|
||||
for col, top_idx, bot_idx, top_card, bot_card in iter_columns(player):
|
||||
# Substitute the new card if it's in this column
|
||||
effective_top = new_card if top_idx == swap_pos else top_card
|
||||
effective_bot = new_card if bot_idx == swap_pos else bot_card
|
||||
|
||||
if effective_top.rank == effective_bot.rank:
|
||||
# Pair cancels (with house rule exceptions)
|
||||
continue
|
||||
total += get_ai_card_value(effective_top, options)
|
||||
total += get_ai_card_value(effective_bot, options)
|
||||
return total
|
||||
|
||||
|
||||
def count_hidden(player: Player) -> int:
|
||||
"""Count face-down cards."""
|
||||
return sum(1 for c in player.cards if not c.face_up)
|
||||
|
||||
|
||||
def hidden_positions(player: Player) -> list[int]:
|
||||
"""Get indices of face-down cards."""
|
||||
return [i for i, c in enumerate(player.cards) if not c.face_up]
|
||||
|
||||
|
||||
def known_score(player: Player, options: GameOptions) -> int:
|
||||
"""Calculate score from face-up cards only, using EXPECTED_HIDDEN_VALUE for unknowns."""
|
||||
# Centralized version of the repeated estimation logic
|
||||
...
|
||||
```
|
||||
|
||||
This replaces duplicated loops at roughly lines: 679, 949, 1002, 1053, 1145, 1213, 1232.
|
||||
|
||||
### Step 3: Decompose `choose_swap_or_discard()`
|
||||
|
||||
Break into focused sub-functions. The current flow is roughly:
|
||||
|
||||
1. **Go-out safety check** (lines ~1087-1186) - "I'm about to go out, pick the best swap to minimize my score"
|
||||
2. **Score all 6 positions** (lines ~1190-1270) - Calculate swap benefit for each position
|
||||
3. **Filter and rank candidates** (lines ~1270-1330) - Safety filters, personality tie-breaking
|
||||
4. **Blackjack special case** (lines ~1330-1380) - If blackjack rule enabled, check for 21
|
||||
5. **Endgame safety** (lines ~1380-1410) - Don't swap 8+ into unknowns in endgame
|
||||
6. **Denial logic** (lines ~1410-1480) - Block opponent by taking their useful cards
|
||||
|
||||
Proposed decomposition:
|
||||
|
||||
```python
|
||||
def choose_swap_or_discard(player, drawn_card, profile, game, ...) -> Optional[int]:
|
||||
"""Main orchestrator - delegates to focused sub-functions."""
|
||||
|
||||
# Check if we should force a go-out swap
|
||||
go_out_pos = _check_go_out_swap(player, drawn_card, profile, game, ...)
|
||||
if go_out_pos is not None:
|
||||
return go_out_pos
|
||||
|
||||
# Score all positions
|
||||
candidates = _score_all_positions(player, drawn_card, profile, game, ...)
|
||||
|
||||
# Apply filters and select best
|
||||
best = _select_best_candidate(candidates, player, drawn_card, profile, game, ...)
|
||||
|
||||
if best is not None:
|
||||
return best
|
||||
|
||||
# Try denial as fallback
|
||||
return _check_denial_swap(player, drawn_card, profile, game, ...)
|
||||
|
||||
|
||||
def _check_go_out_swap(player, drawn_card, profile, game, ...) -> Optional[int]:
|
||||
"""If player is close to going out, find the best position to minimize final score.
|
||||
|
||||
Handles:
|
||||
- All-but-one face-up: find the best slot for the drawn card
|
||||
- Acceptable score threshold based on game state and personality
|
||||
- Pair completion opportunities
|
||||
"""
|
||||
# Lines ~1087-1186 of current choose_swap_or_discard
|
||||
...
|
||||
|
||||
|
||||
def _score_all_positions(player, drawn_card, profile, game, ...) -> list[tuple[int, float]]:
|
||||
"""Calculate swap benefit score for each of the 6 positions.
|
||||
|
||||
Returns list of (position, score) tuples, sorted by score descending.
|
||||
Each score represents how much the swap improves the player's hand.
|
||||
"""
|
||||
# Lines ~1190-1270 - calls calculate_swap_score() for each position
|
||||
...
|
||||
|
||||
|
||||
def _select_best_candidate(candidates, player, drawn_card, profile, game, ...) -> Optional[int]:
|
||||
"""From scored candidates, apply personality modifiers and safety filters.
|
||||
|
||||
Handles:
|
||||
- Minimum improvement threshold
|
||||
- Personality tie-breaking (pair_hunter prefers pair columns, etc.)
|
||||
- Unpredictability (occasional random choice with value threshold)
|
||||
- High-card safety filter (never swap 8+ into hidden positions)
|
||||
- Blackjack special case (swap to reach exactly 21)
|
||||
- Endgame safety (discard 8+ rather than force into unknown)
|
||||
"""
|
||||
# Lines ~1270-1410
|
||||
...
|
||||
|
||||
|
||||
def _check_denial_swap(player, drawn_card, profile, game, ...) -> Optional[int]:
|
||||
"""Check if we should swap to deny opponents a useful card.
|
||||
|
||||
Only triggers for profiles with denial_aggression > 0.
|
||||
Skips hidden positions for high cards (8+).
|
||||
"""
|
||||
# Lines ~1410-1480
|
||||
...
|
||||
```
|
||||
|
||||
### Step 4: Simplify `calculate_swap_score()`
|
||||
|
||||
Currently ~240 lines. Some of its complexity comes from inlined pair calculations and standings pressure. Extract:
|
||||
|
||||
```python
|
||||
def _pair_improvement(player, position, new_card, options) -> float:
|
||||
"""Calculate pair-related benefit of swapping into this position."""
|
||||
# Would the swap create a new pair? Break an existing pair?
|
||||
...
|
||||
|
||||
def _standings_pressure(player, game) -> float:
|
||||
"""Calculate how much standings position should affect decisions."""
|
||||
# Shared between calculate_swap_score and should_take_discard
|
||||
...
|
||||
```
|
||||
|
||||
### Step 5: Simplify `should_take_discard()`
|
||||
|
||||
Currently ~160 lines. Much of the complexity is from re-deriving information that `calculate_swap_score` also computes. After Step 2's utilities exist, this should shrink significantly since `project_score()` and `known_score()` handle the repeated estimation logic.
|
||||
|
||||
### Step 6: Clean up `process_cpu_turn()`
|
||||
|
||||
Currently ~240 lines. This function is the CPU turn orchestrator and is mostly fine structurally, but has some inline logic for:
|
||||
- Flip-as-action decisions (~30 lines)
|
||||
- Knock-early decisions (~30 lines)
|
||||
- Game logging (~20 lines repeated twice)
|
||||
|
||||
Extract:
|
||||
```python
|
||||
def _should_flip_as_action(player, game, profile) -> Optional[int]:
|
||||
"""Decide whether to use flip-as-action and which position."""
|
||||
...
|
||||
|
||||
def _should_knock_early(player, game, profile) -> bool:
|
||||
"""Decide whether to knock early."""
|
||||
...
|
||||
|
||||
def _log_cpu_action(game_id, player, action, card=None, position=None, reason=""):
|
||||
"""Log a CPU action if logger is available."""
|
||||
...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Execution Order
|
||||
|
||||
1. **Step 1** (constants) - Safe, mechanical, reduces cognitive load immediately
|
||||
2. **Step 2** (utilities) - Foundation for everything else
|
||||
3. **Step 3** (decompose choose_swap_or_discard) - The big win
|
||||
4. **Step 4** (simplify calculate_swap_score) - Benefits from Step 2 utilities
|
||||
5. **Step 5** (simplify should_take_discard) - Benefits from Step 2 utilities
|
||||
6. **Step 6** (clean up process_cpu_turn) - Lower priority
|
||||
|
||||
**Run `python server/simulate.py 500` before Step 1 and after each step to verify identical behavior.**
|
||||
|
||||
---
|
||||
|
||||
## Validation Strategy
|
||||
|
||||
```bash
|
||||
# Before any changes - capture baseline
|
||||
python server/simulate.py 500 > /tmp/ai_baseline.txt
|
||||
|
||||
# After each step
|
||||
python server/simulate.py 500 > /tmp/ai_after_stepN.txt
|
||||
|
||||
# Compare key metrics:
|
||||
# - Average scores per personality
|
||||
# - "Swapped 8+ into unknown" rate (should stay < 0.1%)
|
||||
# - Win rate distribution
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Files Touched
|
||||
|
||||
- `server/ai.py` - major restructuring (same file, new internal organization)
|
||||
- No new files needed (all changes within ai.py unless we decide to split constants out)
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
- **Low risk** if done mechanically (cut-paste into functions, update call sites)
|
||||
- **Medium risk** if we accidentally change conditional logic order or miss an early return
|
||||
- Simulation tests are the safety net - run after every step
|
||||
279
docs/v3/refactor-main-game.md
Normal file
279
docs/v3/refactor-main-game.md
Normal file
@@ -0,0 +1,279 @@
|
||||
# Plan 1: main.py & game.py Refactor
|
||||
|
||||
## Overview
|
||||
|
||||
Break apart the 575-line WebSocket handler in `main.py` into discrete message handlers, eliminate repeated patterns (logging, locking, error responses), and clean up `game.py`'s scattered house rule display logic and options boilerplate.
|
||||
|
||||
No backwards-compatibility concerns - no existing userbase.
|
||||
|
||||
---
|
||||
|
||||
## Part A: main.py WebSocket Handler Decomposition
|
||||
|
||||
### A1. Create `server/handlers.py` - Message Handler Registry
|
||||
|
||||
Extract each `elif msg_type == "..."` block from `websocket_endpoint()` into standalone async handler functions. One function per message type:
|
||||
|
||||
```python
|
||||
# server/handlers.py
|
||||
|
||||
async def handle_create_room(ws, data, ctx) -> None: ...
|
||||
async def handle_join_room(ws, data, ctx) -> None: ...
|
||||
async def handle_get_cpu_profiles(ws, data, ctx) -> None: ...
|
||||
async def handle_add_cpu(ws, data, ctx) -> None: ...
|
||||
async def handle_remove_cpu(ws, data, ctx) -> None: ...
|
||||
async def handle_start_game(ws, data, ctx) -> None: ...
|
||||
async def handle_flip_initial(ws, data, ctx) -> None: ...
|
||||
async def handle_draw(ws, data, ctx) -> None: ...
|
||||
async def handle_swap(ws, data, ctx) -> None: ...
|
||||
async def handle_discard(ws, data, ctx) -> None: ...
|
||||
async def handle_cancel_draw(ws, data, ctx) -> None: ...
|
||||
async def handle_flip_card(ws, data, ctx) -> None: ...
|
||||
async def handle_skip_flip(ws, data, ctx) -> None: ...
|
||||
async def handle_flip_as_action(ws, data, ctx) -> None: ...
|
||||
async def handle_knock_early(ws, data, ctx) -> None: ...
|
||||
async def handle_next_round(ws, data, ctx) -> None: ...
|
||||
async def handle_leave_room(ws, data, ctx) -> None: ...
|
||||
async def handle_leave_game(ws, data, ctx) -> None: ...
|
||||
async def handle_end_game(ws, data, ctx) -> None: ...
|
||||
```
|
||||
|
||||
**Context object** passed to every handler:
|
||||
```python
|
||||
@dataclass
|
||||
class ConnectionContext:
|
||||
websocket: WebSocket
|
||||
connection_id: str
|
||||
player_id: str
|
||||
auth_user_id: Optional[str]
|
||||
authenticated_user: Optional[User]
|
||||
current_room: Optional[Room] # mutable reference
|
||||
```
|
||||
|
||||
**Handler dispatch** in `websocket_endpoint()` becomes:
|
||||
```python
|
||||
HANDLERS = {
|
||||
"create_room": handle_create_room,
|
||||
"join_room": handle_join_room,
|
||||
# ... etc
|
||||
}
|
||||
|
||||
while True:
|
||||
data = await websocket.receive_json()
|
||||
handler = HANDLERS.get(data.get("type"))
|
||||
if handler:
|
||||
await handler(data, ctx)
|
||||
```
|
||||
|
||||
This takes `websocket_endpoint()` from ~575 lines to ~30 lines.
|
||||
|
||||
### A2. Extract Game Action Logger Helper
|
||||
|
||||
The pattern repeated 8 times across draw/swap/discard/flip/skip_flip/flip_as_action/knock_early:
|
||||
|
||||
```python
|
||||
game_logger = get_logger()
|
||||
if game_logger and current_room.game_log_id and player:
|
||||
game_logger.log_move(
|
||||
game_id=current_room.game_log_id,
|
||||
player=player,
|
||||
is_cpu=False,
|
||||
action="...",
|
||||
card=...,
|
||||
position=...,
|
||||
game=current_room.game,
|
||||
decision_reason="...",
|
||||
)
|
||||
```
|
||||
|
||||
Extract to:
|
||||
```python
|
||||
# In handlers.py or a small helpers module
|
||||
def log_human_action(room, player, action, card=None, position=None, reason=""):
|
||||
game_logger = get_logger()
|
||||
if game_logger and room.game_log_id and player:
|
||||
game_logger.log_move(
|
||||
game_id=room.game_log_id,
|
||||
player=player,
|
||||
is_cpu=False,
|
||||
action=action,
|
||||
card=card,
|
||||
position=position,
|
||||
game=room.game,
|
||||
decision_reason=reason,
|
||||
)
|
||||
```
|
||||
|
||||
Each handler call site becomes a single line.
|
||||
|
||||
### A3. Replace Static File Routes with `StaticFiles` Mount
|
||||
|
||||
Currently 15+ hand-written `@app.get()` routes for static files (lines 1188-1255). Replace with:
|
||||
|
||||
```python
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
# Serve specific HTML routes first
|
||||
@app.get("/")
|
||||
async def serve_index():
|
||||
return FileResponse(os.path.join(client_path, "index.html"))
|
||||
|
||||
@app.get("/admin")
|
||||
async def serve_admin():
|
||||
return FileResponse(os.path.join(client_path, "admin.html"))
|
||||
|
||||
@app.get("/replay/{share_code}")
|
||||
async def serve_replay_page(share_code: str):
|
||||
return FileResponse(os.path.join(client_path, "index.html"))
|
||||
|
||||
# Mount static files for everything else (JS, CSS, SVG, etc.)
|
||||
app.mount("/", StaticFiles(directory=client_path), name="static")
|
||||
```
|
||||
|
||||
Eliminates ~70 lines and auto-handles any new client files without code changes.
|
||||
|
||||
### A4. Clean Up Lifespan Service Init
|
||||
|
||||
The lifespan function (lines 83-242) has a deeply nested try/except block initializing ~8 services with lots of `set_*` calls. Simplify by extracting service init:
|
||||
|
||||
```python
|
||||
async def _init_database_services():
|
||||
"""Initialize all PostgreSQL-dependent services. Returns dict of services."""
|
||||
# All the import/init/set logic currently in lifespan
|
||||
...
|
||||
|
||||
async def _init_redis(redis_url):
|
||||
"""Initialize Redis client and rate limiter."""
|
||||
...
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
if config.REDIS_URL:
|
||||
await _init_redis(config.REDIS_URL)
|
||||
if config.POSTGRES_URL:
|
||||
await _init_database_services()
|
||||
|
||||
# health check setup
|
||||
...
|
||||
yield
|
||||
# shutdown...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part B: game.py Cleanup
|
||||
|
||||
### B1. Data-Driven Active Rules Display
|
||||
|
||||
Replace the 38-line if-chain in `get_state()` (lines 1546-1584) with a declarative approach:
|
||||
|
||||
```python
|
||||
# On GameOptions class or as module-level constant
|
||||
_RULE_DISPLAY = [
|
||||
# (attribute, display_name, condition_fn_or_None)
|
||||
("knock_penalty", "Knock Penalty", None),
|
||||
("lucky_swing", "Lucky Swing", None),
|
||||
("eagle_eye", "Eagle-Eye", None),
|
||||
("super_kings", "Super Kings", None),
|
||||
("ten_penny", "Ten Penny", None),
|
||||
("knock_bonus", "Knock Bonus", None),
|
||||
("underdog_bonus", "Underdog", None),
|
||||
("tied_shame", "Tied Shame", None),
|
||||
("blackjack", "Blackjack", None),
|
||||
("wolfpack", "Wolfpack", None),
|
||||
("flip_as_action", "Flip as Action", None),
|
||||
("four_of_a_kind", "Four of a Kind", None),
|
||||
("negative_pairs_keep_value", "Negative Pairs Keep Value", None),
|
||||
("one_eyed_jacks", "One-Eyed Jacks", None),
|
||||
("knock_early", "Early Knock", None),
|
||||
]
|
||||
|
||||
def get_active_rules(self) -> list[str]:
|
||||
rules = []
|
||||
# Special: flip mode
|
||||
if self.options.flip_mode == FlipMode.ALWAYS.value:
|
||||
rules.append("Speed Golf")
|
||||
elif self.options.flip_mode == FlipMode.ENDGAME.value:
|
||||
rules.append("Endgame Flip")
|
||||
# Special: jokers (only if not overridden by lucky_swing/eagle_eye)
|
||||
if self.options.use_jokers and not self.options.lucky_swing and not self.options.eagle_eye:
|
||||
rules.append("Jokers")
|
||||
# Boolean rules
|
||||
for attr, display_name, _ in _RULE_DISPLAY:
|
||||
if getattr(self.options, attr):
|
||||
rules.append(display_name)
|
||||
return rules
|
||||
```
|
||||
|
||||
### B2. Simplify `_options_to_dict()`
|
||||
|
||||
Replace the 22-line manual dict construction (lines 791-813) with `dataclasses.asdict()` or a simple comprehension:
|
||||
|
||||
```python
|
||||
from dataclasses import asdict
|
||||
|
||||
def _options_to_dict(self) -> dict:
|
||||
return asdict(self.options)
|
||||
```
|
||||
|
||||
Or if we want to exclude `deck_colors` or similar:
|
||||
```python
|
||||
def _options_to_dict(self) -> dict:
|
||||
return {k: v for k, v in asdict(self.options).items()}
|
||||
```
|
||||
|
||||
### B3. Add `GameOptions.to_start_game_dict()` for main.py
|
||||
|
||||
The `start_game` handler in main.py (lines 663-689) manually maps 17 `data.get()` calls to `GameOptions()`. Add a classmethod:
|
||||
|
||||
```python
|
||||
@classmethod
|
||||
def from_client_data(cls, data: dict) -> "GameOptions":
|
||||
"""Build GameOptions from client WebSocket message data."""
|
||||
return cls(
|
||||
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),
|
||||
lucky_swing=data.get("lucky_swing", False),
|
||||
super_kings=data.get("super_kings", False),
|
||||
ten_penny=data.get("ten_penny", False),
|
||||
knock_bonus=data.get("knock_bonus", False),
|
||||
underdog_bonus=data.get("underdog_bonus", False),
|
||||
tied_shame=data.get("tied_shame", False),
|
||||
blackjack=data.get("blackjack", False),
|
||||
eagle_eye=data.get("eagle_eye", False),
|
||||
wolfpack=data.get("wolfpack", False),
|
||||
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),
|
||||
deck_colors=data.get("deck_colors", ["red", "blue", "gold"]),
|
||||
)
|
||||
```
|
||||
|
||||
This keeps the construction logic on the class that owns it and out of the WebSocket handler.
|
||||
|
||||
---
|
||||
|
||||
## Execution Order
|
||||
|
||||
1. **B2, B3** (game.py small wins) - low risk, immediate cleanup
|
||||
2. **A2** (log helper) - extract before moving handlers, so handlers are clean from the start
|
||||
3. **A1** (handler extraction) - the big refactor, each handler is a cut-paste + cleanup
|
||||
4. **A3** (static file mount) - easy win, independent
|
||||
5. **B1** (active rules) - can do anytime
|
||||
6. **A4** (lifespan cleanup) - lower priority, nice-to-have
|
||||
|
||||
## Files Touched
|
||||
|
||||
- `server/main.py` - major changes (handler extraction, static files, lifespan)
|
||||
- `server/handlers.py` - **new file** with all message handlers
|
||||
- `server/game.py` - minor changes (active rules, options_to_dict, from_client_data)
|
||||
|
||||
## Testing
|
||||
|
||||
- All existing tests in `test_game.py` should continue passing (game.py changes are additive/cosmetic)
|
||||
- The WebSocket handler refactor is structural only - same logic, just reorganized
|
||||
- Manual smoke test: create room, add CPU, play a round, verify everything works
|
||||
175
docs/v3/refactor-misc.md
Normal file
175
docs/v3/refactor-misc.md
Normal file
@@ -0,0 +1,175 @@
|
||||
# Plan 3: Miscellaneous Refactoring & Improvements
|
||||
|
||||
## Overview
|
||||
|
||||
Everything that doesn't fall under the main.py/game.py or ai.py refactors: shared utilities, dead code, test improvements, and structural cleanup.
|
||||
|
||||
---
|
||||
|
||||
## M1. Duplicate `get_card_value` Functions
|
||||
|
||||
There are currently **three** functions that compute card values:
|
||||
|
||||
1. `game.py:get_card_value(card: Card, options)` - Takes Card objects
|
||||
2. `constants.py:get_card_value_for_rank(rank_str, options_dict)` - Takes rank strings
|
||||
3. `ai.py:get_ai_card_value(card, options)` - AI-specific wrapper (also handles face-down estimation)
|
||||
|
||||
**Problem:** `game.py` and `constants.py` do the same thing with different interfaces, and neither handles all house rules identically. The AI version adds face-down logic but duplicates the base value lookup.
|
||||
|
||||
**Fix:**
|
||||
- Keep `game.py:get_card_value()` as the canonical Card-based function (it already is the most complete)
|
||||
- Keep `constants.py:get_card_value_for_rank()` for string-based lookups from logs/JSON
|
||||
- Have `ai.py:get_ai_card_value()` delegate to `game.py:get_card_value()` for the base value, only adding its face-down estimation on top
|
||||
- Add a brief comment in each noting which is canonical and why each variant exists
|
||||
|
||||
This is a minor cleanup - the current code works, it's just slightly confusing to have three entry points.
|
||||
|
||||
## M2. `GameOptions` Boilerplate Reduction
|
||||
|
||||
`GameOptions` currently has 17+ boolean fields. Every time a new house rule is added, you have to update:
|
||||
|
||||
1. `GameOptions` dataclass definition
|
||||
2. `_options_to_dict()` in game.py
|
||||
3. `get_active_rules()` logic in `get_state()`
|
||||
4. `from_client_data()` (proposed in Plan 1)
|
||||
5. `start_game` handler in main.py (currently, will move to handlers.py)
|
||||
|
||||
**Fix:** Use `dataclasses.fields()` introspection to auto-generate the dict and client data parsing:
|
||||
|
||||
```python
|
||||
from dataclasses import fields, asdict
|
||||
|
||||
# _options_to_dict becomes:
|
||||
def _options_to_dict(self) -> dict:
|
||||
return asdict(self.options)
|
||||
|
||||
# from_client_data becomes:
|
||||
@classmethod
|
||||
def from_client_data(cls, data: dict) -> "GameOptions":
|
||||
field_defaults = {f.name: f.default for f in fields(cls)}
|
||||
kwargs = {}
|
||||
for f in fields(cls):
|
||||
if f.name in data:
|
||||
kwargs[f.name] = data[f.name]
|
||||
# Special validation
|
||||
kwargs["initial_flips"] = max(0, min(2, kwargs.get("initial_flips", 2)))
|
||||
return cls(**kwargs)
|
||||
```
|
||||
|
||||
This means adding a new house rule only requires adding the field to `GameOptions` and its entry in the active_rules display table (from Plan 1's B1).
|
||||
|
||||
## M3. Consolidate Game Logger Pattern in AI
|
||||
|
||||
`ai.py:process_cpu_turn()` has the same logger boilerplate as main.py's human handlers. After Plan 1's A2 creates `log_human_action()`, create a parallel:
|
||||
|
||||
```python
|
||||
def log_cpu_action(game_id, player, action, card=None, position=None, game=None, reason=""):
|
||||
game_logger = get_logger()
|
||||
if game_logger and game_id:
|
||||
game_logger.log_move(
|
||||
game_id=game_id,
|
||||
player=player,
|
||||
is_cpu=True,
|
||||
action=action,
|
||||
card=card,
|
||||
position=position,
|
||||
game=game,
|
||||
decision_reason=reason,
|
||||
)
|
||||
```
|
||||
|
||||
This appears ~4 times in `process_cpu_turn()`.
|
||||
|
||||
## M4. `Player.get_player()` Linear Search
|
||||
|
||||
`Game.get_player()` does a linear scan of the players list:
|
||||
|
||||
```python
|
||||
def get_player(self, player_id: str) -> Optional[Player]:
|
||||
for player in self.players:
|
||||
if player.id == player_id:
|
||||
return player
|
||||
return None
|
||||
```
|
||||
|
||||
With max 6 players this is fine performance-wise, but it's called frequently. Could add a `_player_lookup: dict[str, Player]` cache maintained by `add_player`/`remove_player`. Very minor optimization - only worth doing if we're already touching these methods.
|
||||
|
||||
## M5. Room Code Collision Potential
|
||||
|
||||
`RoomManager._generate_code()` generates random 4-letter codes and retries on collision. With 26^4 = 456,976 possibilities this is fine now, but if we ever scale, the while-True loop could theoretically spin. Low priority, but a simple improvement:
|
||||
|
||||
```python
|
||||
def _generate_code(self, max_attempts=100) -> str:
|
||||
for _ in range(max_attempts):
|
||||
code = "".join(random.choices(string.ascii_uppercase, k=4))
|
||||
if code not in self.rooms:
|
||||
return code
|
||||
raise RuntimeError("Could not generate unique room code")
|
||||
```
|
||||
|
||||
## M6. Test Coverage Gaps
|
||||
|
||||
Current test files:
|
||||
- `test_game.py` - Core game logic (good coverage)
|
||||
- `test_house_rules.py` - House rule scoring
|
||||
- `test_v3_features.py` - New v3 features
|
||||
- `test_maya_bug.py` - Specific regression test
|
||||
- `tests/test_event_replay.py`, `test_persistence.py`, `test_replay.py` - Event system
|
||||
|
||||
**Missing:**
|
||||
- No tests for `room.py` (Room, RoomManager, RoomPlayer)
|
||||
- No tests for WebSocket message handlers (will be much easier to test after Plan 1's handler extraction)
|
||||
- No unit tests for individual AI decision functions (will be much easier after Plan 2's decomposition)
|
||||
|
||||
**Recommendation:** After Plans 1 and 2 are complete, add:
|
||||
- `test_handlers.py` - Test each message handler with mock WebSocket/Room
|
||||
- `test_ai_decisions.py` - Test individual AI sub-functions (go-out logic, denial, etc.)
|
||||
- `test_room.py` - Test Room/RoomManager CRUD operations
|
||||
|
||||
## M7. Unused/Dead Code Audit
|
||||
|
||||
Things to verify and potentially remove:
|
||||
- `score_analysis.py` - Is this used anywhere or was it a one-off analysis tool?
|
||||
- `game_analyzer.py` - Same question
|
||||
- `auth.py` (top-level, not in routers/) - Appears to be an old file superseded by `services/auth_service.py`?
|
||||
- `models/game_state.py` - Check if used or leftover from earlier design
|
||||
|
||||
## M8. Type Hints Consistency
|
||||
|
||||
Some functions have full type hints, others don't. The AI functions especially are loosely typed. After the ai.py refactor (Plan 2), ensure all new sub-functions have proper type hints:
|
||||
|
||||
```python
|
||||
def _check_go_out_swap(
|
||||
player: Player,
|
||||
drawn_card: Card,
|
||||
profile: CPUProfile,
|
||||
game: Game,
|
||||
game_state: dict,
|
||||
) -> Optional[int]:
|
||||
```
|
||||
|
||||
This helps with IDE navigation and catching bugs during future changes.
|
||||
|
||||
---
|
||||
|
||||
## Execution Order
|
||||
|
||||
1. **M3** (AI logger helper) - Do alongside Plan 1's A2
|
||||
2. **M2** (GameOptions introspection) - Do alongside Plan 1's B2/B3
|
||||
3. **M1** (card value consolidation) - Quick cleanup
|
||||
4. **M7** (dead code audit) - Quick investigation
|
||||
5. **M5** (room code safety) - 2 lines
|
||||
6. **M6** (tests) - After Plans 1 and 2 are complete
|
||||
7. **M4** (player lookup) - Only if touching add/remove_player for other reasons
|
||||
8. **M8** (type hints) - Ongoing, do as part of Plan 2
|
||||
|
||||
## Files Touched
|
||||
|
||||
- `server/ai.py` - logger helper, card value delegation
|
||||
- `server/game.py` - GameOptions introspection
|
||||
- `server/constants.py` - comments clarifying role
|
||||
- `server/room.py` - room code safety (minor)
|
||||
- `server/test_room.py` - **new file** (eventually)
|
||||
- `server/test_handlers.py` - **new file** (eventually)
|
||||
- `server/test_ai_decisions.py` - **new file** (eventually)
|
||||
- Various files checked in dead code audit
|
||||
42
docs/v3/refactor-remaining.md
Normal file
42
docs/v3/refactor-remaining.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Remaining Refactor Tasks
|
||||
|
||||
Leftover items from the v3 refactor plans that are functional but could benefit from further cleanup.
|
||||
|
||||
---
|
||||
|
||||
## R1. Decompose `calculate_swap_score()` (from Plan 2, Step 4)
|
||||
|
||||
**File:** `server/ai.py` (~236 lines)
|
||||
|
||||
Scores a single position for swapping. Still long with inline pair calculations, point gain logic, reveal bonuses, and comeback bonuses. Could extract:
|
||||
|
||||
- `_pair_improvement(player, position, new_card, options)` — pair-related benefit of swapping into a position
|
||||
- `_standings_pressure(player, game)` — how much standings position should affect decisions (shared with `should_take_discard`)
|
||||
|
||||
**Validation:** `python server/simulate.py 500` before and after — stats should match within normal variance.
|
||||
|
||||
---
|
||||
|
||||
## R2. Decompose `should_take_discard()` (from Plan 2, Step 5)
|
||||
|
||||
**File:** `server/ai.py` (~148 lines)
|
||||
|
||||
Decides whether to take from discard pile. Contains a nested `has_good_swap_option()` helper. After R1's extracted utilities exist, this should shrink since `project_score()` and `known_score()` handle the repeated estimation logic.
|
||||
|
||||
**Validation:** Same simulation approach as R1.
|
||||
|
||||
---
|
||||
|
||||
## R3. New Test Files (from Plan 3, M6)
|
||||
|
||||
After Plans 1 and 2, the extracted handlers and AI sub-functions are much easier to unit test. Add:
|
||||
|
||||
- **`server/test_handlers.py`** — Test each message handler with mock WebSocket/Room
|
||||
- **`server/test_ai_decisions.py`** — Test individual AI sub-functions (go-out logic, denial, etc.)
|
||||
- **`server/test_room.py`** — Test Room/RoomManager CRUD operations
|
||||
|
||||
---
|
||||
|
||||
## Priority
|
||||
|
||||
R1 and R2 are pure structural refactors — no behavior changes, low risk, but also low urgency since the code works fine. R3 adds safety nets for future changes.
|
||||
123
pyproject.toml
Normal file
123
pyproject.toml
Normal file
@@ -0,0 +1,123 @@
|
||||
[project]
|
||||
name = "golfgame"
|
||||
version = "3.1.1"
|
||||
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",
|
||||
"redis>=5.0.0",
|
||||
# V2: Authentication
|
||||
"bcrypt>=4.1.0",
|
||||
"resend>=2.0.0",
|
||||
# V2: Production monitoring (optional but recommended)
|
||||
"sentry-sdk[fastapi]>=1.40.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
|
||||
9
scripts/deploy.sh
Executable file
9
scripts/deploy.sh
Executable file
@@ -0,0 +1,9 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
DROPLET="root@165.245.152.51"
|
||||
REMOTE_DIR="/opt/golfgame"
|
||||
|
||||
echo "Deploying to $DROPLET..."
|
||||
ssh $DROPLET "cd $REMOTE_DIR && git pull origin main && docker compose -f docker-compose.prod.yml up -d --build app"
|
||||
echo "Deploy complete."
|
||||
39
scripts/dev-server.sh
Executable file
39
scripts/dev-server.sh
Executable file
@@ -0,0 +1,39 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Start the Golf Game development server
|
||||
#
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
# Check if venv exists
|
||||
if [ ! -f "bin/python" ]; then
|
||||
echo "Virtual environment not found. Run ./scripts/install.sh first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if Docker services are running
|
||||
if command -v docker &> /dev/null; then
|
||||
if ! docker ps --filter "name=redis" --format "{{.Names}}" 2>/dev/null | grep -q redis; then
|
||||
echo "Warning: Redis container not running. Start with:"
|
||||
echo " docker-compose -f docker-compose.dev.yml up -d"
|
||||
echo ""
|
||||
fi
|
||||
fi
|
||||
|
||||
# Load .env if exists
|
||||
if [ -f ".env" ]; then
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
fi
|
||||
|
||||
echo "Starting Golf Game development server..."
|
||||
echo "Server will be available at http://localhost:${PORT:-8000}"
|
||||
echo ""
|
||||
|
||||
cd server
|
||||
exec ../bin/uvicorn main:app --reload --host "${HOST:-0.0.0.0}" --port "${PORT:-8000}"
|
||||
43
scripts/docker-build.sh
Executable file
43
scripts/docker-build.sh
Executable file
@@ -0,0 +1,43 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Build Docker images for Golf Game
|
||||
#
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
# Colors
|
||||
GREEN='\033[0;32m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
IMAGE_NAME="${IMAGE_NAME:-golfgame}"
|
||||
TAG="${TAG:-latest}"
|
||||
|
||||
echo -e "${BLUE}Building Golf Game Docker image...${NC}"
|
||||
echo "Image: $IMAGE_NAME:$TAG"
|
||||
echo ""
|
||||
|
||||
docker build -t "$IMAGE_NAME:$TAG" .
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}Build complete!${NC}"
|
||||
echo ""
|
||||
echo "To run with docker-compose (production):"
|
||||
echo ""
|
||||
echo " export DB_PASSWORD=your-secure-password"
|
||||
echo " export SECRET_KEY=\$(python3 -c 'import secrets; print(secrets.token_hex(32))')"
|
||||
echo " export ACME_EMAIL=your-email@example.com"
|
||||
echo " export DOMAIN=your-domain.com"
|
||||
echo " docker-compose -f docker-compose.prod.yml up -d"
|
||||
echo ""
|
||||
echo "To run standalone:"
|
||||
echo ""
|
||||
echo " docker run -d -p 8000:8000 \\"
|
||||
echo " -e POSTGRES_URL=postgresql://user:pass@host:5432/golf \\"
|
||||
echo " -e REDIS_URL=redis://host:6379 \\"
|
||||
echo " -e SECRET_KEY=your-secret-key \\"
|
||||
echo " $IMAGE_NAME:$TAG"
|
||||
529
scripts/install.sh
Executable file
529
scripts/install.sh
Executable file
@@ -0,0 +1,529 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Golf Game Installer
|
||||
#
|
||||
# This script provides a menu-driven installation for the Golf card game.
|
||||
# Run with: ./scripts/install.sh
|
||||
#
|
||||
set -e
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Get the directory where this script lives
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
echo -e "${BLUE}"
|
||||
echo "╔══════════════════════════════════════════════════════════════╗"
|
||||
echo "║ Golf Game Installer ║"
|
||||
echo "╚══════════════════════════════════════════════════════════════╝"
|
||||
echo -e "${NC}"
|
||||
|
||||
show_menu() {
|
||||
echo ""
|
||||
echo "Select an option:"
|
||||
echo ""
|
||||
echo " 1) Development Setup"
|
||||
echo " - Start Docker services (PostgreSQL, Redis)"
|
||||
echo " - Create Python virtual environment"
|
||||
echo " - Install dependencies"
|
||||
echo " - Create .env from template"
|
||||
echo ""
|
||||
echo " 2) Production Install to /opt/golfgame"
|
||||
echo " - Install application to /opt/golfgame"
|
||||
echo " - Create production .env"
|
||||
echo " - Set up systemd service"
|
||||
echo ""
|
||||
echo " 3) Docker Services Only"
|
||||
echo " - Start PostgreSQL and Redis containers"
|
||||
echo ""
|
||||
echo " 4) Create/Update Systemd Service"
|
||||
echo " - Create or update the systemd service file"
|
||||
echo ""
|
||||
echo " 5) Uninstall Production"
|
||||
echo " - Stop and remove systemd service"
|
||||
echo " - Optionally remove /opt/golfgame"
|
||||
echo ""
|
||||
echo " 6) Show Status"
|
||||
echo " - Check Docker containers"
|
||||
echo " - Check systemd service"
|
||||
echo " - Test endpoints"
|
||||
echo ""
|
||||
echo " q) Quit"
|
||||
echo ""
|
||||
}
|
||||
|
||||
check_requirements() {
|
||||
local missing=()
|
||||
|
||||
if ! command -v python3 &> /dev/null; then
|
||||
missing+=("python3")
|
||||
fi
|
||||
|
||||
if ! command -v docker &> /dev/null; then
|
||||
missing+=("docker")
|
||||
fi
|
||||
|
||||
if ! command -v docker-compose &> /dev/null && ! docker compose version &> /dev/null; then
|
||||
missing+=("docker-compose")
|
||||
fi
|
||||
|
||||
if [ ${#missing[@]} -gt 0 ]; then
|
||||
echo -e "${RED}Missing required tools: ${missing[*]}${NC}"
|
||||
echo "Please install them before continuing."
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
start_docker_services() {
|
||||
echo -e "${BLUE}Starting Docker services...${NC}"
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
if docker compose version &> /dev/null; then
|
||||
docker compose -f docker-compose.dev.yml up -d
|
||||
else
|
||||
docker-compose -f docker-compose.dev.yml up -d
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}Docker services started.${NC}"
|
||||
echo ""
|
||||
echo "Services:"
|
||||
echo " - PostgreSQL: localhost:5432 (user: golf, password: devpassword, db: golf)"
|
||||
echo " - Redis: localhost:6379"
|
||||
}
|
||||
|
||||
setup_dev_venv() {
|
||||
echo -e "${BLUE}Setting up Python virtual environment...${NC}"
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
# Check Python version
|
||||
PYTHON_VERSION=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
|
||||
echo "Using Python $PYTHON_VERSION"
|
||||
|
||||
# Remove old venv if it exists and is broken
|
||||
if [ -f "pyvenv.cfg" ]; then
|
||||
if [ -L "bin/python" ] && [ ! -e "bin/python" ]; then
|
||||
echo -e "${YELLOW}Removing broken virtual environment...${NC}"
|
||||
rm -rf bin lib lib64 pyvenv.cfg include share 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# Create venv if it doesn't exist
|
||||
if [ ! -f "pyvenv.cfg" ]; then
|
||||
echo "Creating virtual environment..."
|
||||
python3 -m venv .
|
||||
fi
|
||||
|
||||
# Install dependencies
|
||||
echo "Installing dependencies..."
|
||||
./bin/pip install --upgrade pip
|
||||
./bin/pip install -e ".[dev]"
|
||||
|
||||
echo -e "${GREEN}Virtual environment ready.${NC}"
|
||||
}
|
||||
|
||||
setup_dev_env() {
|
||||
echo -e "${BLUE}Setting up .env file...${NC}"
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
if [ -f ".env" ]; then
|
||||
echo -e "${YELLOW}.env file already exists. Overwrite? (y/N)${NC}"
|
||||
read -r response
|
||||
if [[ ! "$response" =~ ^[Yy]$ ]]; then
|
||||
echo "Keeping existing .env"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
cat > .env << 'EOF'
|
||||
# Golf Game Development Configuration
|
||||
# Generated by install.sh
|
||||
|
||||
HOST=0.0.0.0
|
||||
PORT=8000
|
||||
DEBUG=true
|
||||
LOG_LEVEL=DEBUG
|
||||
ENVIRONMENT=development
|
||||
|
||||
# PostgreSQL (from docker-compose.dev.yml)
|
||||
DATABASE_URL=postgresql://golf:devpassword@localhost:5432/golf
|
||||
POSTGRES_URL=postgresql://golf:devpassword@localhost:5432/golf
|
||||
|
||||
# Room Settings
|
||||
MAX_PLAYERS_PER_ROOM=6
|
||||
ROOM_TIMEOUT_MINUTES=60
|
||||
ROOM_CODE_LENGTH=4
|
||||
|
||||
# Game Defaults
|
||||
DEFAULT_ROUNDS=9
|
||||
DEFAULT_INITIAL_FLIPS=2
|
||||
DEFAULT_USE_JOKERS=false
|
||||
DEFAULT_FLIP_ON_DISCARD=false
|
||||
EOF
|
||||
|
||||
echo -e "${GREEN}.env file created.${NC}"
|
||||
}
|
||||
|
||||
dev_setup() {
|
||||
echo -e "${BLUE}=== Development Setup ===${NC}"
|
||||
echo ""
|
||||
|
||||
if ! check_requirements; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
start_docker_services
|
||||
echo ""
|
||||
|
||||
setup_dev_venv
|
||||
echo ""
|
||||
|
||||
setup_dev_env
|
||||
echo ""
|
||||
|
||||
echo -e "${GREEN}=== Development Setup Complete ===${NC}"
|
||||
echo ""
|
||||
echo "To start the development server:"
|
||||
echo ""
|
||||
echo " cd $PROJECT_DIR/server"
|
||||
echo " ../bin/uvicorn main:app --reload --host 0.0.0.0 --port 8000"
|
||||
echo ""
|
||||
echo "Or use the helper script:"
|
||||
echo ""
|
||||
echo " $PROJECT_DIR/scripts/dev-server.sh"
|
||||
echo ""
|
||||
}
|
||||
|
||||
prod_install() {
|
||||
echo -e "${BLUE}=== Production Installation ===${NC}"
|
||||
echo ""
|
||||
|
||||
INSTALL_DIR="/opt/golfgame"
|
||||
|
||||
# Check if running as root or with sudo available
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
if ! command -v sudo &> /dev/null; then
|
||||
echo -e "${RED}This option requires root privileges. Run with sudo or as root.${NC}"
|
||||
return 1
|
||||
fi
|
||||
SUDO="sudo"
|
||||
else
|
||||
SUDO=""
|
||||
fi
|
||||
|
||||
echo "This will install Golf Game to $INSTALL_DIR"
|
||||
echo -e "${YELLOW}Continue? (y/N)${NC}"
|
||||
read -r response
|
||||
if [[ ! "$response" =~ ^[Yy]$ ]]; then
|
||||
echo "Aborted."
|
||||
return
|
||||
fi
|
||||
|
||||
# Create directory
|
||||
echo "Creating $INSTALL_DIR..."
|
||||
$SUDO mkdir -p "$INSTALL_DIR"
|
||||
|
||||
# Copy files
|
||||
echo "Copying application files..."
|
||||
$SUDO cp -r "$PROJECT_DIR/server" "$INSTALL_DIR/"
|
||||
$SUDO cp -r "$PROJECT_DIR/client" "$INSTALL_DIR/"
|
||||
$SUDO cp "$PROJECT_DIR/pyproject.toml" "$INSTALL_DIR/"
|
||||
$SUDO cp "$PROJECT_DIR/README.md" "$INSTALL_DIR/"
|
||||
$SUDO cp "$PROJECT_DIR/INSTALL.md" "$INSTALL_DIR/"
|
||||
$SUDO cp "$PROJECT_DIR/.env.example" "$INSTALL_DIR/"
|
||||
$SUDO cp -r "$PROJECT_DIR/scripts" "$INSTALL_DIR/"
|
||||
|
||||
# Create venv
|
||||
echo "Creating virtual environment..."
|
||||
$SUDO python3 -m venv "$INSTALL_DIR"
|
||||
$SUDO "$INSTALL_DIR/bin/pip" install --upgrade pip
|
||||
$SUDO "$INSTALL_DIR/bin/pip" install "$INSTALL_DIR"
|
||||
|
||||
# Create production .env if it doesn't exist
|
||||
if [ ! -f "$INSTALL_DIR/.env" ]; then
|
||||
echo "Creating production .env..."
|
||||
|
||||
# Generate a secret key
|
||||
SECRET_KEY=$(python3 -c "import secrets; print(secrets.token_hex(32))")
|
||||
|
||||
$SUDO tee "$INSTALL_DIR/.env" > /dev/null << EOF
|
||||
# Golf Game Production Configuration
|
||||
# Generated by install.sh
|
||||
|
||||
HOST=0.0.0.0
|
||||
PORT=8000
|
||||
DEBUG=false
|
||||
LOG_LEVEL=INFO
|
||||
ENVIRONMENT=production
|
||||
|
||||
# PostgreSQL - UPDATE THESE VALUES
|
||||
DATABASE_URL=postgresql://golf:CHANGE_ME@localhost:5432/golf
|
||||
POSTGRES_URL=postgresql://golf:CHANGE_ME@localhost:5432/golf
|
||||
|
||||
# Security
|
||||
SECRET_KEY=$SECRET_KEY
|
||||
|
||||
# Room Settings
|
||||
MAX_PLAYERS_PER_ROOM=6
|
||||
ROOM_TIMEOUT_MINUTES=60
|
||||
ROOM_CODE_LENGTH=4
|
||||
|
||||
# Game Defaults
|
||||
DEFAULT_ROUNDS=9
|
||||
DEFAULT_INITIAL_FLIPS=2
|
||||
DEFAULT_USE_JOKERS=false
|
||||
DEFAULT_FLIP_ON_DISCARD=false
|
||||
|
||||
# Optional: Sentry error tracking
|
||||
# SENTRY_DSN=https://your-sentry-dsn
|
||||
EOF
|
||||
$SUDO chmod 600 "$INSTALL_DIR/.env"
|
||||
fi
|
||||
|
||||
# Set ownership
|
||||
echo "Setting permissions..."
|
||||
$SUDO chown -R www-data:www-data "$INSTALL_DIR"
|
||||
|
||||
echo -e "${GREEN}Application installed to $INSTALL_DIR${NC}"
|
||||
echo ""
|
||||
echo -e "${YELLOW}IMPORTANT: Edit $INSTALL_DIR/.env and update:${NC}"
|
||||
echo " - DATABASE_URL / POSTGRES_URL with your PostgreSQL credentials"
|
||||
echo " - Any other settings as needed"
|
||||
echo ""
|
||||
|
||||
# Offer to set up systemd
|
||||
echo "Set up systemd service now? (Y/n)"
|
||||
read -r response
|
||||
if [[ ! "$response" =~ ^[Nn]$ ]]; then
|
||||
setup_systemd
|
||||
fi
|
||||
}
|
||||
|
||||
setup_systemd() {
|
||||
echo -e "${BLUE}=== Systemd Service Setup ===${NC}"
|
||||
echo ""
|
||||
|
||||
INSTALL_DIR="/opt/golfgame"
|
||||
SERVICE_FILE="/etc/systemd/system/golfgame.service"
|
||||
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
if ! command -v sudo &> /dev/null; then
|
||||
echo -e "${RED}This option requires root privileges.${NC}"
|
||||
return 1
|
||||
fi
|
||||
SUDO="sudo"
|
||||
else
|
||||
SUDO=""
|
||||
fi
|
||||
|
||||
if [ ! -d "$INSTALL_DIR" ]; then
|
||||
echo -e "${RED}$INSTALL_DIR does not exist. Run production install first.${NC}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "Creating systemd service..."
|
||||
|
||||
$SUDO tee "$SERVICE_FILE" > /dev/null << 'EOF'
|
||||
[Unit]
|
||||
Description=Golf Card Game Server
|
||||
Documentation=https://github.com/alee/golfgame
|
||||
After=network.target postgresql.service redis.service
|
||||
Wants=postgresql.service redis.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=www-data
|
||||
Group=www-data
|
||||
WorkingDirectory=/opt/golfgame/server
|
||||
Environment="PATH=/opt/golfgame/bin:/usr/local/bin:/usr/bin:/bin"
|
||||
EnvironmentFile=/opt/golfgame/.env
|
||||
ExecStart=/opt/golfgame/bin/uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4
|
||||
ExecReload=/bin/kill -HUP $MAINPID
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
# Security hardening
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/opt/golfgame
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
echo "Reloading systemd..."
|
||||
$SUDO systemctl daemon-reload
|
||||
|
||||
echo "Enabling service..."
|
||||
$SUDO systemctl enable golfgame
|
||||
|
||||
echo -e "${GREEN}Systemd service created.${NC}"
|
||||
echo ""
|
||||
echo "Commands:"
|
||||
echo " sudo systemctl start golfgame # Start the service"
|
||||
echo " sudo systemctl stop golfgame # Stop the service"
|
||||
echo " sudo systemctl restart golfgame # Restart the service"
|
||||
echo " sudo systemctl status golfgame # Check status"
|
||||
echo " journalctl -u golfgame -f # View logs"
|
||||
echo ""
|
||||
|
||||
echo "Start the service now? (Y/n)"
|
||||
read -r response
|
||||
if [[ ! "$response" =~ ^[Nn]$ ]]; then
|
||||
$SUDO systemctl start golfgame
|
||||
sleep 2
|
||||
$SUDO systemctl status golfgame --no-pager
|
||||
fi
|
||||
}
|
||||
|
||||
uninstall_prod() {
|
||||
echo -e "${BLUE}=== Production Uninstall ===${NC}"
|
||||
echo ""
|
||||
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
if ! command -v sudo &> /dev/null; then
|
||||
echo -e "${RED}This option requires root privileges.${NC}"
|
||||
return 1
|
||||
fi
|
||||
SUDO="sudo"
|
||||
else
|
||||
SUDO=""
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW}This will stop and remove the systemd service.${NC}"
|
||||
echo "Continue? (y/N)"
|
||||
read -r response
|
||||
if [[ ! "$response" =~ ^[Yy]$ ]]; then
|
||||
echo "Aborted."
|
||||
return
|
||||
fi
|
||||
|
||||
# Stop and disable service
|
||||
if [ -f "/etc/systemd/system/golfgame.service" ]; then
|
||||
echo "Stopping service..."
|
||||
$SUDO systemctl stop golfgame 2>/dev/null || true
|
||||
$SUDO systemctl disable golfgame 2>/dev/null || true
|
||||
$SUDO rm -f /etc/systemd/system/golfgame.service
|
||||
$SUDO systemctl daemon-reload
|
||||
echo "Service removed."
|
||||
else
|
||||
echo "No systemd service found."
|
||||
fi
|
||||
|
||||
# Optionally remove installation directory
|
||||
if [ -d "/opt/golfgame" ]; then
|
||||
echo ""
|
||||
echo -e "${YELLOW}Remove /opt/golfgame directory? (y/N)${NC}"
|
||||
read -r response
|
||||
if [[ "$response" =~ ^[Yy]$ ]]; then
|
||||
$SUDO rm -rf /opt/golfgame
|
||||
echo "Directory removed."
|
||||
else
|
||||
echo "Directory kept."
|
||||
fi
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}Uninstall complete.${NC}"
|
||||
}
|
||||
|
||||
show_status() {
|
||||
echo -e "${BLUE}=== Status Check ===${NC}"
|
||||
echo ""
|
||||
|
||||
# Docker containers
|
||||
echo "Docker Containers:"
|
||||
if command -v docker &> /dev/null; then
|
||||
docker ps --filter "name=golfgame" --format " {{.Names}}: {{.Status}}" 2>/dev/null || echo " (none running)"
|
||||
echo ""
|
||||
else
|
||||
echo " Docker not installed"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Systemd service
|
||||
echo "Systemd Service:"
|
||||
if [ -f "/etc/systemd/system/golfgame.service" ]; then
|
||||
systemctl status golfgame --no-pager 2>/dev/null | head -5 || echo " Service not running"
|
||||
else
|
||||
echo " Not installed"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Health check
|
||||
echo "Health Check:"
|
||||
for port in 8000; do
|
||||
if curl -s "http://localhost:$port/health" > /dev/null 2>&1; then
|
||||
response=$(curl -s "http://localhost:$port/health")
|
||||
echo -e " Port $port: ${GREEN}OK${NC} - $response"
|
||||
else
|
||||
echo -e " Port $port: ${RED}Not responding${NC}"
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
|
||||
# Database
|
||||
echo "PostgreSQL:"
|
||||
if command -v pg_isready &> /dev/null; then
|
||||
if pg_isready -h localhost -p 5432 > /dev/null 2>&1; then
|
||||
echo -e " ${GREEN}Running${NC} on localhost:5432"
|
||||
else
|
||||
echo -e " ${RED}Not responding${NC}"
|
||||
fi
|
||||
else
|
||||
if docker ps --filter "name=postgres" --format "{{.Names}}" 2>/dev/null | grep -q postgres; then
|
||||
echo -e " ${GREEN}Running${NC} (Docker)"
|
||||
else
|
||||
echo " Unable to check (pg_isready not installed)"
|
||||
fi
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Redis
|
||||
echo "Redis:"
|
||||
if command -v redis-cli &> /dev/null; then
|
||||
if redis-cli ping > /dev/null 2>&1; then
|
||||
echo -e " ${GREEN}Running${NC} on localhost:6379"
|
||||
else
|
||||
echo -e " ${RED}Not responding${NC}"
|
||||
fi
|
||||
else
|
||||
if docker ps --filter "name=redis" --format "{{.Names}}" 2>/dev/null | grep -q redis; then
|
||||
echo -e " ${GREEN}Running${NC} (Docker)"
|
||||
else
|
||||
echo " Unable to check (redis-cli not installed)"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Main loop
|
||||
while true; do
|
||||
show_menu
|
||||
echo -n "Enter choice: "
|
||||
read -r choice
|
||||
|
||||
case $choice in
|
||||
1) dev_setup ;;
|
||||
2) prod_install ;;
|
||||
3) start_docker_services ;;
|
||||
4) setup_systemd ;;
|
||||
5) uninstall_prod ;;
|
||||
6) show_status ;;
|
||||
q|Q) echo "Goodbye!"; exit 0 ;;
|
||||
*) echo -e "${RED}Invalid option${NC}" ;;
|
||||
esac
|
||||
|
||||
echo ""
|
||||
echo "Press Enter to continue..."
|
||||
read -r
|
||||
done
|
||||
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
1831
server/ai.py
1831
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
|
||||
231
server/config.py
Normal file
231
server/config.py
Normal file
@@ -0,0 +1,231 @@
|
||||
"""
|
||||
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 = True
|
||||
|
||||
# Bootstrap admin (for first-time setup when INVITE_ONLY=true)
|
||||
BOOTSTRAP_ADMIN_USERNAME: str = ""
|
||||
BOOTSTRAP_ADMIN_PASSWORD: str = ""
|
||||
ADMIN_EMAILS: list[str] = field(default_factory=list)
|
||||
|
||||
# Matchmaking
|
||||
MATCHMAKING_ENABLED: bool = True
|
||||
MATCHMAKING_MIN_PLAYERS: int = 2
|
||||
MATCHMAKING_MAX_PLAYERS: int = 4
|
||||
|
||||
# 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", True),
|
||||
BOOTSTRAP_ADMIN_USERNAME=get_env("BOOTSTRAP_ADMIN_USERNAME", ""),
|
||||
BOOTSTRAP_ADMIN_PASSWORD=get_env("BOOTSTRAP_ADMIN_PASSWORD", ""),
|
||||
MATCHMAKING_ENABLED=get_env_bool("MATCHMAKING_ENABLED", True),
|
||||
MATCHMAKING_MIN_PLAYERS=get_env_int("MATCHMAKING_MIN_PLAYERS", 2),
|
||||
MATCHMAKING_MAX_PLAYERS=get_env_int("MATCHMAKING_MAX_PLAYERS", 4),
|
||||
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,30 +1,102 @@
|
||||
# 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 = {
|
||||
'A': 1,
|
||||
'2': -2,
|
||||
'3': 3,
|
||||
'4': 4,
|
||||
'5': 5,
|
||||
'6': 6,
|
||||
'7': 7,
|
||||
'8': 8,
|
||||
'9': 9,
|
||||
'10': 10,
|
||||
'J': 10,
|
||||
'Q': 10,
|
||||
'K': 0,
|
||||
'★': -2, # Joker (standard)
|
||||
}
|
||||
"""
|
||||
Card value constants for 6-Card Golf.
|
||||
|
||||
# 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
|
||||
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
|
||||
|
||||
|
||||
def get_card_value_for_rank(rank_str: str, options: dict | None = None) -> int:
|
||||
# =============================================================================
|
||||
# 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,
|
||||
'4': 4,
|
||||
'5': 5,
|
||||
'6': 6,
|
||||
'7': 7,
|
||||
'8': 8,
|
||||
'9': 9,
|
||||
'10': 10,
|
||||
'J': 10,
|
||||
'Q': 10,
|
||||
'K': 0,
|
||||
'★': -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
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 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
|
||||
|
||||
|
||||
1288
server/game.py
1288
server/game.py
File diff suppressed because it is too large
Load Diff
@@ -3,10 +3,20 @@ Game Analyzer for 6-Card Golf AI decisions.
|
||||
|
||||
Evaluates AI decisions against optimal play baselines and generates
|
||||
reports on decision quality, mistake rates, and areas for improvement.
|
||||
|
||||
NOTE: This analyzer has been updated to use PostgreSQL. It requires
|
||||
POSTGRES_URL to be configured. For quick analysis during simulations,
|
||||
use the SimulationStats class in simulate.py instead.
|
||||
|
||||
Usage:
|
||||
python game_analyzer.py blunders [limit]
|
||||
python game_analyzer.py recent [limit]
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sqlite3
|
||||
import os
|
||||
import sqlite3 # For legacy GameAnalyzer class (deprecated)
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -339,7 +349,12 @@ class DecisionEvaluator:
|
||||
# =============================================================================
|
||||
|
||||
class GameAnalyzer:
|
||||
"""Analyzes logged games for decision quality."""
|
||||
"""Analyzes logged games for decision quality.
|
||||
|
||||
DEPRECATED: This class uses SQLite which has been replaced by PostgreSQL.
|
||||
Use the CLI commands (blunders, recent) instead, or query the moves table
|
||||
in PostgreSQL directly.
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: str = "games.db"):
|
||||
self.db_path = Path(db_path)
|
||||
@@ -579,59 +594,76 @@ def print_blunder_report(blunders: list[dict]):
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CLI Interface
|
||||
# CLI Interface (PostgreSQL version)
|
||||
# =============================================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
async def run_cli():
|
||||
"""Async CLI entry point."""
|
||||
import sys
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage:")
|
||||
print(" python game_analyzer.py blunders [limit]")
|
||||
print(" python game_analyzer.py game <game_id> <player_name>")
|
||||
print(" python game_analyzer.py summary")
|
||||
print(" python game_analyzer.py recent [limit]")
|
||||
print("")
|
||||
print("Requires POSTGRES_URL environment variable.")
|
||||
sys.exit(1)
|
||||
|
||||
postgres_url = os.environ.get("POSTGRES_URL")
|
||||
if not postgres_url:
|
||||
print("Error: POSTGRES_URL environment variable not set.")
|
||||
print("")
|
||||
print("Set it like: export POSTGRES_URL=postgresql://golf:devpassword@localhost:5432/golf")
|
||||
print("")
|
||||
print("For simulation analysis without PostgreSQL, use:")
|
||||
print(" python simulate.py 100 --preset baseline")
|
||||
sys.exit(1)
|
||||
|
||||
from stores.event_store import EventStore
|
||||
|
||||
try:
|
||||
event_store = await EventStore.create(postgres_url)
|
||||
except Exception as e:
|
||||
print(f"Error connecting to PostgreSQL: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
command = sys.argv[1]
|
||||
|
||||
try:
|
||||
analyzer = GameAnalyzer()
|
||||
except FileNotFoundError:
|
||||
print("No games.db found. Play some games first!")
|
||||
sys.exit(1)
|
||||
if command == "blunders":
|
||||
limit = int(sys.argv[2]) if len(sys.argv) > 2 else 20
|
||||
blunders = await event_store.find_suspicious_discards(limit)
|
||||
|
||||
if command == "blunders":
|
||||
limit = int(sys.argv[2]) if len(sys.argv) > 2 else 20
|
||||
blunders = analyzer.find_blunders(limit)
|
||||
print_blunder_report(blunders)
|
||||
print(f"\n=== Suspicious Discards ({len(blunders)} found) ===\n")
|
||||
for b in blunders:
|
||||
print(f"Player: {b.get('player_name', 'Unknown')}")
|
||||
print(f"Action: discard {b.get('card_rank', '?')}")
|
||||
print(f"Room: {b.get('room_code', 'N/A')}")
|
||||
print(f"Reason: {b.get('decision_reason', 'N/A')}")
|
||||
print("-" * 40)
|
||||
|
||||
elif command == "game" and len(sys.argv) >= 4:
|
||||
game_id = sys.argv[2]
|
||||
player_name = sys.argv[3]
|
||||
summary = analyzer.analyze_player_game(game_id, player_name)
|
||||
print(generate_player_report(summary))
|
||||
|
||||
elif command == "summary":
|
||||
# Quick summary of recent games
|
||||
with sqlite3.connect("games.db") as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.execute("""
|
||||
SELECT g.id, g.room_code, g.started_at, g.num_players,
|
||||
COUNT(m.id) as move_count
|
||||
FROM games g
|
||||
LEFT JOIN moves m ON g.id = m.game_id
|
||||
GROUP BY g.id
|
||||
ORDER BY g.started_at DESC
|
||||
LIMIT 10
|
||||
""")
|
||||
elif command == "recent":
|
||||
limit = int(sys.argv[2]) if len(sys.argv) > 2 else 10
|
||||
games = await event_store.get_recent_games_with_stats(limit)
|
||||
|
||||
print("\n=== Recent Games ===\n")
|
||||
for row in cursor:
|
||||
print(f"Game: {row['id'][:8]}... Room: {row['room_code']}")
|
||||
print(f" Players: {row['num_players']}, Moves: {row['move_count']}")
|
||||
print(f" Started: {row['started_at']}")
|
||||
print()
|
||||
for game in games:
|
||||
game_id = str(game.get('id', ''))[:8]
|
||||
room_code = game.get('room_code', 'N/A')
|
||||
status = game.get('status', 'unknown')
|
||||
moves = game.get('total_moves', 0)
|
||||
print(f"{game_id}... | Room: {room_code} | Status: {status} | Moves: {moves}")
|
||||
|
||||
else:
|
||||
print(f"Unknown command: {command}")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print(f"Unknown command: {command}")
|
||||
print("Available: blunders, recent")
|
||||
|
||||
finally:
|
||||
await event_store.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Note: The detailed analysis (GameAnalyzer class) still uses the old SQLite
|
||||
# schema format. For now, use the CLI commands above for PostgreSQL queries.
|
||||
# Full migration of the analysis logic is TODO.
|
||||
asyncio.run(run_cli())
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
"""SQLite game logging for AI decision analysis."""
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from dataclasses import asdict
|
||||
|
||||
from game import Card, Player, Game, GameOptions
|
||||
|
||||
|
||||
class GameLogger:
|
||||
"""Logs game state and AI decisions to SQLite for post-game analysis."""
|
||||
|
||||
def __init__(self, db_path: str = "games.db"):
|
||||
self.db_path = Path(db_path)
|
||||
self._init_db()
|
||||
|
||||
def _init_db(self):
|
||||
"""Initialize database schema."""
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.executescript("""
|
||||
-- Games table
|
||||
CREATE TABLE IF NOT EXISTS games (
|
||||
id TEXT PRIMARY KEY,
|
||||
room_code TEXT,
|
||||
started_at TIMESTAMP,
|
||||
ended_at TIMESTAMP,
|
||||
num_players INTEGER,
|
||||
options_json TEXT
|
||||
);
|
||||
|
||||
-- Moves table (one per AI decision)
|
||||
CREATE TABLE IF NOT EXISTS moves (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
game_id TEXT REFERENCES games(id),
|
||||
move_number INTEGER,
|
||||
timestamp TIMESTAMP,
|
||||
player_id TEXT,
|
||||
player_name TEXT,
|
||||
is_cpu BOOLEAN,
|
||||
|
||||
-- Decision context
|
||||
action TEXT,
|
||||
|
||||
-- Cards involved
|
||||
card_rank TEXT,
|
||||
card_suit TEXT,
|
||||
position INTEGER,
|
||||
|
||||
-- Full state snapshot
|
||||
hand_json TEXT,
|
||||
discard_top_json TEXT,
|
||||
visible_opponents_json TEXT,
|
||||
|
||||
-- AI reasoning
|
||||
decision_reason TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_moves_game_id ON moves(game_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_moves_action ON moves(action);
|
||||
CREATE INDEX IF NOT EXISTS idx_moves_is_cpu ON moves(is_cpu);
|
||||
""")
|
||||
|
||||
def log_game_start(
|
||||
self, room_code: str, num_players: int, options: GameOptions
|
||||
) -> str:
|
||||
"""Log start of a new game. Returns game_id."""
|
||||
game_id = str(uuid.uuid4())
|
||||
options_dict = {
|
||||
"flip_on_discard": options.flip_on_discard,
|
||||
"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,
|
||||
}
|
||||
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO games (id, room_code, started_at, num_players, options_json)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(game_id, room_code, datetime.now(), num_players, json.dumps(options_dict)),
|
||||
)
|
||||
return game_id
|
||||
|
||||
def log_move(
|
||||
self,
|
||||
game_id: str,
|
||||
player: Player,
|
||||
is_cpu: bool,
|
||||
action: str,
|
||||
card: Optional[Card] = None,
|
||||
position: Optional[int] = None,
|
||||
game: Optional[Game] = None,
|
||||
decision_reason: Optional[str] = None,
|
||||
):
|
||||
"""Log a single move/decision."""
|
||||
# Get current move number
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
cursor = conn.execute(
|
||||
"SELECT COALESCE(MAX(move_number), 0) + 1 FROM moves WHERE game_id = ?",
|
||||
(game_id,),
|
||||
)
|
||||
move_number = cursor.fetchone()[0]
|
||||
|
||||
# Serialize hand
|
||||
hand_data = []
|
||||
for c in player.cards:
|
||||
hand_data.append({
|
||||
"rank": c.rank.value,
|
||||
"suit": c.suit.value,
|
||||
"face_up": c.face_up,
|
||||
})
|
||||
|
||||
# Serialize discard top
|
||||
discard_top_data = None
|
||||
if game:
|
||||
discard_top = game.discard_top()
|
||||
if discard_top:
|
||||
discard_top_data = {
|
||||
"rank": discard_top.rank.value,
|
||||
"suit": discard_top.suit.value,
|
||||
}
|
||||
|
||||
# Serialize visible opponent cards
|
||||
visible_opponents = {}
|
||||
if game:
|
||||
for p in game.players:
|
||||
if p.id != player.id:
|
||||
visible = []
|
||||
for c in p.cards:
|
||||
if c.face_up:
|
||||
visible.append({
|
||||
"rank": c.rank.value,
|
||||
"suit": c.suit.value,
|
||||
})
|
||||
visible_opponents[p.name] = visible
|
||||
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO moves (
|
||||
game_id, move_number, timestamp, player_id, player_name, is_cpu,
|
||||
action, card_rank, card_suit, position,
|
||||
hand_json, discard_top_json, visible_opponents_json, decision_reason
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
game_id,
|
||||
move_number,
|
||||
datetime.now(),
|
||||
player.id,
|
||||
player.name,
|
||||
is_cpu,
|
||||
action,
|
||||
card.rank.value if card else None,
|
||||
card.suit.value if card else None,
|
||||
position,
|
||||
json.dumps(hand_data),
|
||||
json.dumps(discard_top_data) if discard_top_data else None,
|
||||
json.dumps(visible_opponents),
|
||||
decision_reason,
|
||||
),
|
||||
)
|
||||
|
||||
def log_game_end(self, game_id: str):
|
||||
"""Mark game as ended."""
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.execute(
|
||||
"UPDATE games SET ended_at = ? WHERE id = ?",
|
||||
(datetime.now(), game_id),
|
||||
)
|
||||
|
||||
|
||||
# Query helpers for analysis
|
||||
|
||||
def find_suspicious_discards(db_path: str = "games.db") -> list[dict]:
|
||||
"""Find cases where AI discarded good cards (Ace, 2, King)."""
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.execute("""
|
||||
SELECT m.*, g.room_code
|
||||
FROM moves m
|
||||
JOIN games g ON m.game_id = g.id
|
||||
WHERE m.action = 'discard'
|
||||
AND m.card_rank IN ('A', '2', 'K')
|
||||
AND m.is_cpu = 1
|
||||
ORDER BY m.timestamp DESC
|
||||
""")
|
||||
return [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
|
||||
def get_player_decisions(db_path: str, game_id: str, player_name: str) -> list[dict]:
|
||||
"""Get all decisions made by a specific player in a game."""
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.execute("""
|
||||
SELECT * FROM moves
|
||||
WHERE game_id = ? AND player_name = ?
|
||||
ORDER BY move_number
|
||||
""", (game_id, player_name))
|
||||
return [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
|
||||
def get_recent_games(db_path: str = "games.db", limit: int = 10) -> list[dict]:
|
||||
"""Get list of recent games."""
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.execute("""
|
||||
SELECT g.*, COUNT(m.id) as total_moves
|
||||
FROM games g
|
||||
LEFT JOIN moves m ON g.id = m.game_id
|
||||
GROUP BY g.id
|
||||
ORDER BY g.started_at DESC
|
||||
LIMIT ?
|
||||
""", (limit,))
|
||||
return [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
|
||||
# Global logger instance (lazy initialization)
|
||||
_logger: Optional[GameLogger] = None
|
||||
|
||||
|
||||
def get_logger() -> GameLogger:
|
||||
"""Get or create the global game logger instance."""
|
||||
global _logger
|
||||
if _logger is None:
|
||||
_logger = GameLogger()
|
||||
return _logger
|
||||
575
server/handlers.py
Normal file
575
server/handlers.py
Normal file
@@ -0,0 +1,575 @@
|
||||
"""WebSocket message handlers for the Golf card game.
|
||||
|
||||
Each handler corresponds to a single message type from the client.
|
||||
Handlers are dispatched via the HANDLERS dict in main.py.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
from config import config
|
||||
from game import GamePhase, GameOptions
|
||||
from ai import GolfAI, get_all_profiles
|
||||
from room import Room
|
||||
from services.game_logger import get_logger
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConnectionContext:
|
||||
"""State tracked per WebSocket connection."""
|
||||
|
||||
websocket: WebSocket
|
||||
connection_id: str
|
||||
player_id: str
|
||||
auth_user_id: Optional[str]
|
||||
authenticated_user: object # Optional[User]
|
||||
current_room: Optional[Room] = None
|
||||
|
||||
|
||||
def log_human_action(room: Room, player, action: str, card=None, position=None, reason: str = ""):
|
||||
"""Log a human player's game action (shared helper for all handlers)."""
|
||||
game_logger = get_logger()
|
||||
if game_logger and room.game_log_id and player:
|
||||
game_logger.log_move(
|
||||
game_id=room.game_log_id,
|
||||
player=player,
|
||||
is_cpu=False,
|
||||
action=action,
|
||||
card=card,
|
||||
position=position,
|
||||
game=room.game,
|
||||
decision_reason=reason,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lobby / Room handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def handle_create_room(data: dict, ctx: ConnectionContext, *, room_manager, count_user_games, max_concurrent, **kw) -> None:
|
||||
if config.INVITE_ONLY and not ctx.authenticated_user:
|
||||
await ctx.websocket.send_json({"type": "error", "message": "You must be logged in to play"})
|
||||
return
|
||||
|
||||
if ctx.auth_user_id and count_user_games(ctx.auth_user_id) >= max_concurrent:
|
||||
await ctx.websocket.send_json({
|
||||
"type": "error",
|
||||
"message": f"Maximum {max_concurrent} concurrent games allowed",
|
||||
})
|
||||
return
|
||||
|
||||
# Use authenticated username as player name
|
||||
player_name = ctx.authenticated_user.username if ctx.authenticated_user else data.get("player_name", "Player")
|
||||
room = room_manager.create_room()
|
||||
room.add_player(ctx.player_id, player_name, ctx.websocket, ctx.auth_user_id)
|
||||
ctx.current_room = room
|
||||
|
||||
await ctx.websocket.send_json({
|
||||
"type": "room_created",
|
||||
"room_code": room.code,
|
||||
"player_id": ctx.player_id,
|
||||
"authenticated": ctx.authenticated_user is not None,
|
||||
})
|
||||
|
||||
await room.broadcast({
|
||||
"type": "player_joined",
|
||||
"players": room.player_list(),
|
||||
})
|
||||
|
||||
|
||||
async def handle_join_room(data: dict, ctx: ConnectionContext, *, room_manager, count_user_games, max_concurrent, **kw) -> None:
|
||||
if config.INVITE_ONLY and not ctx.authenticated_user:
|
||||
await ctx.websocket.send_json({"type": "error", "message": "You must be logged in to play"})
|
||||
return
|
||||
|
||||
room_code = data.get("room_code", "").upper()
|
||||
# Use authenticated username as player name
|
||||
player_name = ctx.authenticated_user.username if ctx.authenticated_user else data.get("player_name", "Player")
|
||||
|
||||
if ctx.auth_user_id and count_user_games(ctx.auth_user_id) >= max_concurrent:
|
||||
await ctx.websocket.send_json({
|
||||
"type": "error",
|
||||
"message": f"Maximum {max_concurrent} concurrent games allowed",
|
||||
})
|
||||
return
|
||||
|
||||
room = room_manager.get_room(room_code)
|
||||
if not room:
|
||||
await ctx.websocket.send_json({"type": "error", "message": "Room not found"})
|
||||
return
|
||||
|
||||
if len(room.players) >= 6:
|
||||
await ctx.websocket.send_json({"type": "error", "message": "Room is full"})
|
||||
return
|
||||
|
||||
if room.game.phase != GamePhase.WAITING:
|
||||
await ctx.websocket.send_json({"type": "error", "message": "Game already in progress"})
|
||||
return
|
||||
|
||||
room.add_player(ctx.player_id, player_name, ctx.websocket, ctx.auth_user_id)
|
||||
ctx.current_room = room
|
||||
|
||||
await ctx.websocket.send_json({
|
||||
"type": "room_joined",
|
||||
"room_code": room.code,
|
||||
"player_id": ctx.player_id,
|
||||
"authenticated": ctx.authenticated_user is not None,
|
||||
})
|
||||
|
||||
await room.broadcast({
|
||||
"type": "player_joined",
|
||||
"players": room.player_list(),
|
||||
})
|
||||
|
||||
|
||||
async def handle_get_cpu_profiles(data: dict, ctx: ConnectionContext, **kw) -> None:
|
||||
if not ctx.current_room:
|
||||
return
|
||||
await ctx.websocket.send_json({
|
||||
"type": "cpu_profiles",
|
||||
"profiles": get_all_profiles(),
|
||||
})
|
||||
|
||||
|
||||
async def handle_add_cpu(data: dict, ctx: ConnectionContext, **kw) -> None:
|
||||
if not ctx.current_room:
|
||||
return
|
||||
|
||||
room_player = ctx.current_room.get_player(ctx.player_id)
|
||||
if not room_player or not room_player.is_host:
|
||||
await ctx.websocket.send_json({"type": "error", "message": "Only the host can add CPU players"})
|
||||
return
|
||||
|
||||
if len(ctx.current_room.players) >= 6:
|
||||
await ctx.websocket.send_json({"type": "error", "message": "Room is full"})
|
||||
return
|
||||
|
||||
cpu_id = f"cpu_{uuid.uuid4().hex[:8]}"
|
||||
profile_name = data.get("profile_name")
|
||||
|
||||
cpu_player = ctx.current_room.add_cpu_player(cpu_id, profile_name)
|
||||
if not cpu_player:
|
||||
await ctx.websocket.send_json({"type": "error", "message": "CPU profile not available"})
|
||||
return
|
||||
|
||||
await ctx.current_room.broadcast({
|
||||
"type": "player_joined",
|
||||
"players": ctx.current_room.player_list(),
|
||||
})
|
||||
|
||||
|
||||
async def handle_remove_cpu(data: dict, ctx: ConnectionContext, **kw) -> None:
|
||||
if not ctx.current_room:
|
||||
return
|
||||
|
||||
room_player = ctx.current_room.get_player(ctx.player_id)
|
||||
if not room_player or not room_player.is_host:
|
||||
return
|
||||
|
||||
cpu_players = ctx.current_room.get_cpu_players()
|
||||
if cpu_players:
|
||||
ctx.current_room.remove_player(cpu_players[-1].id)
|
||||
await ctx.current_room.broadcast({
|
||||
"type": "player_joined",
|
||||
"players": ctx.current_room.player_list(),
|
||||
})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Game lifecycle handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def handle_start_game(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
|
||||
if not ctx.current_room:
|
||||
return
|
||||
|
||||
room_player = ctx.current_room.get_player(ctx.player_id)
|
||||
if not room_player or not room_player.is_host:
|
||||
await ctx.websocket.send_json({"type": "error", "message": "Only the host can start the game"})
|
||||
return
|
||||
|
||||
if len(ctx.current_room.players) < 2:
|
||||
await ctx.websocket.send_json({"type": "error", "message": "Need at least 2 players"})
|
||||
return
|
||||
|
||||
num_decks = max(1, min(3, data.get("decks", 1)))
|
||||
num_rounds = max(1, min(18, data.get("rounds", 1)))
|
||||
options = GameOptions.from_client_data(data)
|
||||
|
||||
async with ctx.current_room.game_lock:
|
||||
ctx.current_room.game.start_game(num_decks, num_rounds, options)
|
||||
|
||||
game_logger = get_logger()
|
||||
if game_logger:
|
||||
ctx.current_room.game_log_id = game_logger.log_game_start(
|
||||
room_code=ctx.current_room.code,
|
||||
num_players=len(ctx.current_room.players),
|
||||
options=options,
|
||||
)
|
||||
|
||||
# CPU players do their initial flips immediately
|
||||
if options.initial_flips > 0:
|
||||
for cpu in ctx.current_room.get_cpu_players():
|
||||
positions = GolfAI.choose_initial_flips(options.initial_flips)
|
||||
ctx.current_room.game.flip_initial_cards(cpu.id, positions)
|
||||
|
||||
# Send game started to all human players
|
||||
for pid, player in ctx.current_room.players.items():
|
||||
if player.websocket and not player.is_cpu:
|
||||
game_state = ctx.current_room.game.get_state(pid)
|
||||
await player.websocket.send_json({
|
||||
"type": "game_started",
|
||||
"game_state": game_state,
|
||||
})
|
||||
|
||||
await check_and_run_cpu_turn(ctx.current_room)
|
||||
|
||||
|
||||
async def handle_flip_initial(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
|
||||
if not ctx.current_room:
|
||||
return
|
||||
|
||||
positions = data.get("positions", [])
|
||||
async with ctx.current_room.game_lock:
|
||||
if ctx.current_room.game.flip_initial_cards(ctx.player_id, positions):
|
||||
await broadcast_game_state(ctx.current_room)
|
||||
await check_and_run_cpu_turn(ctx.current_room)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Turn action handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def handle_draw(data: dict, ctx: ConnectionContext, *, broadcast_game_state, **kw) -> None:
|
||||
if not ctx.current_room:
|
||||
return
|
||||
|
||||
source = data.get("source", "deck")
|
||||
async with ctx.current_room.game_lock:
|
||||
discard_before_draw = ctx.current_room.game.discard_top()
|
||||
card = ctx.current_room.game.draw_card(ctx.player_id, source)
|
||||
|
||||
if card:
|
||||
player = ctx.current_room.game.get_player(ctx.player_id)
|
||||
reason = f"took {discard_before_draw.rank.value} from discard" if source == "discard" else "drew from deck"
|
||||
log_human_action(
|
||||
ctx.current_room, player,
|
||||
"take_discard" if source == "discard" else "draw_deck",
|
||||
card=card, reason=reason,
|
||||
)
|
||||
|
||||
await ctx.websocket.send_json({
|
||||
"type": "card_drawn",
|
||||
"card": card.to_dict(),
|
||||
"source": source,
|
||||
})
|
||||
|
||||
await broadcast_game_state(ctx.current_room)
|
||||
|
||||
|
||||
async def handle_swap(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
|
||||
if not ctx.current_room:
|
||||
return
|
||||
|
||||
position = data.get("position", 0)
|
||||
async with ctx.current_room.game_lock:
|
||||
drawn_card = ctx.current_room.game.drawn_card
|
||||
player = ctx.current_room.game.get_player(ctx.player_id)
|
||||
old_card = player.cards[position] if player and 0 <= position < len(player.cards) else None
|
||||
|
||||
discarded = ctx.current_room.game.swap_card(ctx.player_id, position)
|
||||
|
||||
if discarded:
|
||||
if drawn_card and player:
|
||||
old_rank = old_card.rank.value if old_card else "?"
|
||||
log_human_action(
|
||||
ctx.current_room, player, "swap",
|
||||
card=drawn_card, position=position,
|
||||
reason=f"swapped {drawn_card.rank.value} into position {position}, replaced {old_rank}",
|
||||
)
|
||||
|
||||
await broadcast_game_state(ctx.current_room)
|
||||
await asyncio.sleep(1.0)
|
||||
await check_and_run_cpu_turn(ctx.current_room)
|
||||
|
||||
|
||||
async def handle_discard(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
|
||||
if not ctx.current_room:
|
||||
return
|
||||
|
||||
async with ctx.current_room.game_lock:
|
||||
drawn_card = ctx.current_room.game.drawn_card
|
||||
player = ctx.current_room.game.get_player(ctx.player_id)
|
||||
|
||||
if ctx.current_room.game.discard_drawn(ctx.player_id):
|
||||
if drawn_card and player:
|
||||
log_human_action(
|
||||
ctx.current_room, player, "discard",
|
||||
card=drawn_card,
|
||||
reason=f"discarded {drawn_card.rank.value}",
|
||||
)
|
||||
|
||||
await broadcast_game_state(ctx.current_room)
|
||||
|
||||
if ctx.current_room.game.flip_on_discard:
|
||||
player = ctx.current_room.game.get_player(ctx.player_id)
|
||||
has_face_down = player and any(not c.face_up for c in player.cards)
|
||||
|
||||
if has_face_down:
|
||||
await ctx.websocket.send_json({
|
||||
"type": "can_flip",
|
||||
"optional": ctx.current_room.game.flip_is_optional,
|
||||
})
|
||||
else:
|
||||
await asyncio.sleep(0.5)
|
||||
await check_and_run_cpu_turn(ctx.current_room)
|
||||
else:
|
||||
logger.debug("Player discarded, waiting 0.5s before CPU turn")
|
||||
await asyncio.sleep(0.5)
|
||||
logger.debug("Post-discard delay complete, checking for CPU turn")
|
||||
await check_and_run_cpu_turn(ctx.current_room)
|
||||
|
||||
|
||||
async def handle_cancel_draw(data: dict, ctx: ConnectionContext, *, broadcast_game_state, **kw) -> None:
|
||||
if not ctx.current_room:
|
||||
return
|
||||
|
||||
async with ctx.current_room.game_lock:
|
||||
if ctx.current_room.game.cancel_discard_draw(ctx.player_id):
|
||||
await broadcast_game_state(ctx.current_room)
|
||||
|
||||
|
||||
async def handle_flip_card(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
|
||||
if not ctx.current_room:
|
||||
return
|
||||
|
||||
position = data.get("position", 0)
|
||||
async with ctx.current_room.game_lock:
|
||||
player = ctx.current_room.game.get_player(ctx.player_id)
|
||||
ctx.current_room.game.flip_and_end_turn(ctx.player_id, position)
|
||||
|
||||
if player and 0 <= position < len(player.cards):
|
||||
flipped_card = player.cards[position]
|
||||
log_human_action(
|
||||
ctx.current_room, player, "flip",
|
||||
card=flipped_card, position=position,
|
||||
reason=f"flipped card at position {position}",
|
||||
)
|
||||
|
||||
await broadcast_game_state(ctx.current_room)
|
||||
await check_and_run_cpu_turn(ctx.current_room)
|
||||
|
||||
|
||||
async def handle_skip_flip(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
|
||||
if not ctx.current_room:
|
||||
return
|
||||
|
||||
async with ctx.current_room.game_lock:
|
||||
player = ctx.current_room.game.get_player(ctx.player_id)
|
||||
if ctx.current_room.game.skip_flip_and_end_turn(ctx.player_id):
|
||||
log_human_action(
|
||||
ctx.current_room, player, "skip_flip",
|
||||
reason="skipped optional flip (endgame mode)",
|
||||
)
|
||||
|
||||
await broadcast_game_state(ctx.current_room)
|
||||
await check_and_run_cpu_turn(ctx.current_room)
|
||||
|
||||
|
||||
async def handle_flip_as_action(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
|
||||
if not ctx.current_room:
|
||||
return
|
||||
|
||||
position = data.get("position", 0)
|
||||
async with ctx.current_room.game_lock:
|
||||
player = ctx.current_room.game.get_player(ctx.player_id)
|
||||
if ctx.current_room.game.flip_card_as_action(ctx.player_id, position):
|
||||
if player and 0 <= position < len(player.cards):
|
||||
flipped_card = player.cards[position]
|
||||
log_human_action(
|
||||
ctx.current_room, player, "flip_as_action",
|
||||
card=flipped_card, position=position,
|
||||
reason=f"used flip-as-action to reveal position {position}",
|
||||
)
|
||||
|
||||
await broadcast_game_state(ctx.current_room)
|
||||
await check_and_run_cpu_turn(ctx.current_room)
|
||||
|
||||
|
||||
async def handle_knock_early(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
|
||||
if not ctx.current_room:
|
||||
return
|
||||
|
||||
async with ctx.current_room.game_lock:
|
||||
player = ctx.current_room.game.get_player(ctx.player_id)
|
||||
if ctx.current_room.game.knock_early(ctx.player_id):
|
||||
if player:
|
||||
face_down_count = sum(1 for c in player.cards if not c.face_up)
|
||||
log_human_action(
|
||||
ctx.current_room, player, "knock_early",
|
||||
reason=f"knocked early, revealing {face_down_count} hidden cards",
|
||||
)
|
||||
|
||||
await broadcast_game_state(ctx.current_room)
|
||||
await check_and_run_cpu_turn(ctx.current_room)
|
||||
|
||||
|
||||
async def handle_next_round(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
|
||||
if not ctx.current_room:
|
||||
return
|
||||
|
||||
room_player = ctx.current_room.get_player(ctx.player_id)
|
||||
if not room_player or not room_player.is_host:
|
||||
return
|
||||
|
||||
async with ctx.current_room.game_lock:
|
||||
if ctx.current_room.game.start_next_round():
|
||||
for cpu in ctx.current_room.get_cpu_players():
|
||||
positions = GolfAI.choose_initial_flips()
|
||||
ctx.current_room.game.flip_initial_cards(cpu.id, positions)
|
||||
|
||||
for pid, player in ctx.current_room.players.items():
|
||||
if player.websocket and not player.is_cpu:
|
||||
game_state = ctx.current_room.game.get_state(pid)
|
||||
await player.websocket.send_json({
|
||||
"type": "round_started",
|
||||
"game_state": game_state,
|
||||
})
|
||||
|
||||
await check_and_run_cpu_turn(ctx.current_room)
|
||||
else:
|
||||
await broadcast_game_state(ctx.current_room)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Leave / End handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def handle_leave_room(data: dict, ctx: ConnectionContext, *, handle_player_leave, **kw) -> None:
|
||||
if ctx.current_room:
|
||||
await handle_player_leave(ctx.current_room, ctx.player_id)
|
||||
ctx.current_room = None
|
||||
|
||||
|
||||
async def handle_leave_game(data: dict, ctx: ConnectionContext, *, handle_player_leave, **kw) -> None:
|
||||
if ctx.current_room:
|
||||
await handle_player_leave(ctx.current_room, ctx.player_id)
|
||||
ctx.current_room = None
|
||||
|
||||
|
||||
async def handle_end_game(data: dict, ctx: ConnectionContext, *, room_manager, cleanup_room_profiles, **kw) -> None:
|
||||
if not ctx.current_room:
|
||||
return
|
||||
|
||||
room_player = ctx.current_room.get_player(ctx.player_id)
|
||||
if not room_player or not room_player.is_host:
|
||||
await ctx.websocket.send_json({"type": "error", "message": "Only the host can end the game"})
|
||||
return
|
||||
|
||||
await ctx.current_room.broadcast({
|
||||
"type": "game_ended",
|
||||
"reason": "Host ended the game",
|
||||
})
|
||||
|
||||
room_code = ctx.current_room.code
|
||||
for cpu in list(ctx.current_room.get_cpu_players()):
|
||||
ctx.current_room.remove_player(cpu.id)
|
||||
cleanup_room_profiles(room_code)
|
||||
room_manager.remove_room(room_code)
|
||||
ctx.current_room = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Handler dispatch table
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Matchmaking handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def handle_queue_join(data: dict, ctx: ConnectionContext, *, matchmaking_service=None, rating_service=None, **kw) -> None:
|
||||
if not matchmaking_service:
|
||||
await ctx.websocket.send_json({"type": "error", "message": "Matchmaking not available"})
|
||||
return
|
||||
|
||||
if not ctx.authenticated_user:
|
||||
await ctx.websocket.send_json({"type": "error", "message": "You must be logged in to find a game"})
|
||||
return
|
||||
|
||||
# Get player's rating
|
||||
rating = 1500.0
|
||||
if rating_service:
|
||||
try:
|
||||
player_rating = await rating_service.get_rating(ctx.auth_user_id)
|
||||
rating = player_rating.rating
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
status = await matchmaking_service.join_queue(
|
||||
user_id=ctx.auth_user_id,
|
||||
username=ctx.authenticated_user.username,
|
||||
rating=rating,
|
||||
websocket=ctx.websocket,
|
||||
connection_id=ctx.connection_id,
|
||||
)
|
||||
|
||||
await ctx.websocket.send_json({
|
||||
"type": "queue_joined",
|
||||
**status,
|
||||
})
|
||||
|
||||
|
||||
async def handle_queue_leave(data: dict, ctx: ConnectionContext, *, matchmaking_service=None, **kw) -> None:
|
||||
if not matchmaking_service or not ctx.auth_user_id:
|
||||
return
|
||||
|
||||
removed = await matchmaking_service.leave_queue(ctx.auth_user_id)
|
||||
await ctx.websocket.send_json({
|
||||
"type": "queue_left",
|
||||
"was_queued": removed,
|
||||
})
|
||||
|
||||
|
||||
async def handle_queue_status(data: dict, ctx: ConnectionContext, *, matchmaking_service=None, **kw) -> None:
|
||||
if not matchmaking_service or not ctx.auth_user_id:
|
||||
await ctx.websocket.send_json({"type": "queue_status", "in_queue": False})
|
||||
return
|
||||
|
||||
status = await matchmaking_service.get_queue_status(ctx.auth_user_id)
|
||||
await ctx.websocket.send_json({
|
||||
"type": "queue_status",
|
||||
**status,
|
||||
})
|
||||
|
||||
|
||||
HANDLERS = {
|
||||
"create_room": handle_create_room,
|
||||
"join_room": handle_join_room,
|
||||
"get_cpu_profiles": handle_get_cpu_profiles,
|
||||
"add_cpu": handle_add_cpu,
|
||||
"remove_cpu": handle_remove_cpu,
|
||||
"start_game": handle_start_game,
|
||||
"flip_initial": handle_flip_initial,
|
||||
"draw": handle_draw,
|
||||
"swap": handle_swap,
|
||||
"discard": handle_discard,
|
||||
"cancel_draw": handle_cancel_draw,
|
||||
"flip_card": handle_flip_card,
|
||||
"skip_flip": handle_skip_flip,
|
||||
"flip_as_action": handle_flip_as_action,
|
||||
"knock_early": handle_knock_early,
|
||||
"next_round": handle_next_round,
|
||||
"leave_room": handle_leave_room,
|
||||
"leave_game": handle_leave_game,
|
||||
"end_game": handle_end_game,
|
||||
"queue_join": handle_queue_join,
|
||||
"queue_leave": handle_queue_leave,
|
||||
"queue_status": handle_queue_status,
|
||||
}
|
||||
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))
|
||||
1011
server/main.py
1011
server/main.py
File diff suppressed because it is too large
Load Diff
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)
|
||||
142
server/middleware/security.py
Normal file
142
server/middleware/security.py
Normal file
@@ -0,0 +1,142 @@
|
||||
"""
|
||||
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"ws://{host}")
|
||||
connect_sources.append(f"wss://{host}")
|
||||
for allowed_host in self.allowed_hosts:
|
||||
connect_sources.append(f"ws://{allowed_host}")
|
||||
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 = event.data.get("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,25 @@
|
||||
fastapi==0.109.0
|
||||
uvicorn[standard]==0.27.0
|
||||
websockets==12.0
|
||||
# Core dependencies
|
||||
fastapi>=0.109.0
|
||||
uvicorn[standard]>=0.27.0
|
||||
websockets>=12.0
|
||||
python-dotenv>=1.0.0
|
||||
|
||||
# Database & caching
|
||||
asyncpg>=0.29.0
|
||||
redis>=5.0.0
|
||||
|
||||
# Authentication
|
||||
bcrypt>=4.1.0
|
||||
|
||||
# Email service
|
||||
resend>=2.0.0
|
||||
|
||||
# Production monitoring (optional)
|
||||
sentry-sdk[fastapi]>=1.40.0
|
||||
|
||||
# Testing
|
||||
pytest>=8.0.0
|
||||
pytest-asyncio>=0.23.0
|
||||
pytest-cov>=4.1.0
|
||||
ruff>=0.1.0
|
||||
mypy>=1.8.0
|
||||
|
||||
217
server/room.py
217
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,39 +138,64 @@ 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:
|
||||
room_player = self.players.pop(player_id)
|
||||
self.game.remove_player(player_id)
|
||||
"""
|
||||
Remove a player from the room.
|
||||
|
||||
# Release CPU profile back to the pool
|
||||
if room_player.is_cpu:
|
||||
release_profile(room_player.name)
|
||||
Handles host reassignment if the host leaves, and releases
|
||||
CPU profiles back to the pool.
|
||||
|
||||
# Assign new host if needed
|
||||
if room_player.is_host and self.players:
|
||||
next_host = next(iter(self.players.values()))
|
||||
next_host.is_host = True
|
||||
Args:
|
||||
player_id: ID of the player to remove.
|
||||
|
||||
return room_player
|
||||
return None
|
||||
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 room's pool
|
||||
if room_player.is_cpu:
|
||||
release_profile(room_player.name, self.code)
|
||||
|
||||
# Assign new host if needed
|
||||
if room_player.is_host and self.players:
|
||||
next_host = next(iter(self.players.values()))
|
||||
next_host.is_host = True
|
||||
|
||||
return room_player
|
||||
|
||||
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,69 @@ 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:
|
||||
while True:
|
||||
def _generate_code(self, max_attempts: int = 100) -> str:
|
||||
"""Generate a unique 4-letter room code."""
|
||||
for _ in range(max_attempts):
|
||||
code = "".join(random.choices(string.ascii_uppercase, k=4))
|
||||
if code not in self.rooms:
|
||||
return code
|
||||
raise RuntimeError("Could not generate unique room 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"}
|
||||
529
server/routers/auth.py
Normal file
529
server/routers/auth.py
Normal file
@@ -0,0 +1,529 @@
|
||||
"""
|
||||
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 config import config
|
||||
from models.user import User
|
||||
from services.auth_service import AuthService
|
||||
from services.admin_service import AdminService
|
||||
|
||||
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
|
||||
invite_code: 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
|
||||
_admin_service: Optional[AdminService] = None
|
||||
|
||||
|
||||
def set_auth_service(service: AuthService) -> None:
|
||||
"""Set the auth service instance (called from main.py)."""
|
||||
global _auth_service
|
||||
_auth_service = service
|
||||
|
||||
|
||||
def set_admin_service_for_auth(service: AdminService) -> None:
|
||||
"""Set the admin service instance for invite code validation (called from main.py)."""
|
||||
global _admin_service
|
||||
_admin_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."""
|
||||
# Validate invite code when invite-only mode is enabled
|
||||
if config.INVITE_ONLY:
|
||||
if not request_body.invite_code:
|
||||
raise HTTPException(status_code=400, detail="Invite code required")
|
||||
if not _admin_service:
|
||||
raise HTTPException(status_code=503, detail="Admin service not initialized")
|
||||
if not await _admin_service.validate_invite_code(request_body.invite_code):
|
||||
raise HTTPException(status_code=400, detail="Invalid or expired invite code")
|
||||
|
||||
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)
|
||||
|
||||
# Consume the invite code after successful registration
|
||||
if config.INVITE_ONLY and request_body.invite_code:
|
||||
await _admin_service.use_invite_code(request_body.invite_code)
|
||||
|
||||
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|rating)$"),
|
||||
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|rating)$"),
|
||||
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|rating)$"),
|
||||
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
|
||||
299
server/services/game_logger.py
Normal file
299
server/services/game_logger.py
Normal file
@@ -0,0 +1,299 @@
|
||||
"""
|
||||
PostgreSQL-backed game logging for AI decision analysis.
|
||||
|
||||
Replaces SQLite game_log.py with unified event store integration.
|
||||
Provides sync-compatible interface for existing callers (main.py, ai.py).
|
||||
|
||||
Usage:
|
||||
# Initialize in main.py lifespan
|
||||
from services.game_logger import GameLogger, set_logger
|
||||
game_logger = GameLogger(event_store)
|
||||
set_logger(game_logger)
|
||||
|
||||
# Use in handlers
|
||||
from services.game_logger import get_logger
|
||||
logger = get_logger()
|
||||
if logger:
|
||||
logger.log_move(game_id, player, is_cpu=False, action="swap", ...)
|
||||
"""
|
||||
|
||||
from dataclasses import asdict
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
import asyncio
|
||||
import uuid
|
||||
import logging
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from stores.event_store import EventStore
|
||||
from game import Card, Player, Game, GameOptions
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GameLogger:
|
||||
"""
|
||||
Logs game events and moves to PostgreSQL.
|
||||
|
||||
Provides sync wrappers for compatibility with existing callers.
|
||||
Uses fire-and-forget async tasks to avoid blocking game logic.
|
||||
"""
|
||||
|
||||
def __init__(self, event_store: "EventStore"):
|
||||
"""
|
||||
Initialize the game logger.
|
||||
|
||||
Args:
|
||||
event_store: PostgreSQL event store instance.
|
||||
"""
|
||||
self.event_store = event_store
|
||||
|
||||
@staticmethod
|
||||
def _options_to_dict(options: "GameOptions") -> dict:
|
||||
"""Convert GameOptions to dict for storage, excluding non-rule fields."""
|
||||
d = asdict(options)
|
||||
d.pop("deck_colors", None)
|
||||
return d
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Game Lifecycle
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def log_game_start_async(
|
||||
self,
|
||||
room_code: str,
|
||||
num_players: int,
|
||||
options: "GameOptions",
|
||||
) -> str:
|
||||
"""
|
||||
Log game start, return game_id.
|
||||
|
||||
Creates a game record in games_v2 table.
|
||||
|
||||
Args:
|
||||
room_code: Room code for the game.
|
||||
num_players: Number of players.
|
||||
options: Game options/house rules.
|
||||
|
||||
Returns:
|
||||
Generated game UUID.
|
||||
"""
|
||||
game_id = str(uuid.uuid4())
|
||||
|
||||
try:
|
||||
await self.event_store.create_game(
|
||||
game_id=game_id,
|
||||
room_code=room_code,
|
||||
host_id="system",
|
||||
options=self._options_to_dict(options),
|
||||
)
|
||||
log.debug(f"Logged game start: {game_id} room={room_code}")
|
||||
except Exception as e:
|
||||
log.error(f"Failed to log game start: {e}")
|
||||
|
||||
return game_id
|
||||
|
||||
def log_game_start(
|
||||
self,
|
||||
room_code: str,
|
||||
num_players: int,
|
||||
options: "GameOptions",
|
||||
) -> str:
|
||||
"""
|
||||
Sync wrapper for log_game_start_async.
|
||||
|
||||
In async context: fires task and returns generated ID immediately.
|
||||
In sync context: runs synchronously.
|
||||
"""
|
||||
game_id = str(uuid.uuid4())
|
||||
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
# Already in async context - fire task, return ID immediately
|
||||
asyncio.create_task(self._log_game_start_with_id(game_id, room_code, num_players, options))
|
||||
return game_id
|
||||
except RuntimeError:
|
||||
# Not in async context - run synchronously
|
||||
return asyncio.run(self.log_game_start_async(room_code, num_players, options))
|
||||
|
||||
async def _log_game_start_with_id(
|
||||
self,
|
||||
game_id: str,
|
||||
room_code: str,
|
||||
num_players: int,
|
||||
options: "GameOptions",
|
||||
) -> None:
|
||||
"""Helper to log game start with pre-generated ID."""
|
||||
try:
|
||||
await self.event_store.create_game(
|
||||
game_id=game_id,
|
||||
room_code=room_code,
|
||||
host_id="system",
|
||||
options=self._options_to_dict(options),
|
||||
)
|
||||
log.debug(f"Logged game start: {game_id} room={room_code}")
|
||||
except Exception as e:
|
||||
log.error(f"Failed to log game start: {e}")
|
||||
|
||||
async def log_game_end_async(self, game_id: str) -> None:
|
||||
"""
|
||||
Mark game as ended.
|
||||
|
||||
Args:
|
||||
game_id: Game UUID.
|
||||
"""
|
||||
try:
|
||||
await self.event_store.update_game_completed(game_id)
|
||||
log.debug(f"Logged game end: {game_id}")
|
||||
except Exception as e:
|
||||
log.error(f"Failed to log game end: {e}")
|
||||
|
||||
def log_game_end(self, game_id: str) -> None:
|
||||
"""
|
||||
Sync wrapper for log_game_end_async.
|
||||
|
||||
Fires async task in async context, skips in sync context.
|
||||
"""
|
||||
if not game_id:
|
||||
return
|
||||
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
asyncio.create_task(self.log_game_end_async(game_id))
|
||||
except RuntimeError:
|
||||
# Not in async context - skip (simulations don't need this)
|
||||
pass
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Move Logging
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def log_move_async(
|
||||
self,
|
||||
game_id: str,
|
||||
player: "Player",
|
||||
is_cpu: bool,
|
||||
action: str,
|
||||
card: Optional["Card"] = None,
|
||||
position: Optional[int] = None,
|
||||
game: Optional["Game"] = None,
|
||||
decision_reason: Optional[str] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Log a move with AI context to PostgreSQL.
|
||||
|
||||
Args:
|
||||
game_id: Game UUID.
|
||||
player: Player who made the move.
|
||||
is_cpu: Whether this is a CPU player.
|
||||
action: Action type (draw_deck, take_discard, swap, discard, flip, etc.).
|
||||
card: Card involved in the action.
|
||||
position: Hand position (0-5) for swaps/flips.
|
||||
game: Game instance for context capture.
|
||||
decision_reason: AI reasoning for the decision.
|
||||
"""
|
||||
# Build AI context from game state
|
||||
hand_state = None
|
||||
discard_top = None
|
||||
visible_opponents = None
|
||||
|
||||
if game:
|
||||
# Serialize player's hand
|
||||
hand_state = [
|
||||
{"rank": c.rank.value, "suit": c.suit.value, "face_up": c.face_up}
|
||||
for c in player.cards
|
||||
]
|
||||
|
||||
# Serialize discard top
|
||||
dt = game.discard_top()
|
||||
if dt:
|
||||
discard_top = {"rank": dt.rank.value, "suit": dt.suit.value}
|
||||
|
||||
# Serialize visible opponent cards
|
||||
visible_opponents = {}
|
||||
for p in game.players:
|
||||
if p.id != player.id:
|
||||
visible = [
|
||||
{"rank": c.rank.value, "suit": c.suit.value}
|
||||
for c in p.cards if c.face_up
|
||||
]
|
||||
visible_opponents[p.name] = visible
|
||||
|
||||
try:
|
||||
await self.event_store.append_move(
|
||||
game_id=game_id,
|
||||
player_id=player.id,
|
||||
player_name=player.name,
|
||||
is_cpu=is_cpu,
|
||||
action=action,
|
||||
card_rank=card.rank.value if card else None,
|
||||
card_suit=card.suit.value if card else None,
|
||||
position=position,
|
||||
hand_state=hand_state,
|
||||
discard_top=discard_top,
|
||||
visible_opponents=visible_opponents,
|
||||
decision_reason=decision_reason,
|
||||
)
|
||||
except Exception as e:
|
||||
log.error(f"Failed to log move: {e}")
|
||||
|
||||
def log_move(
|
||||
self,
|
||||
game_id: str,
|
||||
player: "Player",
|
||||
is_cpu: bool,
|
||||
action: str,
|
||||
card: Optional["Card"] = None,
|
||||
position: Optional[int] = None,
|
||||
game: Optional["Game"] = None,
|
||||
decision_reason: Optional[str] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Sync wrapper for log_move_async.
|
||||
|
||||
Fires async task in async context. Does nothing if no game_id or not in async context.
|
||||
"""
|
||||
if not game_id:
|
||||
return
|
||||
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
asyncio.create_task(
|
||||
self.log_move_async(
|
||||
game_id, player, is_cpu, action,
|
||||
card=card, position=position, game=game, decision_reason=decision_reason
|
||||
)
|
||||
)
|
||||
except RuntimeError:
|
||||
# Not in async context - skip logging (simulations)
|
||||
pass
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Global Instance Management
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
_game_logger: Optional[GameLogger] = None
|
||||
|
||||
|
||||
def get_logger() -> Optional[GameLogger]:
|
||||
"""
|
||||
Get the global game logger instance.
|
||||
|
||||
Returns:
|
||||
GameLogger if initialized, None otherwise.
|
||||
"""
|
||||
return _game_logger
|
||||
|
||||
|
||||
def set_logger(logger: GameLogger) -> None:
|
||||
"""
|
||||
Set the global game logger instance.
|
||||
|
||||
Called during application startup in main.py lifespan.
|
||||
|
||||
Args:
|
||||
logger: GameLogger instance to use globally.
|
||||
"""
|
||||
global _game_logger
|
||||
_game_logger = logger
|
||||
log.info("Game logger initialized with PostgreSQL backend")
|
||||
393
server/services/matchmaking.py
Normal file
393
server/services/matchmaking.py
Normal file
@@ -0,0 +1,393 @@
|
||||
"""
|
||||
Matchmaking service for public skill-based games.
|
||||
|
||||
Uses Redis sorted sets to maintain a queue of players looking for games,
|
||||
grouped by rating. A background task periodically scans the queue and
|
||||
creates matches when enough similar-skill players are available.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class QueuedPlayer:
|
||||
"""A player waiting in the matchmaking queue."""
|
||||
user_id: str
|
||||
username: str
|
||||
rating: float
|
||||
queued_at: float # time.time()
|
||||
connection_id: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class MatchmakingConfig:
|
||||
"""Configuration for the matchmaking system."""
|
||||
enabled: bool = True
|
||||
min_players: int = 2
|
||||
max_players: int = 4
|
||||
initial_rating_window: int = 100 # +/- rating range to start
|
||||
expand_interval: int = 15 # seconds between range expansions
|
||||
expand_amount: int = 50 # rating points to expand by
|
||||
max_rating_window: int = 500 # maximum +/- range
|
||||
match_check_interval: float = 3.0 # seconds between match attempts
|
||||
countdown_seconds: int = 5 # countdown before matched game starts
|
||||
|
||||
|
||||
class MatchmakingService:
|
||||
"""
|
||||
Manages the matchmaking queue and creates matches.
|
||||
|
||||
Players join the queue with their rating. A background task
|
||||
periodically scans for groups of similarly-rated players and
|
||||
creates games when matches are found.
|
||||
"""
|
||||
|
||||
def __init__(self, redis_client, config: Optional[MatchmakingConfig] = None):
|
||||
self.redis = redis_client
|
||||
self.config = config or MatchmakingConfig()
|
||||
self._queue: dict[str, QueuedPlayer] = {} # user_id -> QueuedPlayer
|
||||
self._websockets: dict[str, WebSocket] = {} # user_id -> WebSocket
|
||||
self._connection_ids: dict[str, str] = {} # user_id -> connection_id
|
||||
self._running = False
|
||||
self._task: Optional[asyncio.Task] = None
|
||||
|
||||
async def join_queue(
|
||||
self,
|
||||
user_id: str,
|
||||
username: str,
|
||||
rating: float,
|
||||
websocket: WebSocket,
|
||||
connection_id: str,
|
||||
) -> dict:
|
||||
"""
|
||||
Add a player to the matchmaking queue.
|
||||
|
||||
Returns:
|
||||
Queue status dict.
|
||||
"""
|
||||
if user_id in self._queue:
|
||||
return {"position": self._get_position(user_id), "queue_size": len(self._queue)}
|
||||
|
||||
player = QueuedPlayer(
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
rating=rating,
|
||||
queued_at=time.time(),
|
||||
connection_id=connection_id,
|
||||
)
|
||||
|
||||
self._queue[user_id] = player
|
||||
self._websockets[user_id] = websocket
|
||||
self._connection_ids[user_id] = connection_id
|
||||
|
||||
# Also add to Redis for persistence across restarts
|
||||
if self.redis:
|
||||
try:
|
||||
await self.redis.zadd("matchmaking:queue", {user_id: rating})
|
||||
await self.redis.hset(
|
||||
"matchmaking:players",
|
||||
user_id,
|
||||
json.dumps({
|
||||
"username": username,
|
||||
"rating": rating,
|
||||
"queued_at": player.queued_at,
|
||||
"connection_id": connection_id,
|
||||
}),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis matchmaking write failed: {e}")
|
||||
|
||||
position = self._get_position(user_id)
|
||||
logger.info(f"Player {username} ({user_id[:8]}) joined queue (rating={rating:.0f}, pos={position})")
|
||||
|
||||
return {"position": position, "queue_size": len(self._queue)}
|
||||
|
||||
async def leave_queue(self, user_id: str) -> bool:
|
||||
"""Remove a player from the matchmaking queue."""
|
||||
if user_id not in self._queue:
|
||||
return False
|
||||
|
||||
player = self._queue.pop(user_id, None)
|
||||
self._websockets.pop(user_id, None)
|
||||
self._connection_ids.pop(user_id, None)
|
||||
|
||||
if self.redis:
|
||||
try:
|
||||
await self.redis.zrem("matchmaking:queue", user_id)
|
||||
await self.redis.hdel("matchmaking:players", user_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis matchmaking remove failed: {e}")
|
||||
|
||||
if player:
|
||||
logger.info(f"Player {player.username} ({user_id[:8]}) left queue")
|
||||
|
||||
return True
|
||||
|
||||
async def get_queue_status(self, user_id: str) -> dict:
|
||||
"""Get current queue status for a player."""
|
||||
if user_id not in self._queue:
|
||||
return {"in_queue": False}
|
||||
|
||||
player = self._queue[user_id]
|
||||
wait_time = time.time() - player.queued_at
|
||||
current_window = self._get_rating_window(wait_time)
|
||||
|
||||
return {
|
||||
"in_queue": True,
|
||||
"position": self._get_position(user_id),
|
||||
"queue_size": len(self._queue),
|
||||
"wait_time": int(wait_time),
|
||||
"rating_window": current_window,
|
||||
}
|
||||
|
||||
async def find_matches(self, room_manager, broadcast_game_state_fn) -> list[dict]:
|
||||
"""
|
||||
Scan the queue and create matches.
|
||||
|
||||
Returns:
|
||||
List of match info dicts for matches created.
|
||||
"""
|
||||
if len(self._queue) < self.config.min_players:
|
||||
return []
|
||||
|
||||
matches_created = []
|
||||
matched_user_ids = set()
|
||||
|
||||
# Sort players by rating
|
||||
sorted_players = sorted(self._queue.values(), key=lambda p: p.rating)
|
||||
|
||||
for player in sorted_players:
|
||||
if player.user_id in matched_user_ids:
|
||||
continue
|
||||
|
||||
wait_time = time.time() - player.queued_at
|
||||
window = self._get_rating_window(wait_time)
|
||||
|
||||
# Find compatible players
|
||||
candidates = []
|
||||
for other in sorted_players:
|
||||
if other.user_id == player.user_id or other.user_id in matched_user_ids:
|
||||
continue
|
||||
if abs(other.rating - player.rating) <= window:
|
||||
candidates.append(other)
|
||||
|
||||
# Include the player themselves
|
||||
group = [player] + candidates
|
||||
|
||||
if len(group) >= self.config.min_players:
|
||||
# Take up to max_players
|
||||
match_group = group[:self.config.max_players]
|
||||
matched_user_ids.update(p.user_id for p in match_group)
|
||||
|
||||
# Create the match
|
||||
match_info = await self._create_match(match_group, room_manager)
|
||||
if match_info:
|
||||
matches_created.append(match_info)
|
||||
|
||||
return matches_created
|
||||
|
||||
async def _create_match(self, players: list[QueuedPlayer], room_manager) -> Optional[dict]:
|
||||
"""
|
||||
Create a room for matched players and notify them.
|
||||
|
||||
Returns:
|
||||
Match info dict, or None if creation failed.
|
||||
"""
|
||||
try:
|
||||
# Create room
|
||||
room = room_manager.create_room()
|
||||
|
||||
# Add all matched players to the room
|
||||
for player in players:
|
||||
ws = self._websockets.get(player.user_id)
|
||||
if not ws:
|
||||
continue
|
||||
|
||||
room.add_player(
|
||||
player.connection_id,
|
||||
player.username,
|
||||
ws,
|
||||
player.user_id,
|
||||
)
|
||||
|
||||
# Remove matched players from queue
|
||||
for player in players:
|
||||
await self.leave_queue(player.user_id)
|
||||
|
||||
# Notify all matched players
|
||||
match_info = {
|
||||
"room_code": room.code,
|
||||
"players": [
|
||||
{"username": p.username, "rating": round(p.rating)}
|
||||
for p in players
|
||||
],
|
||||
}
|
||||
|
||||
for player in players:
|
||||
ws = self._websockets.get(player.user_id)
|
||||
if ws:
|
||||
try:
|
||||
await ws.send_json({
|
||||
"type": "queue_matched",
|
||||
"room_code": room.code,
|
||||
"players": match_info["players"],
|
||||
"countdown": self.config.countdown_seconds,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to notify matched player {player.user_id[:8]}: {e}")
|
||||
|
||||
# Also send room_joined to each player so the client switches screens
|
||||
for player in players:
|
||||
ws = self._websockets.get(player.user_id)
|
||||
if ws:
|
||||
try:
|
||||
await ws.send_json({
|
||||
"type": "room_joined",
|
||||
"room_code": room.code,
|
||||
"player_id": player.connection_id,
|
||||
"authenticated": True,
|
||||
})
|
||||
# Send player list
|
||||
await ws.send_json({
|
||||
"type": "player_joined",
|
||||
"players": room.player_list(),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
avg_rating = sum(p.rating for p in players) / len(players)
|
||||
logger.info(
|
||||
f"Match created: room={room.code}, "
|
||||
f"players={[p.username for p in players]}, "
|
||||
f"avg_rating={avg_rating:.0f}"
|
||||
)
|
||||
|
||||
# Schedule auto-start after countdown
|
||||
asyncio.create_task(self._auto_start_game(room, self.config.countdown_seconds))
|
||||
|
||||
return match_info
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create match: {e}")
|
||||
return None
|
||||
|
||||
async def _auto_start_game(self, room, countdown: int):
|
||||
"""Auto-start a matched game after countdown."""
|
||||
from game import GamePhase, GameOptions
|
||||
|
||||
await asyncio.sleep(countdown)
|
||||
|
||||
if room.game.phase != GamePhase.WAITING:
|
||||
return # Game already started or room closed
|
||||
|
||||
if len(room.players) < 2:
|
||||
return # Not enough players
|
||||
|
||||
# Standard rules for ranked games
|
||||
options = GameOptions()
|
||||
options.flip_mode = "never"
|
||||
options.initial_flips = 2
|
||||
|
||||
try:
|
||||
async with room.game_lock:
|
||||
room.game.start_game(1, 9, options) # 1 deck, 9 rounds, standard rules
|
||||
|
||||
# Send game started to all players
|
||||
for pid, rp in room.players.items():
|
||||
if rp.websocket and not rp.is_cpu:
|
||||
try:
|
||||
state = room.game.get_state(pid)
|
||||
await rp.websocket.send_json({
|
||||
"type": "game_started",
|
||||
"game_state": state,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.info(f"Auto-started matched game in room {room.code}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to auto-start matched game: {e}")
|
||||
|
||||
def _get_rating_window(self, wait_time: float) -> int:
|
||||
"""Calculate the current rating window based on wait time."""
|
||||
expansions = int(wait_time / self.config.expand_interval)
|
||||
window = self.config.initial_rating_window + (expansions * self.config.expand_amount)
|
||||
return min(window, self.config.max_rating_window)
|
||||
|
||||
def _get_position(self, user_id: str) -> int:
|
||||
"""Get a player's position in the queue (1-indexed)."""
|
||||
sorted_ids = sorted(
|
||||
self._queue.keys(),
|
||||
key=lambda uid: self._queue[uid].queued_at,
|
||||
)
|
||||
try:
|
||||
return sorted_ids.index(user_id) + 1
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
async def start(self, room_manager, broadcast_fn):
|
||||
"""Start the matchmaking background task."""
|
||||
if self._running:
|
||||
return
|
||||
|
||||
self._running = True
|
||||
self._task = asyncio.create_task(
|
||||
self._matchmaking_loop(room_manager, broadcast_fn)
|
||||
)
|
||||
logger.info("Matchmaking service started")
|
||||
|
||||
async def stop(self):
|
||||
"""Stop the matchmaking background task."""
|
||||
self._running = False
|
||||
if self._task:
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
logger.info("Matchmaking service stopped")
|
||||
|
||||
async def _matchmaking_loop(self, room_manager, broadcast_fn):
|
||||
"""Background task that periodically checks for matches."""
|
||||
while self._running:
|
||||
try:
|
||||
matches = await self.find_matches(room_manager, broadcast_fn)
|
||||
if matches:
|
||||
logger.info(f"Created {len(matches)} match(es)")
|
||||
|
||||
# Send queue status updates to all queued players
|
||||
for user_id in list(self._queue.keys()):
|
||||
ws = self._websockets.get(user_id)
|
||||
if ws:
|
||||
try:
|
||||
status = await self.get_queue_status(user_id)
|
||||
await ws.send_json({
|
||||
"type": "queue_status",
|
||||
**status,
|
||||
})
|
||||
except Exception:
|
||||
# Player disconnected, remove from queue
|
||||
await self.leave_queue(user_id)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Matchmaking error: {e}")
|
||||
|
||||
await asyncio.sleep(self.config.match_check_interval)
|
||||
|
||||
async def cleanup(self):
|
||||
"""Clean up Redis queue data on shutdown."""
|
||||
if self.redis:
|
||||
try:
|
||||
await self.redis.delete("matchmaking:queue")
|
||||
await self.redis.delete("matchmaking:players")
|
||||
except Exception:
|
||||
pass
|
||||
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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user