18 Commits
v2.0.0 ... 3.0

Author SHA1 Message Date
adlee-was-taken
850b8d6abf Standard-rules-only leaderboard with client unranked indicators
Only standard-rules games now count toward leaderboard stats. Games
with any house rule variant are marked "Unranked" in the active rules
bar, and a notice appears in the lobby when house rules are selected.
Also fixes game_logger duplicate options dicts (now uses dataclasses.asdict,
capturing all options including previously missing ones) and refactors
duplicated achievement-checking logic into shared helpers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 11:16:45 -05:00
adlee-was-taken
e1cca98b8b Fix client scoring to respect house rules for column pairs
Client-side scoring (points badge and score tally animation) ignored
house rules that modify pair behavior. Extract shared
calculateColumnScores() helper that mirrors server logic for
eagle_eye, negative_pairs_keep_value, wolfpack, four_of_a_kind,
and one_eyed_jacks rules. Server now sends scoring_rules flags
in game state.

Also fix opponent flip animation card font-size matching.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 10:34:40 -05:00
adlee-was-taken
df61d88ec6 Revise rules page strategic impact descriptions for accuracy
Rename "New Variants" to "Game Variants", fix descriptions that
contradicted game mechanics (impossible card scenarios, misleading
value assessments), and clarify Underdog Bonus catch-up intent.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 10:18:26 -05:00
adlee-was-taken
9fc6b83bba v3.0.0: V3 features, server refactoring, and documentation overhaul
- Extract WebSocket handlers from main.py into handlers.py
- Add V3 feature docs (dealer rotation, dealing animation, round end reveal,
  column pair celebration, final turn urgency, opponent thinking, score tallying,
  card hover/selection, knock early drama, column pair indicator, swap animation
  improvements, draw source distinction, card value tooltips, active rules context,
  discard pile history, realistic card sounds)
- Add V3 refactoring docs (ai.py, main.py/game.py, misc improvements)
- Add installation guide with Docker, systemd, and nginx setup
- Add helper scripts (install.sh, dev-server.sh, docker-build.sh)
- Add animation flow diagrams documentation
- Add test files for handlers, rooms, and V3 features
- Add e2e test specs for V3 features
- Update README with complete project structure and current tech stack
- Update CLAUDE.md with full architecture tree and server layer descriptions
- Update .env.example to reflect PostgreSQL (remove SQLite references)
- Update .gitignore to exclude virtualenv files, .claude/, and .db files
- Remove tracked virtualenv files (bin/, lib64, pyvenv.cfg)
- Remove obsolete game_log.py (SQLite) and games.db

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 10:03:45 -05:00
adlee-was-taken
13ab5b9017 Tune knock-early thresholds and fix failing test suite
Tighten should_knock_early() so AI no longer knocks with projected
scores of 12-14. New range: max_acceptable 5-9 (was 8-18), with
scaled knock_chance by score quality and an exception when all
opponents show 25+ visible points.

Fix 5 pre-existing test failures:
- test_event_replay: use game.current_player() instead of hardcoding
  "p1", since dealer logic makes p2 go first
- game.py: include current_player_idx in round_started event so state
  replay knows the correct starting player
- test_house_rules: rename test_rule_config → run_rule_config so
  pytest doesn't collect it as a test fixture

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 09:56:59 -05:00
adlee-was-taken
9bb9d1e397 Refactor ai.py: decompose choose_swap_or_discard and extract utilities
Break the 666-line choose_swap_or_discard into 8 focused sub-functions,
extract named constants for ~15 magic numbers, add column/pair utility
functions (iter_columns, project_score, count_hidden, hidden_positions),
and extract _log_cpu_action helper to reduce logging boilerplate in
process_cpu_turn. No behavior changes - validated with simulate.py 500.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 09:31:37 -05:00
adlee-was-taken
8431cd6fd1 Speed up score animations and fix end-of-round UI
- Cut reveal/tally/celebration timings by ~50% for snappier round end
- Add dealAnimationInProgress flag to suppress flip prompts during deal
- Stop deck/discard pulse animation when round ends
- Update CLAUDE.md with animation race condition documentation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-14 08:45:44 -05:00
adlee-was-taken
49b2490c25 Add PostgreSQL game logging system
- Add GameLogger service for move logging to PostgreSQL
- Add moves table to event_store.py for AI decision analysis
- Update main.py to initialize GameLogger in lifespan
- Update game_analyzer.py to query PostgreSQL instead of SQLite
- Add VDD documentation V2_08_GAME_LOGGING.md

Replaces SQLite game_log.py with unified PostgreSQL backend.
See docs/v2/V2_08_GAME_LOGGING.md for architecture and API.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-14 08:42:49 -05:00
adlee-was-taken
7d28e83a49 Update CLAUDE.md with AI safety checks and architecture
- Add AI decision safety checks documentation
- Add simulation testing commands
- Update architecture with services/, stores/, and new files
- Add PostgreSQL to dependencies

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-14 08:42:38 -05:00
adlee-was-taken
4ad508f84f Fix AI swapping high cards into unknown positions
Prevent CPU players from swapping 8+ value cards (8, 9, 10, J, Q) into
face-down positions, which is statistically bad since expected hidden
card value is ~4.5.

Fixes applied:
- Add value threshold (7) to unpredictability random swap path
- Restrict comeback bonus to cards with value < 8
- Reduce speculative wolfpack Jack bonus from 6x to 2x aggression
- Add safety filter to remove hidden positions for 8+ cards
- Fix endgame logic to discard 8+ instead of forcing swap into hidden
- Skip hidden positions in denial candidate list for 8+ cards
- Add swapped_high_into_unknown tracking to SimulationStats

Reduces "swapped 8+ into unknown" dumb moves from ~85 per 200 games
to ~6 per 500 games (0.054% rate, down from ~2%).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-14 08:28:10 -05:00
adlee-was-taken
9b53e51aa3 Add opponent denial strategy to AI decision making
AI now considers the next player's visible cards before discarding:
- Checks if discarding would give opponent a pair opportunity
- Calculates denial value based on card value and game phase
- May keep a worse card to deny opponent when cost is acceptable
- Denial threshold varies by AI personality (aggression)

Also updates simulation to recognize denial as a valid reason for
swapping good cards, preventing false "swapped good for bad" flags.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 19:15:39 -05:00
adlee-was-taken
cd05930b69 Add house rule presets and comparison mode to simulation runner
Enable testing AI behavior under different rule sets via CLI:
- --preset flag for named configurations (baseline, eagle_eye, etc.)
- --rules flag for custom comma-separated rules
- --compare flag for side-by-side preset comparison with metrics
- Improved dumb move detection for negative_pairs_keep_value rule

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 22:51:20 -05:00
adlee-was-taken
c615c8b433 Fix animation race conditions and improve UI feedback
- Fix discard pile "do-si-do" race condition when CPU draws from discard
- Add isDrawAnimating flag for opponent draw animations
- Skip STEP 2 (discard detection) when draw from discard detected
- Fix deal animation using wrong rect (was using whole player area)
- Add player area highlight when it's their turn (green glow)
- Clear opponent animation flags when your_turn message received
- Hide discard pile during draw-from-discard animation
- Add comprehensive debug logging for animation flags

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 00:28:06 -05:00
adlee-was-taken
4664aae8aa Bump version to 2.0.1
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 23:31:39 -05:00
adlee-was-taken
a5d108f4f2 Add animation system documentation and project context
- client/ANIMATIONS.md: Full documentation of the CardAnimations API, timing config, CSS rules, and common patterns
- CLAUDE.md: Project context for AI assistants with architecture overview and development guidelines

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 23:14:04 -05:00
adlee-was-taken
df422907b0 Speed up animations and reduce CPU turn delays
- Reduce move animation durations by 40% for snappier card movement
- Widen and slow down turn indicator shake for better visibility
- Cut CPU turn delays significantly:
  - Pre-turn pause: 0.6s → 0.25s
  - Initial look: 0.6-0.9s → 0.3-0.5s
  - Post-draw settle: 0.9s → 0.5s
  - Post-draw consider: 0.6-0.9s → 0.3-0.6s
  - Post-action pause: 0.6-0.9s → 0.3-0.5s

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 23:06:17 -05:00
adlee-was-taken
bc1b1b7725 Migrate animation system to unified anime.js framework
- Replace CSS transitions with anime.js for all card animations
- Create card-animations.js as single source for all animation logic
- Remove draw-animations.js (merged into card-animations.js)
- Strip CSS transitions from card elements to prevent conflicts
- Fix held card appearing before draw animation completes
- Make opponent/CPU animations match local player behavior
- Add subtle shake effect for turn indicator (replaces brightness pulse)
- Speed up flip animations by 30% for snappier feel
- Remove unnecessary pulse effects after draws/swaps

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 22:57:53 -05:00
adlee-was-taken
7b64b8c17c Timing and animation changes for a more natural feeling game with CPU opps.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 12:22:54 -05:00
77 changed files with 20245 additions and 2980 deletions

View File

@@ -20,13 +20,19 @@ DEBUG=false
# Logging level: DEBUG, INFO, WARNING, ERROR, CRITICAL # Logging level: DEBUG, INFO, WARNING, ERROR, CRITICAL
LOG_LEVEL=INFO LOG_LEVEL=INFO
# Environment name (development, staging, production)
ENVIRONMENT=development
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Database # Database
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# SQLite database for game logs and stats # PostgreSQL connection URL (event sourcing, game logs, stats)
# For PostgreSQL: postgresql://user:pass@host:5432/dbname # For development with Docker: postgresql://golf:devpassword@localhost:5432/golf
DATABASE_URL=sqlite:///games.db 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 # Room Settings
@@ -42,7 +48,7 @@ ROOM_TIMEOUT_MINUTES=60
ROOM_CODE_LENGTH=4 ROOM_CODE_LENGTH=4
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Security & Authentication (for future auth system) # Security & Authentication
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Secret key for JWT tokens (generate with: python -c "import secrets; print(secrets.token_hex(32))") # Secret key for JWT tokens (generate with: python -c "import secrets; print(secrets.token_hex(32))")
@@ -84,3 +90,19 @@ CARD_JOKER=-2
CARD_SUPER_KINGS=-2 # King value when super_kings enabled CARD_SUPER_KINGS=-2 # King value when super_kings enabled
CARD_TEN_PENNY=1 # 10 value when ten_penny enabled CARD_TEN_PENNY=1 # 10 value when ten_penny enabled
CARD_LUCKY_SWING_JOKER=-5 # Joker value when lucky_swing 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
# Base URL for email links
# BASE_URL=https://your-domain.com

13
.gitignore vendored
View File

@@ -188,6 +188,19 @@ cython_debug/
# you could uncomment the following to ignore the entire vscode folder # you could uncomment the following to ignore the entire vscode folder
# .vscode/ # .vscode/
# Claude Code
.claude/
# Virtualenv in project root
bin/
pyvenv.cfg
# Database files
*.db
# Personal notes
lookfah.md
# Ruff stuff: # Ruff stuff:
.ruff_cache/ .ruff_cache/

285
CLAUDE.md Normal file
View 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

573
INSTALL.md Normal file
View 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

167
README.md
View File

@@ -1,40 +1,39 @@
# Golf Card Game # 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 ## 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 - **AI Opponents:** 8 unique CPU personalities with distinct play styles
- **House Rules:** 15+ optional rule variants - **House Rules:** 15+ optional rule variants
- **Game Logging:** SQLite logging for AI decision analysis - **Smooth Animations:** Anime.js-powered card dealing, drawing, swapping, and flipping
- **Comprehensive Testing:** 80+ tests for rules and AI behavior - **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 ## Quick Start
### 1. Install Dependencies
```bash ```bash
cd server # Install dependencies
pip install -r requirements.txt 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
```
### 3. Open the Game
Open `http://localhost:8000` in your browser.
## How to Play ## How to Play
**6-Card Golf** is a card game where you try to get the **lowest score** across multiple rounds (holes). **6-Card Golf** is a card game where you try to get the **lowest score** across multiple rounds (holes).
- Each player has 6 cards in a 2×3 grid (most start face-down) - 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 - 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! - **Column pairs** (same rank top & bottom) score **0 points** — very powerful!
- When any player reveals all 6 cards, everyone else gets one final turn - When any player reveals all 6 cards, everyone else gets one final turn
@@ -61,7 +60,7 @@ The game supports 15+ optional house rules including:
- **Flip Modes** - Standard, Speed Golf (must flip after discard), Suspense (optional flip near endgame) - **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) - **Point Modifiers** - Super Kings (-2), Ten Penny (10=1), Lucky Swing Joker (-5)
- **Bonuses & Penalties** - Knock bonus/penalty, Underdog bonus, Tied Shame, Blackjack (210) - **Bonuses & Penalties** - Knock bonus/penalty, Underdog bonus, Tied Shame, Blackjack (21->0)
- **Joker Variants** - Standard, Eagle Eye (paired Jokers = -8) - **Joker Variants** - Standard, Eagle Eye (paired Jokers = -8)
See the in-game Rules page or [server/RULES.md](server/RULES.md) for complete explanations. See the in-game Rules page or [server/RULES.md](server/RULES.md) for complete explanations.
@@ -72,51 +71,117 @@ See the in-game Rules page or [server/RULES.md](server/RULES.md) for complete ex
``` ```
golfgame/ golfgame/
├── server/ ├── server/ # Python FastAPI backend
│ ├── main.py # FastAPI WebSocket server │ ├── main.py # HTTP routes, WebSocket server, lifespan
│ ├── game.py # Core game logic │ ├── game.py # Core game logic, state machine
│ ├── ai.py # AI decision making │ ├── ai.py # CPU opponent AI with timing/personality
│ ├── handlers.py # WebSocket message handlers
│ ├── room.py # Room/lobby management │ ├── room.py # Room/lobby management
│ ├── game_log.py # SQLite logging │ ├── 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 │ ├── game_analyzer.py # Decision analysis CLI
│ ├── simulate.py # AI-vs-AI simulation
│ ├── score_analysis.py # Score distribution analysis │ ├── score_analysis.py # Score distribution analysis
│ ├── test_game.py # Game rules tests │ ├── routers/ # FastAPI route modules
│ ├── test_analyzer.py # Analyzer tests │ ├── auth.py # Login, signup, verify endpoints
│ ├── test_maya_bug.py # Bug regression tests │ ├── admin.py # Admin management endpoints
│ ├── test_house_rules.py # House rules testing │ ├── stats.py # Statistics & leaderboard endpoints
└── RULES.md # Rules documentation │ ├── replay.py # Game replay endpoints
├── client/ │ │ └── health.py # Health check endpoints
│ ├── index.html │ ├── services/ # Business logic layer
│ ├── style.css │ ├── auth_service.py # User authentication
└── app.js │ ├── 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 └── README.md
``` ```
### Running Tests ### Running Tests
```bash ```bash
cd server # All server tests
pytest test_game.py test_analyzer.py test_maya_bug.py -v 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 ### AI Simulation
```bash ```bash
# Run 50 games with 4 AI players # Run 500 games and check dumb move rate
python simulate.py 50 4 python server/simulate.py 500
# Run detailed single game # Detailed single game output
python simulate.py detail 4 python server/simulate.py 1 --detailed
# Compare rule presets
python server/simulate.py 100 --compare
# Analyze AI decisions for blunders # Analyze AI decisions for blunders
python game_analyzer.py blunders python server/game_analyzer.py blunders
# Score distribution analysis # Score distribution analysis
python score_analysis.py 100 4 python server/score_analysis.py 100
# Test all house rules
python test_house_rules.py 40
``` ```
### AI Performance ### AI Performance
@@ -129,10 +194,12 @@ From testing (1000+ games):
## Technology Stack ## Technology Stack
- **Backend:** Python 3.12+, FastAPI, WebSockets - **Backend:** Python 3.11+, FastAPI, WebSockets
- **Frontend:** Vanilla HTML/CSS/JavaScript - **Frontend:** Vanilla HTML/CSS/JavaScript, anime.js (animations)
- **Database:** SQLite (optional, for game logging) - **Database:** PostgreSQL (event sourcing, auth, stats, game logs)
- **Testing:** pytest - **Cache:** Redis (state caching, pub/sub)
- **Testing:** pytest, Playwright (e2e)
- **Deployment:** Docker, systemd, nginx
## License ## License

View File

@@ -1,247 +0,0 @@
<#
.Synopsis
Activate a Python virtual environment for the current PowerShell session.
.Description
Pushes the python executable for a virtual environment to the front of the
$Env:PATH environment variable and sets the prompt to signify that you are
in a Python virtual environment. Makes use of the command line switches as
well as the `pyvenv.cfg` file values present in the virtual environment.
.Parameter VenvDir
Path to the directory that contains the virtual environment to activate. The
default value for this is the parent of the directory that the Activate.ps1
script is located within.
.Parameter Prompt
The prompt prefix to display when this virtual environment is activated. By
default, this prompt is the name of the virtual environment folder (VenvDir)
surrounded by parentheses and followed by a single space (ie. '(.venv) ').
.Example
Activate.ps1
Activates the Python virtual environment that contains the Activate.ps1 script.
.Example
Activate.ps1 -Verbose
Activates the Python virtual environment that contains the Activate.ps1 script,
and shows extra information about the activation as it executes.
.Example
Activate.ps1 -VenvDir C:\Users\MyUser\Common\.venv
Activates the Python virtual environment located in the specified location.
.Example
Activate.ps1 -Prompt "MyPython"
Activates the Python virtual environment that contains the Activate.ps1 script,
and prefixes the current prompt with the specified string (surrounded in
parentheses) while the virtual environment is active.
.Notes
On Windows, it may be required to enable this Activate.ps1 script by setting the
execution policy for the user. You can do this by issuing the following PowerShell
command:
PS C:\> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
For more information on Execution Policies:
https://go.microsoft.com/fwlink/?LinkID=135170
#>
Param(
[Parameter(Mandatory = $false)]
[String]
$VenvDir,
[Parameter(Mandatory = $false)]
[String]
$Prompt
)
<# Function declarations --------------------------------------------------- #>
<#
.Synopsis
Remove all shell session elements added by the Activate script, including the
addition of the virtual environment's Python executable from the beginning of
the PATH variable.
.Parameter NonDestructive
If present, do not remove this function from the global namespace for the
session.
#>
function global:deactivate ([switch]$NonDestructive) {
# Revert to original values
# The prior prompt:
if (Test-Path -Path Function:_OLD_VIRTUAL_PROMPT) {
Copy-Item -Path Function:_OLD_VIRTUAL_PROMPT -Destination Function:prompt
Remove-Item -Path Function:_OLD_VIRTUAL_PROMPT
}
# The prior PYTHONHOME:
if (Test-Path -Path Env:_OLD_VIRTUAL_PYTHONHOME) {
Copy-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME -Destination Env:PYTHONHOME
Remove-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME
}
# The prior PATH:
if (Test-Path -Path Env:_OLD_VIRTUAL_PATH) {
Copy-Item -Path Env:_OLD_VIRTUAL_PATH -Destination Env:PATH
Remove-Item -Path Env:_OLD_VIRTUAL_PATH
}
# Just remove the VIRTUAL_ENV altogether:
if (Test-Path -Path Env:VIRTUAL_ENV) {
Remove-Item -Path env:VIRTUAL_ENV
}
# Just remove VIRTUAL_ENV_PROMPT altogether.
if (Test-Path -Path Env:VIRTUAL_ENV_PROMPT) {
Remove-Item -Path env:VIRTUAL_ENV_PROMPT
}
# Just remove the _PYTHON_VENV_PROMPT_PREFIX altogether:
if (Get-Variable -Name "_PYTHON_VENV_PROMPT_PREFIX" -ErrorAction SilentlyContinue) {
Remove-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Scope Global -Force
}
# Leave deactivate function in the global namespace if requested:
if (-not $NonDestructive) {
Remove-Item -Path function:deactivate
}
}
<#
.Description
Get-PyVenvConfig parses the values from the pyvenv.cfg file located in the
given folder, and returns them in a map.
For each line in the pyvenv.cfg file, if that line can be parsed into exactly
two strings separated by `=` (with any amount of whitespace surrounding the =)
then it is considered a `key = value` line. The left hand string is the key,
the right hand is the value.
If the value starts with a `'` or a `"` then the first and last character is
stripped from the value before being captured.
.Parameter ConfigDir
Path to the directory that contains the `pyvenv.cfg` file.
#>
function Get-PyVenvConfig(
[String]
$ConfigDir
) {
Write-Verbose "Given ConfigDir=$ConfigDir, obtain values in pyvenv.cfg"
# Ensure the file exists, and issue a warning if it doesn't (but still allow the function to continue).
$pyvenvConfigPath = Join-Path -Resolve -Path $ConfigDir -ChildPath 'pyvenv.cfg' -ErrorAction Continue
# An empty map will be returned if no config file is found.
$pyvenvConfig = @{ }
if ($pyvenvConfigPath) {
Write-Verbose "File exists, parse `key = value` lines"
$pyvenvConfigContent = Get-Content -Path $pyvenvConfigPath
$pyvenvConfigContent | ForEach-Object {
$keyval = $PSItem -split "\s*=\s*", 2
if ($keyval[0] -and $keyval[1]) {
$val = $keyval[1]
# Remove extraneous quotations around a string value.
if ("'""".Contains($val.Substring(0, 1))) {
$val = $val.Substring(1, $val.Length - 2)
}
$pyvenvConfig[$keyval[0]] = $val
Write-Verbose "Adding Key: '$($keyval[0])'='$val'"
}
}
}
return $pyvenvConfig
}
<# Begin Activate script --------------------------------------------------- #>
# Determine the containing directory of this script
$VenvExecPath = Split-Path -Parent $MyInvocation.MyCommand.Definition
$VenvExecDir = Get-Item -Path $VenvExecPath
Write-Verbose "Activation script is located in path: '$VenvExecPath'"
Write-Verbose "VenvExecDir Fullname: '$($VenvExecDir.FullName)"
Write-Verbose "VenvExecDir Name: '$($VenvExecDir.Name)"
# Set values required in priority: CmdLine, ConfigFile, Default
# First, get the location of the virtual environment, it might not be
# VenvExecDir if specified on the command line.
if ($VenvDir) {
Write-Verbose "VenvDir given as parameter, using '$VenvDir' to determine values"
}
else {
Write-Verbose "VenvDir not given as a parameter, using parent directory name as VenvDir."
$VenvDir = $VenvExecDir.Parent.FullName.TrimEnd("\\/")
Write-Verbose "VenvDir=$VenvDir"
}
# Next, read the `pyvenv.cfg` file to determine any required value such
# as `prompt`.
$pyvenvCfg = Get-PyVenvConfig -ConfigDir $VenvDir
# Next, set the prompt from the command line, or the config file, or
# just use the name of the virtual environment folder.
if ($Prompt) {
Write-Verbose "Prompt specified as argument, using '$Prompt'"
}
else {
Write-Verbose "Prompt not specified as argument to script, checking pyvenv.cfg value"
if ($pyvenvCfg -and $pyvenvCfg['prompt']) {
Write-Verbose " Setting based on value in pyvenv.cfg='$($pyvenvCfg['prompt'])'"
$Prompt = $pyvenvCfg['prompt'];
}
else {
Write-Verbose " Setting prompt based on parent's directory's name. (Is the directory name passed to venv module when creating the virtual environment)"
Write-Verbose " Got leaf-name of $VenvDir='$(Split-Path -Path $venvDir -Leaf)'"
$Prompt = Split-Path -Path $venvDir -Leaf
}
}
Write-Verbose "Prompt = '$Prompt'"
Write-Verbose "VenvDir='$VenvDir'"
# Deactivate any currently active virtual environment, but leave the
# deactivate function in place.
deactivate -nondestructive
# Now set the environment variable VIRTUAL_ENV, used by many tools to determine
# that there is an activated venv.
$env:VIRTUAL_ENV = $VenvDir
if (-not $Env:VIRTUAL_ENV_DISABLE_PROMPT) {
Write-Verbose "Setting prompt to '$Prompt'"
# Set the prompt to include the env name
# Make sure _OLD_VIRTUAL_PROMPT is global
function global:_OLD_VIRTUAL_PROMPT { "" }
Copy-Item -Path function:prompt -Destination function:_OLD_VIRTUAL_PROMPT
New-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Description "Python virtual environment prompt prefix" -Scope Global -Option ReadOnly -Visibility Public -Value $Prompt
function global:prompt {
Write-Host -NoNewline -ForegroundColor Green "($_PYTHON_VENV_PROMPT_PREFIX) "
_OLD_VIRTUAL_PROMPT
}
$env:VIRTUAL_ENV_PROMPT = $Prompt
}
# Clear PYTHONHOME
if (Test-Path -Path Env:PYTHONHOME) {
Copy-Item -Path Env:PYTHONHOME -Destination Env:_OLD_VIRTUAL_PYTHONHOME
Remove-Item -Path Env:PYTHONHOME
}
# Add the venv to the PATH
Copy-Item -Path Env:PATH -Destination Env:_OLD_VIRTUAL_PATH
$Env:PATH = "$VenvExecDir$([System.IO.Path]::PathSeparator)$Env:PATH"

View File

@@ -1,76 +0,0 @@
# This file must be used with "source bin/activate" *from bash*
# You cannot run it directly
deactivate () {
# reset old environment variables
if [ -n "${_OLD_VIRTUAL_PATH:-}" ] ; then
PATH="${_OLD_VIRTUAL_PATH:-}"
export PATH
unset _OLD_VIRTUAL_PATH
fi
if [ -n "${_OLD_VIRTUAL_PYTHONHOME:-}" ] ; then
PYTHONHOME="${_OLD_VIRTUAL_PYTHONHOME:-}"
export PYTHONHOME
unset _OLD_VIRTUAL_PYTHONHOME
fi
# This should detect bash and zsh, which have a hash command that must
# be called to get it to forget past commands. Without forgetting
# past commands the $PATH changes we made may not be respected
if [ -n "${BASH:-}" -o -n "${ZSH_VERSION:-}" ] ; then
hash -r 2> /dev/null
fi
if [ -n "${_OLD_VIRTUAL_PS1:-}" ] ; then
PS1="${_OLD_VIRTUAL_PS1:-}"
export PS1
unset _OLD_VIRTUAL_PS1
fi
unset VIRTUAL_ENV
unset VIRTUAL_ENV_PROMPT
if [ ! "${1:-}" = "nondestructive" ] ; then
# Self destruct!
unset -f deactivate
fi
}
# unset irrelevant variables
deactivate nondestructive
# on Windows, a path can contain colons and backslashes and has to be converted:
if [ "$OSTYPE" = "cygwin" ] || [ "$OSTYPE" = "msys" ] ; then
# transform D:\path\to\venv to /d/path/to/venv on MSYS
# and to /cygdrive/d/path/to/venv on Cygwin
export VIRTUAL_ENV=$(cygpath "/home/alee/Sources/golfgame")
else
# use the path as-is
export VIRTUAL_ENV="/home/alee/Sources/golfgame"
fi
_OLD_VIRTUAL_PATH="$PATH"
PATH="$VIRTUAL_ENV/bin:$PATH"
export PATH
# unset PYTHONHOME if set
# this will fail if PYTHONHOME is set to the empty string (which is bad anyway)
# could use `if (set -u; : $PYTHONHOME) ;` in bash
if [ -n "${PYTHONHOME:-}" ] ; then
_OLD_VIRTUAL_PYTHONHOME="${PYTHONHOME:-}"
unset PYTHONHOME
fi
if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT:-}" ] ; then
_OLD_VIRTUAL_PS1="${PS1:-}"
PS1="(golfgame) ${PS1:-}"
export PS1
VIRTUAL_ENV_PROMPT="(golfgame) "
export VIRTUAL_ENV_PROMPT
fi
# This should detect bash and zsh, which have a hash command that must
# be called to get it to forget past commands. Without forgetting
# past commands the $PATH changes we made may not be respected
if [ -n "${BASH:-}" -o -n "${ZSH_VERSION:-}" ] ; then
hash -r 2> /dev/null
fi

View File

@@ -1,27 +0,0 @@
# This file must be used with "source bin/activate.csh" *from csh*.
# You cannot run it directly.
# Created by Davide Di Blasi <davidedb@gmail.com>.
# Ported to Python 3.3 venv by Andrew Svetlov <andrew.svetlov@gmail.com>
alias deactivate 'test $?_OLD_VIRTUAL_PATH != 0 && setenv PATH "$_OLD_VIRTUAL_PATH" && unset _OLD_VIRTUAL_PATH; rehash; test $?_OLD_VIRTUAL_PROMPT != 0 && set prompt="$_OLD_VIRTUAL_PROMPT" && unset _OLD_VIRTUAL_PROMPT; unsetenv VIRTUAL_ENV; unsetenv VIRTUAL_ENV_PROMPT; test "\!:*" != "nondestructive" && unalias deactivate'
# Unset irrelevant variables.
deactivate nondestructive
setenv VIRTUAL_ENV "/home/alee/Sources/golfgame"
set _OLD_VIRTUAL_PATH="$PATH"
setenv PATH "$VIRTUAL_ENV/bin:$PATH"
set _OLD_VIRTUAL_PROMPT="$prompt"
if (! "$?VIRTUAL_ENV_DISABLE_PROMPT") then
set prompt = "(golfgame) $prompt"
setenv VIRTUAL_ENV_PROMPT "(golfgame) "
endif
alias pydoc python -m pydoc
rehash

View File

@@ -1,69 +0,0 @@
# This file must be used with "source <venv>/bin/activate.fish" *from fish*
# (https://fishshell.com/). You cannot run it directly.
function deactivate -d "Exit virtual environment and return to normal shell environment"
# reset old environment variables
if test -n "$_OLD_VIRTUAL_PATH"
set -gx PATH $_OLD_VIRTUAL_PATH
set -e _OLD_VIRTUAL_PATH
end
if test -n "$_OLD_VIRTUAL_PYTHONHOME"
set -gx PYTHONHOME $_OLD_VIRTUAL_PYTHONHOME
set -e _OLD_VIRTUAL_PYTHONHOME
end
if test -n "$_OLD_FISH_PROMPT_OVERRIDE"
set -e _OLD_FISH_PROMPT_OVERRIDE
# prevents error when using nested fish instances (Issue #93858)
if functions -q _old_fish_prompt
functions -e fish_prompt
functions -c _old_fish_prompt fish_prompt
functions -e _old_fish_prompt
end
end
set -e VIRTUAL_ENV
set -e VIRTUAL_ENV_PROMPT
if test "$argv[1]" != "nondestructive"
# Self-destruct!
functions -e deactivate
end
end
# Unset irrelevant variables.
deactivate nondestructive
set -gx VIRTUAL_ENV "/home/alee/Sources/golfgame"
set -gx _OLD_VIRTUAL_PATH $PATH
set -gx PATH "$VIRTUAL_ENV/bin" $PATH
# Unset PYTHONHOME if set.
if set -q PYTHONHOME
set -gx _OLD_VIRTUAL_PYTHONHOME $PYTHONHOME
set -e PYTHONHOME
end
if test -z "$VIRTUAL_ENV_DISABLE_PROMPT"
# fish uses a function instead of an env var to generate the prompt.
# Save the current fish_prompt function as the function _old_fish_prompt.
functions -c fish_prompt _old_fish_prompt
# With the original prompt function renamed, we can override with our own.
function fish_prompt
# Save the return status of the last command.
set -l old_status $status
# Output the venv prompt; color taken from the blue of the Python logo.
printf "%s%s%s" (set_color 4B8BBE) "(golfgame) " (set_color normal)
# Restore the return status of the previous command.
echo "exit $old_status" | .
# Output the original/"old" prompt.
_old_fish_prompt
end
set -gx _OLD_FISH_PROMPT_OVERRIDE "$VIRTUAL_ENV"
set -gx VIRTUAL_ENV_PROMPT "(golfgame) "
end

View File

@@ -1,8 +0,0 @@
#!/home/alee/Sources/golfgame/bin/python
# -*- coding: utf-8 -*-
import re
import sys
from pip._internal.cli.main import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

View File

@@ -1,8 +0,0 @@
#!/home/alee/Sources/golfgame/bin/python
# -*- coding: utf-8 -*-
import re
import sys
from pip._internal.cli.main import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

View File

@@ -1,8 +0,0 @@
#!/home/alee/Sources/golfgame/bin/python
# -*- coding: utf-8 -*-
import re
import sys
from pip._internal.cli.main import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

View File

@@ -1 +0,0 @@
/home/alee/.pyenv/versions/3.12.0/bin/python

View File

@@ -1 +0,0 @@
python

View File

@@ -1 +0,0 @@
python

307
client/ANIMATIONS.md Normal file
View 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

View File

@@ -11,16 +11,23 @@ class AnimationQueue {
this.processing = false; this.processing = false;
this.animationInProgress = false; this.animationInProgress = false;
// Timing configuration (ms) // Timing configuration (ms) - use centralized TIMING config
// Rhythm: action → settle → action → breathe const T = window.TIMING || {};
this.timing = { this.timing = {
flipDuration: 540, // Must match CSS .card-inner transition (0.54s) flipDuration: T.card?.flip || 540,
moveDuration: 270, moveDuration: T.card?.move || 270,
pauseAfterFlip: 144, // Brief settle after flip before move cardLift: T.card?.lift || 100,
pauseAfterDiscard: 550, // Let discard land + pulse (400ms) + settle pauseAfterFlip: T.pause?.afterFlip || 144,
pauseBeforeNewCard: 150, // Anticipation before new card moves in pauseAfterDiscard: T.pause?.afterDiscard || 550,
pauseAfterSwapComplete: 400, // Breathing room after swap completes pauseBeforeNewCard: T.pause?.beforeNewCard || 150,
pauseBetweenAnimations: 90 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,
}; };
} }
@@ -124,7 +131,7 @@ class AnimationQueue {
// Animate the flip // Animate the flip
this.playSound('flip'); this.playSound('flip');
await this.delay(50); // Brief pause before flip await this.delay(this.timing.pauseBeforeFlip);
// Remove flipped to trigger animation to front // Remove flipped to trigger animation to front
inner.classList.remove('flipped'); inner.classList.remove('flipped');
@@ -136,11 +143,10 @@ class AnimationQueue {
animCard.remove(); animCard.remove();
} }
// Animate a card swap (hand card to discard, drawn card to hand) // Animate a card swap - smooth continuous motion
async animateSwap(movement) { async animateSwap(movement) {
const { playerId, position, oldCard, newCard } = movement; const { playerId, position, oldCard, newCard } = movement;
// Get positions
const slotRect = this.getSlotRect(playerId, position); const slotRect = this.getSlotRect(playerId, position);
const discardRect = this.getLocationRect('discard'); const discardRect = this.getLocationRect('discard');
const holdingRect = this.getLocationRect('holding'); const holdingRect = this.getLocationRect('holding');
@@ -149,67 +155,54 @@ class AnimationQueue {
return; return;
} }
// Create a temporary card element for the animation // Create animation cards
const animCard = this.createAnimCard(); const handCard = this.createAnimCard();
this.cardManager.cardLayer.appendChild(animCard); this.cardManager.cardLayer.appendChild(handCard);
this.setCardPosition(handCard, slotRect);
// Position at slot const handInner = handCard.querySelector('.card-inner');
this.setCardPosition(animCard, slotRect); const handFront = handCard.querySelector('.card-face-front');
// Start face down (showing back) const heldCard = this.createAnimCard();
const inner = animCard.querySelector('.card-inner'); this.cardManager.cardLayer.appendChild(heldCard);
const front = animCard.querySelector('.card-face-front'); this.setCardPosition(heldCard, holdingRect || discardRect);
inner.classList.add('flipped');
// Step 1: If card was face down, flip to reveal it const heldInner = heldCard.querySelector('.card-inner');
this.setCardFront(front, oldCard); 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) { if (!oldCard.face_up) {
this.playSound('flip'); this.playSound('flip');
inner.classList.remove('flipped'); handInner.classList.remove('flipped');
await this.delay(this.timing.flipDuration); await this.delay(this.timing.flipDuration);
await this.delay(this.timing.pauseAfterFlip);
} else {
// Already face up, just show it immediately
inner.classList.remove('flipped');
} }
// Step 2: Move card to discard pile // 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'); this.playSound('card');
animCard.classList.add('moving'); handCard.classList.remove('fade-out');
this.setCardPosition(animCard, discardRect); heldCard.classList.remove('fade-out');
await this.delay(this.timing.moveDuration); handCard.classList.add('fade-in');
animCard.classList.remove('moving'); heldCard.classList.add('fade-in');
await this.delay(150);
// Let discard land and pulse settle // Clean up
await this.delay(this.timing.pauseAfterDiscard); handCard.remove();
heldCard.remove();
// Step 3: Create second card for the new card coming into hand
const newAnimCard = this.createAnimCard();
this.cardManager.cardLayer.appendChild(newAnimCard);
// New card starts at holding/discard position
this.setCardPosition(newAnimCard, holdingRect || discardRect);
const newInner = newAnimCard.querySelector('.card-inner');
const newFront = newAnimCard.querySelector('.card-face-front');
// Show new card (it's face up from the drawn card)
this.setCardFront(newFront, newCard);
newInner.classList.remove('flipped');
// Brief anticipation before new card moves
await this.delay(this.timing.pauseBeforeNewCard);
// Step 4: Move new card to the hand slot
this.playSound('card');
newAnimCard.classList.add('moving');
this.setCardPosition(newAnimCard, slotRect);
await this.delay(this.timing.moveDuration);
newAnimCard.classList.remove('moving');
// Breathing room after swap completes
await this.delay(this.timing.pauseAfterSwapComplete);
animCard.remove();
newAnimCard.remove();
} }
// Create a temporary animation card element // Create a temporary animation card element
@@ -337,22 +330,47 @@ class AnimationQueue {
animCard.remove(); animCard.remove();
} }
// Animate drawing from discard // Animate drawing from discard - show card lifting and moving to holding position
async animateDrawDiscard(movement) { async animateDrawDiscard(movement) {
const { playerId } = movement; const { card } = movement;
// Discard to holding is mostly visual feedback
// The card "lifts" slightly
const discardRect = this.getLocationRect('discard'); const discardRect = this.getLocationRect('discard');
const holdingRect = this.getLocationRect('holding'); const holdingRect = this.getLocationRect('holding');
if (!discardRect || !holdingRect) return; if (!discardRect || !holdingRect) return;
// Just play sound - visual handled by CSS :holding state // Create animation card at discard position (face UP - visible card)
this.playSound('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); 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 // Check if animations are currently playing

8
client/anime.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

1708
client/card-animations.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -52,7 +52,7 @@ class CardManager {
card.innerHTML = ` card.innerHTML = `
<div class="card-inner"> <div class="card-inner">
<div class="card-face card-face-front"></div> <div class="card-face card-face-front"></div>
<div class="card-face card-face-back"><span>?</span></div> <div class="card-face card-face-back"></div>
</div> </div>
`; `;
@@ -64,10 +64,22 @@ class CardManager {
updateCardAppearance(cardEl, cardData) { updateCardAppearance(cardEl, cardData) {
const inner = cardEl.querySelector('.card-inner'); const inner = cardEl.querySelector('.card-inner');
const front = cardEl.querySelector('.card-face-front'); const front = cardEl.querySelector('.card-face-front');
const back = cardEl.querySelector('.card-face-back');
// Reset front classes // Reset front classes
front.className = 'card-face card-face-front'; 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) { if (!cardData || !cardData.face_up || !cardData.rank) {
// Face down or no data // Face down or no data
inner.classList.add('flipped'); inner.classList.add('flipped');
@@ -88,6 +100,17 @@ class CardManager {
} }
} }
// 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) { getSuitSymbol(suit) {
return { hearts: '♥', diamonds: '♦', clubs: '♣', spades: '♠' }[suit] || ''; return { hearts: '♥', diamonds: '♦', clubs: '♣', spades: '♠' }[suit] || '';
} }
@@ -104,7 +127,8 @@ class CardManager {
cardEl.style.height = `${rect.height}px`; cardEl.style.height = `${rect.height}px`;
if (animate) { if (animate) {
setTimeout(() => cardEl.classList.remove('moving'), 350); const moveDuration = window.TIMING?.card?.moving || 350;
setTimeout(() => cardEl.classList.remove('moving'), moveDuration);
} }
} }
@@ -130,7 +154,11 @@ class CardManager {
} }
// Animate a card flip // Animate a card flip
async flipCard(playerId, position, newCardData, duration = 400) { 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); const cardInfo = this.getHandCard(playerId, position);
if (!cardInfo) return; if (!cardInfo) return;
@@ -158,7 +186,11 @@ class CardManager {
} }
// Animate a swap: hand card goes to discard, new card comes to hand // Animate a swap: hand card goes to discard, new card comes to hand
async animateSwap(playerId, position, oldCardData, newCardData, getSlotRect, getDiscardRect, duration = 300) { 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); const cardInfo = this.getHandCard(playerId, position);
if (!cardInfo) return; if (!cardInfo) return;
@@ -192,7 +224,8 @@ class CardManager {
} }
inner.classList.remove('flipped'); inner.classList.remove('flipped');
await this.delay(400); const flipDuration = window.TIMING?.cardManager?.flipDuration || 400;
await this.delay(flipDuration);
} }
// Step 2: Move card to discard // Step 2: Move card to discard
@@ -202,7 +235,8 @@ class CardManager {
cardEl.classList.remove('moving'); cardEl.classList.remove('moving');
// Pause to show the discarded card // Pause to show the discarded card
await this.delay(250); const pauseDuration = window.TIMING?.cardManager?.moveDuration || 250;
await this.delay(pauseDuration);
// Step 3: Update card to show new card and move back to hand // Step 3: Update card to show new card and move back to hand
front.className = 'card-face card-face-front'; front.className = 'card-face card-face-front';

View File

@@ -78,12 +78,13 @@
<h3>Game Settings</h3> <h3>Game Settings</h3>
<div class="basic-settings-row"> <div class="basic-settings-row">
<div class="form-group"> <div class="form-group">
<label for="num-decks">Decks</label> <label>Decks</label>
<select id="num-decks"> <div class="stepper-control">
<option value="1">1</option> <button type="button" id="decks-minus" class="stepper-btn"></button>
<option value="2">2</option> <span id="num-decks-display" class="stepper-value">1</span>
<option value="3">3</option> <input type="hidden" id="num-decks" value="1">
</select> <button type="button" id="decks-plus" class="stepper-btn">+</button>
</div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="num-rounds">Holes</label> <label for="num-rounds">Holes</label>
@@ -94,13 +95,36 @@
<option value="1">1</option> <option value="1">1</option>
</select> </select>
</div> </div>
<div class="form-group"> <div id="deck-colors-group" class="form-group">
<label for="initial-flips">Cards Revealed</label> <label for="deck-color-preset">Card Backs</label>
<select id="initial-flips"> <div class="deck-color-selector">
<option value="2" selected>2 cards</option> <select id="deck-color-preset">
<option value="1">1 card</option> <optgroup label="Themes">
<option value="0">None</option> <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> </select>
<div id="deck-color-preview" class="deck-color-preview">
<div class="preview-card deck-red"></div>
</div>
</div>
</div> </div>
</div> </div>
<p id="deck-recommendation" class="recommendation hidden">Recommended: 2+ decks for 4+ players</p> <p id="deck-recommendation" class="recommendation hidden">Recommended: 2+ decks for 4+ players</p>
@@ -235,6 +259,7 @@
</div> </div>
</details> </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> <button id="start-game-btn" class="btn btn-primary">Start Game</button>
</div> </div>
@@ -258,7 +283,11 @@
</div> </div>
<div class="header-col header-col-center"> <div class="header-col header-col-center">
<div id="status-message" class="status-message"></div> <div id="status-message" class="status-message"></div>
<div id="final-turn-badge" class="final-turn-badge hidden">⚡ FINAL TURN</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>
<span class="final-turn-remaining"></span>
</div>
</div> </div>
<div class="header-col header-col-right"> <div class="header-col header-col-right">
<span id="game-username" class="game-username hidden"></span> <span id="game-username" class="game-username hidden"></span>
@@ -281,9 +310,7 @@
</div> </div>
<span class="held-label">Holding</span> <span class="held-label">Holding</span>
</div> </div>
<div id="deck" class="card card-back"> <div id="deck" class="card card-back"></div>
<span>?</span>
</div>
<div class="discard-stack"> <div class="discard-stack">
<div id="discard" class="card"> <div id="discard" class="card">
<span id="discard-content"></span> <span id="discard-content"></span>
@@ -312,14 +339,14 @@
<div id="swap-card-from-hand" class="swap-card"> <div id="swap-card-from-hand" class="swap-card">
<div class="swap-card-inner"> <div class="swap-card-inner">
<div class="swap-card-front"></div> <div class="swap-card-front"></div>
<div class="swap-card-back">?</div> <div class="swap-card-back"></div>
</div> </div>
</div> </div>
<!-- Drawn card being held (animates to hand) --> <!-- Drawn card being held (animates to hand) -->
<div id="held-card" class="swap-card hidden"> <div id="held-card" class="swap-card hidden">
<div class="swap-card-inner"> <div class="swap-card-inner">
<div class="swap-card-front"></div> <div class="swap-card-front"></div>
<div class="swap-card-back">?</div> <div class="swap-card-back"></div>
</div> </div>
</div> </div>
</div> </div>
@@ -335,11 +362,12 @@
<!-- Right panel: Scores --> <!-- Right panel: Scores -->
<div id="scoreboard" class="side-panel right-panel"> <div id="scoreboard" class="side-panel right-panel">
<h4>Scores</h4>
<div id="game-buttons" class="game-buttons hidden"> <div id="game-buttons" class="game-buttons hidden">
<button id="next-round-btn" class="btn btn-next-round hidden">Next Hole</button> <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> <button id="new-game-btn" class="btn btn-small btn-secondary hidden">New Game</button>
<hr class="scores-divider">
</div> </div>
<h4>Scores</h4>
<table id="score-table"> <table id="score-table">
<thead> <thead>
<tr> <tr>
@@ -520,12 +548,12 @@ TOTAL: 0 + 8 + 16 = 24 points</pre>
<div class="house-rule"> <div class="house-rule">
<h4>Super Kings</h4> <h4>Super Kings</h4>
<p>Kings are worth <strong>-2 points</strong> instead of 0.</p> <p>Kings are worth <strong>-2 points</strong> instead of 0.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> Kings become valuable to keep unpaired, not just pairing fodder. Creates interesting decisions - do you pair Kings for 0, or keep them separate for -4 total?</p> <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>
<div class="house-rule"> <div class="house-rule">
<h4>Ten Penny</h4> <h4>Ten Penny</h4>
<p>10s are worth <strong>1 point</strong> instead of 10.</p> <p>10s are worth <strong>1 point</strong> instead of 10.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> Removes the "10 disaster" - drawing a 10 is no longer a crisis. Queens and Jacks become the only truly bad cards. Makes the game more forgiving.</p> <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> </div>
@@ -534,12 +562,12 @@ TOTAL: 0 + 8 + 16 = 24 points</pre>
<div class="house-rule"> <div class="house-rule">
<h4>Standard Jokers</h4> <h4>Standard Jokers</h4>
<p>2 Jokers per deck, each worth <strong>-2 points</strong>.</p> <p>2 Jokers per deck, each worth <strong>-2 points</strong>.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> Jokers are great to find but pairing them is wasteful (0 points instead of -4). Best kept in different columns. Adds 2 premium cards to hunt for.</p> <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>
<div class="house-rule"> <div class="house-rule">
<h4>Lucky Swing</h4> <h4>Lucky Swing</h4>
<p>Only <strong>1 Joker</strong> in the entire deck, worth <strong>-5 points</strong>.</p> <p>Only <strong>1 Joker</strong> in the entire deck, worth <strong>-5 points</strong>.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> High variance. Whoever finds this rare card gets a significant advantage. Increases the luck factor - sometimes you get it, sometimes your opponent does.</p> <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>
<div class="house-rule"> <div class="house-rule">
<h4>Eagle Eye</h4> <h4>Eagle Eye</h4>
@@ -553,12 +581,12 @@ TOTAL: 0 + 8 + 16 = 24 points</pre>
<div class="house-rule"> <div class="house-rule">
<h4>Knock Penalty</h4> <h4>Knock Penalty</h4>
<p><strong>+10 points</strong> if you go out but don't have the lowest score.</p> <p><strong>+10 points</strong> if you go out but don't have the lowest score.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> Discourages reckless rushing. You need to be confident you're winning before going out. Rewards patience and reading your opponents' likely scores. Can backfire spectacularly if you misjudge.</p> <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>
<div class="house-rule"> <div class="house-rule">
<h4>Knock Bonus</h4> <h4>Knock Bonus</h4>
<p><strong>-5 points</strong> for going out first (regardless of who wins).</p> <p><strong>-5 points</strong> for going out first (regardless of who wins).</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> Encourages racing to finish, even with a mediocre hand. The 5-point bonus might make up for a slightly worse score. Speeds up gameplay.</p> <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> </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> <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>
@@ -568,27 +596,27 @@ TOTAL: 0 + 8 + 16 = 24 points</pre>
<div class="house-rule"> <div class="house-rule">
<h4>Underdog Bonus</h4> <h4>Underdog Bonus</h4>
<p>Round winner gets <strong>-3 points</strong> extra.</p> <p>Round winner gets <strong>-3 points</strong> extra.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> Amplifies winning - the best player each round pulls further ahead. Can lead to snowballing leads over multiple holes. Rewards consistency.</p> <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>
<div class="house-rule"> <div class="house-rule">
<h4>Tied Shame</h4> <h4>Tied Shame</h4>
<p>If you tie another player's score, <strong>both get +5 penalty</strong>.</p> <p>If you tie another player's score, <strong>both get +5 penalty</strong>.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> Punishes playing it safe. If you suspect a tie, you need to take risks to differentiate your score. Creates interesting late-round decisions.</p> <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>
<div class="house-rule"> <div class="house-rule">
<h4>Blackjack</h4> <h4>Blackjack</h4>
<p>Score of exactly <strong>21 becomes 0</strong>.</p> <p>Score of exactly <strong>21 becomes 0</strong>.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> A "hail mary" comeback. If you're stuck at 21, you're suddenly in great shape. Mostly luck, but adds exciting moments when it happens.</p> <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>
<div class="house-rule"> <div class="house-rule">
<h4>Wolfpack</h4> <h4>Wolfpack</h4>
<p>Having <strong>all 4 Jacks</strong> (2 pairs) gives <strong>-20 bonus</strong>.</p> <p>Having <strong>all 4 Jacks</strong> (2 pairs) gives <strong>-20 bonus</strong>.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> Extremely rare but now a significant reward! Turns a potential disaster (40 points of Jacks) into a triumph. The huge bonus makes it worth celebrating when achieved, though still not worth actively pursuing.</p> <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> </div>
<div class="rules-mode"> <div class="rules-mode">
<h3>New Variants</h3> <h3>Game Variants</h3>
<div class="house-rule"> <div class="house-rule">
<h4>Flip as Action</h4> <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>Use your turn to flip one of your face-down cards without drawing. Ends your turn immediately.</p>
@@ -597,7 +625,7 @@ TOTAL: 0 + 8 + 16 = 24 points</pre>
<div class="house-rule"> <div class="house-rule">
<h4>Four of a Kind</h4> <h4>Four of a Kind</h4>
<p>Having 4 cards of the same rank across two columns scores <strong>-20 bonus</strong>.</p> <p>Having 4 cards of the same rank across two columns scores <strong>-20 bonus</strong>.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> Rewards collecting matching cards beyond just column pairs. Changes whether you should take a third or fourth copy of a rank. If you already have two pairs of 8s, that's -20 extra! Stacks with Wolfpack: four Jacks = -40 total.</p> <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>
<div class="house-rule"> <div class="house-rule">
<h4>Negative Pairs Keep Value</h4> <h4>Negative Pairs Keep Value</h4>
@@ -805,6 +833,9 @@ TOTAL: 0 + 8 + 16 = 24 points</pre>
</div> </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="card-manager.js"></script>
<script src="state-differ.js"></script> <script src="state-differ.js"></script>
<script src="animation-queue.js"></script> <script src="animation-queue.js"></script>

View File

@@ -114,7 +114,8 @@ class StateDiffer {
movements.push({ movements.push({
type: drewFromDiscard ? 'draw-discard' : 'draw-deck', type: drewFromDiscard ? 'draw-discard' : 'draw-deck',
playerId: currentPlayerId playerId: currentPlayerId,
card: drewFromDiscard ? oldState.discard_top : null // Include card for discard draw animation
}); });
} }

File diff suppressed because it is too large Load Diff

155
client/timing-config.js Normal file
View File

@@ -0,0 +1,155 @@
// 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)
},
// 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;
}

616
docs/ANIMATION-FLOWS.md Normal file
View 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.

View 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

View File

@@ -0,0 +1,290 @@
# 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 |
---
## 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

View 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

View 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)`)

View 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

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

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

View 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

View 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`)

View 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

View 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

View 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

View 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

View 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

View 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

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

View 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

View 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

276
docs/v3/refactor-ai.md Normal file
View 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

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

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

1
lib64
View File

@@ -1 +0,0 @@
lib

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "golfgame" name = "golfgame"
version = "0.1.0" version = "2.0.1"
description = "6-Card Golf card game with AI opponents" description = "6-Card Golf card game with AI opponents"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
@@ -27,6 +27,12 @@ dependencies = [
"python-dotenv>=1.0.0", "python-dotenv>=1.0.0",
# V2: Event sourcing infrastructure # V2: Event sourcing infrastructure
"asyncpg>=0.29.0", "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] [project.optional-dependencies]

View File

@@ -1,5 +0,0 @@
home = /home/alee/.pyenv/versions/3.12.0/bin
include-system-site-packages = false
version = 3.12.0
executable = /home/alee/.pyenv/versions/3.12.0/bin/python3.12
command = /home/alee/.pyenv/versions/3.12.0/bin/python -m venv /home/alee/Sources/golfgame

39
scripts/dev-server.sh Executable file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

View File

@@ -21,7 +21,7 @@ Card Layout:
import random import random
import uuid import uuid
from collections import Counter from collections import Counter
from dataclasses import dataclass, field from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone from datetime import datetime, timezone
from enum import Enum from enum import Enum
from typing import Optional, Callable, Any from typing import Optional, Callable, Any
@@ -130,11 +130,13 @@ class Card:
suit: The card's suit (hearts, diamonds, clubs, spades). suit: The card's suit (hearts, diamonds, clubs, spades).
rank: The card's rank (A, 2-10, J, Q, K, or Joker). rank: The card's rank (A, 2-10, J, Q, K, or Joker).
face_up: Whether the card is visible to all players. face_up: Whether the card is visible to all players.
deck_id: Which deck this card came from (0-indexed, for multi-deck games).
""" """
suit: Suit suit: Suit
rank: Rank rank: Rank
face_up: bool = False face_up: bool = False
deck_id: int = 0
def to_dict(self, reveal: bool = False) -> dict: def to_dict(self, reveal: bool = False) -> dict:
""" """
@@ -154,24 +156,27 @@ class Card:
"suit": self.suit.value, "suit": self.suit.value,
"rank": self.rank.value, "rank": self.rank.value,
"face_up": self.face_up, "face_up": self.face_up,
"deck_id": self.deck_id,
} }
def to_client_dict(self) -> dict: def to_client_dict(self) -> dict:
""" """
Convert card to dictionary for client display. Convert card to dictionary for client display.
Hides card details if face-down to prevent cheating. Hides card details if face-down to prevent cheating, but always
includes deck_id so the client can show the correct back color.
Returns: Returns:
Dict with card info, or just {face_up: False} if hidden. Dict with card info, or just {face_up: False, deck_id} if hidden.
""" """
if self.face_up: if self.face_up:
return { return {
"suit": self.suit.value, "suit": self.suit.value,
"rank": self.rank.value, "rank": self.rank.value,
"face_up": True, "face_up": True,
"deck_id": self.deck_id,
} }
return {"face_up": False} return {"face_up": False, "deck_id": self.deck_id}
def value(self) -> int: def value(self) -> int:
"""Get base point value (without house rule modifications).""" """Get base point value (without house rule modifications)."""
@@ -210,20 +215,20 @@ class Deck:
self.seed: int = seed if seed is not None else random.randint(0, 2**31 - 1) self.seed: int = seed if seed is not None else random.randint(0, 2**31 - 1)
# Build deck(s) with standard cards # Build deck(s) with standard cards
for _ in range(num_decks): for deck_idx in range(num_decks):
for suit in Suit: for suit in Suit:
for rank in Rank: for rank in Rank:
if rank != Rank.JOKER: if rank != Rank.JOKER:
self.cards.append(Card(suit, rank)) self.cards.append(Card(suit, rank, deck_id=deck_idx))
# Standard jokers: 2 per deck, worth -2 each # Standard jokers: 2 per deck, worth -2 each
if use_jokers and not lucky_swing: if use_jokers and not lucky_swing:
self.cards.append(Card(Suit.HEARTS, Rank.JOKER)) self.cards.append(Card(Suit.HEARTS, Rank.JOKER, deck_id=deck_idx))
self.cards.append(Card(Suit.SPADES, Rank.JOKER)) self.cards.append(Card(Suit.SPADES, Rank.JOKER, deck_id=deck_idx))
# Lucky Swing: Single joker total, worth -5 # Lucky Swing: Single joker total, worth -5
if use_jokers and lucky_swing: if use_jokers and lucky_swing:
self.cards.append(Card(Suit.HEARTS, Rank.JOKER)) self.cards.append(Card(Suit.HEARTS, Rank.JOKER, deck_id=0))
self.shuffle() self.shuffle()
@@ -256,6 +261,12 @@ class Deck:
"""Return the number of cards left in the deck.""" """Return the number of cards left in the deck."""
return len(self.cards) return len(self.cards)
def top_card_deck_id(self) -> Optional[int]:
"""Return the deck_id of the top card (for showing correct back color)."""
if self.cards:
return self.cards[-1].deck_id
return None
def add_cards(self, cards: list[Card]) -> None: def add_cards(self, cards: list[Card]) -> None:
""" """
Add cards to the deck and shuffle. Add cards to the deck and shuffle.
@@ -498,6 +509,58 @@ class GameOptions:
knock_early: bool = False knock_early: bool = False
"""Allow going out early by flipping all remaining cards (max 2 face-down).""" """Allow going out early by flipping all remaining cards (max 2 face-down)."""
deck_colors: list[str] = field(default_factory=lambda: ["red", "blue", "gold"])
"""Colors for card backs from different decks (in order by deck_id)."""
def is_standard_rules(self) -> bool:
"""Check if all rules are standard (no house rules active)."""
return not any([
self.flip_mode != "never",
self.initial_flips != 2,
self.knock_penalty,
self.use_jokers,
self.lucky_swing, self.super_kings, self.ten_penny,
self.knock_bonus, self.underdog_bonus, self.tied_shame,
self.blackjack, self.wolfpack, self.eagle_eye,
self.flip_as_action, self.four_of_a_kind,
self.negative_pairs_keep_value, self.one_eyed_jacks, self.knock_early,
])
_ALLOWED_COLORS = {
"red", "blue", "gold", "teal", "purple", "orange", "yellow",
"green", "pink", "cyan", "brown", "slate",
}
@classmethod
def from_client_data(cls, data: dict) -> "GameOptions":
"""Build GameOptions from client WebSocket message data."""
raw_deck_colors = data.get("deck_colors", ["red", "blue", "gold"])
deck_colors = [c for c in raw_deck_colors if c in cls._ALLOWED_COLORS]
if not deck_colors:
deck_colors = ["red", "blue", "gold"]
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=deck_colors,
)
@dataclass @dataclass
class Game: class Game:
@@ -543,6 +606,7 @@ class Game:
players_with_final_turn: set = field(default_factory=set) players_with_final_turn: set = field(default_factory=set)
initial_flips_done: set = field(default_factory=set) initial_flips_done: set = field(default_factory=set)
options: GameOptions = field(default_factory=GameOptions) options: GameOptions = field(default_factory=GameOptions)
dealer_idx: int = 0
# Event sourcing support # Event sourcing support
game_id: str = field(default_factory=lambda: str(uuid.uuid4())) game_id: str = field(default_factory=lambda: str(uuid.uuid4()))
@@ -711,6 +775,9 @@ class Game:
for i, player in enumerate(self.players): for i, player in enumerate(self.players):
if player.id == player_id: if player.id == player_id:
removed = self.players.pop(i) removed = self.players.pop(i)
# Adjust dealer_idx if needed after removal
if self.players and self.dealer_idx >= len(self.players):
self.dealer_idx = 0
self._emit("player_left", player_id=player_id, reason=reason) self._emit("player_left", player_id=player_id, reason=reason)
return removed return removed
return None return None
@@ -772,26 +839,49 @@ class Game:
def _options_to_dict(self) -> dict: def _options_to_dict(self) -> dict:
"""Convert GameOptions to dictionary for event storage.""" """Convert GameOptions to dictionary for event storage."""
return { return asdict(self.options)
"flip_mode": self.options.flip_mode,
"initial_flips": self.options.initial_flips, # Boolean rules that map directly to display names
"knock_penalty": self.options.knock_penalty, _RULE_DISPLAY = [
"use_jokers": self.options.use_jokers, ("knock_penalty", "Knock Penalty"),
"lucky_swing": self.options.lucky_swing, ("lucky_swing", "Lucky Swing"),
"super_kings": self.options.super_kings, ("eagle_eye", "Eagle-Eye"),
"ten_penny": self.options.ten_penny, ("super_kings", "Super Kings"),
"knock_bonus": self.options.knock_bonus, ("ten_penny", "Ten Penny"),
"underdog_bonus": self.options.underdog_bonus, ("knock_bonus", "Knock Bonus"),
"tied_shame": self.options.tied_shame, ("underdog_bonus", "Underdog"),
"blackjack": self.options.blackjack, ("tied_shame", "Tied Shame"),
"eagle_eye": self.options.eagle_eye, ("blackjack", "Blackjack"),
"wolfpack": self.options.wolfpack, ("wolfpack", "Wolfpack"),
"flip_as_action": self.options.flip_as_action, ("flip_as_action", "Flip as Action"),
"four_of_a_kind": self.options.four_of_a_kind, ("four_of_a_kind", "Four of a Kind"),
"negative_pairs_keep_value": self.options.negative_pairs_keep_value, ("negative_pairs_keep_value", "Negative Pairs Keep Value"),
"one_eyed_jacks": self.options.one_eyed_jacks, ("one_eyed_jacks", "One-Eyed Jacks"),
"knock_early": self.options.knock_early, ("knock_early", "Early Knock"),
} ]
def _get_active_rules(self) -> list[str]:
"""Build list of active house rule display names."""
rules = []
if not self.options:
return 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 self._RULE_DISPLAY:
if getattr(self.options, attr):
rules.append(display_name)
return rules
def start_round(self) -> None: def start_round(self) -> None:
""" """
@@ -838,7 +928,12 @@ class Game:
"suit": first_discard.suit.value, "suit": first_discard.suit.value,
} }
self.current_player_index = 0 # Rotate dealer clockwise each round (first round: host deals)
if self.current_round > 1:
self.dealer_idx = (self.dealer_idx + 1) % len(self.players)
# First player is to the left of dealer (next in order)
self.current_player_index = (self.dealer_idx + 1) % len(self.players)
# Emit round_started event with deck seed and all dealt cards # Emit round_started event with deck seed and all dealt cards
self._emit( self._emit(
@@ -847,6 +942,7 @@ class Game:
deck_seed=self.deck.seed, deck_seed=self.deck.seed,
dealt_cards=dealt_cards, dealt_cards=dealt_cards,
first_discard=first_discard_dict, first_discard=first_discard_dict,
current_player_idx=self.current_player_index,
) )
# Skip initial flip phase if 0 flips required # Skip initial flip phase if 0 flips required
@@ -1518,56 +1614,22 @@ class Game:
discard_top = self.discard_top() discard_top = self.discard_top()
# Build active rules list for display active_rules = self._get_active_rules()
active_rules = []
if self.options:
if self.options.flip_mode == FlipMode.ALWAYS.value:
active_rules.append("Speed Golf")
elif self.options.flip_mode == FlipMode.ENDGAME.value:
active_rules.append("Endgame Flip")
if self.options.knock_penalty:
active_rules.append("Knock Penalty")
if self.options.use_jokers and not self.options.lucky_swing and not self.options.eagle_eye:
active_rules.append("Jokers")
if self.options.lucky_swing:
active_rules.append("Lucky Swing")
if self.options.eagle_eye:
active_rules.append("Eagle-Eye")
if self.options.super_kings:
active_rules.append("Super Kings")
if self.options.ten_penny:
active_rules.append("Ten Penny")
if self.options.knock_bonus:
active_rules.append("Knock Bonus")
if self.options.underdog_bonus:
active_rules.append("Underdog")
if self.options.tied_shame:
active_rules.append("Tied Shame")
if self.options.blackjack:
active_rules.append("Blackjack")
if self.options.wolfpack:
active_rules.append("Wolfpack")
# New house rules
if self.options.flip_as_action:
active_rules.append("Flip as Action")
if self.options.four_of_a_kind:
active_rules.append("Four of a Kind")
if self.options.negative_pairs_keep_value:
active_rules.append("Negative Pairs Keep Value")
if self.options.one_eyed_jacks:
active_rules.append("One-Eyed Jacks")
if self.options.knock_early:
active_rules.append("Early Knock")
return { return {
"phase": self.phase.value, "phase": self.phase.value,
"players": players_data, "players": players_data,
"current_player_id": current.id if current else None, "current_player_id": current.id if current else None,
"dealer_id": self.players[self.dealer_idx].id if self.players else None,
"dealer_idx": self.dealer_idx,
"discard_top": discard_top.to_dict(reveal=True) if discard_top else None, "discard_top": discard_top.to_dict(reveal=True) if discard_top else None,
"deck_remaining": self.deck.cards_remaining() if self.deck else 0, "deck_remaining": self.deck.cards_remaining() if self.deck else 0,
"deck_top_deck_id": self.deck.top_card_deck_id() if self.deck else None,
"current_round": self.current_round, "current_round": self.current_round,
"total_rounds": self.num_rounds, "total_rounds": self.num_rounds,
"has_drawn_card": self.drawn_card is not None, "has_drawn_card": self.drawn_card is not None,
"drawn_card": self.drawn_card.to_dict(reveal=True) if self.drawn_card else None,
"drawn_player_id": current.id if current and self.drawn_card else None,
"can_discard": self.can_discard_drawn() if self.drawn_card else True, "can_discard": self.can_discard_drawn() if self.drawn_card else True,
"waiting_for_initial_flip": ( "waiting_for_initial_flip": (
self.phase == GamePhase.INITIAL_FLIP and self.phase == GamePhase.INITIAL_FLIP and
@@ -1579,6 +1641,16 @@ class Game:
"flip_is_optional": self.flip_is_optional, "flip_is_optional": self.flip_is_optional,
"flip_as_action": self.options.flip_as_action, "flip_as_action": self.options.flip_as_action,
"knock_early": self.options.knock_early, "knock_early": self.options.knock_early,
"finisher_id": self.finisher_id,
"card_values": self.get_card_values(), "card_values": self.get_card_values(),
"active_rules": active_rules, "active_rules": active_rules,
"scoring_rules": {
"negative_pairs_keep_value": self.options.negative_pairs_keep_value,
"eagle_eye": self.options.eagle_eye,
"wolfpack": self.options.wolfpack,
"four_of_a_kind": self.options.four_of_a_kind,
"one_eyed_jacks": self.options.one_eyed_jacks,
},
"deck_colors": self.options.deck_colors,
"is_standard_rules": self.options.is_standard_rules(),
} }

View File

@@ -3,10 +3,20 @@ Game Analyzer for 6-Card Golf AI decisions.
Evaluates AI decisions against optimal play baselines and generates Evaluates AI decisions against optimal play baselines and generates
reports on decision quality, mistake rates, and areas for improvement. 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 json
import sqlite3 import os
import sqlite3 # For legacy GameAnalyzer class (deprecated)
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@@ -339,7 +349,12 @@ class DecisionEvaluator:
# ============================================================================= # =============================================================================
class GameAnalyzer: 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"): def __init__(self, db_path: str = "games.db"):
self.db_path = Path(db_path) 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 import sys
if len(sys.argv) < 2: if len(sys.argv) < 2:
print("Usage:") print("Usage:")
print(" python game_analyzer.py blunders [limit]") print(" python game_analyzer.py blunders [limit]")
print(" python game_analyzer.py game <game_id> <player_name>") print(" python game_analyzer.py recent [limit]")
print(" python game_analyzer.py summary") 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) sys.exit(1)
command = sys.argv[1] command = sys.argv[1]
try: try:
analyzer = GameAnalyzer()
except FileNotFoundError:
print("No games.db found. Play some games first!")
sys.exit(1)
if command == "blunders": if command == "blunders":
limit = int(sys.argv[2]) if len(sys.argv) > 2 else 20 limit = int(sys.argv[2]) if len(sys.argv) > 2 else 20
blunders = analyzer.find_blunders(limit) blunders = await event_store.find_suspicious_discards(limit)
print_blunder_report(blunders)
elif command == "game" and len(sys.argv) >= 4: print(f"\n=== Suspicious Discards ({len(blunders)} found) ===\n")
game_id = sys.argv[2] for b in blunders:
player_name = sys.argv[3] print(f"Player: {b.get('player_name', 'Unknown')}")
summary = analyzer.analyze_player_game(game_id, player_name) print(f"Action: discard {b.get('card_rank', '?')}")
print(generate_player_report(summary)) print(f"Room: {b.get('room_code', 'N/A')}")
print(f"Reason: {b.get('decision_reason', 'N/A')}")
print("-" * 40)
elif command == "summary": elif command == "recent":
# Quick summary of recent games limit = int(sys.argv[2]) if len(sys.argv) > 2 else 10
with sqlite3.connect("games.db") as conn: games = await event_store.get_recent_games_with_stats(limit)
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
""")
print("\n=== Recent Games ===\n") print("\n=== Recent Games ===\n")
for row in cursor: for game in games:
print(f"Game: {row['id'][:8]}... Room: {row['room_code']}") game_id = str(game.get('id', ''))[:8]
print(f" Players: {row['num_players']}, Moves: {row['move_count']}") room_code = game.get('room_code', 'N/A')
print(f" Started: {row['started_at']}") status = game.get('status', 'unknown')
print() moves = game.get('total_moves', 0)
print(f"{game_id}... | Room: {room_code} | Status: {status} | Moves: {moves}")
else: else:
print(f"Unknown command: {command}") print(f"Unknown command: {command}")
sys.exit(1) 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())

View File

@@ -1,239 +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_mode": options.flip_mode,
"initial_flips": options.initial_flips,
"knock_penalty": options.knock_penalty,
"use_jokers": options.use_jokers,
"lucky_swing": options.lucky_swing,
"super_kings": options.super_kings,
"ten_penny": options.ten_penny,
"knock_bonus": options.knock_bonus,
"underdog_bonus": options.underdog_bonus,
"tied_shame": options.tied_shame,
"blackjack": options.blackjack,
"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

Binary file not shown.

506
server/handlers.py Normal file
View File

@@ -0,0 +1,506 @@
"""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 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 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
player_name = data.get("player_name", "Player")
if ctx.authenticated_user and ctx.authenticated_user.display_name:
player_name = ctx.authenticated_user.display_name
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:
room_code = data.get("room_code", "").upper()
player_name = 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
if ctx.authenticated_user and ctx.authenticated_user.display_name:
player_name = ctx.authenticated_user.display_name
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
# ---------------------------------------------------------------------------
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,
}

View File

@@ -9,6 +9,7 @@ from typing import Optional
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Depends, Header from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Depends, Header
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel from pydantic import BaseModel
import redis.asyncio as redis import redis.asyncio as redis
@@ -16,7 +17,8 @@ from config import config
from room import RoomManager, Room from room import RoomManager, Room
from game import GamePhase, GameOptions from game import GamePhase, GameOptions
from ai import GolfAI, process_cpu_turn, get_all_profiles, reset_all_profiles, cleanup_room_profiles from ai import GolfAI, process_cpu_turn, get_all_profiles, reset_all_profiles, cleanup_room_profiles
from game_log import get_logger from handlers import HANDLERS, ConnectionContext
from services.game_logger import GameLogger, get_logger, set_logger
# Import production components # Import production components
from logging_config import setup_logging from logging_config import setup_logging
@@ -79,22 +81,14 @@ async def _periodic_leaderboard_refresh():
logger.error(f"Leaderboard refresh failed: {e}") logger.error(f"Leaderboard refresh failed: {e}")
@asynccontextmanager async def _init_redis():
async def lifespan(app: FastAPI): """Initialize Redis client and rate limiter."""
"""Application lifespan handler for async service initialization.""" global _redis_client, _rate_limiter
global _user_store, _auth_service, _admin_service, _stats_service, _replay_service
global _spectator_manager, _leaderboard_refresh_task, _redis_client, _rate_limiter
# Note: Uvicorn handles SIGINT/SIGTERM and triggers lifespan cleanup automatically
# Initialize Redis client (for rate limiting, health checks, etc.)
if config.REDIS_URL:
try: try:
_redis_client = redis.from_url(config.REDIS_URL, decode_responses=False) _redis_client = redis.from_url(config.REDIS_URL, decode_responses=False)
await _redis_client.ping() await _redis_client.ping()
logger.info("Redis client connected") logger.info("Redis client connected")
# Initialize rate limiter
if config.RATE_LIMIT_ENABLED: if config.RATE_LIMIT_ENABLED:
from services.ratelimit import get_rate_limiter from services.ratelimit import get_rate_limiter
_rate_limiter = await get_rate_limiter(_redis_client) _rate_limiter = await get_rate_limiter(_redis_client)
@@ -104,9 +98,12 @@ async def lifespan(app: FastAPI):
_redis_client = None _redis_client = None
_rate_limiter = None _rate_limiter = None
# Initialize auth, admin, and stats services (requires PostgreSQL)
if config.POSTGRES_URL: async def _init_database_services():
try: """Initialize all PostgreSQL-dependent services."""
global _user_store, _auth_service, _admin_service, _stats_service
global _replay_service, _spectator_manager, _leaderboard_refresh_task
from stores.user_store import get_user_store from stores.user_store import get_user_store
from stores.event_store import get_event_store from stores.event_store import get_event_store
from services.auth_service import get_auth_service from services.auth_service import get_auth_service
@@ -117,33 +114,35 @@ async def lifespan(app: FastAPI):
from routers.stats import set_stats_service as set_stats_router_service from routers.stats import set_stats_service as set_stats_router_service
from routers.stats import set_auth_service as set_stats_auth_service from routers.stats import set_auth_service as set_stats_auth_service
logger.info("Initializing auth services...") # Auth
_user_store = await get_user_store(config.POSTGRES_URL) _user_store = await get_user_store(config.POSTGRES_URL)
_auth_service = await get_auth_service(_user_store) _auth_service = await get_auth_service(_user_store)
set_auth_service(_auth_service) set_auth_service(_auth_service)
logger.info("Auth services initialized successfully") logger.info("Auth services initialized")
# Initialize admin service # Admin
logger.info("Initializing admin services...")
_admin_service = await get_admin_service( _admin_service = await get_admin_service(
pool=_user_store.pool, pool=_user_store.pool,
user_store=_user_store, user_store=_user_store,
state_cache=None, # Will add Redis state cache when available state_cache=None,
) )
set_admin_service(_admin_service) set_admin_service(_admin_service)
logger.info("Admin services initialized successfully") logger.info("Admin services initialized")
# Initialize stats service # Stats + event store
logger.info("Initializing stats services...")
_event_store = await get_event_store(config.POSTGRES_URL) _event_store = await get_event_store(config.POSTGRES_URL)
_stats_service = StatsService(_user_store.pool, _event_store) _stats_service = StatsService(_user_store.pool, _event_store)
set_stats_service(_stats_service) set_stats_service(_stats_service)
set_stats_router_service(_stats_service) set_stats_router_service(_stats_service)
set_stats_auth_service(_auth_service) set_stats_auth_service(_auth_service)
logger.info("Stats services initialized successfully") logger.info("Stats services initialized")
# Initialize replay service # Game logger
logger.info("Initializing replay services...") _game_logger = GameLogger(_event_store)
set_logger(_game_logger)
logger.info("Game logger initialized")
# Replay + spectator
from services.replay_service import get_replay_service, set_replay_service from services.replay_service import get_replay_service, set_replay_service
from services.spectator import get_spectator_manager from services.spectator import get_spectator_manager
from routers.replay import ( from routers.replay import (
@@ -159,41 +158,20 @@ async def lifespan(app: FastAPI):
set_replay_auth_service(_auth_service) set_replay_auth_service(_auth_service)
set_replay_spectator(_spectator_manager) set_replay_spectator(_spectator_manager)
set_replay_room_manager(room_manager) set_replay_room_manager(room_manager)
logger.info("Replay services initialized successfully") logger.info("Replay services initialized")
# Start periodic leaderboard refresh task # Periodic leaderboard refresh
_leaderboard_refresh_task = asyncio.create_task(_periodic_leaderboard_refresh()) _leaderboard_refresh_task = asyncio.create_task(_periodic_leaderboard_refresh())
logger.info("Leaderboard refresh task started") logger.info("Leaderboard refresh task started")
except Exception as e:
logger.error(f"Failed to initialize services: {e}")
raise
else:
logger.warning("POSTGRES_URL not configured - auth/admin/stats endpoints will not work")
# Set up health check dependencies async def _shutdown_services():
from routers.health import set_health_dependencies """Gracefully shut down all services."""
db_pool = _user_store.pool if _user_store else None
set_health_dependencies(
db_pool=db_pool,
redis_client=_redis_client,
room_manager=room_manager,
)
logger.info(f"Golf server started (environment={config.ENVIRONMENT})")
yield
# Graceful shutdown
logger.info("Shutdown initiated...")
# Signal shutdown to all components
_shutdown_event.set() _shutdown_event.set()
# Close all WebSocket connections gracefully
await _close_all_websockets() await _close_all_websockets()
# Clean up all rooms and release CPU profiles # Clean up rooms and CPU profiles
for room in list(room_manager.rooms.values()): for room in list(room_manager.rooms.values()):
for cpu in list(room.get_cpu_players()): for cpu in list(room.get_cpu_players()):
room.remove_player(cpu.id) room.remove_player(cpu.id)
@@ -201,7 +179,6 @@ async def lifespan(app: FastAPI):
reset_all_profiles() reset_all_profiles()
logger.info("All rooms and CPU profiles cleaned up") logger.info("All rooms and CPU profiles cleaned up")
# Cancel background tasks
if _leaderboard_refresh_task: if _leaderboard_refresh_task:
_leaderboard_refresh_task.cancel() _leaderboard_refresh_task.cancel()
try: try:
@@ -228,11 +205,40 @@ async def lifespan(app: FastAPI):
close_admin_service() close_admin_service()
await close_user_store() await close_user_store()
# Close Redis connection
if _redis_client: if _redis_client:
await _redis_client.close() await _redis_client.close()
logger.info("Redis connection closed") logger.info("Redis connection closed")
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan handler for async service initialization."""
if config.REDIS_URL:
await _init_redis()
if config.POSTGRES_URL:
try:
await _init_database_services()
except Exception as e:
logger.error(f"Failed to initialize services: {e}")
raise
else:
logger.warning("POSTGRES_URL not configured - auth/admin/stats endpoints will not work")
# Set up health check dependencies
from routers.health import set_health_dependencies
set_health_dependencies(
db_pool=_user_store.pool if _user_store else None,
redis_client=_redis_client,
room_manager=room_manager,
)
logger.info(f"Golf server started (environment={config.ENVIRONMENT})")
yield
logger.info("Shutdown initiated...")
await _shutdown_services()
logger.info("Shutdown complete") logger.info("Shutdown complete")
@@ -251,7 +257,7 @@ async def _close_all_websockets():
app = FastAPI( app = FastAPI(
title="Golf Card Game", title="Golf Card Game",
debug=config.DEBUG, debug=config.DEBUG,
version="0.1.0", version="2.0.1",
lifespan=lifespan, lifespan=lifespan,
) )
@@ -343,9 +349,8 @@ app.add_middleware(LazyRateLimitMiddleware)
room_manager = RoomManager() room_manager = RoomManager()
# Initialize game logger database at startup # Game logger is initialized in lifespan after event_store is available
_game_logger = get_logger() # The get_logger() function returns None until set_logger() is called
logger.info(f"Game analytics database initialized at: {_game_logger.db_path}")
# ============================================================================= # =============================================================================
@@ -462,11 +467,7 @@ async def websocket_endpoint(websocket: WebSocket):
except Exception as e: except Exception as e:
logger.debug(f"WebSocket auth failed: {e}") logger.debug(f"WebSocket auth failed: {e}")
# Each connection gets a unique ID (allows multi-tab play)
connection_id = str(uuid.uuid4()) connection_id = str(uuid.uuid4())
player_id = connection_id
# Track auth user separately for stats/limits (can be None)
auth_user_id = str(authenticated_user.id) if authenticated_user else None auth_user_id = str(authenticated_user.id) if authenticated_user else None
if authenticated_user: if authenticated_user:
@@ -474,543 +475,34 @@ async def websocket_endpoint(websocket: WebSocket):
else: else:
logger.debug(f"WebSocket connected anonymously as {connection_id}") logger.debug(f"WebSocket connected anonymously as {connection_id}")
current_room: Room | None = None ctx = ConnectionContext(
websocket=websocket,
connection_id=connection_id,
player_id=connection_id,
auth_user_id=auth_user_id,
authenticated_user=authenticated_user,
)
# Shared dependencies passed to every handler
handler_deps = dict(
room_manager=room_manager,
count_user_games=count_user_games,
max_concurrent=MAX_CONCURRENT_GAMES,
broadcast_game_state=broadcast_game_state,
check_and_run_cpu_turn=check_and_run_cpu_turn,
handle_player_leave=handle_player_leave,
cleanup_room_profiles=cleanup_room_profiles,
)
try: try:
while True: while True:
data = await websocket.receive_json() data = await websocket.receive_json()
msg_type = data.get("type") handler = HANDLERS.get(data.get("type"))
if handler:
if msg_type == "create_room": await handler(data, ctx, **handler_deps)
# Check concurrent game limit for authenticated users
if auth_user_id and count_user_games(auth_user_id) >= MAX_CONCURRENT_GAMES:
await websocket.send_json({
"type": "error",
"message": f"Maximum {MAX_CONCURRENT_GAMES} concurrent games allowed",
})
continue
player_name = data.get("player_name", "Player")
# Use authenticated user's name if available
if authenticated_user and authenticated_user.display_name:
player_name = authenticated_user.display_name
room = room_manager.create_room()
room.add_player(player_id, player_name, websocket, auth_user_id)
current_room = room
await websocket.send_json({
"type": "room_created",
"room_code": room.code,
"player_id": player_id,
"authenticated": authenticated_user is not None,
})
await room.broadcast({
"type": "player_joined",
"players": room.player_list(),
})
elif msg_type == "join_room":
room_code = data.get("room_code", "").upper()
player_name = data.get("player_name", "Player")
# Check concurrent game limit for authenticated users
if auth_user_id and count_user_games(auth_user_id) >= MAX_CONCURRENT_GAMES:
await websocket.send_json({
"type": "error",
"message": f"Maximum {MAX_CONCURRENT_GAMES} concurrent games allowed",
})
continue
room = room_manager.get_room(room_code)
if not room:
await websocket.send_json({
"type": "error",
"message": "Room not found",
})
continue
if len(room.players) >= 6:
await websocket.send_json({
"type": "error",
"message": "Room is full",
})
continue
if room.game.phase != GamePhase.WAITING:
await websocket.send_json({
"type": "error",
"message": "Game already in progress",
})
continue
# Use authenticated user's name if available
if authenticated_user and authenticated_user.display_name:
player_name = authenticated_user.display_name
room.add_player(player_id, player_name, websocket, auth_user_id)
current_room = room
await websocket.send_json({
"type": "room_joined",
"room_code": room.code,
"player_id": player_id,
"authenticated": authenticated_user is not None,
})
await room.broadcast({
"type": "player_joined",
"players": room.player_list(),
})
elif msg_type == "get_cpu_profiles":
if not current_room:
continue
await websocket.send_json({
"type": "cpu_profiles",
"profiles": get_all_profiles(),
})
elif msg_type == "add_cpu":
if not current_room:
continue
room_player = current_room.get_player(player_id)
if not room_player or not room_player.is_host:
await websocket.send_json({
"type": "error",
"message": "Only the host can add CPU players",
})
continue
if len(current_room.players) >= 6:
await websocket.send_json({
"type": "error",
"message": "Room is full",
})
continue
cpu_id = f"cpu_{uuid.uuid4().hex[:8]}"
profile_name = data.get("profile_name")
cpu_player = current_room.add_cpu_player(cpu_id, profile_name)
if not cpu_player:
await websocket.send_json({
"type": "error",
"message": "CPU profile not available",
})
continue
await current_room.broadcast({
"type": "player_joined",
"players": current_room.player_list(),
})
elif msg_type == "remove_cpu":
if not current_room:
continue
room_player = current_room.get_player(player_id)
if not room_player or not room_player.is_host:
continue
# Remove the last CPU player
cpu_players = current_room.get_cpu_players()
if cpu_players:
current_room.remove_player(cpu_players[-1].id)
await current_room.broadcast({
"type": "player_joined",
"players": current_room.player_list(),
})
elif msg_type == "start_game":
if not current_room:
continue
room_player = current_room.get_player(player_id)
if not room_player or not room_player.is_host:
await websocket.send_json({
"type": "error",
"message": "Only the host can start the game",
})
continue
if len(current_room.players) < 2:
await websocket.send_json({
"type": "error",
"message": "Need at least 2 players",
})
continue
num_decks = data.get("decks", 1)
num_rounds = data.get("rounds", 1)
# Build game options
options = GameOptions(
# Standard options
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),
# House Rules - Point Modifiers
lucky_swing=data.get("lucky_swing", False),
super_kings=data.get("super_kings", False),
ten_penny=data.get("ten_penny", False),
# House Rules - Bonuses/Penalties
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),
# House Rules - New Variants
flip_as_action=data.get("flip_as_action", False),
four_of_a_kind=data.get("four_of_a_kind", False),
negative_pairs_keep_value=data.get("negative_pairs_keep_value", False),
one_eyed_jacks=data.get("one_eyed_jacks", False),
knock_early=data.get("knock_early", False),
)
# Validate settings
num_decks = max(1, min(3, num_decks))
num_rounds = max(1, min(18, num_rounds))
async with current_room.game_lock:
current_room.game.start_game(num_decks, num_rounds, options)
# Log game start for AI analysis
game_logger = get_logger()
current_room.game_log_id = game_logger.log_game_start(
room_code=current_room.code,
num_players=len(current_room.players),
options=options,
)
# CPU players do their initial flips immediately (if required)
if options.initial_flips > 0:
for cpu in current_room.get_cpu_players():
positions = GolfAI.choose_initial_flips(options.initial_flips)
current_room.game.flip_initial_cards(cpu.id, positions)
# Send game started to all human players with their personal view
for pid, player in current_room.players.items():
if player.websocket and not player.is_cpu:
game_state = current_room.game.get_state(pid)
await player.websocket.send_json({
"type": "game_started",
"game_state": game_state,
})
# Check if it's a CPU's turn to start
await check_and_run_cpu_turn(current_room)
elif msg_type == "flip_initial":
if not current_room:
continue
positions = data.get("positions", [])
async with current_room.game_lock:
if current_room.game.flip_initial_cards(player_id, positions):
await broadcast_game_state(current_room)
# Check if it's a CPU's turn
await check_and_run_cpu_turn(current_room)
elif msg_type == "draw":
if not current_room:
continue
source = data.get("source", "deck")
async with current_room.game_lock:
# Capture discard top before draw (for logging decision context)
discard_before_draw = current_room.game.discard_top()
card = current_room.game.draw_card(player_id, source)
if card:
# Log draw decision for human player
if current_room.game_log_id:
game_logger = get_logger()
player = current_room.game.get_player(player_id)
if player:
reason = f"took {discard_before_draw.rank.value} from discard" if source == "discard" else "drew from deck"
game_logger.log_move(
game_id=current_room.game_log_id,
player=player,
is_cpu=False,
action="take_discard" if source == "discard" else "draw_deck",
card=card,
game=current_room.game,
decision_reason=reason,
)
# Send drawn card only to the player who drew
await websocket.send_json({
"type": "card_drawn",
"card": card.to_dict(),
"source": source,
})
await broadcast_game_state(current_room)
elif msg_type == "swap":
if not current_room:
continue
position = data.get("position", 0)
async with current_room.game_lock:
# Capture drawn card before swap for logging
drawn_card = current_room.game.drawn_card
player = current_room.game.get_player(player_id)
old_card = player.cards[position] if player and 0 <= position < len(player.cards) else None
discarded = current_room.game.swap_card(player_id, position)
if discarded:
# Log swap decision for human player
if current_room.game_log_id and drawn_card and player:
game_logger = get_logger()
old_rank = old_card.rank.value if old_card else "?"
game_logger.log_move(
game_id=current_room.game_log_id,
player=player,
is_cpu=False,
action="swap",
card=drawn_card,
position=position,
game=current_room.game,
decision_reason=f"swapped {drawn_card.rank.value} into position {position}, replaced {old_rank}",
)
await broadcast_game_state(current_room)
# Let client swap animation complete (~550ms), then pause to show result
# Total 1.0s = 550ms animation + 450ms visible pause
await asyncio.sleep(1.0)
await check_and_run_cpu_turn(current_room)
elif msg_type == "discard":
if not current_room:
continue
async with current_room.game_lock:
# Capture drawn card before discard for logging
drawn_card = current_room.game.drawn_card
player = current_room.game.get_player(player_id)
if current_room.game.discard_drawn(player_id):
# Log discard decision for human player
if current_room.game_log_id and drawn_card and player:
game_logger = get_logger()
game_logger.log_move(
game_id=current_room.game_log_id,
player=player,
is_cpu=False,
action="discard",
card=drawn_card,
game=current_room.game,
decision_reason=f"discarded {drawn_card.rank.value}",
)
await broadcast_game_state(current_room)
if current_room.game.flip_on_discard:
# Check if player has face-down cards to flip
player = current_room.game.get_player(player_id)
has_face_down = player and any(not c.face_up for c in player.cards)
if has_face_down:
await websocket.send_json({
"type": "can_flip",
"optional": current_room.game.flip_is_optional,
})
else:
# Let client animation complete before CPU turn
await asyncio.sleep(0.5)
await check_and_run_cpu_turn(current_room)
else:
# Turn ended - let client animation complete before CPU turn
# (player discard swoop animation is ~500ms: 350ms swoop + 150ms settle)
logger.debug(f"Player discarded, waiting 0.5s before CPU turn")
await asyncio.sleep(0.5)
logger.debug(f"Post-discard delay complete, checking for CPU turn")
await check_and_run_cpu_turn(current_room)
elif msg_type == "cancel_draw":
if not current_room:
continue
async with current_room.game_lock:
if current_room.game.cancel_discard_draw(player_id):
await broadcast_game_state(current_room)
elif msg_type == "flip_card":
if not current_room:
continue
position = data.get("position", 0)
async with current_room.game_lock:
player = current_room.game.get_player(player_id)
current_room.game.flip_and_end_turn(player_id, position)
# Log flip decision for human player
if current_room.game_log_id and player and 0 <= position < len(player.cards):
game_logger = get_logger()
flipped_card = player.cards[position]
game_logger.log_move(
game_id=current_room.game_log_id,
player=player,
is_cpu=False,
action="flip",
card=flipped_card,
position=position,
game=current_room.game,
decision_reason=f"flipped card at position {position}",
)
await broadcast_game_state(current_room)
await check_and_run_cpu_turn(current_room)
elif msg_type == "skip_flip":
if not current_room:
continue
async with current_room.game_lock:
player = current_room.game.get_player(player_id)
if current_room.game.skip_flip_and_end_turn(player_id):
# Log skip flip decision for human player
if current_room.game_log_id and player:
game_logger = get_logger()
game_logger.log_move(
game_id=current_room.game_log_id,
player=player,
is_cpu=False,
action="skip_flip",
card=None,
game=current_room.game,
decision_reason="skipped optional flip (endgame mode)",
)
await broadcast_game_state(current_room)
await check_and_run_cpu_turn(current_room)
elif msg_type == "flip_as_action":
if not current_room:
continue
position = data.get("position", 0)
async with current_room.game_lock:
player = current_room.game.get_player(player_id)
if current_room.game.flip_card_as_action(player_id, position):
# Log flip-as-action for human player
if current_room.game_log_id and player and 0 <= position < len(player.cards):
game_logger = get_logger()
flipped_card = player.cards[position]
game_logger.log_move(
game_id=current_room.game_log_id,
player=player,
is_cpu=False,
action="flip_as_action",
card=flipped_card,
position=position,
game=current_room.game,
decision_reason=f"used flip-as-action to reveal position {position}",
)
await broadcast_game_state(current_room)
await check_and_run_cpu_turn(current_room)
elif msg_type == "knock_early":
if not current_room:
continue
async with current_room.game_lock:
player = current_room.game.get_player(player_id)
if current_room.game.knock_early(player_id):
# Log knock early for human player
if current_room.game_log_id and player:
game_logger = get_logger()
face_down_count = sum(1 for c in player.cards if not c.face_up)
game_logger.log_move(
game_id=current_room.game_log_id,
player=player,
is_cpu=False,
action="knock_early",
card=None,
game=current_room.game,
decision_reason=f"knocked early, revealing {face_down_count} hidden cards",
)
await broadcast_game_state(current_room)
await check_and_run_cpu_turn(current_room)
elif msg_type == "next_round":
if not current_room:
continue
room_player = current_room.get_player(player_id)
if not room_player or not room_player.is_host:
continue
async with current_room.game_lock:
if current_room.game.start_next_round():
# CPU players do their initial flips
for cpu in current_room.get_cpu_players():
positions = GolfAI.choose_initial_flips()
current_room.game.flip_initial_cards(cpu.id, positions)
for pid, player in current_room.players.items():
if player.websocket and not player.is_cpu:
game_state = current_room.game.get_state(pid)
await player.websocket.send_json({
"type": "round_started",
"game_state": game_state,
})
await check_and_run_cpu_turn(current_room)
else:
# Game over
await broadcast_game_state(current_room)
elif msg_type == "leave_room":
if current_room:
await handle_player_leave(current_room, player_id)
current_room = None
elif msg_type == "leave_game":
# Player leaves during an active game
if current_room:
await handle_player_leave(current_room, player_id)
current_room = None
elif msg_type == "end_game":
# Host ends the game for everyone
if not current_room:
continue
room_player = current_room.get_player(player_id)
if not room_player or not room_player.is_host:
await websocket.send_json({
"type": "error",
"message": "Only the host can end the game",
})
continue
# Notify all players that the game has ended
await current_room.broadcast({
"type": "game_ended",
"reason": "Host ended the game",
})
# Clean up the room
room_code = current_room.code
for cpu in list(current_room.get_cpu_players()):
current_room.remove_player(cpu.id)
cleanup_room_profiles(room_code)
room_manager.remove_room(room_code)
current_room = None
except WebSocketDisconnect: except WebSocketDisconnect:
if current_room: if ctx.current_room:
await handle_player_leave(current_room, player_id) await handle_player_leave(ctx.current_room, ctx.player_id)
async def _process_stats_safe(room: Room): async def _process_stats_safe(room: Room):
@@ -1039,6 +531,7 @@ async def _process_stats_safe(room: Room):
winner_id=winner_id, winner_id=winner_id,
num_rounds=room.game.num_rounds, num_rounds=room.game.num_rounds,
player_user_ids=player_user_ids, player_user_ids=player_user_ids,
game_options=room.game.options,
) )
logger.debug(f"Stats processed for room {room.code}") logger.debug(f"Stats processed for room {room.code}")
except Exception as e: except Exception as e:
@@ -1086,8 +579,8 @@ async def broadcast_game_state(room: Room):
# Check for game over # Check for game over
elif room.game.phase == GamePhase.GAME_OVER: elif room.game.phase == GamePhase.GAME_OVER:
# Log game end # Log game end
if room.game_log_id:
game_logger = get_logger() game_logger = get_logger()
if game_logger and room.game_log_id:
game_logger.log_game_end(room.game_log_id) game_logger.log_game_end(room.game_log_id)
room.game_log_id = None # Clear to avoid duplicate logging room.game_log_id = None # Clear to avoid duplicate logging
@@ -1132,6 +625,9 @@ async def check_and_run_cpu_turn(room: Room):
if not room_player or not room_player.is_cpu: if not room_player or not room_player.is_cpu:
return return
# Brief pause before CPU starts - animations are faster now
await asyncio.sleep(0.25)
# Run CPU turn # Run CPU turn
async def broadcast_cb(): async def broadcast_cb():
await broadcast_game_state(room) await broadcast_game_state(room)
@@ -1171,56 +667,17 @@ if os.path.exists(client_path):
async def serve_index(): async def serve_index():
return FileResponse(os.path.join(client_path, "index.html")) return FileResponse(os.path.join(client_path, "index.html"))
@app.get("/style.css")
async def serve_css():
return FileResponse(os.path.join(client_path, "style.css"), media_type="text/css")
@app.get("/app.js")
async def serve_js():
return FileResponse(os.path.join(client_path, "app.js"), media_type="application/javascript")
@app.get("/card-manager.js")
async def serve_card_manager():
return FileResponse(os.path.join(client_path, "card-manager.js"), media_type="application/javascript")
@app.get("/state-differ.js")
async def serve_state_differ():
return FileResponse(os.path.join(client_path, "state-differ.js"), media_type="application/javascript")
@app.get("/animation-queue.js")
async def serve_animation_queue():
return FileResponse(os.path.join(client_path, "animation-queue.js"), media_type="application/javascript")
@app.get("/leaderboard.js")
async def serve_leaderboard_js():
return FileResponse(os.path.join(client_path, "leaderboard.js"), media_type="application/javascript")
@app.get("/golfball-logo.svg")
async def serve_golfball_logo():
return FileResponse(os.path.join(client_path, "golfball-logo.svg"), media_type="image/svg+xml")
# Admin dashboard
@app.get("/admin") @app.get("/admin")
async def serve_admin(): async def serve_admin():
return FileResponse(os.path.join(client_path, "admin.html")) return FileResponse(os.path.join(client_path, "admin.html"))
@app.get("/admin.css")
async def serve_admin_css():
return FileResponse(os.path.join(client_path, "admin.css"), media_type="text/css")
@app.get("/admin.js")
async def serve_admin_js():
return FileResponse(os.path.join(client_path, "admin.js"), media_type="application/javascript")
@app.get("/replay.js")
async def serve_replay_js():
return FileResponse(os.path.join(client_path, "replay.js"), media_type="application/javascript")
# Serve replay page for share links
@app.get("/replay/{share_code}") @app.get("/replay/{share_code}")
async def serve_replay_page(share_code: str): async def serve_replay_page(share_code: str):
return FileResponse(os.path.join(client_path, "index.html")) 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")
def run(): def run():
"""Run the server using uvicorn.""" """Run the server using uvicorn."""

View File

@@ -237,7 +237,7 @@ class RebuiltGameState:
self.initial_flips_done = set() self.initial_flips_done = set()
self.drawn_card = None self.drawn_card = None
self.drawn_from_discard = False self.drawn_from_discard = False
self.current_player_idx = 0 self.current_player_idx = event.data.get("current_player_idx", 0)
self.discard_pile = [] self.discard_pile = []
# Deal cards to players (all face-down) # Deal cards to players (all face-down)

View File

@@ -1,16 +1,25 @@
# Core dependencies
fastapi>=0.109.0 fastapi>=0.109.0
uvicorn[standard]>=0.27.0 uvicorn[standard]>=0.27.0
websockets>=12.0 websockets>=12.0
python-dotenv>=1.0.0 python-dotenv>=1.0.0
# V2: Event sourcing infrastructure
# Database & caching
asyncpg>=0.29.0 asyncpg>=0.29.0
redis>=5.0.0 redis>=5.0.0
# V2: Authentication
resend>=2.0.0 # Authentication
bcrypt>=4.1.0 bcrypt>=4.1.0
# V2: Production monitoring (optional)
# Email service
resend>=2.0.0
# Production monitoring (optional)
sentry-sdk[fastapi]>=1.40.0 sentry-sdk[fastapi]>=1.40.0
# Testing # Testing
pytest>=8.0.0 pytest>=8.0.0
pytest-asyncio>=0.23.0 pytest-asyncio>=0.23.0
pytest-cov>=4.1.0
ruff>=0.1.0
mypy>=1.8.0

View File

@@ -254,12 +254,13 @@ class RoomManager:
"""Initialize an empty room manager.""" """Initialize an empty room manager."""
self.rooms: dict[str, Room] = {} self.rooms: dict[str, Room] = {}
def _generate_code(self) -> str: def _generate_code(self, max_attempts: int = 100) -> str:
"""Generate a unique 4-letter room code.""" """Generate a unique 4-letter room code."""
while True: for _ in range(max_attempts):
code = "".join(random.choices(string.ascii_uppercase, k=4)) code = "".join(random.choices(string.ascii_uppercase, k=4))
if code not in self.rooms: if code not in self.rooms:
return code return code
raise RuntimeError("Could not generate unique room code")
def create_room(self) -> Room: def create_room(self) -> Room:
""" """

View 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")

View File

@@ -14,6 +14,7 @@ import asyncpg
from stores.event_store import EventStore from stores.event_store import EventStore
from models.events import EventType from models.events import EventType
from game import GameOptions
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -584,6 +585,47 @@ class StatsService:
return data if data["num_rounds"] > 0 else None return data if data["num_rounds"] > 0 else None
@staticmethod
def _check_win_milestones(stats_row, earned_ids: set) -> List[str]:
"""Check win/streak achievement milestones. Shared by event and legacy paths."""
new = []
wins = stats_row["games_won"]
for threshold, achievement_id in [(1, "first_win"), (10, "win_10"), (50, "win_50"), (100, "win_100")]:
if wins >= threshold and achievement_id not in earned_ids:
new.append(achievement_id)
streak = stats_row["current_win_streak"]
for threshold, achievement_id in [(5, "streak_5"), (10, "streak_10")]:
if streak >= threshold and achievement_id not in earned_ids:
new.append(achievement_id)
return new
@staticmethod
async def _get_earned_ids(conn: asyncpg.Connection, user_id: str) -> set:
"""Get set of already-earned achievement IDs for a user."""
earned = await conn.fetch(
"SELECT achievement_id FROM user_achievements WHERE user_id = $1",
user_id,
)
return {e["achievement_id"] for e in earned}
@staticmethod
async def _award_achievements(
conn: asyncpg.Connection,
user_id: str,
achievement_ids: List[str],
game_id: Optional[str] = None,
) -> None:
"""Insert achievement records for a user."""
for achievement_id in achievement_ids:
try:
await conn.execute("""
INSERT INTO user_achievements (user_id, achievement_id, game_id)
VALUES ($1, $2, $3)
ON CONFLICT DO NOTHING
""", user_id, achievement_id, game_id)
except Exception as e:
logger.error(f"Failed to award achievement {achievement_id}: {e}")
async def _check_achievements( async def _check_achievements(
self, self,
conn: asyncpg.Connection, conn: asyncpg.Connection,
@@ -605,8 +647,6 @@ class StatsService:
Returns: Returns:
List of newly awarded achievement IDs. List of newly awarded achievement IDs.
""" """
new_achievements = []
# Get current stats (after update) # Get current stats (after update)
stats = await conn.fetchrow(""" stats = await conn.fetchrow("""
SELECT games_won, knockouts, best_win_streak, current_win_streak, perfect_rounds, wolfpacks SELECT games_won, knockouts, best_win_streak, current_win_streak, perfect_rounds, wolfpacks
@@ -617,35 +657,15 @@ class StatsService:
if not stats: if not stats:
return [] return []
# Get already earned achievements earned_ids = await self._get_earned_ids(conn, user_id)
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 # Win/streak milestones (shared logic)
wins = stats["games_won"] new_achievements = self._check_win_milestones(stats, earned_ids)
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 # Game-specific achievements (event path only)
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: if stats["knockouts"] >= 10 and "knockout_10" not in earned_ids:
new_achievements.append("knockout_10") new_achievements.append("knockout_10")
# Check round-specific achievements from this game
best_round = player_data.get("best_round") best_round = player_data.get("best_round")
if best_round is not None: if best_round is not None:
if best_round <= 0 and "perfect_round" not in earned_ids: if best_round <= 0 and "perfect_round" not in earned_ids:
@@ -653,21 +673,10 @@ class StatsService:
if best_round < 0 and "negative_round" not in earned_ids: if best_round < 0 and "negative_round" not in earned_ids:
new_achievements.append("negative_round") new_achievements.append("negative_round")
# Check wolfpack
if player_data.get("wolfpacks", 0) > 0 and "wolfpack" not in earned_ids: if player_data.get("wolfpacks", 0) > 0 and "wolfpack" not in earned_ids:
new_achievements.append("wolfpack") new_achievements.append("wolfpack")
# Award new achievements await self._award_achievements(conn, user_id, new_achievements, game_id)
for achievement_id in new_achievements:
try:
await conn.execute("""
INSERT INTO user_achievements (user_id, achievement_id, game_id)
VALUES ($1, $2, $3)
ON CONFLICT DO NOTHING
""", user_id, achievement_id, game_id)
except Exception as e:
logger.error(f"Failed to award achievement {achievement_id}: {e}")
return new_achievements return new_achievements
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@@ -680,18 +689,21 @@ class StatsService:
winner_id: Optional[str], winner_id: Optional[str],
num_rounds: int, num_rounds: int,
player_user_ids: dict[str, str] = None, player_user_ids: dict[str, str] = None,
game_options: Optional[GameOptions] = None,
) -> List[str]: ) -> List[str]:
""" """
Process game stats directly from game state (for legacy games). Process game stats directly from game state (for legacy games).
This is used when games don't have event sourcing. Stats are updated This is used when games don't have event sourcing. Stats are updated
based on final game state. based on final game state. Only standard-rules games count toward
leaderboard stats.
Args: Args:
players: List of game.Player objects with final scores. players: List of game.Player objects with final scores.
winner_id: Player ID of the winner. winner_id: Player ID of the winner.
num_rounds: Total rounds played. num_rounds: Total rounds played.
player_user_ids: Optional mapping of player_id to user_id (for authenticated players). player_user_ids: Optional mapping of player_id to user_id (for authenticated players).
game_options: Optional game options to check for standard rules.
Returns: Returns:
List of newly awarded achievement IDs. List of newly awarded achievement IDs.
@@ -699,6 +711,11 @@ class StatsService:
if not players: if not players:
return [] return []
# Only track stats for standard-rules games
if game_options and not game_options.is_standard_rules():
logger.debug("Skipping stats for non-standard rules game")
return []
# Count human players for has_human_opponents calculation # Count human players for has_human_opponents calculation
# For legacy games, we assume all players are human unless otherwise indicated # For legacy games, we assume all players are human unless otherwise indicated
human_count = len(players) human_count = len(players)
@@ -800,9 +817,6 @@ class StatsService:
Only checks win-based achievements since we don't have round-level data. Only checks win-based achievements since we don't have round-level data.
""" """
new_achievements = []
# Get current stats
stats = await conn.fetchrow(""" stats = await conn.fetchrow("""
SELECT games_won, current_win_streak FROM player_stats SELECT games_won, current_win_streak FROM player_stats
WHERE user_id = $1 WHERE user_id = $1
@@ -811,41 +825,9 @@ class StatsService:
if not stats: if not stats:
return [] return []
# Get already earned achievements earned_ids = await self._get_earned_ids(conn, user_id)
earned = await conn.fetch(""" new_achievements = self._check_win_milestones(stats, earned_ids)
SELECT achievement_id FROM user_achievements WHERE user_id = $1 await self._award_achievements(conn, user_id, new_achievements)
""", user_id)
earned_ids = {e["achievement_id"] for e in earned}
# Check win milestones
wins = stats["games_won"]
if wins >= 1 and "first_win" not in earned_ids:
new_achievements.append("first_win")
if wins >= 10 and "win_10" not in earned_ids:
new_achievements.append("win_10")
if wins >= 50 and "win_50" not in earned_ids:
new_achievements.append("win_50")
if wins >= 100 and "win_100" not in earned_ids:
new_achievements.append("win_100")
# Check streak achievements
streak = stats["current_win_streak"]
if streak >= 5 and "streak_5" not in earned_ids:
new_achievements.append("streak_5")
if streak >= 10 and "streak_10" not in earned_ids:
new_achievements.append("streak_10")
# Award new achievements
for achievement_id in new_achievements:
try:
await conn.execute("""
INSERT INTO user_achievements (user_id, achievement_id)
VALUES ($1, $2)
ON CONFLICT DO NOTHING
""", user_id, achievement_id)
except Exception as e:
logger.error(f"Failed to award achievement {achievement_id}: {e}")
return new_achievements return new_achievements
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------

View File

@@ -6,15 +6,19 @@ No server/websocket needed - runs games directly.
Usage: Usage:
python simulate.py [num_games] [num_players] python simulate.py [num_games] [num_players]
python simulate.py 100 --rules use_jokers,eagle_eye
python simulate.py 100 --preset competitive
python simulate.py 100 --compare baseline eagle_eye negative_pairs
Examples: Examples:
python simulate.py 10 # Run 10 games with 4 players each python simulate.py 10 # Run 10 games with 4 players each
python simulate.py 50 2 # Run 50 games with 2 players each python simulate.py 50 2 # Run 50 games with 2 players each
python simulate.py 100 --preset eagle_eye
python simulate.py detail --preset competitive
""" """
import asyncio import argparse
import random import random
import sys
from typing import Optional from typing import Optional
from game import Game, Player, GamePhase, GameOptions from game import Game, Player, GamePhase, GameOptions
@@ -24,7 +28,81 @@ from ai import (
filter_bad_pair_positions, get_column_partner_position filter_bad_pair_positions, get_column_partner_position
) )
from game import Rank from game import Rank
from game_log import GameLogger
# Note: Simulations run standalone without PostgreSQL database logging.
# In-memory SimulationStats provides all the analysis needed for bulk runs.
# Named rule presets for quick configuration
RULE_PRESETS: dict[str, dict] = {
"baseline": {
# Default classic rules, no special options
},
"jokers": {
"use_jokers": True,
},
"eagle_eye": {
"use_jokers": True,
"eagle_eye": True,
},
"negative_pairs": {
"use_jokers": True,
"negative_pairs_keep_value": True,
},
"four_kind": {
"four_of_a_kind": True,
},
"wolfpack": {
"wolfpack": True,
},
"competitive": {
"knock_penalty": True,
"knock_bonus": True,
},
"wild": {
"use_jokers": True,
"lucky_swing": True,
"eagle_eye": True,
"negative_pairs_keep_value": True,
},
"all_bonuses": {
"knock_bonus": True,
"underdog_bonus": True,
"four_of_a_kind": True,
"wolfpack": True,
},
}
def get_preset_options(preset_name: str) -> GameOptions:
"""Get GameOptions for a named preset."""
if preset_name not in RULE_PRESETS:
available = ", ".join(sorted(RULE_PRESETS.keys()))
raise ValueError(f"Unknown preset '{preset_name}'. Available: {available}")
rules = RULE_PRESETS[preset_name]
return GameOptions(
initial_flips=2,
flip_mode="never",
**rules
)
def parse_rules_string(rules_str: str) -> GameOptions:
"""Parse comma-separated rule names into GameOptions."""
if not rules_str:
return GameOptions(initial_flips=2, flip_mode="never")
rules = {}
for rule in rules_str.split(","):
rule = rule.strip()
if rule:
# Validate that it's a valid GameOptions field
if not hasattr(GameOptions, rule):
raise ValueError(f"Unknown rule '{rule}'. Check GameOptions for valid fields.")
rules[rule] = True
return GameOptions(initial_flips=2, flip_mode="never", **rules)
class SimulationStats: class SimulationStats:
@@ -45,6 +123,7 @@ class SimulationStats:
self.took_bad_card_without_pair = 0 self.took_bad_card_without_pair = 0
self.paired_negative_cards = 0 self.paired_negative_cards = 0
self.swapped_good_for_bad = 0 self.swapped_good_for_bad = 0
self.swapped_high_into_unknown = 0 # Cards 8+ swapped into face-down position
self.total_opportunities = 0 # Total decision points self.total_opportunities = 0 # Total decision points
def record_game(self, game: Game, winner_name: str): def record_game(self, game: Game, winner_name: str):
@@ -82,6 +161,8 @@ class SimulationStats:
self.paired_negative_cards += 1 self.paired_negative_cards += 1
elif move_type == "swapped_good_for_bad": elif move_type == "swapped_good_for_bad":
self.swapped_good_for_bad += 1 self.swapped_good_for_bad += 1
elif move_type == "swapped_high_into_unknown":
self.swapped_high_into_unknown += 1
def record_opportunity(self): def record_opportunity(self):
"""Record a decision opportunity for rate calculation.""" """Record a decision opportunity for rate calculation."""
@@ -96,7 +177,8 @@ class SimulationStats:
self.discarded_kings + self.discarded_kings +
self.took_bad_card_without_pair + self.took_bad_card_without_pair +
self.paired_negative_cards + self.paired_negative_cards +
self.swapped_good_for_bad self.swapped_good_for_bad +
self.swapped_high_into_unknown
) )
if self.total_opportunities == 0: if self.total_opportunities == 0:
return 0.0 return 0.0
@@ -154,6 +236,7 @@ class SimulationStats:
lines.append(" Mistakes (should be < 0.1%):") lines.append(" Mistakes (should be < 0.1%):")
lines.append(f" Discarded Kings: {self.discarded_kings}") lines.append(f" Discarded Kings: {self.discarded_kings}")
lines.append(f" Swapped good for bad: {self.swapped_good_for_bad}") lines.append(f" Swapped good for bad: {self.swapped_good_for_bad}")
lines.append(f" Swapped 8+ into unknown: {self.swapped_high_into_unknown}")
return "\n".join(lines) return "\n".join(lines)
@@ -175,8 +258,6 @@ def run_cpu_turn(
game: Game, game: Game,
player: Player, player: Player,
profile: CPUProfile, profile: CPUProfile,
logger: Optional[GameLogger],
game_id: Optional[str],
stats: SimulationStats stats: SimulationStats
) -> str: ) -> str:
"""Run a single CPU turn synchronously. Returns action taken.""" """Run a single CPU turn synchronously. Returns action taken."""
@@ -215,31 +296,37 @@ def run_cpu_turn(
if not has_pair_potential and not has_worse_to_replace: if not has_pair_potential and not has_worse_to_replace:
stats.record_dumb_move("took_bad_without_pair") stats.record_dumb_move("took_bad_without_pair")
# Log draw decision
if logger and game_id:
reason = f"took {discard_top.rank.value} from discard" if take_discard else "drew from deck"
logger.log_move(
game_id=game_id,
player=player,
is_cpu=True,
action=action,
card=drawn,
game=game,
decision_reason=reason,
)
# Decide whether to swap or discard # Decide whether to swap or discard
swap_pos = GolfAI.choose_swap_or_discard(drawn, player, profile, game) swap_pos = GolfAI.choose_swap_or_discard(drawn, player, profile, game)
ai_chose_swap = swap_pos is not None # Track if AI made this decision vs fallback
# If drawn from discard, must swap # If drawn from discard, must swap
if swap_pos is None and game.drawn_from_discard: if swap_pos is None and game.drawn_from_discard:
drawn_val = get_ai_card_value(drawn, game.options)
# First, check if there's a visible card WORSE than what we drew
# (prefer swapping visible bad cards over face-down unknowns)
worst_visible_pos = None
worst_visible_val = drawn_val # Only consider cards worse than drawn
for i, c in enumerate(player.cards):
if c.face_up:
card_val = get_ai_card_value(c, game.options)
if card_val > worst_visible_val:
worst_visible_val = card_val
worst_visible_pos = i
if worst_visible_pos is not None:
# Found a visible card worse than drawn - swap with it
swap_pos = worst_visible_pos
else:
# No visible card worse than drawn - must use face-down
face_down = [i for i, c in enumerate(player.cards) if not c.face_up] face_down = [i for i, c in enumerate(player.cards) if not c.face_up]
if face_down: if face_down:
# Use filter to avoid bad pairs with negative cards # Use filter to avoid bad pairs with negative cards
safe_positions = filter_bad_pair_positions(face_down, drawn, player, game.options) safe_positions = filter_bad_pair_positions(face_down, drawn, player, game.options)
swap_pos = random.choice(safe_positions) swap_pos = random.choice(safe_positions)
else: else:
# Find worst card using house rules # All cards face-up, find worst card overall
worst_pos = 0 worst_pos = 0
worst_val = -999 worst_val = -999
for i, c in enumerate(player.cards): for i, c in enumerate(player.cards):
@@ -254,37 +341,69 @@ def run_cpu_turn(
if swap_pos is not None: if swap_pos is not None:
old_card = player.cards[swap_pos] old_card = player.cards[swap_pos]
partner_pos = get_column_partner_position(swap_pos)
partner = player.cards[partner_pos]
# Check for dumb moves: swapping good card for bad # Check for dumb moves: swapping good card for bad
drawn_val = get_ai_card_value(drawn, game.options) drawn_val = get_ai_card_value(drawn, game.options)
old_val = get_ai_card_value(old_card, game.options) old_val = get_ai_card_value(old_card, game.options)
# Only flag as dumb if:
# 1. Old card was face-up and good (value <= 1)
# 2. We're putting a worse card in
# 3. We're NOT creating a pair (pairing is a valid reason to replace a good card)
# 4. We're NOT in a forced-swap-from-discard situation
# 5. We're NOT denying the next opponent a pair (strategic denial)
creates_pair = partner.face_up and partner.rank == drawn.rank
# Check if this was a denial move (next player has unpaired visible card of drawn rank)
is_denial_move = False
current_idx = next((i for i, p in enumerate(game.players) if p.id == player.id), 0)
next_idx = (current_idx + 1) % len(game.players)
next_player = game.players[next_idx]
for i, opp_card in enumerate(next_player.cards):
if opp_card.face_up and opp_card.rank == drawn.rank:
opp_partner_pos = get_column_partner_position(i)
opp_partner = next_player.cards[opp_partner_pos]
if not (opp_partner.face_up and opp_partner.rank == drawn.rank):
is_denial_move = True
break
if old_card.face_up and old_val < drawn_val and old_val <= 1: if old_card.face_up and old_val < drawn_val and old_val <= 1:
if not creates_pair and not is_denial_move:
stats.record_dumb_move("swapped_good_for_bad") stats.record_dumb_move("swapped_good_for_bad")
# Check for dumb move: swapping high card into unknown position
# Cards 8+ (8, 9, 10, J, Q) should never be swapped into face-down positions
# since expected value of hidden card is only ~4.5
# Exception: pairing, denial moves, or forced swaps from discard
if not old_card.face_up and drawn_val >= 8:
if not creates_pair and not is_denial_move:
# Only count as dumb if:
# 1. AI actively chose this (not fallback from forced discard swap)
# 2. OR if drawn from discard but a worse visible card existed
worse_visible_exists = has_worse_visible_card(player, drawn_val, game.options)
if ai_chose_swap:
# AI chose to swap 8+ into hidden - this is dumb
stats.record_dumb_move("swapped_high_into_unknown")
elif game.drawn_from_discard and worse_visible_exists:
# Fallback chose hidden when worse visible existed - also dumb
stats.record_dumb_move("swapped_high_into_unknown")
# Check for dumb move: creating bad pair with negative card # Check for dumb move: creating bad pair with negative card
partner_pos = get_column_partner_position(swap_pos)
partner = player.cards[partner_pos]
if (partner.face_up and if (partner.face_up and
partner.rank == drawn.rank and partner.rank == drawn.rank and
drawn_val < 0 and drawn_val < 0 and
not (game.options.eagle_eye and drawn.rank == Rank.JOKER)): not (game.options.eagle_eye and drawn.rank == Rank.JOKER) and
not game.options.negative_pairs_keep_value):
stats.record_dumb_move("paired_negative") stats.record_dumb_move("paired_negative")
print(f" !!! PAIRED NEGATIVE: {player.name} paired {drawn.rank.value} "
f"at pos {swap_pos} (partner at {partner_pos})")
game.swap_card(player.id, swap_pos) game.swap_card(player.id, swap_pos)
action = "swap" action = "swap"
stats.record_turn(player.name, action) stats.record_turn(player.name, action)
if logger and game_id:
logger.log_move(
game_id=game_id,
player=player,
is_cpu=True,
action="swap",
card=drawn,
position=swap_pos,
game=game,
decision_reason=f"swapped {drawn.rank.value} for {old_card.rank.value} at pos {swap_pos}",
)
else: else:
# Check for dumb moves: discarding excellent cards # Check for dumb moves: discarding excellent cards
if drawn.rank == Rank.JOKER: if drawn.rank == Rank.JOKER:
@@ -298,41 +417,16 @@ def run_cpu_turn(
action = "discard" action = "discard"
stats.record_turn(player.name, action) stats.record_turn(player.name, action)
if logger and game_id:
logger.log_move(
game_id=game_id,
player=player,
is_cpu=True,
action="discard",
card=drawn,
game=game,
decision_reason=f"discarded {drawn.rank.value}",
)
if game.flip_on_discard: if game.flip_on_discard:
flip_pos = GolfAI.choose_flip_after_discard(player, profile) flip_pos = GolfAI.choose_flip_after_discard(player, profile)
game.flip_and_end_turn(player.id, flip_pos) game.flip_and_end_turn(player.id, flip_pos)
if logger and game_id:
flipped = player.cards[flip_pos]
logger.log_move(
game_id=game_id,
player=player,
is_cpu=True,
action="flip",
card=flipped,
position=flip_pos,
game=game,
decision_reason=f"flipped position {flip_pos}",
)
return action return action
def run_game( def run_game(
players_with_profiles: list[tuple[Player, CPUProfile]], players_with_profiles: list[tuple[Player, CPUProfile]],
options: GameOptions, options: GameOptions,
logger: Optional[GameLogger],
stats: SimulationStats, stats: SimulationStats,
verbose: bool = False verbose: bool = False
) -> tuple[str, int]: ) -> tuple[str, int]:
@@ -353,15 +447,6 @@ def run_game(
game.start_game(num_decks=1, num_rounds=1, options=options) game.start_game(num_decks=1, num_rounds=1, options=options)
# Log game start
game_id = None
if logger:
game_id = logger.log_game_start(
room_code="SIM",
num_players=len(players_with_profiles),
options=options
)
# Do initial flips for all players # Do initial flips for all players
if options.initial_flips > 0: if options.initial_flips > 0:
for player, profile in players_with_profiles: for player, profile in players_with_profiles:
@@ -378,17 +463,13 @@ def run_game(
break break
profile = profiles[current.id] profile = profiles[current.id]
action = run_cpu_turn(game, current, profile, logger, game_id, stats) action = run_cpu_turn(game, current, profile, stats)
if verbose and turn_count % 10 == 0: if verbose and turn_count % 10 == 0:
print(f" Turn {turn_count}: {current.name} - {action}") print(f" Turn {turn_count}: {current.name} - {action}")
turn_count += 1 turn_count += 1
# Log game end
if logger and game_id:
logger.log_game_end(game_id)
# Find winner # Find winner
winner = min(game.players, key=lambda p: p.total_score) winner = min(game.players, key=lambda p: p.total_score)
stats.record_game(game, winner.name) stats.record_game(game, winner.name)
@@ -399,23 +480,30 @@ def run_game(
def run_simulation( def run_simulation(
num_games: int = 10, num_games: int = 10,
num_players: int = 4, num_players: int = 4,
options: Optional[GameOptions] = None,
verbose: bool = True verbose: bool = True
): ) -> SimulationStats:
"""Run multiple games and report statistics.""" """Run multiple games and report statistics."""
if options is None:
options = GameOptions(initial_flips=2, flip_mode="never")
# Build description of active rules
active_rules = []
for field_name in ["use_jokers", "eagle_eye", "negative_pairs_keep_value",
"knock_penalty", "knock_bonus", "four_of_a_kind",
"wolfpack", "lucky_swing", "underdog_bonus"]:
if getattr(options, field_name, False):
active_rules.append(field_name)
rules_desc = ", ".join(active_rules) if active_rules else "baseline (no special rules)"
print(f"\nRunning {num_games} games with {num_players} players each...") print(f"\nRunning {num_games} games with {num_players} players each...")
print(f"Rules: {rules_desc}")
print("=" * 50) print("=" * 50)
logger = GameLogger()
stats = SimulationStats() stats = SimulationStats()
# Default options
options = GameOptions(
initial_flips=2,
flip_mode="never",
use_jokers=False,
)
for i in range(num_games): for i in range(num_games):
players = create_cpu_players(num_players) players = create_cpu_players(num_players)
@@ -423,7 +511,7 @@ def run_simulation(
names = [p.name for p, _ in players] names = [p.name for p, _ in players]
print(f"\nGame {i+1}/{num_games}: {', '.join(names)}") print(f"\nGame {i+1}/{num_games}: {', '.join(names)}")
winner, score = run_game(players, options, logger, stats, verbose=False) winner, score = run_game(players, options, stats, verbose=False)
if verbose: if verbose:
print(f" Winner: {winner} (score: {score})") print(f" Winner: {winner} (score: {score})")
@@ -431,29 +519,31 @@ def run_simulation(
print("\n") print("\n")
print(stats.report()) print(stats.report())
print("\n" + "=" * 50) return stats
print("ANALYSIS")
print("=" * 50)
print("\nRun analysis with:")
print(" python game_analyzer.py blunders")
print(" python game_analyzer.py summary")
def run_detailed_game(num_players: int = 4): def run_detailed_game(num_players: int = 4, options: Optional[GameOptions] = None):
"""Run a single game with detailed output.""" """Run a single game with detailed output."""
if options is None:
options = GameOptions(initial_flips=2, flip_mode="never")
# Build description of active rules
active_rules = []
for field_name in ["use_jokers", "eagle_eye", "negative_pairs_keep_value",
"knock_penalty", "knock_bonus", "four_of_a_kind",
"wolfpack", "lucky_swing", "underdog_bonus"]:
if getattr(options, field_name, False):
active_rules.append(field_name)
rules_desc = ", ".join(active_rules) if active_rules else "baseline (no special rules)"
print(f"\nRunning detailed game with {num_players} players...") print(f"\nRunning detailed game with {num_players} players...")
print(f"Rules: {rules_desc}")
print("=" * 50) print("=" * 50)
logger = GameLogger()
stats = SimulationStats() stats = SimulationStats()
options = GameOptions(
initial_flips=2,
flip_mode="never",
use_jokers=False,
)
players_with_profiles = create_cpu_players(num_players) players_with_profiles = create_cpu_players(num_players)
game = Game() game = Game()
@@ -466,12 +556,6 @@ def run_detailed_game(num_players: int = 4):
game.start_game(num_decks=1, num_rounds=1, options=options) game.start_game(num_decks=1, num_rounds=1, options=options)
game_id = logger.log_game_start(
room_code="DETAIL",
num_players=num_players,
options=options
)
# Initial flips # Initial flips
print("\nInitial flips:") print("\nInitial flips:")
for player, profile in players_with_profiles: for player, profile in players_with_profiles:
@@ -502,7 +586,7 @@ def run_detailed_game(num_players: int = 4):
print(f" Discard: {discard_before.rank.value}") print(f" Discard: {discard_before.rank.value}")
# Run turn # Run turn
action = run_cpu_turn(game, current, profile, logger, game_id, stats) action = run_cpu_turn(game, current, profile, stats)
# Show result # Show result
discard_after = game.discard_top() discard_after = game.discard_top()
@@ -515,8 +599,6 @@ def run_detailed_game(num_players: int = 4):
turn += 1 turn += 1
# Game over # Game over
logger.log_game_end(game_id)
print("\n" + "=" * 50) print("\n" + "=" * 50)
print("FINAL SCORES") print("FINAL SCORES")
print("=" * 50) print("=" * 50)
@@ -528,18 +610,158 @@ def run_detailed_game(num_players: int = 4):
winner = min(game.players, key=lambda p: p.total_score) winner = min(game.players, key=lambda p: p.total_score)
print(f"\nWinner: {winner.name}!") print(f"\nWinner: {winner.name}!")
print(f"\nGame logged as: {game_id[:8]}...")
print("Run: python game_analyzer.py game", game_id, winner.name) print("Run: python game_analyzer.py game", game_id, winner.name)
def compare_rule_sets(presets: list[str], num_games: int = 100, num_players: int = 4):
"""Run simulations with different rule sets and compare results."""
print(f"\nComparing {len(presets)} rule sets with {num_games} games each...")
print("=" * 60)
results: dict[str, SimulationStats] = {}
for preset in presets:
print(f"\n{'='*60}")
print(f"RUNNING PRESET: {preset}")
print(f"{'='*60}")
options = get_preset_options(preset)
stats = run_simulation(num_games, num_players, options, verbose=False)
results[preset] = stats
# Print comparison summary
print("\n")
print("=" * 70)
print("COMPARISON SUMMARY")
print("=" * 70)
# Header
print(f"\n{'Preset':<20} {'Avg Score':<12} {'Dumb %':<10} {'Swap %':<10} {'Discard %':<10}")
print("-" * 70)
for preset in presets:
stats = results[preset]
# Calculate average score across all players
all_scores = []
for scores in stats.player_scores.values():
all_scores.extend(scores)
avg_score = sum(all_scores) / len(all_scores) if all_scores else 0
# Calculate swap vs discard ratio
total_swaps = 0
total_discards = 0
for actions in stats.decisions.values():
total_swaps += actions.get("swap", 0)
total_discards += actions.get("discard", 0)
total_actions = total_swaps + total_discards
swap_pct = (total_swaps / total_actions * 100) if total_actions > 0 else 0
discard_pct = (total_discards / total_actions * 100) if total_actions > 0 else 0
print(f"{preset:<20} {avg_score:<12.1f} {stats.dumb_move_rate:<10.3f} {swap_pct:<10.1f} {discard_pct:<10.1f}")
# Detailed dumb move breakdown
print("\n\nDUMB MOVE BREAKDOWN BY PRESET:")
print("-" * 70)
print(f"{'Preset':<20} {'Jokers':<8} {'2s':<8} {'Kings':<8} {'BadTake':<8} {'NegPair':<8} {'BadSwap':<8}")
print("-" * 70)
for preset in presets:
stats = results[preset]
print(f"{preset:<20} {stats.discarded_jokers:<8} {stats.discarded_twos:<8} "
f"{stats.discarded_kings:<8} {stats.took_bad_card_without_pair:<8} "
f"{stats.paired_negative_cards:<8} {stats.swapped_good_for_bad:<8}")
def main():
"""Main entry point with argparse CLI."""
parser = argparse.ArgumentParser(
description="Golf AI Simulation Runner - test AI behavior under different rule sets",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
python simulate.py 100 # 100 games, baseline rules
python simulate.py 100 4 # 100 games, 4 players
python simulate.py 100 --preset eagle_eye # Use eagle_eye preset
python simulate.py 100 --rules use_jokers,knock_penalty
python simulate.py 100 --compare baseline eagle_eye negative_pairs
python simulate.py detail --preset competitive # Single detailed game
Available presets:
baseline - Classic rules (no special options)
jokers - Jokers enabled
eagle_eye - Jokers + eagle_eye rule
negative_pairs - Jokers + negative pairs keep value
four_kind - Four of a kind bonus
wolfpack - Wolfpack bonus
competitive - Knock penalty + knock bonus
wild - Jokers + lucky_swing + eagle_eye + negative_pairs
all_bonuses - All bonus rules enabled
"""
)
parser.add_argument(
"num_games",
nargs="?",
default="10",
help="Number of games to run, or 'detail' for a single detailed game"
)
parser.add_argument(
"num_players",
nargs="?",
type=int,
default=4,
help="Number of players (default: 4)"
)
parser.add_argument(
"--preset",
type=str,
help="Use a named rule preset (e.g., eagle_eye, competitive)"
)
parser.add_argument(
"--rules",
type=str,
help="Comma-separated list of rules to enable (e.g., use_jokers,knock_penalty)"
)
parser.add_argument(
"--compare",
nargs="+",
metavar="PRESET",
help="Compare multiple presets side-by-side"
)
parser.add_argument(
"-q", "--quiet",
action="store_true",
help="Reduce output verbosity"
)
args = parser.parse_args()
# Determine options
options = None
if args.preset and args.rules:
parser.error("Cannot use both --preset and --rules")
if args.preset:
options = get_preset_options(args.preset)
elif args.rules:
options = parse_rules_string(args.rules)
# Handle compare mode
if args.compare:
num_games = int(args.num_games) if args.num_games != "detail" else 100
compare_rule_sets(args.compare, num_games, args.num_players)
return
# Handle detail mode
if args.num_games == "detail":
run_detailed_game(args.num_players, options)
return
# Standard batch simulation
num_games = int(args.num_games)
run_simulation(num_games, args.num_players, options, verbose=not args.quiet)
if __name__ == "__main__": if __name__ == "__main__":
if len(sys.argv) > 1 and sys.argv[1] == "detail": main()
# Detailed single game
num_players = int(sys.argv[2]) if len(sys.argv) > 2 else 4
run_detailed_game(num_players)
else:
# Batch simulation
num_games = int(sys.argv[1]) if len(sys.argv) > 1 else 10
num_players = int(sys.argv[2]) if len(sys.argv) > 2 else 4
run_simulation(num_games, num_players)

View File

@@ -62,6 +62,32 @@ CREATE TABLE IF NOT EXISTS games_v2 (
player_ids VARCHAR(50)[] DEFAULT '{}' player_ids VARCHAR(50)[] DEFAULT '{}'
); );
-- Moves table (denormalized for AI decision analysis)
-- Replaces SQLite game_log.py - provides efficient queries for move-level analysis
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)
);
-- Indexes for common queries -- Indexes for common queries
CREATE INDEX IF NOT EXISTS idx_events_game_seq ON events(game_id, sequence_num); CREATE INDEX IF NOT EXISTS idx_events_game_seq ON events(game_id, sequence_num);
CREATE INDEX IF NOT EXISTS idx_events_type ON events(event_type); CREATE INDEX IF NOT EXISTS idx_events_type ON events(event_type);
@@ -72,6 +98,11 @@ CREATE INDEX IF NOT EXISTS idx_games_status ON games_v2(status);
CREATE INDEX IF NOT EXISTS idx_games_room ON games_v2(room_code) WHERE status = 'active'; CREATE INDEX IF NOT EXISTS idx_games_room ON games_v2(room_code) WHERE status = 'active';
CREATE INDEX IF NOT EXISTS idx_games_players ON games_v2 USING GIN(player_ids); CREATE INDEX IF NOT EXISTS idx_games_players ON games_v2 USING GIN(player_ids);
CREATE INDEX IF NOT EXISTS idx_games_completed ON games_v2(completed_at) WHERE status = 'completed'; CREATE INDEX IF NOT EXISTS idx_games_completed ON games_v2(completed_at) WHERE status = 'completed';
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);
""" """
@@ -441,6 +472,230 @@ class EventStore:
) )
return dict(row) if row else None return dict(row) if row else None
# -------------------------------------------------------------------------
# Move Logging (for AI decision analysis)
# -------------------------------------------------------------------------
async def append_move(
self,
game_id: str,
player_id: str,
player_name: str,
is_cpu: bool,
action: str,
card_rank: Optional[str] = None,
card_suit: Optional[str] = None,
position: Optional[int] = None,
hand_state: Optional[list] = None,
discard_top: Optional[dict] = None,
visible_opponents: Optional[dict] = None,
decision_reason: Optional[str] = None,
) -> int:
"""
Append a move to the moves table for AI decision analysis.
Args:
game_id: Game UUID.
player_id: Player who made the move.
player_name: Display name of the player.
is_cpu: Whether this is a CPU player.
action: Action type (draw_deck, take_discard, swap, discard, flip, etc.).
card_rank: Rank of the card involved.
card_suit: Suit of the card involved.
position: Hand position (0-5) for swaps/flips.
hand_state: Player's hand at decision time.
discard_top: Top of discard pile at decision time.
visible_opponents: Face-up cards of opponents.
decision_reason: AI reasoning for the decision.
Returns:
The database ID of the inserted move.
"""
async with self.pool.acquire() as conn:
# Get next sequence number for this game
seq_row = await conn.fetchrow(
"SELECT COALESCE(MAX(sequence_num), 0) + 1 as seq FROM moves WHERE game_id = $1",
game_id,
)
sequence_num = seq_row["seq"]
row = await conn.fetchrow(
"""
INSERT INTO moves (
game_id, sequence_num, player_id, player_name, is_cpu,
action, card_rank, card_suit, position,
hand_state, discard_top, visible_opponents, decision_reason
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
RETURNING id
""",
game_id,
sequence_num,
player_id,
player_name,
is_cpu,
action,
card_rank,
card_suit,
position,
json.dumps(hand_state) if hand_state else None,
json.dumps(discard_top) if discard_top else None,
json.dumps(visible_opponents) if visible_opponents else None,
decision_reason,
)
return row["id"]
async def get_moves(
self,
game_id: str,
player_id: Optional[str] = None,
is_cpu: Optional[bool] = None,
action: Optional[str] = None,
limit: int = 100,
) -> list[dict]:
"""
Get moves for a game with optional filters.
Args:
game_id: Game UUID.
player_id: Filter by player ID.
is_cpu: Filter by CPU status.
action: Filter by action type.
limit: Maximum number of moves to return.
Returns:
List of move dicts.
"""
conditions = ["game_id = $1"]
params = [game_id]
param_idx = 2
if player_id is not None:
conditions.append(f"player_id = ${param_idx}")
params.append(player_id)
param_idx += 1
if is_cpu is not None:
conditions.append(f"is_cpu = ${param_idx}")
params.append(is_cpu)
param_idx += 1
if action is not None:
conditions.append(f"action = ${param_idx}")
params.append(action)
param_idx += 1
params.append(limit)
where_clause = " AND ".join(conditions)
async with self.pool.acquire() as conn:
rows = await conn.fetch(
f"""
SELECT id, game_id, sequence_num, timestamp, player_id, player_name, is_cpu,
action, card_rank, card_suit, position,
hand_state, discard_top, visible_opponents, decision_reason
FROM moves
WHERE {where_clause}
ORDER BY sequence_num
LIMIT ${param_idx}
""",
*params,
)
return [self._row_to_move(row) for row in rows]
async def get_player_decisions(
self,
game_id: str,
player_name: str,
) -> list[dict]:
"""
Get all decisions made by a specific player in a game.
Args:
game_id: Game UUID.
player_name: Display name of the player.
Returns:
List of move dicts.
"""
async with self.pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT id, game_id, sequence_num, timestamp, player_id, player_name, is_cpu,
action, card_rank, card_suit, position,
hand_state, discard_top, visible_opponents, decision_reason
FROM moves
WHERE game_id = $1 AND player_name = $2
ORDER BY sequence_num
""",
game_id,
player_name,
)
return [self._row_to_move(row) for row in rows]
async def find_suspicious_discards(self, limit: int = 50) -> list[dict]:
"""
Find cases where CPU discarded good cards (Ace, 2, King).
Used for AI decision quality analysis.
Args:
limit: Maximum number of results.
Returns:
List of suspicious move dicts with game room_code.
"""
async with self.pool.acquire() as conn:
rows = await conn.fetch(
"""
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 $1
""",
limit,
)
return [self._row_to_move(row) for row in rows]
async def get_recent_games_with_stats(self, limit: int = 10) -> list[dict]:
"""
Get recent games with move counts.
Args:
limit: Maximum number of games.
Returns:
List of game dicts with total_moves count.
"""
async with self.pool.acquire() as conn:
rows = await conn.fetch(
"""
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 $1
""",
limit,
)
return [dict(row) for row in rows]
def _row_to_move(self, row: asyncpg.Record) -> dict:
"""Convert a database row to a move dict."""
result = dict(row)
# Parse JSON fields
if result.get("hand_state"):
result["hand_state"] = json.loads(result["hand_state"])
if result.get("discard_top"):
result["discard_top"] = json.loads(result["discard_top"])
if result.get("visible_opponents"):
result["visible_opponents"] = json.loads(result["visible_opponents"])
return result
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# Helpers # Helpers
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------

683
server/test_ai_decisions.py Normal file
View File

@@ -0,0 +1,683 @@
"""
Test suite for AI decision sub-functions extracted from ai.py.
Covers:
- _pair_improvement(): pair bonus / negative pair / spread bonus
- _point_gain(): face-up replacement, hidden card discount
- _reveal_and_bonus_score(): reveal scaling, comeback bonus
- _check_auto_take(): joker/king/one-eyed-jack/wolfpack auto-takes
- _has_good_swap_option(): good/bad swap previews
- calculate_swap_score(): go-out safety penalty
- should_take_discard(): integration of sub-decisions
- should_knock_early(): knock timing decisions
Run with: pytest test_ai_decisions.py -v
"""
import pytest
from game import (
Card, Deck, Player, Game, GamePhase, GameOptions,
Suit, Rank, RANK_VALUES
)
from ai import GolfAI, CPUProfile, get_ai_card_value
# =============================================================================
# Helpers (shared with test_v3_features.py pattern)
# =============================================================================
def make_game(num_players=2, options=None, rounds=1):
"""Create a game with N players, dealt and in PLAYING phase."""
opts = options or GameOptions()
game = Game(num_rounds=rounds, options=opts)
for i in range(num_players):
game.add_player(Player(id=f"p{i}", name=f"Player {i}"))
game.start_round()
if game.phase == GamePhase.INITIAL_FLIP:
for p in game.players:
game.flip_initial_cards(p.id, [0, 1])
return game
def set_hand(player, ranks, face_up=True):
"""Set player hand to specific ranks (all hearts, all face-up by default)."""
player.cards = [
Card(Suit.HEARTS, rank, face_up=face_up) for rank in ranks
]
def flip_all_but(player, keep_down=0):
"""Flip all cards face-up except `keep_down` cards (from the end)."""
for i, card in enumerate(player.cards):
card.face_up = i < len(player.cards) - keep_down
def make_profile(**overrides):
"""Create a CPUProfile with sensible defaults, overridable."""
defaults = dict(
name="TestBot",
style="balanced",
pair_hope=0.5,
aggression=0.5,
swap_threshold=4,
unpredictability=0.0, # Deterministic by default for tests
)
defaults.update(overrides)
return CPUProfile(**defaults)
# =============================================================================
# _pair_improvement
# =============================================================================
class TestPairImprovement:
"""Test pair bonus and spread bonus scoring."""
def test_positive_pair_bonus(self):
"""Pairing two positive cards should yield a positive score."""
game = make_game()
player = game.players[0]
# Position 0 has a 7, partner (pos 3) has a 7 face-up
set_hand(player, [Rank.SEVEN, Rank.THREE, Rank.FIVE,
Rank.SEVEN, Rank.FOUR, Rank.SIX])
profile = make_profile(pair_hope=0.5)
drawn_card = Card(Suit.DIAMONDS, Rank.SEVEN)
drawn_value = get_ai_card_value(drawn_card, game.options)
score = GolfAI._pair_improvement(
0, drawn_card, drawn_value, player, game.options, profile
)
# Pairing two 7s: bonus = (7+7) * pair_weight(1.5) = 21
assert score > 0
def test_negative_pair_penalty_standard(self):
"""Under standard rules, pairing negative cards is penalized."""
game = make_game()
player = game.players[0]
set_hand(player, [Rank.TWO, Rank.THREE, Rank.FIVE,
Rank.TWO, Rank.FOUR, Rank.SIX])
profile = make_profile(pair_hope=0.5)
drawn_card = Card(Suit.DIAMONDS, Rank.TWO)
drawn_value = get_ai_card_value(drawn_card, game.options)
score = GolfAI._pair_improvement(
0, drawn_card, drawn_value, player, game.options, profile
)
# Penalty for wasting negative cards under standard rules
assert score < 0
def test_eagle_eye_joker_pair_bonus(self):
"""Eagle Eye Joker pairs should get a large bonus."""
opts = GameOptions(eagle_eye=True)
game = make_game(options=opts)
player = game.players[0]
set_hand(player, [Rank.JOKER, Rank.THREE, Rank.FIVE,
Rank.JOKER, Rank.FOUR, Rank.SIX])
profile = make_profile(pair_hope=0.5)
drawn_card = Card(Suit.DIAMONDS, Rank.JOKER)
drawn_value = get_ai_card_value(drawn_card, opts)
score = GolfAI._pair_improvement(
0, drawn_card, drawn_value, player, opts, profile
)
# Eagle Eye Joker pairs = 8 * pair_weight
assert score > 0
def test_negative_pairs_keep_value(self):
"""With negative_pairs_keep_value, pairing 2s should be good."""
opts = GameOptions(negative_pairs_keep_value=True)
game = make_game(options=opts)
player = game.players[0]
set_hand(player, [Rank.TWO, Rank.THREE, Rank.FIVE,
Rank.TWO, Rank.FOUR, Rank.SIX])
profile = make_profile(pair_hope=0.5)
drawn_card = Card(Suit.DIAMONDS, Rank.TWO)
drawn_value = get_ai_card_value(drawn_card, opts)
score = GolfAI._pair_improvement(
0, drawn_card, drawn_value, player, opts, profile
)
assert score > 0
def test_spread_bonus_for_excellent_card(self):
"""Spreading an Ace across columns should get a spread bonus."""
game = make_game()
player = game.players[0]
set_hand(player, [Rank.SEVEN, Rank.THREE, Rank.FIVE,
Rank.EIGHT, Rank.FOUR, Rank.SIX])
profile = make_profile(pair_hope=0.0) # Spreader, not pair hunter
drawn_card = Card(Suit.DIAMONDS, Rank.ACE)
drawn_value = get_ai_card_value(drawn_card, game.options)
score = GolfAI._pair_improvement(
0, drawn_card, drawn_value, player, game.options, profile
)
# spread_weight = 2.0, bonus = 2.0 * 0.5 = 1.0
assert score == pytest.approx(1.0)
def test_no_spread_bonus_for_bad_card(self):
"""No spread bonus for high-value cards."""
game = make_game()
player = game.players[0]
set_hand(player, [Rank.SEVEN, Rank.THREE, Rank.FIVE,
Rank.EIGHT, Rank.FOUR, Rank.SIX])
profile = make_profile(pair_hope=0.0)
drawn_card = Card(Suit.DIAMONDS, Rank.NINE)
drawn_value = get_ai_card_value(drawn_card, game.options)
score = GolfAI._pair_improvement(
0, drawn_card, drawn_value, player, game.options, profile
)
assert score == 0.0
# =============================================================================
# _point_gain
# =============================================================================
class TestPointGain:
"""Test point gain from replacing cards."""
def test_replace_high_with_low(self):
"""Replacing a face-up 10 with a 3 should give positive point gain."""
game = make_game()
player = game.players[0]
set_hand(player, [Rank.TEN, Rank.THREE, Rank.FIVE,
Rank.EIGHT, Rank.FOUR, Rank.SIX])
profile = make_profile()
drawn_card = Card(Suit.DIAMONDS, Rank.THREE)
drawn_value = get_ai_card_value(drawn_card, game.options)
gain = GolfAI._point_gain(
0, drawn_card, drawn_value, player, game.options, profile
)
# 10 - 3 = 7
assert gain == pytest.approx(7.0)
def test_breaking_pair_is_bad(self):
"""Breaking an existing pair should produce a negative point gain."""
game = make_game()
player = game.players[0]
# Column 0: positions 0 and 3 are both 5s (paired)
set_hand(player, [Rank.FIVE, Rank.THREE, Rank.EIGHT,
Rank.FIVE, Rank.FOUR, Rank.SIX])
profile = make_profile()
drawn_card = Card(Suit.DIAMONDS, Rank.FOUR)
drawn_value = get_ai_card_value(drawn_card, game.options)
gain = GolfAI._point_gain(
0, drawn_card, drawn_value, player, game.options, profile
)
# Breaking pair: old_column=0, new_column=4+5=9, gain=0-9=-9
assert gain < 0
def test_creating_pair(self):
"""Creating a new pair should produce a positive point gain."""
game = make_game()
player = game.players[0]
# Position 0 has 9, partner (pos 3) has 5. Draw a 5 to pair with pos 3.
set_hand(player, [Rank.NINE, Rank.THREE, Rank.EIGHT,
Rank.FIVE, Rank.FOUR, Rank.SIX])
profile = make_profile()
drawn_card = Card(Suit.DIAMONDS, Rank.FIVE)
drawn_value = get_ai_card_value(drawn_card, game.options)
gain = GolfAI._point_gain(
0, drawn_card, drawn_value, player, game.options, profile
)
# Creating pair: old_column=9+5=14, new_column=0, gain=14
assert gain > 0
def test_hidden_card_discount(self):
"""Hidden card replacement should use expected value with discount."""
game = make_game()
player = game.players[0]
set_hand(player, [Rank.TEN, Rank.THREE, Rank.FIVE,
Rank.EIGHT, Rank.FOUR, Rank.SIX])
player.cards[0].face_up = False # Position 0 is hidden
profile = make_profile(swap_threshold=4)
drawn_card = Card(Suit.DIAMONDS, Rank.ACE)
drawn_value = get_ai_card_value(drawn_card, game.options)
gain = GolfAI._point_gain(
0, drawn_card, drawn_value, player, game.options, profile
)
# expected_hidden=4.5, drawn_value=1, gain=(4.5-1)*discount
# discount = 0.5 + (4/16) = 0.75
assert gain == pytest.approx((4.5 - 1) * 0.75)
def test_hidden_card_negative_pair_no_bonus(self):
"""No point gain bonus when creating a negative pair on hidden card."""
game = make_game()
player = game.players[0]
set_hand(player, [Rank.TWO, Rank.THREE, Rank.FIVE,
Rank.TWO, Rank.FOUR, Rank.SIX])
player.cards[0].face_up = False # Position 0 hidden
# Partner (pos 3) is face-up TWO
profile = make_profile()
drawn_card = Card(Suit.DIAMONDS, Rank.TWO)
drawn_value = get_ai_card_value(drawn_card, game.options)
gain = GolfAI._point_gain(
0, drawn_card, drawn_value, player, game.options, profile
)
# Creates negative pair → returns 0.0
assert gain == 0.0
# =============================================================================
# _reveal_and_bonus_score
# =============================================================================
class TestRevealAndBonusScore:
"""Test reveal bonus, comeback bonus, and strategic bonuses."""
def test_reveal_bonus_scales_by_quality(self):
"""Better cards get bigger reveal bonuses."""
game = make_game()
player = game.players[0]
set_hand(player, [Rank.TEN, Rank.THREE, Rank.FIVE,
Rank.EIGHT, Rank.FOUR, Rank.SIX])
player.cards[0].face_up = False
player.cards[3].face_up = False
profile = make_profile(aggression=0.5)
# Excellent card (value 0)
king = Card(Suit.DIAMONDS, Rank.KING)
king_score = GolfAI._reveal_and_bonus_score(
0, king, 0, player, game.options, game, profile
)
# Bad card (value 8)
eight = Card(Suit.DIAMONDS, Rank.EIGHT)
eight_score = GolfAI._reveal_and_bonus_score(
0, eight, 8, player, game.options, game, profile
)
assert king_score > eight_score
def test_comeback_bonus_when_behind(self):
"""Player behind in standings should get a comeback bonus."""
opts = GameOptions()
game = make_game(options=opts, rounds=5)
player = game.players[0]
player.total_score = 40 # Behind
game.players[1].total_score = 10 # Leader
game.current_round = 4 # Late game
set_hand(player, [Rank.TEN, Rank.THREE, Rank.FIVE,
Rank.EIGHT, Rank.FOUR, Rank.SIX])
player.cards[0].face_up = False
profile = make_profile(aggression=0.8)
drawn_card = Card(Suit.DIAMONDS, Rank.THREE)
drawn_value = get_ai_card_value(drawn_card, game.options)
score = GolfAI._reveal_and_bonus_score(
0, drawn_card, drawn_value, player, game.options, game, profile
)
# Should include comeback bonus (standings_pressure > 0.3, hidden card, value < 8)
assert score > 0
def test_no_comeback_for_high_card(self):
"""No comeback bonus for cards with value >= 8."""
opts = GameOptions()
game = make_game(options=opts, rounds=5)
player = game.players[0]
player.total_score = 40
game.players[1].total_score = 10
game.current_round = 4
set_hand(player, [Rank.TEN, Rank.THREE, Rank.FIVE,
Rank.EIGHT, Rank.FOUR, Rank.SIX])
player.cards[0].face_up = False
profile = make_profile(aggression=0.8)
# Draw a Queen (value 10 >= 8)
drawn_card = Card(Suit.DIAMONDS, Rank.QUEEN)
drawn_value = get_ai_card_value(drawn_card, game.options)
score_with_queen = GolfAI._reveal_and_bonus_score(
0, drawn_card, drawn_value, player, game.options, game, profile
)
# Bad cards get no reveal bonus and no comeback bonus
# So score should be 0 or very small (only future pair potential)
assert score_with_queen < 2.0
# =============================================================================
# _check_auto_take
# =============================================================================
class TestCheckAutoTake:
"""Test auto-take rules for discard pile decisions."""
def test_always_take_joker(self):
game = make_game()
player = game.players[0]
set_hand(player, [Rank.SEVEN, Rank.THREE, Rank.FIVE,
Rank.EIGHT, Rank.FOUR, Rank.SIX])
profile = make_profile()
joker = Card(Suit.HEARTS, Rank.JOKER)
value = get_ai_card_value(joker, game.options)
result = GolfAI._check_auto_take(joker, value, player, game.options, profile)
assert result is True
def test_always_take_king(self):
game = make_game()
player = game.players[0]
set_hand(player, [Rank.SEVEN, Rank.THREE, Rank.FIVE,
Rank.EIGHT, Rank.FOUR, Rank.SIX])
profile = make_profile()
king = Card(Suit.HEARTS, Rank.KING)
value = get_ai_card_value(king, game.options)
result = GolfAI._check_auto_take(king, value, player, game.options, profile)
assert result is True
def test_one_eyed_jack_auto_take(self):
opts = GameOptions(one_eyed_jacks=True)
game = make_game(options=opts)
player = game.players[0]
set_hand(player, [Rank.SEVEN, Rank.THREE, Rank.FIVE,
Rank.EIGHT, Rank.FOUR, Rank.SIX])
profile = make_profile()
# J♥ is one-eyed
jack_hearts = Card(Suit.HEARTS, Rank.JACK)
value = get_ai_card_value(jack_hearts, opts)
result = GolfAI._check_auto_take(jack_hearts, value, player, opts, profile)
assert result is True
# J♦ is NOT one-eyed
jack_diamonds = Card(Suit.DIAMONDS, Rank.JACK)
value = get_ai_card_value(jack_diamonds, opts)
result = GolfAI._check_auto_take(jack_diamonds, value, player, opts, profile)
assert result is None # No auto-take
def test_wolfpack_jack_pursuit(self):
opts = GameOptions(wolfpack=True)
game = make_game(options=opts)
player = game.players[0]
# Player has 2 visible Jacks
set_hand(player, [Rank.JACK, Rank.JACK, Rank.FIVE,
Rank.EIGHT, Rank.FOUR, Rank.SIX])
profile = make_profile(aggression=0.8)
jack = Card(Suit.DIAMONDS, Rank.JACK)
value = get_ai_card_value(jack, opts)
result = GolfAI._check_auto_take(jack, value, player, opts, profile)
assert result is True
def test_wolfpack_jack_not_aggressive_enough(self):
opts = GameOptions(wolfpack=True)
game = make_game(options=opts)
player = game.players[0]
set_hand(player, [Rank.JACK, Rank.JACK, Rank.FIVE,
Rank.EIGHT, Rank.FOUR, Rank.SIX])
profile = make_profile(aggression=0.3) # Too passive
jack = Card(Suit.DIAMONDS, Rank.JACK)
value = get_ai_card_value(jack, opts)
result = GolfAI._check_auto_take(jack, value, player, opts, profile)
assert result is None
def test_ten_penny_auto_take(self):
opts = GameOptions(ten_penny=True)
game = make_game(options=opts)
player = game.players[0]
set_hand(player, [Rank.SEVEN, Rank.THREE, Rank.FIVE,
Rank.EIGHT, Rank.FOUR, Rank.SIX])
profile = make_profile()
ten = Card(Suit.HEARTS, Rank.TEN)
value = get_ai_card_value(ten, opts)
result = GolfAI._check_auto_take(ten, value, player, opts, profile)
assert result is True
def test_pair_potential_auto_take(self):
"""Take card that can pair with a visible card."""
game = make_game()
player = game.players[0]
# Position 0 has a 7 face-up, partner (pos 3) is face-down
set_hand(player, [Rank.SEVEN, Rank.THREE, Rank.FIVE,
Rank.EIGHT, Rank.FOUR, Rank.SIX])
player.cards[3].face_up = False
profile = make_profile()
seven = Card(Suit.DIAMONDS, Rank.SEVEN)
value = get_ai_card_value(seven, game.options)
result = GolfAI._check_auto_take(seven, value, player, game.options, profile)
assert result is True
def test_no_auto_take_for_mediocre_card(self):
"""A random 8 should not be auto-taken."""
game = make_game()
player = game.players[0]
set_hand(player, [Rank.SEVEN, Rank.THREE, Rank.FIVE,
Rank.FOUR, Rank.FOUR, Rank.SIX])
profile = make_profile()
eight = Card(Suit.HEARTS, Rank.EIGHT)
value = get_ai_card_value(eight, game.options)
result = GolfAI._check_auto_take(eight, value, player, game.options, profile)
assert result is None
# =============================================================================
# _has_good_swap_option
# =============================================================================
class TestHasGoodSwapOption:
def test_good_swap_available(self):
"""With high cards in hand and a low card to swap, should return True."""
game = make_game()
player = game.players[0]
set_hand(player, [Rank.TEN, Rank.QUEEN, Rank.NINE,
Rank.EIGHT, Rank.JACK, Rank.SEVEN])
profile = make_profile()
drawn_card = Card(Suit.DIAMONDS, Rank.ACE)
drawn_value = get_ai_card_value(drawn_card, game.options)
result = GolfAI._has_good_swap_option(
drawn_card, drawn_value, player, game.options, game, profile
)
assert result is True
def test_no_good_swap(self):
"""With all low cards in hand and a high card, should return False."""
game = make_game()
player = game.players[0]
set_hand(player, [Rank.ACE, Rank.TWO, Rank.KING,
Rank.ACE, Rank.TWO, Rank.KING])
profile = make_profile()
drawn_card = Card(Suit.DIAMONDS, Rank.QUEEN)
drawn_value = get_ai_card_value(drawn_card, game.options)
result = GolfAI._has_good_swap_option(
drawn_card, drawn_value, player, game.options, game, profile
)
assert result is False
# =============================================================================
# calculate_swap_score (integration: go-out safety)
# =============================================================================
class TestCalculateSwapScore:
def test_go_out_safety_penalty(self):
"""Going out with a bad score should apply a -100 penalty."""
game = make_game()
player = game.players[0]
# All face-up except position 5, hand is all high cards
set_hand(player, [Rank.TEN, Rank.QUEEN, Rank.NINE,
Rank.EIGHT, Rank.JACK, Rank.SEVEN])
player.cards[5].face_up = False # Only pos 5 is hidden
profile = make_profile(aggression=0.0) # Conservative
# Draw a Queen (bad card) - swapping into the only hidden pos would go out
drawn_card = Card(Suit.DIAMONDS, Rank.QUEEN)
drawn_value = get_ai_card_value(drawn_card, game.options)
score = GolfAI.calculate_swap_score(
5, drawn_card, drawn_value, player, game.options, game, profile
)
# Should be heavily penalized (projected score would be terrible)
assert score < -50
def test_components_sum_correctly(self):
"""Verify calculate_swap_score equals sum of sub-functions plus go-out check."""
game = make_game()
player = game.players[0]
set_hand(player, [Rank.TEN, Rank.THREE, Rank.FIVE,
Rank.EIGHT, Rank.FOUR, Rank.SIX])
profile = make_profile()
drawn_card = Card(Suit.DIAMONDS, Rank.ACE)
drawn_value = get_ai_card_value(drawn_card, game.options)
total = GolfAI.calculate_swap_score(
0, drawn_card, drawn_value, player, game.options, game, profile
)
pair = GolfAI._pair_improvement(
0, drawn_card, drawn_value, player, game.options, profile
)
point = GolfAI._point_gain(
0, drawn_card, drawn_value, player, game.options, profile
)
bonus = GolfAI._reveal_and_bonus_score(
0, drawn_card, drawn_value, player, game.options, game, profile
)
# No go-out safety penalty here (not the last hidden card)
assert total == pytest.approx(pair + point + bonus)
# =============================================================================
# should_take_discard (integration)
# =============================================================================
class TestShouldTakeDiscard:
def test_take_joker(self):
game = make_game()
player = game.players[0]
set_hand(player, [Rank.SEVEN, Rank.THREE, Rank.FIVE,
Rank.EIGHT, Rank.FOUR, Rank.SIX])
profile = make_profile()
joker = Card(Suit.HEARTS, Rank.JOKER)
result = GolfAI.should_take_discard(joker, player, profile, game)
assert result is True
def test_pass_on_none(self):
game = make_game()
player = game.players[0]
profile = make_profile()
result = GolfAI.should_take_discard(None, player, profile, game)
assert result is False
def test_go_out_safeguard_rejects_bad_card(self):
"""With 1 hidden card and bad projected score, should reject mediocre discard."""
game = make_game()
player = game.players[0]
# All face-up except position 5, hand is all high cards
set_hand(player, [Rank.TEN, Rank.QUEEN, Rank.NINE,
Rank.EIGHT, Rank.JACK, Rank.SEVEN])
player.cards[5].face_up = False
profile = make_profile(aggression=0.0)
# A 6 is mediocre - go-out check should reject since projected score is terrible
six = Card(Suit.HEARTS, Rank.SIX)
result = GolfAI.should_take_discard(six, player, profile, game)
assert result is False
# =============================================================================
# should_knock_early
# =============================================================================
class TestShouldKnockEarly:
def test_requires_knock_early_option(self):
game = make_game()
player = game.players[0]
flip_all_but(player, keep_down=1)
profile = make_profile(aggression=1.0)
result = GolfAI.should_knock_early(game, player, profile)
assert result is False
def test_no_knock_with_many_hidden(self):
"""Should not knock with more than 2 face-down cards."""
opts = GameOptions(knock_early=True)
game = make_game(options=opts)
player = game.players[0]
flip_all_but(player, keep_down=3)
profile = make_profile(aggression=1.0)
result = GolfAI.should_knock_early(game, player, profile)
assert result is False
def test_no_knock_all_face_up(self):
"""Should not knock with 0 face-down cards."""
opts = GameOptions(knock_early=True)
game = make_game(options=opts)
player = game.players[0]
for card in player.cards:
card.face_up = True
profile = make_profile(aggression=1.0)
result = GolfAI.should_knock_early(game, player, profile)
assert result is False
def test_low_aggression_unlikely_to_knock(self):
"""Conservative players should almost never knock early."""
opts = GameOptions(knock_early=True, initial_flips=0)
game = make_game(options=opts)
player = game.players[0]
# Good hand but passive player
set_hand(player, [Rank.ACE, Rank.TWO, Rank.KING,
Rank.ACE, Rank.TWO, Rank.THREE])
player.cards[5].face_up = False # 1 hidden
profile = make_profile(aggression=0.0)
# With aggression=0.0, knock_chance = 0.0 → never knocks
result = GolfAI.should_knock_early(game, player, profile)
assert result is False
def test_high_projected_score_never_knocks(self):
"""Projected score >9 with normal opponents should always reject."""
opts = GameOptions(knock_early=True, initial_flips=0)
game = make_game(options=opts)
player = game.players[0]
# Mediocre hand: visible 8+7+6 = 21, plus 1 hidden (~4.5) → ~25.5
set_hand(player, [Rank.EIGHT, Rank.SEVEN, Rank.SIX,
Rank.FOUR, Rank.FIVE, Rank.NINE])
player.cards[5].face_up = False # 1 hidden
profile = make_profile(aggression=1.0)
# max_acceptable = 5 + 4 = 9, projected ~25.5 >> 9
for _ in range(50):
result = GolfAI.should_knock_early(game, player, profile)
assert result is False
def test_low_aggression_mediocre_hand_never_knocks(self):
"""Low aggression with a middling hand should never knock."""
opts = GameOptions(knock_early=True, initial_flips=0)
game = make_game(options=opts)
player = game.players[0]
# Decent but not great: visible 1+2+5 = 8, plus 1 hidden (~4.5) → ~12.5
set_hand(player, [Rank.ACE, Rank.TWO, Rank.FIVE,
Rank.THREE, Rank.FOUR, Rank.SIX])
player.cards[5].face_up = False # 1 hidden
profile = make_profile(aggression=0.2)
# max_acceptable = 5 + 0 = 5, projected ~12.5 >> 5
for _ in range(50):
result = GolfAI.should_knock_early(game, player, profile)
assert result is False

View File

@@ -196,10 +196,12 @@ class TestDrawDiscardMechanics:
self.game.add_player(Player(id="p2", name="Player 2")) self.game.add_player(Player(id="p2", name="Player 2"))
# Skip initial flip phase to test draw/discard mechanics directly # Skip initial flip phase to test draw/discard mechanics directly
self.game.start_game(options=GameOptions(initial_flips=0)) self.game.start_game(options=GameOptions(initial_flips=0))
# Get the actual current player (after dealer rotation, it's p2)
self.current_player_id = self.game.current_player().id
def test_can_draw_from_deck(self): def test_can_draw_from_deck(self):
"""Player can draw from deck.""" """Player can draw from deck."""
card = self.game.draw_card("p1", "deck") card = self.game.draw_card(self.current_player_id, "deck")
assert card is not None assert card is not None
assert self.game.drawn_card == card assert self.game.drawn_card == card
assert self.game.drawn_from_discard is False assert self.game.drawn_from_discard is False
@@ -207,7 +209,7 @@ class TestDrawDiscardMechanics:
def test_can_draw_from_discard(self): def test_can_draw_from_discard(self):
"""Player can draw from discard pile.""" """Player can draw from discard pile."""
discard_top = self.game.discard_top() discard_top = self.game.discard_top()
card = self.game.draw_card("p1", "discard") card = self.game.draw_card(self.current_player_id, "discard")
assert card is not None assert card is not None
assert card == discard_top assert card == discard_top
assert self.game.drawn_card == card assert self.game.drawn_card == card
@@ -215,40 +217,40 @@ class TestDrawDiscardMechanics:
def test_can_discard_deck_draw(self): def test_can_discard_deck_draw(self):
"""Card drawn from deck CAN be discarded.""" """Card drawn from deck CAN be discarded."""
self.game.draw_card("p1", "deck") self.game.draw_card(self.current_player_id, "deck")
assert self.game.can_discard_drawn() is True assert self.game.can_discard_drawn() is True
result = self.game.discard_drawn("p1") result = self.game.discard_drawn(self.current_player_id)
assert result is True assert result is True
def test_cannot_discard_discard_draw(self): def test_cannot_discard_discard_draw(self):
"""Card drawn from discard pile CANNOT be re-discarded.""" """Card drawn from discard pile CANNOT be re-discarded."""
self.game.draw_card("p1", "discard") self.game.draw_card(self.current_player_id, "discard")
assert self.game.can_discard_drawn() is False assert self.game.can_discard_drawn() is False
result = self.game.discard_drawn("p1") result = self.game.discard_drawn(self.current_player_id)
assert result is False assert result is False
def test_must_swap_discard_draw(self): def test_must_swap_discard_draw(self):
"""When drawing from discard, must swap with a hand card.""" """When drawing from discard, must swap with a hand card."""
self.game.draw_card("p1", "discard") self.game.draw_card(self.current_player_id, "discard")
# Can't discard, must swap # Can't discard, must swap
assert self.game.can_discard_drawn() is False assert self.game.can_discard_drawn() is False
# Swap works # Swap works
old_card = self.game.swap_card("p1", 0) old_card = self.game.swap_card(self.current_player_id, 0)
assert old_card is not None assert old_card is not None
assert self.game.drawn_card is None assert self.game.drawn_card is None
def test_swap_makes_card_face_up(self): def test_swap_makes_card_face_up(self):
"""Swapped card is placed face up.""" """Swapped card is placed face up."""
player = self.game.get_player("p1") player = self.game.get_player(self.current_player_id)
assert player.cards[0].face_up is False # Initially face down assert player.cards[0].face_up is False # Initially face down
self.game.draw_card("p1", "deck") self.game.draw_card(self.current_player_id, "deck")
self.game.swap_card("p1", 0) self.game.swap_card(self.current_player_id, 0)
assert player.cards[0].face_up is True assert player.cards[0].face_up is True
def test_cannot_peek_before_swap(self): def test_cannot_peek_before_swap(self):
"""Face-down cards stay hidden until swapped/revealed.""" """Face-down cards stay hidden until swapped/revealed."""
player = self.game.get_player("p1") player = self.game.get_player(self.current_player_id)
# Card is face down # Card is face down
assert player.cards[0].face_up is False assert player.cards[0].face_up is False
# to_client_dict hides face-down card details from clients # to_client_dict hides face-down card details from clients
@@ -274,38 +276,42 @@ class TestTurnFlow:
self.game.add_player(Player(id="p3", name="Player 3")) self.game.add_player(Player(id="p3", name="Player 3"))
# Skip initial flip phase # Skip initial flip phase
self.game.start_game(options=GameOptions(initial_flips=0)) self.game.start_game(options=GameOptions(initial_flips=0))
# With dealer rotation (V3_01): dealer=p1(idx 0), first player=p2(idx 1)
def test_turn_advances_after_discard(self): def test_turn_advances_after_discard(self):
"""Turn advances to next player after discarding.""" """Turn advances to next player after discarding."""
assert self.game.current_player().id == "p1" # First player after dealer is p2
self.game.draw_card("p1", "deck")
self.game.discard_drawn("p1")
assert self.game.current_player().id == "p2" assert self.game.current_player().id == "p2"
def test_turn_advances_after_swap(self):
"""Turn advances to next player after swapping."""
assert self.game.current_player().id == "p1"
self.game.draw_card("p1", "deck")
self.game.swap_card("p1", 0)
assert self.game.current_player().id == "p2"
def test_turn_wraps_around(self):
"""Turn wraps from last player to first."""
# Complete turns for p1 and p2
self.game.draw_card("p1", "deck")
self.game.discard_drawn("p1")
self.game.draw_card("p2", "deck") self.game.draw_card("p2", "deck")
self.game.discard_drawn("p2") self.game.discard_drawn("p2")
assert self.game.current_player().id == "p3" assert self.game.current_player().id == "p3"
def test_turn_advances_after_swap(self):
"""Turn advances to next player after swapping."""
assert self.game.current_player().id == "p2"
self.game.draw_card("p2", "deck")
self.game.swap_card("p2", 0)
assert self.game.current_player().id == "p3"
def test_turn_wraps_around(self):
"""Turn wraps from last player to first."""
# Order is: p2 -> p3 -> p1 -> p2 (wraps)
# Complete turns for p2 and p3
self.game.draw_card("p2", "deck")
self.game.discard_drawn("p2")
self.game.draw_card("p3", "deck") self.game.draw_card("p3", "deck")
self.game.discard_drawn("p3") self.game.discard_drawn("p3")
assert self.game.current_player().id == "p1" # Wrapped assert self.game.current_player().id == "p1"
self.game.draw_card("p1", "deck")
self.game.discard_drawn("p1")
assert self.game.current_player().id == "p2" # Wrapped
def test_only_current_player_can_act(self): def test_only_current_player_can_act(self):
"""Only current player can draw.""" """Only current player can draw."""
assert self.game.current_player().id == "p1" # First player is p2 (after dealer p1)
card = self.game.draw_card("p2", "deck") # Wrong player assert self.game.current_player().id == "p2"
card = self.game.draw_card("p1", "deck") # Wrong player (dealer can't go first)
assert card is None assert card is None
@@ -321,6 +327,7 @@ class TestRoundEnd:
self.game.add_player(Player(id="p1", name="Player 1")) self.game.add_player(Player(id="p1", name="Player 1"))
self.game.add_player(Player(id="p2", name="Player 2")) self.game.add_player(Player(id="p2", name="Player 2"))
self.game.start_game(options=GameOptions(initial_flips=0)) self.game.start_game(options=GameOptions(initial_flips=0))
# With dealer rotation: dealer=p1(idx 0), first player=p2(idx 1)
def reveal_all_cards(self, player_id: str): def reveal_all_cards(self, player_id: str):
"""Helper to flip all cards for a player.""" """Helper to flip all cards for a player."""
@@ -330,61 +337,64 @@ class TestRoundEnd:
def test_revealing_all_triggers_final_turn(self): def test_revealing_all_triggers_final_turn(self):
"""When a player reveals all cards, final turn phase begins.""" """When a player reveals all cards, final turn phase begins."""
# Reveal 5 cards for p1 # First player is p2 (after dealer p1)
player = self.game.get_player("p1") # Reveal 5 cards for p2
player = self.game.get_player("p2")
for i in range(5): for i in range(5):
player.cards[i].face_up = True player.cards[i].face_up = True
assert self.game.phase == GamePhase.PLAYING assert self.game.phase == GamePhase.PLAYING
# Draw and swap into last face-down position # Draw and swap into last face-down position
self.game.draw_card("p1", "deck") self.game.draw_card("p2", "deck")
self.game.swap_card("p1", 5) # Last card self.game.swap_card("p2", 5) # Last card
assert self.game.phase == GamePhase.FINAL_TURN assert self.game.phase == GamePhase.FINAL_TURN
assert self.game.finisher_id == "p1" assert self.game.finisher_id == "p2"
def test_other_players_get_final_turn(self): def test_other_players_get_final_turn(self):
"""After one player finishes, others each get one more turn.""" """After one player finishes, others each get one more turn."""
# P1 reveals all # First player is p2, they reveal all
self.reveal_all_cards("p1") self.reveal_all_cards("p2")
self.game.draw_card("p1", "deck")
self.game.discard_drawn("p1")
assert self.game.phase == GamePhase.FINAL_TURN
assert self.game.current_player().id == "p2"
# P2 takes final turn
self.game.draw_card("p2", "deck") self.game.draw_card("p2", "deck")
self.game.discard_drawn("p2") self.game.discard_drawn("p2")
assert self.game.phase == GamePhase.FINAL_TURN
assert self.game.current_player().id == "p1"
# P1 takes final turn
self.game.draw_card("p1", "deck")
self.game.discard_drawn("p1")
# Round should be over # Round should be over
assert self.game.phase == GamePhase.ROUND_OVER assert self.game.phase == GamePhase.ROUND_OVER
def test_finisher_does_not_get_extra_turn(self): def test_finisher_does_not_get_extra_turn(self):
"""The player who went out doesn't get another turn.""" """The player who went out doesn't get another turn."""
# P1 reveals all and triggers final turn # p2 goes first, reveals all and triggers final turn
self.reveal_all_cards("p1") self.reveal_all_cards("p2")
self.game.draw_card("p1", "deck")
self.game.discard_drawn("p1")
# P2's turn
assert self.game.current_player().id == "p2"
self.game.draw_card("p2", "deck") self.game.draw_card("p2", "deck")
self.game.discard_drawn("p2") self.game.discard_drawn("p2")
# Should be round over, not p1's turn again # P1's turn (the other player)
assert self.game.current_player().id == "p1"
self.game.draw_card("p1", "deck")
self.game.discard_drawn("p1")
# Should be round over, not p2's turn again
assert self.game.phase == GamePhase.ROUND_OVER assert self.game.phase == GamePhase.ROUND_OVER
def test_all_cards_revealed_at_round_end(self): def test_all_cards_revealed_at_round_end(self):
"""At round end, all cards are revealed.""" """At round end, all cards are revealed."""
self.reveal_all_cards("p1") # p2 goes first, reveals all
self.game.draw_card("p1", "deck") self.reveal_all_cards("p2")
self.game.discard_drawn("p1")
self.game.draw_card("p2", "deck") self.game.draw_card("p2", "deck")
self.game.discard_drawn("p2") self.game.discard_drawn("p2")
# p1 takes final turn
self.game.draw_card("p1", "deck")
self.game.discard_drawn("p1")
assert self.game.phase == GamePhase.ROUND_OVER assert self.game.phase == GamePhase.ROUND_OVER
# All cards should be face up now # All cards should be face up now
@@ -536,9 +546,11 @@ class TestEdgeCases:
game.add_player(Player(id="p1", name="Player 1")) game.add_player(Player(id="p1", name="Player 1"))
game.add_player(Player(id="p2", name="Player 2")) game.add_player(Player(id="p2", name="Player 2"))
game.start_game(options=GameOptions(initial_flips=0)) game.start_game(options=GameOptions(initial_flips=0))
# First player is p2 after dealer rotation
current_id = game.current_player().id
game.draw_card("p1", "deck") game.draw_card(current_id, "deck")
second_draw = game.draw_card("p1", "deck") second_draw = game.draw_card(current_id, "deck")
assert second_draw is None assert second_draw is None
def test_swap_position_bounds(self): def test_swap_position_bounds(self):
@@ -547,16 +559,17 @@ class TestEdgeCases:
game.add_player(Player(id="p1", name="Player 1")) game.add_player(Player(id="p1", name="Player 1"))
game.add_player(Player(id="p2", name="Player 2")) game.add_player(Player(id="p2", name="Player 2"))
game.start_game(options=GameOptions(initial_flips=0)) game.start_game(options=GameOptions(initial_flips=0))
current_id = game.current_player().id
game.draw_card("p1", "deck") game.draw_card(current_id, "deck")
result = game.swap_card("p1", -1) result = game.swap_card(current_id, -1)
assert result is None assert result is None
result = game.swap_card("p1", 6) result = game.swap_card(current_id, 6)
assert result is None assert result is None
result = game.swap_card("p1", 3) # Valid result = game.swap_card(current_id, 3) # Valid
assert result is not None assert result is not None
def test_empty_discard_pile(self): def test_empty_discard_pile(self):
@@ -565,11 +578,12 @@ class TestEdgeCases:
game.add_player(Player(id="p1", name="Player 1")) game.add_player(Player(id="p1", name="Player 1"))
game.add_player(Player(id="p2", name="Player 2")) game.add_player(Player(id="p2", name="Player 2"))
game.start_game(options=GameOptions(initial_flips=0)) game.start_game(options=GameOptions(initial_flips=0))
current_id = game.current_player().id
# Clear discard pile (normally has 1 card) # Clear discard pile (normally has 1 card)
game.discard_pile = [] game.discard_pile = []
card = game.draw_card("p1", "discard") card = game.draw_card(current_id, "discard")
assert card is None assert card is None

293
server/test_handlers.py Normal file
View File

@@ -0,0 +1,293 @@
"""
Test suite for WebSocket message handlers.
Tests handler basic flows and validation using mock WebSocket/Room.
Run with: pytest test_handlers.py -v
"""
import asyncio
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from game import Game, GamePhase, GameOptions, Player
from room import Room, RoomPlayer, RoomManager
from handlers import (
ConnectionContext,
handle_create_room,
handle_join_room,
handle_draw,
handle_swap,
handle_discard,
)
# =============================================================================
# Mock helpers
# =============================================================================
class MockWebSocket:
"""Mock WebSocket that collects sent messages."""
def __init__(self):
self.messages: list[dict] = []
async def send_json(self, data: dict):
self.messages.append(data)
def last_message(self) -> dict:
return self.messages[-1] if self.messages else {}
def messages_of_type(self, msg_type: str) -> list[dict]:
return [m for m in self.messages if m.get("type") == msg_type]
def make_ctx(websocket=None, player_id="test_player", room=None):
"""Create a ConnectionContext with sensible defaults."""
ws = websocket or MockWebSocket()
return ConnectionContext(
websocket=ws,
connection_id="conn_123",
player_id=player_id,
auth_user_id=None,
authenticated_user=None,
current_room=room,
)
def make_room_manager():
"""Create a RoomManager for testing."""
return RoomManager()
def make_room_with_game(num_players=2):
"""Create a Room with players and a game in PLAYING phase."""
room = Room(code="TEST")
for i in range(num_players):
ws = MockWebSocket()
room.add_player(f"p{i}", f"Player {i}", ws)
room.game.start_game(num_decks=1, num_rounds=1, options=GameOptions(initial_flips=0))
# Skip initial flip phase
for p in room.game.players:
room.game.flip_initial_cards(p.id, [0, 1])
return room
# =============================================================================
# Lobby handlers
# =============================================================================
class TestHandleCreateRoom:
@pytest.mark.asyncio
async def test_creates_room(self):
ws = MockWebSocket()
ctx = make_ctx(websocket=ws)
rm = make_room_manager()
await handle_create_room(
{"player_name": "Alice"},
ctx,
room_manager=rm,
count_user_games=lambda uid: 0,
max_concurrent=5,
)
assert ctx.current_room is not None
assert len(rm.rooms) == 1
assert ws.messages_of_type("room_created")
@pytest.mark.asyncio
async def test_max_concurrent_rejects(self):
ws = MockWebSocket()
ctx = make_ctx(websocket=ws)
ctx.auth_user_id = "user1"
rm = make_room_manager()
await handle_create_room(
{"player_name": "Alice"},
ctx,
room_manager=rm,
count_user_games=lambda uid: 5,
max_concurrent=5,
)
assert ctx.current_room is None
assert ws.messages_of_type("error")
class TestHandleJoinRoom:
@pytest.mark.asyncio
async def test_join_existing_room(self):
rm = make_room_manager()
room = rm.create_room()
host_ws = MockWebSocket()
room.add_player("host", "Host", host_ws)
ws = MockWebSocket()
ctx = make_ctx(websocket=ws, player_id="joiner")
await handle_join_room(
{"room_code": room.code, "player_name": "Bob"},
ctx,
room_manager=rm,
count_user_games=lambda uid: 0,
max_concurrent=5,
)
assert ctx.current_room is room
assert ws.messages_of_type("room_joined")
assert len(room.players) == 2
@pytest.mark.asyncio
async def test_join_nonexistent_room(self):
rm = make_room_manager()
ws = MockWebSocket()
ctx = make_ctx(websocket=ws)
await handle_join_room(
{"room_code": "ZZZZ", "player_name": "Bob"},
ctx,
room_manager=rm,
count_user_games=lambda uid: 0,
max_concurrent=5,
)
assert ctx.current_room is None
assert ws.messages_of_type("error")
assert "not found" in ws.last_message().get("message", "").lower()
@pytest.mark.asyncio
async def test_join_full_room(self):
rm = make_room_manager()
room = rm.create_room()
for i in range(6):
room.add_player(f"p{i}", f"Player {i}", MockWebSocket())
ws = MockWebSocket()
ctx = make_ctx(websocket=ws, player_id="extra")
await handle_join_room(
{"room_code": room.code, "player_name": "Extra"},
ctx,
room_manager=rm,
count_user_games=lambda uid: 0,
max_concurrent=5,
)
assert ws.messages_of_type("error")
assert "full" in ws.last_message().get("message", "").lower()
@pytest.mark.asyncio
async def test_join_in_progress_game(self):
rm = make_room_manager()
room = rm.create_room()
room.add_player("host", "Host", MockWebSocket())
room.add_player("p2", "Player 2", MockWebSocket())
room.game.start_game(1, 1, GameOptions(initial_flips=0))
ws = MockWebSocket()
ctx = make_ctx(websocket=ws, player_id="late")
await handle_join_room(
{"room_code": room.code, "player_name": "Late"},
ctx,
room_manager=rm,
count_user_games=lambda uid: 0,
max_concurrent=5,
)
assert ws.messages_of_type("error")
assert "in progress" in ws.last_message().get("message", "").lower()
# =============================================================================
# Turn action handlers
# =============================================================================
class TestHandleDraw:
@pytest.mark.asyncio
async def test_draw_from_deck(self):
room = make_room_with_game()
current_pid = room.game.players[room.game.current_player_index].id
ws = room.players[current_pid].websocket
ctx = make_ctx(websocket=ws, player_id=current_pid, room=room)
broadcast = AsyncMock()
await handle_draw(
{"source": "deck"},
ctx,
broadcast_game_state=broadcast,
)
assert ws.messages_of_type("card_drawn")
broadcast.assert_called_once()
@pytest.mark.asyncio
async def test_draw_no_room(self):
ws = MockWebSocket()
ctx = make_ctx(websocket=ws, room=None)
broadcast = AsyncMock()
await handle_draw(
{"source": "deck"},
ctx,
broadcast_game_state=broadcast,
)
assert len(ws.messages) == 0
broadcast.assert_not_called()
class TestHandleSwap:
@pytest.mark.asyncio
async def test_swap_card(self):
room = make_room_with_game()
current_pid = room.game.players[room.game.current_player_index].id
ws = room.players[current_pid].websocket
# Draw a card first
room.game.draw_card(current_pid, "deck")
ctx = make_ctx(websocket=ws, player_id=current_pid, room=room)
broadcast = AsyncMock()
check_cpu = AsyncMock()
await handle_swap(
{"position": 0},
ctx,
broadcast_game_state=broadcast,
check_and_run_cpu_turn=check_cpu,
)
broadcast.assert_called_once()
class TestHandleDiscard:
@pytest.mark.asyncio
async def test_discard_drawn_card(self):
room = make_room_with_game()
current_pid = room.game.players[room.game.current_player_index].id
ws = room.players[current_pid].websocket
room.game.draw_card(current_pid, "deck")
ctx = make_ctx(websocket=ws, player_id=current_pid, room=room)
broadcast = AsyncMock()
check_cpu = AsyncMock()
await handle_discard(
{},
ctx,
broadcast_game_state=broadcast,
check_and_run_cpu_turn=check_cpu,
)
broadcast.assert_called_once()

View File

@@ -138,7 +138,7 @@ def run_game_with_options(options: GameOptions, num_players: int = 4) -> tuple[l
return [], 0, f"Exception: {str(e)}" return [], 0, f"Exception: {str(e)}"
def test_rule_config(name: str, options: GameOptions, num_games: int = 50) -> RuleTestResult: def run_rule_config(name: str, options: GameOptions, num_games: int = 50) -> RuleTestResult:
"""Test a specific rule configuration.""" """Test a specific rule configuration."""
all_scores = [] all_scores = []
@@ -516,7 +516,7 @@ def main():
for i, (name, options) in enumerate(configs): for i, (name, options) in enumerate(configs):
print(f"[{i+1}/{len(configs)}] Testing: {name}...") print(f"[{i+1}/{len(configs)}] Testing: {name}...")
result = test_rule_config(name, options, num_games) result = run_rule_config(name, options, num_games)
results.append(result) results.append(result)
# Quick status # Quick status

317
server/test_room.py Normal file
View File

@@ -0,0 +1,317 @@
"""
Test suite for Room and RoomManager CRUD operations.
Covers:
- Room creation and uniqueness
- Player add/remove with host reassignment
- CPU player management
- Case-insensitive room lookup
- Cross-room player search
- Message broadcast and send_to
Run with: pytest test_room.py -v
"""
import asyncio
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from room import Room, RoomPlayer, RoomManager
# =============================================================================
# Mock helpers
# =============================================================================
class MockWebSocket:
"""Mock WebSocket that collects sent messages."""
def __init__(self):
self.messages: list[dict] = []
async def send_json(self, data: dict):
self.messages.append(data)
# =============================================================================
# RoomManager tests
# =============================================================================
class TestRoomManagerCreate:
def test_create_room_returns_room(self):
rm = RoomManager()
room = rm.create_room()
assert room is not None
assert len(room.code) == 4
assert room.code in rm.rooms
def test_create_multiple_rooms_unique_codes(self):
rm = RoomManager()
codes = set()
for _ in range(20):
room = rm.create_room()
codes.add(room.code)
assert len(codes) == 20
def test_remove_room(self):
rm = RoomManager()
room = rm.create_room()
code = room.code
rm.remove_room(code)
assert code not in rm.rooms
def test_remove_nonexistent_room(self):
rm = RoomManager()
rm.remove_room("ZZZZ") # Should not raise
class TestRoomManagerLookup:
def test_get_room_case_insensitive(self):
rm = RoomManager()
room = rm.create_room()
code = room.code
assert rm.get_room(code.lower()) is room
assert rm.get_room(code.upper()) is room
def test_get_room_not_found(self):
rm = RoomManager()
assert rm.get_room("ZZZZ") is None
def test_find_player_room(self):
rm = RoomManager()
room = rm.create_room()
ws = MockWebSocket()
room.add_player("player1", "Alice", ws)
found = rm.find_player_room("player1")
assert found is room
def test_find_player_room_not_found(self):
rm = RoomManager()
rm.create_room()
assert rm.find_player_room("nobody") is None
def test_find_player_room_cross_room(self):
rm = RoomManager()
room1 = rm.create_room()
room2 = rm.create_room()
room1.add_player("p1", "Alice", MockWebSocket())
room2.add_player("p2", "Bob", MockWebSocket())
assert rm.find_player_room("p1") is room1
assert rm.find_player_room("p2") is room2
# =============================================================================
# Room player management
# =============================================================================
class TestRoomPlayers:
def test_add_player_first_is_host(self):
room = Room(code="TEST")
ws = MockWebSocket()
rp = room.add_player("p1", "Alice", ws)
assert rp.is_host is True
def test_add_player_second_is_not_host(self):
room = Room(code="TEST")
room.add_player("p1", "Alice", MockWebSocket())
rp2 = room.add_player("p2", "Bob", MockWebSocket())
assert rp2.is_host is False
def test_remove_player(self):
room = Room(code="TEST")
room.add_player("p1", "Alice", MockWebSocket())
removed = room.remove_player("p1")
assert removed is not None
assert removed.id == "p1"
assert "p1" not in room.players
def test_remove_nonexistent_player(self):
room = Room(code="TEST")
result = room.remove_player("nobody")
assert result is None
def test_host_reassignment_on_remove(self):
room = Room(code="TEST")
room.add_player("p1", "Alice", MockWebSocket())
room.add_player("p2", "Bob", MockWebSocket())
room.remove_player("p1")
assert room.players["p2"].is_host is True
def test_get_player(self):
room = Room(code="TEST")
room.add_player("p1", "Alice", MockWebSocket())
assert room.get_player("p1") is not None
assert room.get_player("p1").name == "Alice"
assert room.get_player("nobody") is None
def test_is_empty(self):
room = Room(code="TEST")
assert room.is_empty() is True
room.add_player("p1", "Alice", MockWebSocket())
assert room.is_empty() is False
def test_player_list(self):
room = Room(code="TEST")
room.add_player("p1", "Alice", MockWebSocket())
room.add_player("p2", "Bob", MockWebSocket())
plist = room.player_list()
assert len(plist) == 2
assert plist[0]["name"] == "Alice"
assert plist[0]["is_host"] is True
assert plist[1]["is_cpu"] is False
def test_human_player_count(self):
room = Room(code="TEST")
room.add_player("p1", "Alice", MockWebSocket())
assert room.human_player_count() == 1
def test_auth_user_id_stored(self):
room = Room(code="TEST")
rp = room.add_player("p1", "Alice", MockWebSocket(), auth_user_id="auth_123")
assert rp.auth_user_id == "auth_123"
# =============================================================================
# CPU player management
# =============================================================================
class TestCPUPlayers:
def test_add_cpu_player(self):
room = Room(code="TEST")
room.add_player("host", "Host", MockWebSocket())
with patch("room.assign_profile") as mock_assign:
from ai import CPUProfile
mock_assign.return_value = CPUProfile(
name="TestBot", style="balanced",
pair_hope=0.5, aggression=0.5,
swap_threshold=4, unpredictability=0.1,
)
rp = room.add_cpu_player("cpu_1")
assert rp is not None
assert rp.is_cpu is True
assert rp.name == "TestBot"
def test_add_cpu_player_no_profile(self):
room = Room(code="TEST")
room.add_player("host", "Host", MockWebSocket())
with patch("room.assign_profile", return_value=None):
rp = room.add_cpu_player("cpu_1")
assert rp is None
def test_get_cpu_players(self):
room = Room(code="TEST")
room.add_player("host", "Host", MockWebSocket())
with patch("room.assign_profile") as mock_assign:
from ai import CPUProfile
mock_assign.return_value = CPUProfile(
name="Bot", style="balanced",
pair_hope=0.5, aggression=0.5,
swap_threshold=4, unpredictability=0.1,
)
room.add_cpu_player("cpu_1")
cpus = room.get_cpu_players()
assert len(cpus) == 1
assert cpus[0].is_cpu is True
def test_remove_cpu_releases_profile(self):
room = Room(code="TEST")
room.add_player("host", "Host", MockWebSocket())
with patch("room.assign_profile") as mock_assign:
from ai import CPUProfile
mock_assign.return_value = CPUProfile(
name="Bot", style="balanced",
pair_hope=0.5, aggression=0.5,
swap_threshold=4, unpredictability=0.1,
)
room.add_cpu_player("cpu_1")
with patch("room.release_profile") as mock_release:
room.remove_player("cpu_1")
mock_release.assert_called_once_with("Bot", "TEST")
# =============================================================================
# Broadcast / send_to
# =============================================================================
class TestMessaging:
@pytest.mark.asyncio
async def test_broadcast_to_all_humans(self):
room = Room(code="TEST")
ws1 = MockWebSocket()
ws2 = MockWebSocket()
room.add_player("p1", "Alice", ws1)
room.add_player("p2", "Bob", ws2)
await room.broadcast({"type": "test_msg"})
assert len(ws1.messages) == 1
assert len(ws2.messages) == 1
assert ws1.messages[0]["type"] == "test_msg"
@pytest.mark.asyncio
async def test_broadcast_excludes_player(self):
room = Room(code="TEST")
ws1 = MockWebSocket()
ws2 = MockWebSocket()
room.add_player("p1", "Alice", ws1)
room.add_player("p2", "Bob", ws2)
await room.broadcast({"type": "test_msg"}, exclude="p1")
assert len(ws1.messages) == 0
assert len(ws2.messages) == 1
@pytest.mark.asyncio
async def test_broadcast_skips_cpu(self):
room = Room(code="TEST")
ws1 = MockWebSocket()
room.add_player("p1", "Alice", ws1)
# Add a CPU player manually (no websocket)
room.players["cpu_1"] = RoomPlayer(
id="cpu_1", name="Bot", websocket=None, is_cpu=True
)
await room.broadcast({"type": "test_msg"})
assert len(ws1.messages) == 1
# CPU has no websocket, no error
@pytest.mark.asyncio
async def test_send_to_specific_player(self):
room = Room(code="TEST")
ws1 = MockWebSocket()
ws2 = MockWebSocket()
room.add_player("p1", "Alice", ws1)
room.add_player("p2", "Bob", ws2)
await room.send_to("p1", {"type": "private_msg"})
assert len(ws1.messages) == 1
assert len(ws2.messages) == 0
@pytest.mark.asyncio
async def test_send_to_nonexistent_player(self):
room = Room(code="TEST")
await room.send_to("nobody", {"type": "test"}) # Should not raise
@pytest.mark.asyncio
async def test_send_to_cpu_is_noop(self):
room = Room(code="TEST")
room.players["cpu_1"] = RoomPlayer(
id="cpu_1", name="Bot", websocket=None, is_cpu=True
)
await room.send_to("cpu_1", {"type": "test"}) # Should not raise

318
server/test_v3_features.py Normal file
View File

@@ -0,0 +1,318 @@
"""
Test suite for V3 features in 6-Card Golf.
Covers:
- V3_01: Dealer rotation
- V3_03/V3_05: Finisher tracking, knock penalty/bonus
- V3_09: Knock early
Run with: pytest test_v3_features.py -v
"""
import pytest
from game import (
Card, Deck, Player, Game, GamePhase, GameOptions,
Suit, Rank, RANK_VALUES
)
# =============================================================================
# Helper: create a game with N players in PLAYING phase
# =============================================================================
def make_game(num_players=2, options=None, rounds=1):
"""Create a game with N players, dealt and in PLAYING phase."""
opts = options or GameOptions()
game = Game(num_rounds=rounds, options=opts)
for i in range(num_players):
game.add_player(Player(id=f"p{i}", name=f"Player {i}"))
game.start_round()
# Force into PLAYING phase (skip initial flip)
if game.phase == GamePhase.INITIAL_FLIP:
for p in game.players:
game.flip_initial_cards(p.id, [0, 1])
return game
def flip_all_but(player, keep_down=0):
"""Flip all cards face-up except `keep_down` cards."""
for i, card in enumerate(player.cards):
if i < len(player.cards) - keep_down:
card.face_up = True
else:
card.face_up = False
def set_hand(player, ranks):
"""Set player hand to specific ranks (all hearts, all face-up)."""
player.cards = [
Card(Suit.HEARTS, rank, face_up=True) for rank in ranks
]
# =============================================================================
# V3_01: Dealer Rotation
# =============================================================================
class TestDealerRotation:
"""Verify dealer rotates each round and first player is after dealer."""
def test_initial_dealer_is_zero(self):
game = make_game(3)
assert game.dealer_idx == 0
def test_first_player_is_after_dealer(self):
game = make_game(3)
# Dealer is 0, first player should be 1
assert game.current_player_index == 1
def test_dealer_rotates_after_round(self):
game = make_game(3, rounds=3)
assert game.dealer_idx == 0
# End the round by having a player flip all cards
player = game.players[game.current_player_index]
for card in player.cards:
card.face_up = True
game.finisher_id = player.id
game.phase = GamePhase.FINAL_TURN
# Give remaining players their final turns
game.players_with_final_turn = {p.id for p in game.players}
game._end_round()
# Start next round
game.start_next_round()
assert game.dealer_idx == 1
# First player should be after new dealer
assert game.current_player_index == 2
def test_dealer_wraps_around(self):
game = make_game(3, rounds=4)
# Simulate 3 rounds to wrap dealer
for expected_dealer in [0, 1, 2]:
assert game.dealer_idx == expected_dealer
# Force round end
player = game.players[game.current_player_index]
for card in player.cards:
card.face_up = True
game.finisher_id = player.id
game.phase = GamePhase.FINAL_TURN
game.players_with_final_turn = {p.id for p in game.players}
game._end_round()
game.start_next_round()
# After 3 rotations with 3 players, wraps back to 0
assert game.dealer_idx == 0
def test_dealer_in_state_dict(self):
game = make_game(3)
state = game.get_state("p0")
assert "dealer_id" in state
assert "dealer_idx" in state
assert state["dealer_id"] == "p0"
assert state["dealer_idx"] == 0
# =============================================================================
# V3_03/V3_05: Finisher Tracking + Knock Penalty/Bonus
# =============================================================================
class TestFinisherTracking:
"""Verify finisher_id is set and penalties/bonuses apply."""
def test_finisher_id_initially_none(self):
game = make_game(2)
assert game.finisher_id is None
def test_finisher_set_when_all_flipped(self):
game = make_game(2)
# Get current player and flip all their cards
player = game.players[game.current_player_index]
for card in player.cards:
card.face_up = True
# Draw and discard to trigger _check_end_turn
card = game.deck.draw()
if card:
game.drawn_card = card
game.discard_drawn(player.id)
assert game.finisher_id == player.id
assert game.phase == GamePhase.FINAL_TURN
def test_finisher_in_state_dict(self):
game = make_game(2)
game.finisher_id = "p0"
state = game.get_state("p0")
assert state["finisher_id"] == "p0"
def test_knock_penalty_applied(self):
"""Finisher gets +10 if they don't have the lowest score."""
opts = GameOptions(knock_penalty=True, initial_flips=0)
game = make_game(2, options=opts)
# Set hands with different ranks per column to avoid column pairing
# Layout: [0][1][2] / [3][4][5], columns: (0,3),(1,4),(2,5)
set_hand(game.players[0], [Rank.TEN, Rank.NINE, Rank.EIGHT,
Rank.SEVEN, Rank.SIX, Rank.FIVE]) # 10+9+8+7+6+5 = 45
set_hand(game.players[1], [Rank.ACE, Rank.THREE, Rank.FOUR,
Rank.TWO, Rank.KING, Rank.ACE]) # 1+3+4+(-2)+0+1 = 7
game.finisher_id = "p0"
game.phase = GamePhase.FINAL_TURN
game.players_with_final_turn = {"p0", "p1"}
game._end_round()
# p0 had score 45, gets +10 penalty = 55
assert game.players[0].score == 55
# p1 unaffected
assert game.players[1].score == 7
def test_knock_bonus_applied(self):
"""Finisher gets -5 bonus."""
opts = GameOptions(knock_bonus=True, initial_flips=0)
game = make_game(2, options=opts)
# Different ranks per column to avoid pairing
set_hand(game.players[0], [Rank.ACE, Rank.THREE, Rank.FOUR,
Rank.TWO, Rank.KING, Rank.ACE]) # 1+3+4+(-2)+0+1 = 7
set_hand(game.players[1], [Rank.TEN, Rank.NINE, Rank.EIGHT,
Rank.SEVEN, Rank.SIX, Rank.FIVE]) # 10+9+8+7+6+5 = 45
game.finisher_id = "p0"
game.phase = GamePhase.FINAL_TURN
game.players_with_final_turn = {"p0", "p1"}
game._end_round()
# p0 gets -5 bonus: 7 - 5 = 2
assert game.players[0].score == 2
assert game.players[1].score == 45
# =============================================================================
# V3_09: Knock Early
# =============================================================================
class TestKnockEarly:
"""Verify knock_early house rule mechanics."""
def test_knock_early_disabled_by_default(self):
opts = GameOptions()
assert opts.knock_early is False
def test_knock_early_requires_option(self):
game = make_game(2)
player = game.players[game.current_player_index]
# Flip 4 cards, leave 2 face-down
for i in range(4):
player.cards[i].face_up = True
result = game.knock_early(player.id)
assert result is False
def test_knock_early_with_option_enabled(self):
opts = GameOptions(knock_early=True, initial_flips=0)
game = make_game(2, options=opts)
player = game.players[game.current_player_index]
# Flip 4 cards, leave 2 face-down
for i in range(4):
player.cards[i].face_up = True
for i in range(4, 6):
player.cards[i].face_up = False
result = game.knock_early(player.id)
assert result is True
def test_knock_early_requires_face_up_cards(self):
"""Must have at least 4 face-up (at most 2 face-down) to knock."""
opts = GameOptions(knock_early=True, initial_flips=0)
game = make_game(2, options=opts)
player = game.players[game.current_player_index]
# Only 3 face-up, 3 face-down — too many hidden
for i in range(3):
player.cards[i].face_up = True
for i in range(3, 6):
player.cards[i].face_up = False
result = game.knock_early(player.id)
assert result is False
def test_knock_early_triggers_final_turn(self):
opts = GameOptions(knock_early=True, initial_flips=0)
game = make_game(2, options=opts)
player = game.players[game.current_player_index]
flip_all_but(player, keep_down=2)
game.knock_early(player.id)
assert game.phase == GamePhase.FINAL_TURN
def test_knock_early_sets_finisher(self):
opts = GameOptions(knock_early=True, initial_flips=0)
game = make_game(2, options=opts)
player = game.players[game.current_player_index]
flip_all_but(player, keep_down=1)
game.knock_early(player.id)
assert game.finisher_id == player.id
def test_knock_early_not_during_initial_flip(self):
"""Knock early should fail during initial flip phase."""
opts = GameOptions(knock_early=True, initial_flips=2)
game = Game(num_rounds=1, options=opts)
game.add_player(Player(id="p0", name="Player 0"))
game.add_player(Player(id="p1", name="Player 1"))
game.start_round()
# Should be in INITIAL_FLIP
assert game.phase == GamePhase.INITIAL_FLIP
player = game.players[0]
flip_all_but(player, keep_down=2)
result = game.knock_early(player.id)
assert result is False
def test_knock_early_fails_with_drawn_card(self):
"""Can't knock if you've already drawn a card."""
opts = GameOptions(knock_early=True, initial_flips=0)
game = make_game(2, options=opts)
player = game.players[game.current_player_index]
flip_all_but(player, keep_down=2)
# Simulate having drawn a card
game.drawn_card = Card(Suit.HEARTS, Rank.ACE)
result = game.knock_early(player.id)
assert result is False
def test_knock_early_fails_all_face_up(self):
"""Can't knock early if all cards are already face-up (0 face-down)."""
opts = GameOptions(knock_early=True, initial_flips=0)
game = make_game(2, options=opts)
player = game.players[game.current_player_index]
for card in player.cards:
card.face_up = True
result = game.knock_early(player.id)
assert result is False
def test_knock_early_in_state_dict(self):
opts = GameOptions(knock_early=True)
game = make_game(2, options=opts)
state = game.get_state("p0")
assert state["knock_early"] is True
def test_knock_early_active_rules(self):
"""Knock early should appear in active_rules list."""
opts = GameOptions(knock_early=True)
game = make_game(2, options=opts)
state = game.get_state("p0")
assert "Early Knock" in state["active_rules"]

View File

@@ -125,8 +125,9 @@ class TestEventEmission:
game, collector = create_test_game(num_players=2) game, collector = create_test_game(num_players=2)
game.start_game(num_decks=1, num_rounds=1, options=GameOptions(initial_flips=0)) game.start_game(num_decks=1, num_rounds=1, options=GameOptions(initial_flips=0))
current = game.current_player()
initial_count = len(collector.events) initial_count = len(collector.events)
card = game.draw_card("p1", "deck") card = game.draw_card(current.id, "deck")
assert card is not None assert card is not None
new_events = collector.events[initial_count:] new_events = collector.events[initial_count:]
@@ -134,7 +135,7 @@ class TestEventEmission:
assert len(draw_events) == 1 assert len(draw_events) == 1
event = draw_events[0] event = draw_events[0]
assert event.player_id == "p1" assert event.player_id == current.id
assert event.data["source"] == "deck" assert event.data["source"] == "deck"
assert event.data["card"]["rank"] == card.rank.value assert event.data["card"]["rank"] == card.rank.value
@@ -142,10 +143,12 @@ class TestEventEmission:
"""Swapping a card should emit card_swapped event.""" """Swapping a card should emit card_swapped event."""
game, collector = create_test_game(num_players=2) game, collector = create_test_game(num_players=2)
game.start_game(num_decks=1, num_rounds=1, options=GameOptions(initial_flips=0)) game.start_game(num_decks=1, num_rounds=1, options=GameOptions(initial_flips=0))
game.draw_card("p1", "deck")
current = game.current_player()
game.draw_card(current.id, "deck")
initial_count = len(collector.events) initial_count = len(collector.events)
old_card = game.swap_card("p1", 0) old_card = game.swap_card(current.id, 0)
assert old_card is not None assert old_card is not None
new_events = collector.events[initial_count:] new_events = collector.events[initial_count:]
@@ -153,24 +156,26 @@ class TestEventEmission:
assert len(swap_events) == 1 assert len(swap_events) == 1
event = swap_events[0] event = swap_events[0]
assert event.player_id == "p1" assert event.player_id == current.id
assert event.data["position"] == 0 assert event.data["position"] == 0
def test_discard_card_event(self): def test_discard_card_event(self):
"""Discarding drawn card should emit card_discarded event.""" """Discarding drawn card should emit card_discarded event."""
game, collector = create_test_game(num_players=2) game, collector = create_test_game(num_players=2)
game.start_game(num_decks=1, num_rounds=1, options=GameOptions(initial_flips=0)) game.start_game(num_decks=1, num_rounds=1, options=GameOptions(initial_flips=0))
drawn = game.draw_card("p1", "deck")
current = game.current_player()
drawn = game.draw_card(current.id, "deck")
initial_count = len(collector.events) initial_count = len(collector.events)
game.discard_drawn("p1") game.discard_drawn(current.id)
new_events = collector.events[initial_count:] new_events = collector.events[initial_count:]
discard_events = [e for e in new_events if e.event_type == EventType.CARD_DISCARDED] discard_events = [e for e in new_events if e.event_type == EventType.CARD_DISCARDED]
assert len(discard_events) == 1 assert len(discard_events) == 1
event = discard_events[0] event = discard_events[0]
assert event.player_id == "p1" assert event.player_id == current.id
assert event.data["card"]["rank"] == drawn.rank.value assert event.data["card"]["rank"] == drawn.rank.value
@@ -383,13 +388,14 @@ class TestFullGameReplay:
game.start_game(num_decks=1, num_rounds=1, options=GameOptions(initial_flips=0)) game.start_game(num_decks=1, num_rounds=1, options=GameOptions(initial_flips=0))
# Do a swap # Do a swap
drawn = game.draw_card("p1", "deck") current = game.current_player()
old_card = game.get_player("p1").cards[0] drawn = game.draw_card(current.id, "deck")
game.swap_card("p1", 0) old_card = game.get_player(current.id).cards[0]
game.swap_card(current.id, 0)
# Rebuild and verify # Rebuild and verify
state = rebuild_state(collector.events) state = rebuild_state(collector.events)
rebuilt_player = state.get_player("p1") rebuilt_player = state.get_player(current.id)
# The swapped card should be in the hand # The swapped card should be in the hand
assert rebuilt_player.cards[0].rank == drawn.rank.value assert rebuilt_player.cards[0].rank == drawn.rank.value

View File

@@ -0,0 +1,402 @@
/**
* V3 Feature Integration Tests
*
* Tests that V3 features are properly integrated and visible in the DOM.
* Transient animations and audio are excluded (manual QA only).
*/
import { test, expect } from '@playwright/test';
import { GolfBot } from '../bot/golf-bot';
import { FreezeDetector } from '../health/freeze-detector';
import { SELECTORS } from '../utils/selectors';
import { waitForAnimations } from '../utils/timing';
/**
* Helper: create a game with one CPU opponent and start it
*/
async function setupGame(
page: import('@playwright/test').Page,
options: Parameters<GolfBot['startGame']>[0] = {}
) {
const bot = new GolfBot(page);
await bot.goto();
await bot.createGame('V3Tester');
await bot.addCPU('Sofia');
await bot.startGame({ holes: 1, ...options });
return bot;
}
// =============================================================================
// V3_01: Dealer Rotation
// =============================================================================
test.describe('V3_01: Dealer Rotation', () => {
test('dealer badge exists after game starts', async ({ page }) => {
const bot = await setupGame(page);
await waitForAnimations(page);
// The game state should include dealer info — check that the UI renders
// a dealer indicator somewhere in the player areas
const dealerBadge = page.locator(SELECTORS.v3.dealerBadge);
// At least one dealer badge should be visible
const count = await dealerBadge.count();
expect(count).toBeGreaterThanOrEqual(1);
});
});
// =============================================================================
// V3_02: Dealing Animation
// =============================================================================
test.describe('V3_02: Dealing Animation', () => {
test('cards are dealt without errors', async ({ page }) => {
const bot = await setupGame(page);
await waitForAnimations(page);
// Verify player has 6 cards rendered
const playerCards = page.locator(`${SELECTORS.game.playerCards} .card`);
await expect(playerCards).toHaveCount(6);
// No console errors during deal
const errors = bot.getConsoleErrors();
expect(errors).toHaveLength(0);
});
});
// =============================================================================
// V3_06: CPU Thinking Indicator
// =============================================================================
test.describe('V3_06: CPU Thinking Indicator', () => {
test('thinking indicator element exists on CPU opponent', async ({ page }) => {
const bot = await setupGame(page);
await waitForAnimations(page);
// The thinking indicator span should exist in the DOM for CPU opponents
const indicator = page.locator(SELECTORS.v3.thinkingIndicator);
const count = await indicator.count();
expect(count).toBeGreaterThanOrEqual(1);
});
test('thinking indicator is hidden when not CPU turn', async ({ page }) => {
const bot = await setupGame(page);
await bot.completeInitialFlips();
await waitForAnimations(page);
// Wait for bot's turn (not CPU's turn)
const isMyTurn = await bot.isMyTurn();
if (isMyTurn) {
// During our turn, CPU indicator should be hidden
const indicator = page.locator(`${SELECTORS.v3.thinkingIndicator}:not(.hidden)`);
const visibleCount = await indicator.count();
expect(visibleCount).toBe(0);
}
});
});
// =============================================================================
// V3_08: Swap Highlight
// =============================================================================
test.describe('V3_08: Swap Highlight', () => {
test('player area gets can-swap class when holding a card', async ({ page }) => {
const bot = await setupGame(page);
await bot.completeInitialFlips();
await waitForAnimations(page);
// Wait for our turn
await bot.waitForMyTurn(15000);
// Before drawing: no can-swap
const playerArea = page.locator(SELECTORS.game.playerArea);
await expect(playerArea).not.toHaveClass(/can-swap/);
// Draw from deck
const deck = page.locator(SELECTORS.game.deck);
if (await deck.isVisible()) {
await deck.click();
await page.waitForTimeout(800);
// After drawing: should have can-swap
await expect(playerArea).toHaveClass(/can-swap/);
}
});
});
// =============================================================================
// V3_09: Knock Early
// =============================================================================
test.describe('V3_09: Knock Early', () => {
test('knock early button exists when rule is enabled', async ({ page }) => {
// Enable knock early via the settings
const bot = new GolfBot(page);
await bot.goto();
await bot.createGame('V3Tester');
await bot.addCPU('Sofia');
// Check the knock-early checkbox before starting
const advancedSection = page.locator('.advanced-options-section');
if (await advancedSection.isVisible()) {
const isOpen = await advancedSection.evaluate(el => el.hasAttribute('open'));
if (!isOpen) {
await advancedSection.locator('summary').click();
await page.waitForTimeout(300);
}
}
const knockCheckbox = page.locator('#knock-early');
await knockCheckbox.check();
// Start game
await page.locator(SELECTORS.waiting.startGameBtn).click();
await page.waitForSelector(SELECTORS.screens.game, {
state: 'visible',
timeout: 10000,
});
await waitForAnimations(page);
// The knock early button should exist in the DOM
const knockBtn = page.locator(SELECTORS.game.knockEarlyBtn);
const count = await knockBtn.count();
expect(count).toBe(1);
});
test('knock early button hidden with default rules', async ({ page }) => {
const bot = await setupGame(page);
await waitForAnimations(page);
const knockBtn = page.locator(SELECTORS.game.knockEarlyBtn);
// Should be hidden or not present
const isVisible = await knockBtn.isVisible().catch(() => false);
expect(isVisible).toBe(false);
});
});
// =============================================================================
// V3_10: Pair Indicators
// =============================================================================
test.describe('V3_10: Pair Indicators', () => {
test('paired class applied to matching column cards', async ({ page }) => {
const bot = await setupGame(page);
await bot.completeInitialFlips();
// Play a few turns to increase chance of pairs forming
for (let i = 0; i < 5; i++) {
const phase = await bot.getGamePhase();
if (phase === 'round_over' || phase === 'game_over') break;
if (await bot.isMyTurn()) {
await bot.playTurn();
}
await page.waitForTimeout(500);
}
// Check if any paired classes exist (may or may not depending on game state)
// This test just verifies the CSS class system works without errors
const pairedCards = page.locator('.card.paired');
const count = await pairedCards.count();
// count >= 0 is always true, but the point is no errors were thrown
expect(count).toBeGreaterThanOrEqual(0);
// No console errors from pair indicator rendering
const errors = bot.getConsoleErrors();
expect(errors).toHaveLength(0);
});
});
// =============================================================================
// V3_13: Card Tooltips
// =============================================================================
test.describe('V3_13: Card Tooltips', () => {
test('tooltip appears on long press of face-up card', async ({ page }) => {
const bot = await setupGame(page);
await bot.completeInitialFlips();
await waitForAnimations(page);
// Find a face-up card in player's hand
const faceUpCard = page.locator(`${SELECTORS.game.playerCards} .card:not(.face-down)`).first();
if (await faceUpCard.count() > 0) {
// Simulate long press (mousedown, wait, then check tooltip)
const box = await faceUpCard.boundingBox();
if (box) {
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.waitForTimeout(600); // Tooltip delay
const tooltip = page.locator(SELECTORS.v3.cardTooltip);
// Tooltip might or might not appear depending on implementation details
const tooltipCount = await tooltip.count();
// Just verify no crash
expect(tooltipCount).toBeGreaterThanOrEqual(0);
await page.mouse.up();
}
}
});
});
// =============================================================================
// V3_14: Active Rules Context
// =============================================================================
test.describe('V3_14: Active Rules Context', () => {
test('rule tags have data-rule attributes', async ({ page }) => {
// Start game with a house rule enabled
const bot = new GolfBot(page);
await bot.goto();
await bot.createGame('V3Tester');
await bot.addCPU('Sofia');
// Enable knock penalty
const advancedSection = page.locator('.advanced-options-section');
if (await advancedSection.isVisible()) {
const isOpen = await advancedSection.evaluate(el => el.hasAttribute('open'));
if (!isOpen) {
await advancedSection.locator('summary').click();
await page.waitForTimeout(300);
}
}
const knockPenalty = page.locator(SELECTORS.waiting.knockPenalty);
if (await knockPenalty.isVisible()) {
await knockPenalty.check();
}
await page.locator(SELECTORS.waiting.startGameBtn).click();
await page.waitForSelector(SELECTORS.screens.game, {
state: 'visible',
timeout: 10000,
});
await waitForAnimations(page);
// Check that rule tags exist with data-rule attributes
const ruleTags = page.locator(`${SELECTORS.v3.ruleTag}[data-rule]`);
const count = await ruleTags.count();
expect(count).toBeGreaterThanOrEqual(1);
// Verify the knock_penalty rule tag specifically
const knockTag = page.locator(`${SELECTORS.v3.ruleTag}[data-rule="knock_penalty"]`);
const knockCount = await knockTag.count();
expect(knockCount).toBe(1);
});
test('standard game shows no rule tags or standard tag', async ({ page }) => {
const bot = await setupGame(page);
await waitForAnimations(page);
// With no house rules, should show "Standard" or no rule tags
const activeRulesBar = page.locator(SELECTORS.game.activeRulesBar);
const isVisible = await activeRulesBar.isVisible();
// Bar may be hidden or show "Standard"
if (isVisible) {
const text = await activeRulesBar.textContent();
// Should contain "Standard" or be empty/minimal
expect(text).toBeDefined();
}
});
});
// =============================================================================
// V3_15: Discard Pile History
// =============================================================================
test.describe('V3_15: Discard Pile History', () => {
test('discard pile shows depth after multiple discards', async ({ page }) => {
const bot = await setupGame(page);
await bot.completeInitialFlips();
// Play several turns to accumulate discards
for (let i = 0; i < 8; i++) {
const phase = await bot.getGamePhase();
if (phase === 'round_over' || phase === 'game_over') break;
if (await bot.isMyTurn()) {
await bot.playTurn();
}
await page.waitForTimeout(500);
}
// Check if discard has depth data attribute
const discard = page.locator(SELECTORS.game.discard);
const depth = await discard.getAttribute('data-depth');
// After several turns, depth should be > 0
// (initial discard + player/CPU discards)
if (depth !== null) {
expect(parseInt(depth)).toBeGreaterThanOrEqual(1);
}
});
});
// =============================================================================
// Integration: Full Game Stability with V3 Features
// =============================================================================
test.describe('V3 Integration: Full Game Stability', () => {
test('complete 3-hole game with zero errors', async ({ page }) => {
test.setTimeout(180000); // 3 minutes
const bot = new GolfBot(page);
const freezeDetector = new FreezeDetector(page);
await bot.goto();
await bot.createGame('V3Tester');
await bot.addCPU('Sofia');
await bot.startGame({ holes: 3 });
const result = await bot.playGame(3);
expect(result.success).toBe(true);
expect(result.rounds).toBeGreaterThanOrEqual(1);
// Zero console errors
const errors = bot.getConsoleErrors();
expect(errors).toHaveLength(0);
// No UI freezes
const health = await freezeDetector.runHealthCheck();
expect(health.healthy).toBe(true);
});
test('game with house rules completes without errors', async ({ page }) => {
test.setTimeout(120000); // 2 minutes
const bot = new GolfBot(page);
const freezeDetector = new FreezeDetector(page);
await bot.goto();
await bot.createGame('V3Tester');
await bot.addCPU('Marcus');
// Enable some house rules before starting
const advancedSection = page.locator('.advanced-options-section');
if (await advancedSection.isVisible()) {
const isOpen = await advancedSection.evaluate(el => el.hasAttribute('open'));
if (!isOpen) {
await advancedSection.locator('summary').click();
await page.waitForTimeout(300);
}
}
// Enable knock penalty and knock early
const knockPenalty = page.locator(SELECTORS.waiting.knockPenalty);
if (await knockPenalty.isVisible()) {
await knockPenalty.check();
}
const knockEarly = page.locator('#knock-early');
if (await knockEarly.isVisible()) {
await knockEarly.check();
}
await bot.startGame({ holes: 2 });
const result = await bot.playGame(2);
expect(result.success).toBe(true);
const errors = bot.getConsoleErrors();
expect(errors).toHaveLength(0);
const health = await freezeDetector.runHealthCheck();
expect(health.healthy).toBe(true);
});
});

View File

@@ -124,6 +124,13 @@ export const SELECTORS = {
hasCard: 'has-card', hasCard: 'has-card',
pickedUp: 'picked-up', pickedUp: 'picked-up',
disabled: 'disabled', disabled: 'disabled',
// V3 classes
canSwap: 'can-swap',
paired: 'paired',
pairTop: 'pair-top',
pairBottom: 'pair-bottom',
ruleHighlighted: 'rule-highlighted',
thinkingHidden: 'hidden',
}, },
// Animation-related // Animation-related
@@ -133,6 +140,23 @@ export const SELECTORS = {
animCard: '.anim-card', animCard: '.anim-card',
realCard: '.real-card', realCard: '.real-card',
}, },
// V3 feature selectors
v3: {
thinkingIndicator: '.thinking-indicator',
cardTooltip: '.card-value-tooltip',
tooltipValue: '.tooltip-value',
tooltipNote: '.tooltip-note',
ruleTag: '.rule-tag',
ruleHighlighted: '.rule-tag.rule-highlighted',
ruleMessage: '.rule-message',
pairIndicator: '.paired',
dealerBadge: '.dealer-badge',
knockBanner: '#knock-banner',
knockConfirmDialog: '#knock-confirm-dialog',
cardValueOverlay: '.card-value-overlay',
pairCancelOverlay: '.pair-cancel-overlay',
},
}; };
/** /**