Compare commits
202 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
420928f11e | ||
|
|
bc9445f06e | ||
|
|
ea34ddf8e4 | ||
|
|
5408867921 | ||
|
|
a8b521f7f7 | ||
|
|
7f0f580631 | ||
|
|
215849703c | ||
|
|
72eab2c811 | ||
|
|
dfb3397dcb | ||
|
|
b1d3aa7b77 | ||
|
|
67d06d9799 | ||
|
|
82aa3dfb3e | ||
|
|
7001232658 | ||
|
|
13e98d330a | ||
|
|
bfe29bb665 | ||
|
|
e601c3eac4 | ||
|
|
6461a7f0c7 | ||
|
|
3d02d739e5 | ||
|
|
3ca52eb7d1 | ||
|
|
3c63af91f2 | ||
|
|
5fcf8bab60 | ||
|
|
8bc8595b39 | ||
|
|
7c58543ec8 | ||
|
|
4b00094140 | ||
|
|
65d6598a51 | ||
|
|
baa471307e | ||
|
|
26778e4b02 | ||
|
|
cce2d661a2 | ||
|
|
1b748470a0 | ||
|
|
d32ae83ce2 | ||
|
|
e542cadedf | ||
|
|
cd2d7535e3 | ||
|
|
4dff1da875 | ||
|
|
8f21a40a6a | ||
|
|
0ae999aca6 | ||
|
|
a87cd7f4b0 | ||
|
|
eb072dbfb4 | ||
|
|
4c16147ace | ||
|
|
cac1e26bac | ||
|
|
31dcb70fc8 | ||
|
|
15339d390f | ||
|
|
c523b144f5 | ||
|
|
0f3ae992f9 | ||
|
|
ce6b276c11 | ||
|
|
231e666407 | ||
|
|
7842de3a96 | ||
|
|
aab41c5413 | ||
|
|
625320992e | ||
|
|
61713f28c8 | ||
|
|
0eac6d443c | ||
|
|
dc936d7e1c | ||
|
|
1cdf1cf281 | ||
|
|
17f7d8ce7a | ||
|
|
9a5bc888cb | ||
|
|
3dcad3dfdf | ||
|
|
b129aa4f29 | ||
|
|
86697dd454 | ||
|
|
77cbefc30c | ||
|
|
e2c7a55dac | ||
|
|
8d5b2ee655 | ||
|
|
06b15f002d | ||
|
|
76f80f3f44 | ||
|
|
0a9993a82f | ||
|
|
e463d929e3 | ||
|
|
1b923838e0 | ||
|
|
4503198021 | ||
|
|
cb49fd545b | ||
|
|
cb311ec0da | ||
|
|
873bdfc75a | ||
|
|
bd41afbca8 | ||
|
|
21985b7e9b | ||
|
|
56305424ff | ||
|
|
0bfe9d5f9f | ||
|
|
a0bb28d5eb | ||
|
|
55006d6ff4 | ||
|
|
adcc59b6fc | ||
|
|
7e0c006f5e | ||
|
|
02f9b3c44d | ||
|
|
9f75cdb0dc | ||
|
|
519d08a2a6 | ||
|
|
9419cb562e | ||
|
|
17c8e574ab | ||
|
|
94edb685a7 | ||
|
|
6b7d6c459e | ||
|
|
1de282afc2 | ||
|
|
9b0a8295eb | ||
|
|
28a0f90374 | ||
|
|
0df451aa99 | ||
|
|
8d7b024525 | ||
|
|
9c08b4735a | ||
|
|
49916e6a6c | ||
|
|
e0641de449 | ||
|
|
e2a90c0f34 | ||
|
|
86f5222746 | ||
|
|
60997e8ad4 | ||
|
|
3e133b17c0 | ||
|
|
9866fb8e92 | ||
|
|
4a5cfb68f1 | ||
|
|
ebb00f613c | ||
|
|
98aa0823ed | ||
|
|
4a3d62e26e | ||
|
|
d958258066 | ||
|
|
26bc151458 | ||
|
|
0d5c0c613d | ||
|
|
e9692de6c6 | ||
|
|
3414bfad1a | ||
|
|
ecad259db2 | ||
|
|
932e9ca4ef | ||
|
|
10825e8b82 | ||
|
|
53abde53ac | ||
|
|
d7ba3154a1 | ||
|
|
197595fc4d | ||
|
|
e38d8c1561 | ||
|
|
afb4869b21 | ||
|
|
c6769f9257 | ||
|
|
8657a0501f | ||
|
|
730ba9c462 | ||
|
|
1ba80606a7 | ||
|
|
3261e6ee26 | ||
|
|
de3495635b | ||
|
|
4c23f2b4a9 | ||
|
|
7b071afdfb | ||
|
|
c7fb85d281 | ||
|
|
118912dd13 | ||
|
|
0e594a5e28 | ||
|
|
a6ec72d72c | ||
|
|
e2f353d4ab | ||
|
|
e601eb04c9 | ||
|
|
6c771810f7 | ||
|
|
dbad7037d1 | ||
|
|
21362ba125 | ||
|
|
2dcdaf2b49 | ||
|
|
1fa13bbe3b | ||
|
|
a76fd8da32 | ||
|
|
634d101f2c | ||
|
|
28c9882b17 | ||
|
|
a1d8a127dc | ||
|
|
65b4af9831 | ||
|
|
8942238f9c | ||
|
|
7dc27fe882 | ||
|
|
097f241c6f | ||
|
|
1c5d6b09e2 | ||
|
|
889f8ce1cd | ||
|
|
b4e9390f16 | ||
|
|
94e2bdaaa7 | ||
|
|
d322403764 | ||
|
|
9c6ce255bd | ||
|
|
06d52a9d2c | ||
|
|
76cbd4ae22 | ||
|
|
9b04bc85c2 | ||
|
|
2ccbfc8120 | ||
|
|
1678077c53 | ||
|
|
0dbb2d13ed | ||
|
|
82e5226acc | ||
|
|
b81874f5ba | ||
|
|
797d1e0280 | ||
|
|
538ca51ba5 | ||
|
|
9339abe19c | ||
|
|
ac2d53b404 | ||
|
|
7e108a71f9 | ||
|
|
7642d120e2 | ||
|
|
6ba0639d51 | ||
|
|
3b9522fec3 | ||
|
|
aa2093d6c8 | ||
|
|
3227c92d63 | ||
|
|
b7b21d8378 | ||
|
|
fb3bd53b0a | ||
|
|
4fcdf13f66 | ||
|
|
6673e63241 | ||
|
|
62e7d4e1dd | ||
|
|
bae5d8da3c | ||
|
|
62e3dc0395 | ||
|
|
bda88d8218 | ||
|
|
b5a8e1fe7b | ||
|
|
929ab0f320 | ||
|
|
7026d86081 | ||
|
|
b2ce6f5cf1 | ||
|
|
d4a39fe234 | ||
|
|
9966fd9470 | ||
|
|
050294754c | ||
|
|
1856019a95 | ||
|
|
f68d0bc26d | ||
|
|
c59c1e28e2 | ||
|
|
bfa94830a7 | ||
|
|
850b8d6abf | ||
|
|
e1cca98b8b | ||
|
|
df61d88ec6 | ||
|
|
9fc6b83bba | ||
|
|
13ab5b9017 | ||
|
|
9bb9d1e397 | ||
|
|
8431cd6fd1 | ||
|
|
49b2490c25 | ||
|
|
7d28e83a49 | ||
|
|
4ad508f84f | ||
|
|
9b53e51aa3 | ||
|
|
cd05930b69 | ||
|
|
c615c8b433 | ||
|
|
4664aae8aa | ||
|
|
a5d108f4f2 | ||
|
|
df422907b0 | ||
|
|
bc1b1b7725 | ||
|
|
7b64b8c17c |
72
.env.example
72
.env.example
@ -20,13 +20,37 @@ DEBUG=false
|
|||||||
# Logging level: DEBUG, INFO, WARNING, ERROR, CRITICAL
|
# Logging level: DEBUG, INFO, WARNING, ERROR, CRITICAL
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
# Per-module log level overrides (optional)
|
||||||
|
# These override LOG_LEVEL for specific modules.
|
||||||
|
# LOG_LEVEL_GAME=DEBUG # Core game logic
|
||||||
|
# LOG_LEVEL_AI=DEBUG # AI decisions (very verbose at DEBUG)
|
||||||
|
# LOG_LEVEL_HANDLERS=DEBUG # WebSocket message handlers
|
||||||
|
# LOG_LEVEL_ROOM=DEBUG # Room/lobby management
|
||||||
|
# LOG_LEVEL_AUTH=DEBUG # Auth stack (auth, routers.auth, services.auth_service)
|
||||||
|
# LOG_LEVEL_STORES=DEBUG # Database/Redis operations
|
||||||
|
|
||||||
|
# --- Preset examples ---
|
||||||
|
# Staging (debug game logic, quiet everything else):
|
||||||
|
# LOG_LEVEL=INFO
|
||||||
|
# LOG_LEVEL_GAME=DEBUG
|
||||||
|
# LOG_LEVEL_AI=DEBUG
|
||||||
|
#
|
||||||
|
# Production (minimal logging):
|
||||||
|
# LOG_LEVEL=WARNING
|
||||||
|
|
||||||
|
# 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,14 +66,28 @@ 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))")
|
||||||
SECRET_KEY=
|
SECRET_KEY=
|
||||||
|
|
||||||
# Enable invite-only mode (requires invitation to register)
|
# Enable invite-only mode (requires invitation to register)
|
||||||
INVITE_ONLY=false
|
INVITE_ONLY=true
|
||||||
|
|
||||||
|
# Metered open signups (public beta)
|
||||||
|
# 0 = disabled (invite-only enforced), -1 = unlimited, N = max open signups per day
|
||||||
|
# When set > 0, users can register without an invite code up to the daily limit.
|
||||||
|
# Invite codes always work regardless of this limit.
|
||||||
|
DAILY_OPEN_SIGNUPS=0
|
||||||
|
|
||||||
|
# Max signups per IP address per day (0 = unlimited)
|
||||||
|
DAILY_SIGNUPS_PER_IP=3
|
||||||
|
|
||||||
|
# Bootstrap admin account (for first-time setup with INVITE_ONLY=true)
|
||||||
|
# Remove these after first login!
|
||||||
|
# BOOTSTRAP_ADMIN_USERNAME=admin
|
||||||
|
# BOOTSTRAP_ADMIN_PASSWORD=changeme12345
|
||||||
|
|
||||||
# Comma-separated list of admin email addresses
|
# Comma-separated list of admin email addresses
|
||||||
ADMIN_EMAILS=
|
ADMIN_EMAILS=
|
||||||
@ -84,3 +122,27 @@ 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
|
||||||
|
|
||||||
|
# Redis URL (required for matchmaking and rate limiting)
|
||||||
|
# REDIS_URL=redis://localhost:6379
|
||||||
|
|
||||||
|
# Base URL for email links
|
||||||
|
# BASE_URL=https://your-domain.com
|
||||||
|
|
||||||
|
# Matchmaking (skill-based public games)
|
||||||
|
MATCHMAKING_ENABLED=true
|
||||||
|
MATCHMAKING_MIN_PLAYERS=2
|
||||||
|
MATCHMAKING_MAX_PLAYERS=4
|
||||||
|
|||||||
40
.gitignore
vendored
40
.gitignore
vendored
@ -136,7 +136,31 @@ celerybeat.pid
|
|||||||
|
|
||||||
# Environments
|
# Environments
|
||||||
.env
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
.envrc
|
.envrc
|
||||||
|
|
||||||
|
# Private keys and certificates
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.p12
|
||||||
|
*.pfx
|
||||||
|
*.jks
|
||||||
|
*.keystore
|
||||||
|
|
||||||
|
# Service credentials
|
||||||
|
credentials.json
|
||||||
|
service-account.json
|
||||||
|
*-credentials.json
|
||||||
|
|
||||||
|
# SSH keys
|
||||||
|
id_rsa
|
||||||
|
id_ecdsa
|
||||||
|
id_ed25519
|
||||||
|
|
||||||
|
# Other sensitive files
|
||||||
|
*.secrets
|
||||||
|
.htpasswd
|
||||||
.venv
|
.venv
|
||||||
env/
|
env/
|
||||||
venv/
|
venv/
|
||||||
@ -188,6 +212,22 @@ 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
|
||||||
|
|
||||||
|
# Internal docs (deployment info, credentials references, etc.)
|
||||||
|
internal/
|
||||||
|
|
||||||
# Ruff stuff:
|
# Ruff stuff:
|
||||||
.ruff_cache/
|
.ruff_cache/
|
||||||
|
|
||||||
|
|||||||
304
.secrets.baseline
Normal file
304
.secrets.baseline
Normal file
@ -0,0 +1,304 @@
|
|||||||
|
{
|
||||||
|
"version": "1.5.0",
|
||||||
|
"plugins_used": [
|
||||||
|
{
|
||||||
|
"name": "ArtifactoryDetector"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "AWSKeyDetector"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "AzureStorageKeyDetector"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Base64HighEntropyString",
|
||||||
|
"limit": 4.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "BasicAuthDetector"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CloudantDetector"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "DiscordBotTokenDetector"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "GitHubTokenDetector"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "GitLabTokenDetector"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "HexHighEntropyString",
|
||||||
|
"limit": 3.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "IbmCloudIamDetector"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "IbmCosHmacDetector"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "IPPublicDetector"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "JwtTokenDetector"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "KeywordDetector",
|
||||||
|
"keyword_exclude": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "MailchimpDetector"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "NpmDetector"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "OpenAIDetector"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "PrivateKeyDetector"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "PypiTokenDetector"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "SendGridDetector"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "SlackDetector"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "SoftlayerDetector"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "SquareOAuthDetector"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "StripeDetector"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TelegramBotTokenDetector"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TwilioKeyDetector"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"filters_used": [
|
||||||
|
{
|
||||||
|
"path": "detect_secrets.filters.allowlist.is_line_allowlisted"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "detect_secrets.filters.common.is_baseline_file",
|
||||||
|
"filename": ".secrets.baseline"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies",
|
||||||
|
"min_level": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "detect_secrets.filters.heuristic.is_indirect_reference"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "detect_secrets.filters.heuristic.is_likely_id_string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "detect_secrets.filters.heuristic.is_lock_file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "detect_secrets.filters.heuristic.is_not_alphanumeric_string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "detect_secrets.filters.heuristic.is_potential_uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "detect_secrets.filters.heuristic.is_prefixed_with_dollar_sign"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "detect_secrets.filters.heuristic.is_sequential_string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "detect_secrets.filters.heuristic.is_swagger_file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "detect_secrets.filters.heuristic.is_templated_secret"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "detect_secrets.filters.regex.should_exclude_file",
|
||||||
|
"pattern": [
|
||||||
|
"\\.env\\.example$",
|
||||||
|
"server/\\.env\\.example$"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"results": {
|
||||||
|
"INSTALL.md": [
|
||||||
|
{
|
||||||
|
"type": "Secret Keyword",
|
||||||
|
"filename": "INSTALL.md",
|
||||||
|
"hashed_secret": "365e24291fd19bba10a0d8504c0ed90d5c8bef7f",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 75
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Basic Auth Credentials",
|
||||||
|
"filename": "INSTALL.md",
|
||||||
|
"hashed_secret": "4f4944a7117fd2e95169da2b40af33b68a65a161",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 114
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Secret Keyword",
|
||||||
|
"filename": "INSTALL.md",
|
||||||
|
"hashed_secret": "c35bdb821a941808a150db95d0f934f449bbff17",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 182
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Basic Auth Credentials",
|
||||||
|
"filename": "INSTALL.md",
|
||||||
|
"hashed_secret": "c35bdb821a941808a150db95d0f934f449bbff17",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 225
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Secret Keyword",
|
||||||
|
"filename": "INSTALL.md",
|
||||||
|
"hashed_secret": "001c1654cb8dff7c4ddb1ae6d2203d0dd15a6096",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 391
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Secret Keyword",
|
||||||
|
"filename": "INSTALL.md",
|
||||||
|
"hashed_secret": "53fe8c55272f9c3ceebb5e6058788e8981a359cb",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 397
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"docker-compose.dev.yml": [
|
||||||
|
{
|
||||||
|
"type": "Secret Keyword",
|
||||||
|
"filename": "docker-compose.dev.yml",
|
||||||
|
"hashed_secret": "4f4944a7117fd2e95169da2b40af33b68a65a161",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 44
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"docs/v2/V2_BUILD_PLAN.md": [
|
||||||
|
{
|
||||||
|
"type": "Basic Auth Credentials",
|
||||||
|
"filename": "docs/v2/V2_BUILD_PLAN.md",
|
||||||
|
"hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 301
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"scripts/docker-build.sh": [
|
||||||
|
{
|
||||||
|
"type": "Basic Auth Credentials",
|
||||||
|
"filename": "scripts/docker-build.sh",
|
||||||
|
"hashed_secret": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 40
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"scripts/install.sh": [
|
||||||
|
{
|
||||||
|
"type": "Basic Auth Credentials",
|
||||||
|
"filename": "scripts/install.sh",
|
||||||
|
"hashed_secret": "4f4944a7117fd2e95169da2b40af33b68a65a161",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 156
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Basic Auth Credentials",
|
||||||
|
"filename": "scripts/install.sh",
|
||||||
|
"hashed_secret": "7205a0abf00d1daec13c63ece029057c974795a9",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 267
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"server/RULES.md": [
|
||||||
|
{
|
||||||
|
"type": "Secret Keyword",
|
||||||
|
"filename": "server/RULES.md",
|
||||||
|
"hashed_secret": "a6778f1880744bd1a342a8e3789135412d8f9da2",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 904
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Secret Keyword",
|
||||||
|
"filename": "server/RULES.md",
|
||||||
|
"hashed_secret": "aafdc23870ecbcd3d557b6423a8982134e17927e",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 949
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"server/config.py": [
|
||||||
|
{
|
||||||
|
"type": "Basic Auth Credentials",
|
||||||
|
"filename": "server/config.py",
|
||||||
|
"hashed_secret": "5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 124
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"server/game_analyzer.py": [
|
||||||
|
{
|
||||||
|
"type": "Basic Auth Credentials",
|
||||||
|
"filename": "server/game_analyzer.py",
|
||||||
|
"hashed_secret": "4f4944a7117fd2e95169da2b40af33b68a65a161",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 617
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"server/test_auth.py": [
|
||||||
|
{
|
||||||
|
"type": "Secret Keyword",
|
||||||
|
"filename": "server/test_auth.py",
|
||||||
|
"hashed_secret": "cbfdac6008f9cab4083784cbd1874f76618d2a97",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 39
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Secret Keyword",
|
||||||
|
"filename": "server/test_auth.py",
|
||||||
|
"hashed_secret": "f0578f1e7174b1a41c4ea8c6e17f7a8a3b88c92a",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 51
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Secret Keyword",
|
||||||
|
"filename": "server/test_auth.py",
|
||||||
|
"hashed_secret": "8be52126a6fde450a7162a3651d589bb51e9579d",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 65
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Secret Keyword",
|
||||||
|
"filename": "server/test_auth.py",
|
||||||
|
"hashed_secret": "74913f5cd5f61ec0bcfdb775414c2fb3d161b620",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 75
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Secret Keyword",
|
||||||
|
"filename": "server/test_auth.py",
|
||||||
|
"hashed_secret": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 92
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Secret Keyword",
|
||||||
|
"filename": "server/test_auth.py",
|
||||||
|
"hashed_secret": "1e99b09f6eb835305555cc43c3e0768b1a39226b",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 104
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"generated_at": "2026-04-05T13:26:03Z"
|
||||||
|
}
|
||||||
285
CLAUDE.md
Normal file
285
CLAUDE.md
Normal file
@ -0,0 +1,285 @@
|
|||||||
|
# Golf Card Game - Project Context
|
||||||
|
|
||||||
|
A real-time multiplayer 6-card Golf card game with CPU opponents and smooth anime.js animations.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
pip install -r server/requirements.txt
|
||||||
|
|
||||||
|
# Run the server
|
||||||
|
python server/main.py
|
||||||
|
|
||||||
|
# Visit http://localhost:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
For full installation (Docker, PostgreSQL, Redis, production), see [INSTALL.md](INSTALL.md).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
golfgame/
|
||||||
|
├── server/ # Python FastAPI backend
|
||||||
|
│ ├── main.py # HTTP routes, WebSocket server, lifespan
|
||||||
|
│ ├── game.py # Core game logic, state machine
|
||||||
|
│ ├── ai.py # CPU opponent AI with timing/personality
|
||||||
|
│ ├── handlers.py # WebSocket message handlers
|
||||||
|
│ ├── room.py # Room/lobby management
|
||||||
|
│ ├── config.py # Environment configuration (pydantic)
|
||||||
|
│ ├── constants.py # Card values, game constants
|
||||||
|
│ ├── auth.py # Authentication (JWT, passwords)
|
||||||
|
│ ├── logging_config.py # Structured logging setup
|
||||||
|
│ ├── simulate.py # AI simulation runner with stats
|
||||||
|
│ ├── game_analyzer.py # Query tools for game analysis
|
||||||
|
│ ├── score_analysis.py # Score distribution analysis
|
||||||
|
│ ├── routers/ # FastAPI route modules
|
||||||
|
│ │ ├── auth.py # Login, signup, verify endpoints
|
||||||
|
│ │ ├── admin.py # Admin management endpoints
|
||||||
|
│ │ ├── stats.py # Statistics & leaderboard endpoints
|
||||||
|
│ │ ├── replay.py # Game replay endpoints
|
||||||
|
│ │ └── health.py # Health check endpoints
|
||||||
|
│ ├── services/ # Business logic layer
|
||||||
|
│ │ ├── auth_service.py # User authentication
|
||||||
|
│ │ ├── admin_service.py # Admin tools
|
||||||
|
│ │ ├── stats_service.py # Player statistics & leaderboards
|
||||||
|
│ │ ├── replay_service.py # Game replay functionality
|
||||||
|
│ │ ├── game_logger.py # PostgreSQL game move logging
|
||||||
|
│ │ ├── spectator.py # Spectator mode
|
||||||
|
│ │ ├── email_service.py # Email notifications (Resend)
|
||||||
|
│ │ ├── recovery_service.py # Account recovery
|
||||||
|
│ │ └── ratelimit.py # Rate limiting
|
||||||
|
│ ├── stores/ # Data persistence layer
|
||||||
|
│ │ ├── event_store.py # PostgreSQL event sourcing
|
||||||
|
│ │ ├── user_store.py # User persistence
|
||||||
|
│ │ ├── state_cache.py # Redis state caching
|
||||||
|
│ │ └── pubsub.py # Pub/sub messaging
|
||||||
|
│ ├── models/ # Data models
|
||||||
|
│ │ ├── events.py # Event types for event sourcing
|
||||||
|
│ │ ├── game_state.py # Game state representation
|
||||||
|
│ │ └── user.py # User data model
|
||||||
|
│ └── middleware/ # Request middleware
|
||||||
|
│ ├── security.py # CORS, CSP, security headers
|
||||||
|
│ ├── request_id.py # Request ID tracking
|
||||||
|
│ └── ratelimit.py # Rate limiting middleware
|
||||||
|
│
|
||||||
|
├── client/ # Vanilla JS frontend
|
||||||
|
│ ├── index.html # Main game page
|
||||||
|
│ ├── app.js # Main game controller
|
||||||
|
│ ├── card-animations.js # Unified anime.js animation system
|
||||||
|
│ ├── card-manager.js # DOM management for cards
|
||||||
|
│ ├── animation-queue.js # Animation sequencing
|
||||||
|
│ ├── timing-config.js # Centralized timing configuration
|
||||||
|
│ ├── state-differ.js # Diff game state for animations
|
||||||
|
│ ├── style.css # Styles (NO card transitions)
|
||||||
|
│ ├── admin.html # Admin panel
|
||||||
|
│ ├── admin.js # Admin panel interface
|
||||||
|
│ ├── admin.css # Admin panel styles
|
||||||
|
│ ├── replay.js # Game replay viewer
|
||||||
|
│ ├── leaderboard.js # Leaderboard display
|
||||||
|
│ └── ANIMATIONS.md # Animation system documentation
|
||||||
|
│
|
||||||
|
├── docs/
|
||||||
|
│ ├── ANIMATION-FLOWS.md # Animation flow diagrams
|
||||||
|
│ ├── v2/ # V2 architecture docs (event sourcing, auth, etc.)
|
||||||
|
│ └── v3/ # V3 feature & refactoring docs
|
||||||
|
│
|
||||||
|
├── scripts/ # Helper scripts
|
||||||
|
│ ├── install.sh # Interactive installer
|
||||||
|
│ ├── dev-server.sh # Development server launcher
|
||||||
|
│ └── docker-build.sh # Docker image builder
|
||||||
|
│
|
||||||
|
└── tests/e2e/ # End-to-end tests (Playwright)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Technical Decisions
|
||||||
|
|
||||||
|
### Animation System
|
||||||
|
|
||||||
|
**When to use anime.js vs CSS:**
|
||||||
|
- **Anime.js (CardAnimations)**: Card movements, flips, swaps, draws - anything involving card elements
|
||||||
|
- **CSS keyframes/transitions**: Simple UI feedback (button hover, badge entrance, status message fades) - non-card elements
|
||||||
|
|
||||||
|
**General rule:** If it moves a card, use anime.js. If it's UI chrome, CSS is fine.
|
||||||
|
|
||||||
|
- See `client/ANIMATIONS.md` for full documentation
|
||||||
|
- See `docs/ANIMATION-FLOWS.md` for flow diagrams
|
||||||
|
- `CardAnimations` class in `card-animations.js` handles everything
|
||||||
|
- Timing configured in `timing-config.js`
|
||||||
|
|
||||||
|
### State Management
|
||||||
|
|
||||||
|
- Server is source of truth
|
||||||
|
- Client receives full game state on each update
|
||||||
|
- `state-differ.js` computes diffs to trigger appropriate animations
|
||||||
|
|
||||||
|
### Animation Race Condition Flags
|
||||||
|
|
||||||
|
Several flags in `app.js` prevent `renderGame()` from updating the discard pile during animations:
|
||||||
|
|
||||||
|
| Flag | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `isDrawAnimating` | Local or opponent draw animation in progress |
|
||||||
|
| `localDiscardAnimating` | Local player discarding drawn card |
|
||||||
|
| `opponentDiscardAnimating` | Opponent discarding without swap |
|
||||||
|
| `opponentSwapAnimation` | Opponent swap animation in progress |
|
||||||
|
| `dealAnimationInProgress` | Deal animation running (suppresses flip prompts) |
|
||||||
|
|
||||||
|
**Critical:** These flags must be cleared in ALL code paths (success, error, fallback). Failure to clear causes UI to freeze.
|
||||||
|
|
||||||
|
**Clear flags when:**
|
||||||
|
- Animation completes (callback)
|
||||||
|
- New animation starts (clear stale flags)
|
||||||
|
- `your_turn` message received (safety clear)
|
||||||
|
- Error/fallback paths
|
||||||
|
|
||||||
|
### CPU Players
|
||||||
|
|
||||||
|
- AI logic in `server/ai.py`
|
||||||
|
- Configurable timing delays for natural feel
|
||||||
|
- Multiple personality types affect decision-making (pair hunters, aggressive, conservative, etc.)
|
||||||
|
|
||||||
|
**AI Decision Safety Checks:**
|
||||||
|
- Never swap high cards (8+) into unknown positions (expected value ~4.5)
|
||||||
|
- Unpredictability has value threshold (7) to prevent obviously bad random plays
|
||||||
|
- Comeback bonus only applies to cards < 8
|
||||||
|
- Denial logic skips hidden positions for 8+ cards
|
||||||
|
|
||||||
|
**Testing AI with simulations:**
|
||||||
|
```bash
|
||||||
|
# Run 500 games and check dumb move rate
|
||||||
|
python server/simulate.py 500
|
||||||
|
|
||||||
|
# Detailed single game output
|
||||||
|
python server/simulate.py 1 --detailed
|
||||||
|
|
||||||
|
# Compare rule presets
|
||||||
|
python server/simulate.py 100 --compare
|
||||||
|
```
|
||||||
|
|
||||||
|
### Server Architecture
|
||||||
|
|
||||||
|
- **Routers** (`server/routers/`): FastAPI route modules for auth, admin, stats, replay, health
|
||||||
|
- **Services** (`server/services/`): Business logic layer (auth, admin, stats, replay, email, rate limiting)
|
||||||
|
- **Stores** (`server/stores/`): Data persistence (PostgreSQL event store, user store, Redis state cache, pub/sub)
|
||||||
|
- **Models** (`server/models/`): Data models (events, game state, user)
|
||||||
|
- **Middleware** (`server/middleware/`): Security headers, request ID tracking, rate limiting
|
||||||
|
- **Handlers** (`server/handlers.py`): WebSocket message dispatch (extracted from main.py)
|
||||||
|
|
||||||
|
## Common Development Tasks
|
||||||
|
|
||||||
|
### Adjusting Animation Speed
|
||||||
|
|
||||||
|
Edit `timing-config.js` - all timings are centralized there.
|
||||||
|
|
||||||
|
### Adding New Animations
|
||||||
|
|
||||||
|
1. Add method to `CardAnimations` class in `card-animations.js`
|
||||||
|
2. Use anime.js, not CSS transitions
|
||||||
|
3. Track in `activeAnimations` Map for cancellation support
|
||||||
|
4. Add timing config to `timing-config.js` if needed
|
||||||
|
|
||||||
|
### Debugging Animations
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Check what's animating
|
||||||
|
console.log(window.cardAnimations.activeAnimations);
|
||||||
|
|
||||||
|
// Force cleanup
|
||||||
|
window.cardAnimations.cancelAll();
|
||||||
|
|
||||||
|
// Check timing config
|
||||||
|
console.log(window.TIMING);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing CPU Behavior
|
||||||
|
|
||||||
|
Adjust delays in `server/ai.py` `CPU_TIMING` dict.
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# All server tests
|
||||||
|
cd server && pytest -v
|
||||||
|
|
||||||
|
# AI simulation
|
||||||
|
python server/simulate.py 500
|
||||||
|
```
|
||||||
|
|
||||||
|
## Important Patterns
|
||||||
|
|
||||||
|
### No CSS Transitions on Cards
|
||||||
|
|
||||||
|
Cards animate via anime.js only. The following should NOT have `transition` (especially on `transform`):
|
||||||
|
- `.card`, `.card-inner`
|
||||||
|
- `.real-card`, `.swap-card`
|
||||||
|
- `.held-card-floating`
|
||||||
|
|
||||||
|
Card hover effects are handled by `CardAnimations.hoverIn()/hoverOut()` methods.
|
||||||
|
CSS may still use box-shadow transitions for hover glow effects.
|
||||||
|
|
||||||
|
### State Differ Logic (triggerAnimationsForStateChange)
|
||||||
|
|
||||||
|
The state differ in `app.js` detects what changed between game states:
|
||||||
|
|
||||||
|
**STEP 1: Draw Detection**
|
||||||
|
- Detects when `drawn_card` goes from null to something
|
||||||
|
- Triggers draw animation (from deck or discard)
|
||||||
|
- Sets `isDrawAnimating` flag
|
||||||
|
|
||||||
|
**STEP 2: Discard/Swap Detection**
|
||||||
|
- Detects when `discard_top` changes and it was another player's turn
|
||||||
|
- Triggers swap or discard animation
|
||||||
|
- **Important:** Skip STEP 2 if STEP 1 detected a draw from discard (the discard change was from REMOVING a card, not adding one)
|
||||||
|
|
||||||
|
### Animation Overlays
|
||||||
|
|
||||||
|
Complex animations create temporary overlay elements:
|
||||||
|
1. Create `.draw-anim-card` positioned over source
|
||||||
|
2. Hide original card (or set `opacity: 0` on discard pile during draw-from-discard)
|
||||||
|
3. Animate overlay
|
||||||
|
4. Remove overlay, reveal updated card, restore visibility
|
||||||
|
|
||||||
|
### Fire-and-Forget for Opponents
|
||||||
|
|
||||||
|
Opponent animations don't block - no callbacks needed:
|
||||||
|
```javascript
|
||||||
|
cardAnimations.animateOpponentFlip(cardElement, cardData);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common Animation Pitfalls
|
||||||
|
|
||||||
|
**Card position before append:** Always set `left`/`top` styles BEFORE appending overlay cards to body, otherwise they flash at (0,0).
|
||||||
|
|
||||||
|
**Deal animation source:** Use `getDeckRect()` for deal animations, not `getDealerRect()`. The dealer rect returns the whole player area, causing cards to animate at wrong size.
|
||||||
|
|
||||||
|
**Element rects during hidden:** `visibility: hidden` still allows `getBoundingClientRect()` to work. `display: none` does not.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
### Server
|
||||||
|
- FastAPI + uvicorn (web framework & ASGI server)
|
||||||
|
- websockets (WebSocket support)
|
||||||
|
- asyncpg (PostgreSQL async driver)
|
||||||
|
- redis (state caching, pub/sub)
|
||||||
|
- bcrypt (password hashing)
|
||||||
|
- resend (email service)
|
||||||
|
- python-dotenv (environment management)
|
||||||
|
- sentry-sdk (error tracking, optional)
|
||||||
|
|
||||||
|
### Client
|
||||||
|
- anime.js (animations)
|
||||||
|
- No other frameworks
|
||||||
|
|
||||||
|
### Infrastructure
|
||||||
|
- PostgreSQL (event sourcing, auth, stats, game logs)
|
||||||
|
- Redis (state caching, pub/sub)
|
||||||
|
|
||||||
|
## Game Rules Reference
|
||||||
|
|
||||||
|
- 6 cards per player in 2x3 grid
|
||||||
|
- Lower score wins
|
||||||
|
- Matching columns cancel out (0 points)
|
||||||
|
- Jokers are -2 points
|
||||||
|
- Kings are 0 points
|
||||||
|
- Game ends when a player flips all cards
|
||||||
@ -33,5 +33,6 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
|||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
# Run with uvicorn
|
# Run with uvicorn from the server directory (server uses relative imports)
|
||||||
CMD ["python", "-m", "uvicorn", "server.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
WORKDIR /app/server
|
||||||
|
CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|||||||
573
INSTALL.md
Normal file
573
INSTALL.md
Normal file
@ -0,0 +1,573 @@
|
|||||||
|
# Golf Game Installation Guide
|
||||||
|
|
||||||
|
Complete guide for installing and running the Golf card game server.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Quick Start](#quick-start)
|
||||||
|
- [Requirements](#requirements)
|
||||||
|
- [Development Setup](#development-setup)
|
||||||
|
- [Production Installation](#production-installation)
|
||||||
|
- [Docker Deployment](#docker-deployment)
|
||||||
|
- [Configuration Reference](#configuration-reference)
|
||||||
|
- [Troubleshooting](#troubleshooting)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
The fastest way to get started is using the interactive installer:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This provides a menu with options for:
|
||||||
|
- Development setup (Docker services + virtualenv + dependencies)
|
||||||
|
- Production installation to /opt/golfgame
|
||||||
|
- Systemd service configuration
|
||||||
|
- Status checks
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### For Development
|
||||||
|
|
||||||
|
- **Python 3.11+** (3.12, 3.13, 3.14 also work)
|
||||||
|
- **Docker** and **Docker Compose** (for PostgreSQL and Redis)
|
||||||
|
- **Git**
|
||||||
|
|
||||||
|
### For Production
|
||||||
|
|
||||||
|
- **Python 3.11+**
|
||||||
|
- **PostgreSQL 16+**
|
||||||
|
- **Redis 7+**
|
||||||
|
- **systemd** (for service management)
|
||||||
|
- **nginx** (recommended, for reverse proxy)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development Setup
|
||||||
|
|
||||||
|
### Option A: Using the Installer (Recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/install.sh
|
||||||
|
# Select option 1: Development Setup
|
||||||
|
```
|
||||||
|
|
||||||
|
This will:
|
||||||
|
1. Start PostgreSQL and Redis in Docker containers
|
||||||
|
2. Create a Python virtual environment
|
||||||
|
3. Install all dependencies
|
||||||
|
4. Generate a `.env` file configured for local development
|
||||||
|
|
||||||
|
### Option B: Manual Setup
|
||||||
|
|
||||||
|
#### 1. Start Docker Services
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose -f docker-compose.dev.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
This starts:
|
||||||
|
- **PostgreSQL** on `localhost:5432` (user: `golf`, password: `devpassword`, database: `golf`)
|
||||||
|
- **Redis** on `localhost:6379`
|
||||||
|
|
||||||
|
Verify services are running:
|
||||||
|
```bash
|
||||||
|
docker-compose -f docker-compose.dev.yml ps
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Create Python Virtual Environment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create venv in project root
|
||||||
|
python3 -m venv .
|
||||||
|
|
||||||
|
# Activate it
|
||||||
|
source bin/activate
|
||||||
|
|
||||||
|
# Upgrade pip
|
||||||
|
pip install --upgrade pip
|
||||||
|
|
||||||
|
# Install dependencies (including dev tools)
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Configure Environment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit `.env` for development:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
HOST=0.0.0.0
|
||||||
|
PORT=8000
|
||||||
|
DEBUG=true
|
||||||
|
LOG_LEVEL=DEBUG
|
||||||
|
ENVIRONMENT=development
|
||||||
|
|
||||||
|
DATABASE_URL=postgresql://golf:devpassword@localhost:5432/golf
|
||||||
|
POSTGRES_URL=postgresql://golf:devpassword@localhost:5432/golf
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. Run the Development Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd server
|
||||||
|
../bin/uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use the helper script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/dev-server.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The server will be available at http://localhost:8000
|
||||||
|
|
||||||
|
#### 5. Verify Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Health check
|
||||||
|
curl http://localhost:8000/health
|
||||||
|
|
||||||
|
# Should return: {"status":"ok","timestamp":"..."}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stopping Development Services
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Stop the server: Ctrl+C
|
||||||
|
|
||||||
|
# Stop Docker containers
|
||||||
|
docker-compose -f docker-compose.dev.yml down
|
||||||
|
|
||||||
|
# Stop and remove volumes (clean slate)
|
||||||
|
docker-compose -f docker-compose.dev.yml down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Production Installation
|
||||||
|
|
||||||
|
### Option A: Using the Installer (Recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ./scripts/install.sh
|
||||||
|
# Select option 2: Production Install to /opt/golfgame
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option B: Manual Installation
|
||||||
|
|
||||||
|
#### 1. Install System Dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Debian/Ubuntu
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install -y python3 python3-venv python3-pip postgresql redis-server nginx
|
||||||
|
|
||||||
|
# Start and enable services
|
||||||
|
sudo systemctl enable --now postgresql redis-server nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Create PostgreSQL Database
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo -u postgres psql << EOF
|
||||||
|
CREATE USER golf WITH PASSWORD 'your_secure_password';
|
||||||
|
CREATE DATABASE golf OWNER golf;
|
||||||
|
GRANT ALL PRIVILEGES ON DATABASE golf TO golf;
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Create Installation Directory
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /opt/golfgame
|
||||||
|
sudo chown $USER:$USER /opt/golfgame
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. Clone and Install Application
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/golfgame
|
||||||
|
git clone https://github.com/alee/golfgame.git .
|
||||||
|
|
||||||
|
# Create virtual environment
|
||||||
|
python3 -m venv .
|
||||||
|
source bin/activate
|
||||||
|
|
||||||
|
# Install application
|
||||||
|
pip install --upgrade pip
|
||||||
|
pip install .
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5. Configure Production Environment
|
||||||
|
|
||||||
|
Create `/opt/golfgame/.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate a secret key
|
||||||
|
SECRET_KEY=$(python3 -c "import secrets; print(secrets.token_hex(32))")
|
||||||
|
|
||||||
|
cat > /opt/golfgame/.env << EOF
|
||||||
|
HOST=0.0.0.0
|
||||||
|
PORT=8000
|
||||||
|
DEBUG=false
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
ENVIRONMENT=production
|
||||||
|
|
||||||
|
DATABASE_URL=postgresql://golf:your_secure_password@localhost:5432/golf
|
||||||
|
POSTGRES_URL=postgresql://golf:your_secure_password@localhost:5432/golf
|
||||||
|
|
||||||
|
SECRET_KEY=$SECRET_KEY
|
||||||
|
|
||||||
|
MAX_PLAYERS_PER_ROOM=6
|
||||||
|
ROOM_TIMEOUT_MINUTES=60
|
||||||
|
|
||||||
|
# Optional: Error tracking with Sentry
|
||||||
|
# SENTRY_DSN=https://your-sentry-dsn
|
||||||
|
|
||||||
|
# Optional: Email via Resend
|
||||||
|
# RESEND_API_KEY=your-api-key
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Secure the file
|
||||||
|
chmod 600 /opt/golfgame/.env
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 6. Set Ownership
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo chown -R www-data:www-data /opt/golfgame
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 7. Create Systemd Service
|
||||||
|
|
||||||
|
Create `/etc/systemd/system/golfgame.service`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=Golf Card Game Server
|
||||||
|
Documentation=https://github.com/alee/golfgame
|
||||||
|
After=network.target postgresql.service redis.service
|
||||||
|
Wants=postgresql.service redis.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=www-data
|
||||||
|
Group=www-data
|
||||||
|
WorkingDirectory=/opt/golfgame/server
|
||||||
|
Environment="PATH=/opt/golfgame/bin:/usr/local/bin:/usr/bin:/bin"
|
||||||
|
EnvironmentFile=/opt/golfgame/.env
|
||||||
|
ExecStart=/opt/golfgame/bin/uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4
|
||||||
|
ExecReload=/bin/kill -HUP $MAINPID
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
|
||||||
|
# Security hardening
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
ReadWritePaths=/opt/golfgame
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 8. Enable and Start Service
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable golfgame
|
||||||
|
sudo systemctl start golfgame
|
||||||
|
|
||||||
|
# Check status
|
||||||
|
sudo systemctl status golfgame
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
journalctl -u golfgame -f
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 9. Configure Nginx Reverse Proxy
|
||||||
|
|
||||||
|
Create `/etc/nginx/sites-available/golfgame`:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
upstream golfgame {
|
||||||
|
server 127.0.0.1:8000;
|
||||||
|
keepalive 64;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name your-domain.com;
|
||||||
|
|
||||||
|
# Redirect HTTP to HTTPS
|
||||||
|
return 301 https://$server_name$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name your-domain.com;
|
||||||
|
|
||||||
|
# SSL configuration (use certbot for Let's Encrypt)
|
||||||
|
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://golfgame;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
|
# WebSocket support
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
|
||||||
|
# Standard proxy headers
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# Timeouts for WebSocket
|
||||||
|
proxy_read_timeout 86400;
|
||||||
|
proxy_send_timeout 86400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Enable the site:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ln -s /etc/nginx/sites-available/golfgame /etc/nginx/sites-enabled/
|
||||||
|
sudo nginx -t
|
||||||
|
sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 10. SSL Certificate (Let's Encrypt)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install certbot python3-certbot-nginx
|
||||||
|
sudo certbot --nginx -d your-domain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docker Deployment
|
||||||
|
|
||||||
|
### Build the Docker Image
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/docker-build.sh
|
||||||
|
|
||||||
|
# Or manually:
|
||||||
|
docker build -t golfgame:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development with Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start dev services only (PostgreSQL + Redis)
|
||||||
|
docker-compose -f docker-compose.dev.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production with Docker Compose
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Set required environment variables
|
||||||
|
export DB_PASSWORD="your-secure-database-password"
|
||||||
|
export SECRET_KEY=$(python3 -c "import secrets; print(secrets.token_hex(32))")
|
||||||
|
export ACME_EMAIL="your-email@example.com"
|
||||||
|
export DOMAIN="your-domain.com"
|
||||||
|
|
||||||
|
# Optional
|
||||||
|
export RESEND_API_KEY="your-resend-key"
|
||||||
|
export SENTRY_DSN="your-sentry-dsn"
|
||||||
|
|
||||||
|
# Start all services
|
||||||
|
docker-compose -f docker-compose.prod.yml up -d
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
docker-compose -f docker-compose.prod.yml logs -f app
|
||||||
|
|
||||||
|
# Scale app instances
|
||||||
|
docker-compose -f docker-compose.prod.yml up -d --scale app=3
|
||||||
|
```
|
||||||
|
|
||||||
|
The production compose file includes:
|
||||||
|
- **app**: The Golf game server (scalable)
|
||||||
|
- **postgres**: PostgreSQL database
|
||||||
|
- **redis**: Redis for sessions
|
||||||
|
- **traefik**: Reverse proxy with automatic HTTPS
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration Reference
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `HOST` | `0.0.0.0` | Server bind address |
|
||||||
|
| `PORT` | `8000` | Server port |
|
||||||
|
| `DEBUG` | `false` | Enable debug mode |
|
||||||
|
| `LOG_LEVEL` | `INFO` | Logging level (DEBUG, INFO, WARNING, ERROR) |
|
||||||
|
| `ENVIRONMENT` | `production` | Environment name |
|
||||||
|
| `DATABASE_URL` | - | PostgreSQL URL (event sourcing, game logs, stats) |
|
||||||
|
| `POSTGRES_URL` | - | PostgreSQL URL for auth/stats (can be same as DATABASE_URL) |
|
||||||
|
| `SECRET_KEY` | - | Secret key for JWT tokens |
|
||||||
|
| `MAX_PLAYERS_PER_ROOM` | `6` | Maximum players per game room |
|
||||||
|
| `ROOM_TIMEOUT_MINUTES` | `60` | Inactive room cleanup timeout |
|
||||||
|
| `ROOM_CODE_LENGTH` | `4` | Length of room codes |
|
||||||
|
| `DEFAULT_ROUNDS` | `9` | Default holes per game |
|
||||||
|
| `SENTRY_DSN` | - | Sentry error tracking DSN |
|
||||||
|
| `RESEND_API_KEY` | - | Resend API key for emails |
|
||||||
|
| `RATE_LIMIT_ENABLED` | `false` | Enable rate limiting |
|
||||||
|
|
||||||
|
### File Locations
|
||||||
|
|
||||||
|
| Path | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `/opt/golfgame/` | Production installation root |
|
||||||
|
| `/opt/golfgame/.env` | Production environment config |
|
||||||
|
| `/opt/golfgame/server/` | Server application code |
|
||||||
|
| `/opt/golfgame/client/` | Static web client |
|
||||||
|
| `/opt/golfgame/bin/` | Python virtualenv binaries |
|
||||||
|
| `/etc/systemd/system/golfgame.service` | Systemd service file |
|
||||||
|
| `/etc/nginx/sites-available/golfgame` | Nginx site config |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Check Service Status
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Systemd service
|
||||||
|
sudo systemctl status golfgame
|
||||||
|
journalctl -u golfgame -n 100
|
||||||
|
|
||||||
|
# Docker containers
|
||||||
|
docker-compose -f docker-compose.dev.yml ps
|
||||||
|
docker-compose -f docker-compose.dev.yml logs
|
||||||
|
|
||||||
|
# Using the installer
|
||||||
|
./scripts/install.sh
|
||||||
|
# Select option 6: Show Status
|
||||||
|
```
|
||||||
|
|
||||||
|
### Health Check
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8000/health
|
||||||
|
# Expected: {"status":"ok","timestamp":"..."}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
#### "No module named 'config'"
|
||||||
|
|
||||||
|
The server must be started from the `server/` directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/golfgame/server
|
||||||
|
../bin/uvicorn main:app --host 0.0.0.0 --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
#### "Connection refused" on PostgreSQL
|
||||||
|
|
||||||
|
1. Check PostgreSQL is running:
|
||||||
|
```bash
|
||||||
|
sudo systemctl status postgresql
|
||||||
|
# Or for Docker:
|
||||||
|
docker ps | grep postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Verify connection settings in `.env`
|
||||||
|
|
||||||
|
3. Test connection:
|
||||||
|
```bash
|
||||||
|
psql -h localhost -U golf -d golf
|
||||||
|
```
|
||||||
|
|
||||||
|
#### "POSTGRES_URL not configured" warning
|
||||||
|
|
||||||
|
Add `POSTGRES_URL` to your `.env` file. This is required for authentication and stats features.
|
||||||
|
|
||||||
|
#### Broken virtualenv symlinks
|
||||||
|
|
||||||
|
If Python was upgraded, the virtualenv symlinks may break. Recreate it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm -rf bin lib lib64 pyvenv.cfg include share
|
||||||
|
python3 -m venv .
|
||||||
|
source bin/activate
|
||||||
|
pip install -e ".[dev]" # or just: pip install .
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Permission denied on /opt/golfgame
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo chown -R www-data:www-data /opt/golfgame
|
||||||
|
sudo chmod 600 /opt/golfgame/.env
|
||||||
|
```
|
||||||
|
|
||||||
|
### Updating
|
||||||
|
|
||||||
|
#### Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git pull
|
||||||
|
source bin/activate
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
# Server auto-reloads with --reload flag
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Production
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/golfgame
|
||||||
|
sudo systemctl stop golfgame
|
||||||
|
sudo -u www-data git pull
|
||||||
|
sudo -u www-data ./bin/pip install .
|
||||||
|
sudo systemctl start golfgame
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose -f docker-compose.prod.yml down
|
||||||
|
git pull
|
||||||
|
docker-compose -f docker-compose.prod.yml build
|
||||||
|
docker-compose -f docker-compose.prod.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scripts Reference
|
||||||
|
|
||||||
|
| Script | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `scripts/install.sh` | Interactive installer menu |
|
||||||
|
| `scripts/dev-server.sh` | Start development server |
|
||||||
|
| `scripts/docker-build.sh` | Build production Docker image |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
- GitHub Issues: https://github.com/alee/golfgame/issues
|
||||||
|
- Documentation: See `README.md` for game rules and API docs
|
||||||
674
LICENSE
Normal file
674
LICENSE
Normal file
@ -0,0 +1,674 @@
|
|||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
the GNU General Public License is intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users. We, the Free Software Foundation, use the
|
||||||
|
GNU General Public License for most of our software; it applies also to
|
||||||
|
any other work released this way by its authors. You can apply it to
|
||||||
|
your programs, too.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
To protect your rights, we need to prevent others from denying you
|
||||||
|
these rights or asking you to surrender the rights. Therefore, you have
|
||||||
|
certain responsibilities if you distribute copies of the software, or if
|
||||||
|
you modify it: responsibilities to respect the freedom of others.
|
||||||
|
|
||||||
|
For example, if you distribute copies of such a program, whether
|
||||||
|
gratis or for a fee, you must pass on to the recipients the same
|
||||||
|
freedoms that you received. You must make sure that they, too, receive
|
||||||
|
or can get the source code. And you must show them these terms so they
|
||||||
|
know their rights.
|
||||||
|
|
||||||
|
Developers that use the GNU GPL protect your rights with two steps:
|
||||||
|
(1) assert copyright on the software, and (2) offer you this License
|
||||||
|
giving you legal permission to copy, distribute and/or modify it.
|
||||||
|
|
||||||
|
For the developers' and authors' protection, the GPL clearly explains
|
||||||
|
that there is no warranty for this free software. For both users' and
|
||||||
|
authors' sake, the GPL requires that modified versions be marked as
|
||||||
|
changed, so that their problems will not be attributed erroneously to
|
||||||
|
authors of previous versions.
|
||||||
|
|
||||||
|
Some devices are designed to deny users access to install or run
|
||||||
|
modified versions of the software inside them, although the manufacturer
|
||||||
|
can do so. This is fundamentally incompatible with the aim of
|
||||||
|
protecting users' freedom to change the software. The systematic
|
||||||
|
pattern of such abuse occurs in the area of products for individuals to
|
||||||
|
use, which is precisely where it is most unacceptable. Therefore, we
|
||||||
|
have designed this version of the GPL to prohibit the practice for those
|
||||||
|
products. If such problems arise substantially in other domains, we
|
||||||
|
stand ready to extend this provision to those domains in future versions
|
||||||
|
of the GPL, as needed to protect the freedom of users.
|
||||||
|
|
||||||
|
Finally, every program is threatened constantly by software patents.
|
||||||
|
States should not allow patents to restrict development and use of
|
||||||
|
software on general-purpose computers, but in those that do, we wish to
|
||||||
|
avoid the special danger that patents applied to a free program could
|
||||||
|
make it effectively proprietary. To prevent this, the GPL assures that
|
||||||
|
patents cannot be used to render the program non-free.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Use with the GNU Affero General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU Affero General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the special requirements of the GNU Affero General Public License,
|
||||||
|
section 13, concerning interaction through a network will apply to the
|
||||||
|
combination as such.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU General Public License from time to time. Such new versions will
|
||||||
|
be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If the program does terminal interaction, make it output a short
|
||||||
|
notice like this when it starts in an interactive mode:
|
||||||
|
|
||||||
|
<program> Copyright (C) <year> <name of author>
|
||||||
|
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
|
This is free software, and you are welcome to redistribute it
|
||||||
|
under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||||
|
parts of the General Public License. Of course, your program's commands
|
||||||
|
might be different; for a GUI interface, you would use an "about box".
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU GPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
The GNU General Public License does not permit incorporating your program
|
||||||
|
into proprietary programs. If your program is a subroutine library, you
|
||||||
|
may consider it more useful to permit linking proprietary applications with
|
||||||
|
the library. If this is what you want to do, use the GNU Lesser General
|
||||||
|
Public License instead of this License. But first, please read
|
||||||
|
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||||
169
README.md
169
README.md
@ -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 (21→0)
|
- **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,11 +194,13 @@ 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
|
||||||
|
|
||||||
MIT
|
GPL-3.0-or-later — see [LICENSE](LICENSE) for the full text.
|
||||||
|
|||||||
247
bin/Activate.ps1
247
bin/Activate.ps1
@ -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"
|
|
||||||
76
bin/activate
76
bin/activate
@ -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
|
|
||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
8
bin/pip
8
bin/pip
@ -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())
|
|
||||||
8
bin/pip3
8
bin/pip3
@ -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())
|
|
||||||
@ -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())
|
|
||||||
@ -1 +0,0 @@
|
|||||||
/home/alee/.pyenv/versions/3.12.0/bin/python
|
|
||||||
@ -1 +0,0 @@
|
|||||||
python
|
|
||||||
@ -1 +0,0 @@
|
|||||||
python
|
|
||||||
307
client/ANIMATIONS.md
Normal file
307
client/ANIMATIONS.md
Normal file
@ -0,0 +1,307 @@
|
|||||||
|
# Card Animation System
|
||||||
|
|
||||||
|
This document describes the unified animation system for the Golf card game client.
|
||||||
|
|
||||||
|
For detailed animation flow diagrams (what triggers what, in what order, with what flags), see [`docs/ANIMATION-FLOWS.md`](../docs/ANIMATION-FLOWS.md).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
**When to use anime.js vs CSS:**
|
||||||
|
- **Anime.js (CardAnimations)**: Card movements, flips, swaps, draws - anything involving card elements
|
||||||
|
- **CSS keyframes/transitions**: Simple UI feedback (button hover, badge entrance, status message fades) - non-card elements
|
||||||
|
|
||||||
|
**General rule:** If it moves a card, use anime.js. If it's UI chrome, CSS is fine.
|
||||||
|
|
||||||
|
| What | How |
|
||||||
|
|------|-----|
|
||||||
|
| Card movements | anime.js |
|
||||||
|
| Card flips | anime.js |
|
||||||
|
| Swap animations | anime.js |
|
||||||
|
| Pulse/glow effects on cards | anime.js |
|
||||||
|
| Button hover/active states | CSS transitions |
|
||||||
|
| Badge entrance/exit | CSS transitions |
|
||||||
|
| Status message fades | CSS transitions |
|
||||||
|
| Card hover states | anime.js `hoverIn()`/`hoverOut()` |
|
||||||
|
| Show/hide | CSS `.hidden` class only |
|
||||||
|
|
||||||
|
### Why anime.js?
|
||||||
|
|
||||||
|
- Consistent timing and easing across all animations
|
||||||
|
- Coordinated multi-element sequences via timelines
|
||||||
|
- Proper animation cancellation via `activeAnimations` tracking
|
||||||
|
- No conflicts between CSS and JS animation systems
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `card-animations.js` | Unified `CardAnimations` class - all animation logic |
|
||||||
|
| `timing-config.js` | Centralized timing/easing configuration |
|
||||||
|
| `style.css` | Static styles only (no transitions on cards) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CardAnimations Class API
|
||||||
|
|
||||||
|
Global instance available at `window.cardAnimations`.
|
||||||
|
|
||||||
|
### Draw Animations
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Draw from deck - lift, move to hold area, flip to reveal
|
||||||
|
cardAnimations.animateDrawDeck(cardData, onComplete)
|
||||||
|
|
||||||
|
// Draw from discard - quick grab, no flip
|
||||||
|
cardAnimations.animateDrawDiscard(cardData, onComplete)
|
||||||
|
|
||||||
|
// For opponent draw-then-discard - deck to discard with flip
|
||||||
|
cardAnimations.animateDeckToDiscard(card, onComplete)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Flip Animations
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Generic flip animation on any card element
|
||||||
|
cardAnimations.animateFlip(element, cardData, onComplete)
|
||||||
|
|
||||||
|
// Initial flip at game start (local player)
|
||||||
|
cardAnimations.animateInitialFlip(cardElement, cardData, onComplete)
|
||||||
|
|
||||||
|
// Opponent card flip (fire-and-forget)
|
||||||
|
cardAnimations.animateOpponentFlip(cardElement, cardData, rotation)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Swap Animations
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Player swaps drawn card with hand card
|
||||||
|
cardAnimations.animateSwap(position, oldCard, newCard, handCardElement, onComplete)
|
||||||
|
|
||||||
|
// Opponent swap (fire-and-forget)
|
||||||
|
cardAnimations.animateOpponentSwap(playerId, position, discardCard, sourceCardElement, rotation, wasFaceUp)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Discard Animations
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Animate held card swooping to discard pile
|
||||||
|
cardAnimations.animateDiscard(heldCardElement, targetCard, onComplete)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ambient Effects (Looping)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// "Your turn to draw" shake effect
|
||||||
|
cardAnimations.startTurnPulse(element)
|
||||||
|
cardAnimations.stopTurnPulse(element)
|
||||||
|
|
||||||
|
// CPU thinking glow
|
||||||
|
cardAnimations.startCpuThinking(element)
|
||||||
|
cardAnimations.stopCpuThinking(element)
|
||||||
|
|
||||||
|
// Initial flip phase - clickable cards glow
|
||||||
|
cardAnimations.startInitialFlipPulse(element)
|
||||||
|
cardAnimations.stopInitialFlipPulse(element)
|
||||||
|
cardAnimations.stopAllInitialFlipPulses()
|
||||||
|
```
|
||||||
|
|
||||||
|
### One-Shot Effects
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Pulse when card lands on discard
|
||||||
|
cardAnimations.pulseDiscard()
|
||||||
|
|
||||||
|
// Pulse effect on face-up swap
|
||||||
|
cardAnimations.pulseSwap(element)
|
||||||
|
|
||||||
|
// Pop-in when element appears (use sparingly)
|
||||||
|
cardAnimations.popIn(element)
|
||||||
|
|
||||||
|
// Gold ring expanding effect before draw
|
||||||
|
cardAnimations.startDrawPulse(element)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Utility Methods
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Check if animation is in progress
|
||||||
|
cardAnimations.isBusy()
|
||||||
|
|
||||||
|
// Cancel all running animations
|
||||||
|
cardAnimations.cancel()
|
||||||
|
cardAnimations.cancelAll()
|
||||||
|
|
||||||
|
// Clean up animation elements
|
||||||
|
cardAnimations.cleanup()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Animation Coordination
|
||||||
|
|
||||||
|
### Server-Client Timing
|
||||||
|
|
||||||
|
Server CPU timing (in `server/ai.py` `CPU_TIMING`) must account for client animation durations:
|
||||||
|
- `post_draw_settle`: Must be >= draw animation duration (~1.1s for deck draw)
|
||||||
|
- `post_action_pause`: Must be >= swap/discard animation duration (~0.5s)
|
||||||
|
|
||||||
|
### Preventing Animation Overlap
|
||||||
|
|
||||||
|
Animation overlay cards are marked with `data-animating="true"` while active.
|
||||||
|
Methods like `animateUnifiedSwap` and `animateOpponentDiscard` check for active
|
||||||
|
animations and wait before starting new ones.
|
||||||
|
|
||||||
|
### Card Hover Initialization
|
||||||
|
|
||||||
|
Call `cardAnimations.initHoverListeners(container)` after dynamically creating cards.
|
||||||
|
This is done automatically in `renderGame()` for player and opponent card areas.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Animation Overlay Pattern
|
||||||
|
|
||||||
|
For complex animations (flips, swaps), the system:
|
||||||
|
|
||||||
|
1. Creates a temporary overlay element (`.draw-anim-card`)
|
||||||
|
2. Positions it exactly over the source card
|
||||||
|
3. Hides the original card (`opacity: 0` or `.swap-out`)
|
||||||
|
4. Animates the overlay
|
||||||
|
5. Removes overlay and reveals updated original card
|
||||||
|
|
||||||
|
This ensures smooth animations without modifying the DOM structure of game cards.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Timing Configuration
|
||||||
|
|
||||||
|
All timing values are in `timing-config.js` and exposed as `window.TIMING`.
|
||||||
|
|
||||||
|
### Key Durations
|
||||||
|
|
||||||
|
All durations are configured in `timing-config.js` and read via `window.TIMING`.
|
||||||
|
|
||||||
|
| Animation | Duration | Config Key | Notes |
|
||||||
|
|-----------|----------|------------|-------|
|
||||||
|
| Flip | 320ms | `card.flip` | 3D rotateY with slight overshoot |
|
||||||
|
| Deck lift | 120ms | `draw.deckLift` | Visible lift before travel |
|
||||||
|
| Deck move | 250ms | `draw.deckMove` | Smooth travel to hold position |
|
||||||
|
| Deck flip | 320ms | `draw.deckFlip` | Reveal drawn card |
|
||||||
|
| Discard lift | 80ms | `draw.discardLift` | Quick decisive grab |
|
||||||
|
| Discard move | 200ms | `draw.discardMove` | Travel to hold position |
|
||||||
|
| Swap lift | 100ms | `swap.lift` | Pickup before arc travel |
|
||||||
|
| Swap arc | 320ms | `swap.arc` | Arc travel between positions |
|
||||||
|
| Swap settle | 100ms | `swap.settle` | Landing with gentle overshoot |
|
||||||
|
| Swap pulse | 400ms | — | Scale + brightness (face-up swap) |
|
||||||
|
| Turn shake | 400ms | — | Every 3 seconds |
|
||||||
|
|
||||||
|
### Easing Functions
|
||||||
|
|
||||||
|
Custom cubic bezier curves give cards natural weight and momentum:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
window.TIMING.anime.easing = {
|
||||||
|
flip: 'cubicBezier(0.34, 1.2, 0.64, 1)', // Slight overshoot snap
|
||||||
|
move: 'cubicBezier(0.22, 0.68, 0.35, 1.0)', // Smooth deceleration
|
||||||
|
lift: 'cubicBezier(0.0, 0.0, 0.2, 1)', // Quick out, soft stop
|
||||||
|
settle: 'cubicBezier(0.34, 1.05, 0.64, 1)', // Tiny overshoot on landing
|
||||||
|
arc: 'cubicBezier(0.45, 0, 0.15, 1)', // Smooth S-curve for arcs
|
||||||
|
pulse: 'easeInOutSine', // Smooth oscillation (loops)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CSS Rules
|
||||||
|
|
||||||
|
### What CSS Does
|
||||||
|
|
||||||
|
- Static card appearance (colors, borders, sizing)
|
||||||
|
- Layout and positioning
|
||||||
|
- Card hover states (`:hover` scale/shadow - no movement)
|
||||||
|
- Show/hide via `.hidden` class
|
||||||
|
- **UI chrome animations** (buttons, badges, status messages):
|
||||||
|
- Button hover/active transitions
|
||||||
|
- Badge entrance/exit animations
|
||||||
|
- Status message fade in/out
|
||||||
|
- Modal transitions
|
||||||
|
|
||||||
|
### What CSS Does NOT Do (on card elements)
|
||||||
|
|
||||||
|
- No `transition` on any card element (`.card`, `.card-inner`, `.real-card`, `.swap-card`, `.held-card-floating`)
|
||||||
|
- No `@keyframes` for card movements or flips
|
||||||
|
- No `.flipped`, `.moving`, `.flipping` transition triggers for cards
|
||||||
|
|
||||||
|
### Important Classes
|
||||||
|
|
||||||
|
| Class | Purpose |
|
||||||
|
|-------|---------|
|
||||||
|
| `.draw-anim-card` | Temporary overlay during animation |
|
||||||
|
| `.draw-anim-inner` | 3D flip container |
|
||||||
|
| `.swap-out` | Hides original during swap animation |
|
||||||
|
| `.hidden` | Opacity 0, no display change |
|
||||||
|
| `.draw-pulse` | Gold ring expanding effect |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### Preventing Premature UI Updates
|
||||||
|
|
||||||
|
The `isDrawAnimating` flag in `app.js` prevents the held card from appearing before the draw animation completes:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// In renderGame()
|
||||||
|
if (!this.isDrawAnimating && /* other conditions */) {
|
||||||
|
// Show held card
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Animation Sequencing
|
||||||
|
|
||||||
|
Use anime.js timelines for coordinated sequences:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const T = window.TIMING;
|
||||||
|
const timeline = anime.timeline({
|
||||||
|
easing: T.anime.easing.move,
|
||||||
|
complete: () => { /* cleanup */ }
|
||||||
|
});
|
||||||
|
|
||||||
|
timeline.add({ targets: el, translateY: -15, duration: T.card.lift, easing: T.anime.easing.lift });
|
||||||
|
timeline.add({ targets: el, left: x, top: y, duration: T.card.move });
|
||||||
|
timeline.add({ targets: inner, rotateY: 0, duration: T.card.flip, easing: T.anime.easing.flip });
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fire-and-Forget Animations
|
||||||
|
|
||||||
|
For opponent/CPU animations that don't block game flow:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// No onComplete callback needed
|
||||||
|
cardAnimations.animateOpponentFlip(cardElement, cardData);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Debugging
|
||||||
|
|
||||||
|
### Check Active Animations
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
console.log(window.cardAnimations.activeAnimations);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Force Cleanup
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
window.cardAnimations.cancelAll();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Animation Not Working?
|
||||||
|
|
||||||
|
1. Check that anime.js is loaded before card-animations.js
|
||||||
|
2. Verify element exists and is visible
|
||||||
|
3. Check for CSS transitions that might conflict
|
||||||
|
4. Look for errors in console
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
/**
|
/**
|
||||||
* Golf Admin Dashboard
|
* Golf Admin Dashboard
|
||||||
* JavaScript for admin interface functionality
|
* JavaScript for admin interface functionality
|
||||||
@ -317,7 +318,7 @@ async function loadUsers() {
|
|||||||
<td>${user.games_played} (${user.games_won} wins)</td>
|
<td>${user.games_played} (${user.games_won} wins)</td>
|
||||||
<td>${formatDateShort(user.created_at)}</td>
|
<td>${formatDateShort(user.created_at)}</td>
|
||||||
<td>
|
<td>
|
||||||
<button class="btn btn-small" onclick="viewUser('${user.id}')">View</button>
|
<button class="btn btn-small" data-action="view-user" data-id="${user.id}">View</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`;
|
||||||
@ -404,7 +405,7 @@ async function loadGames() {
|
|||||||
<td><span class="badge badge-${game.status === 'playing' ? 'success' : 'info'}">${game.status}</span></td>
|
<td><span class="badge badge-${game.status === 'playing' ? 'success' : 'info'}">${game.status}</span></td>
|
||||||
<td>${formatDate(game.created_at)}</td>
|
<td>${formatDate(game.created_at)}</td>
|
||||||
<td>
|
<td>
|
||||||
<button class="btn btn-small btn-danger" onclick="promptEndGame('${game.game_id}')">End</button>
|
<button class="btn btn-small btn-danger" data-action="end-game" data-id="${game.game_id}">End</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`;
|
||||||
@ -454,7 +455,8 @@ async function loadInvites() {
|
|||||||
<td>${status}</td>
|
<td>${status}</td>
|
||||||
<td>
|
<td>
|
||||||
${invite.is_active && !isExpired && invite.remaining_uses > 0
|
${invite.is_active && !isExpired && invite.remaining_uses > 0
|
||||||
? `<button class="btn btn-small btn-danger" onclick="promptRevokeInvite('${invite.code}')">Revoke</button>`
|
? `<button class="btn btn-small" data-action="copy-invite" data-code="${escapeHtml(invite.code)}">Copy Link</button>
|
||||||
|
<button class="btn btn-small btn-danger" data-action="revoke-invite" data-code="${escapeHtml(invite.code)}">Revoke</button>`
|
||||||
: '-'
|
: '-'
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
@ -619,6 +621,16 @@ async function handleCreateInvite() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function copyInviteLink(code) {
|
||||||
|
const link = `${window.location.origin}/?invite=${encodeURIComponent(code)}`;
|
||||||
|
navigator.clipboard.writeText(link).then(() => {
|
||||||
|
showToast('Invite link copied!', 'success');
|
||||||
|
}).catch(() => {
|
||||||
|
// Fallback: select text for manual copy
|
||||||
|
prompt('Copy this link:', link);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function promptRevokeInvite(code) {
|
async function promptRevokeInvite(code) {
|
||||||
if (!confirm(`Are you sure you want to revoke invite code ${code}?`)) return;
|
if (!confirm(`Are you sure you want to revoke invite code ${code}?`)) return;
|
||||||
|
|
||||||
@ -804,6 +816,18 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Delegated click handlers for dynamically-created buttons
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
const btn = e.target.closest('[data-action]');
|
||||||
|
if (!btn) return;
|
||||||
|
|
||||||
|
const action = btn.dataset.action;
|
||||||
|
if (action === 'view-user') viewUser(btn.dataset.id);
|
||||||
|
else if (action === 'end-game') promptEndGame(btn.dataset.id);
|
||||||
|
else if (action === 'copy-invite') copyInviteLink(btn.dataset.code);
|
||||||
|
else if (action === 'revoke-invite') promptRevokeInvite(btn.dataset.code);
|
||||||
|
});
|
||||||
|
|
||||||
// Check auth on load
|
// Check auth on load
|
||||||
checkAuth();
|
checkAuth();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
// AnimationQueue - Sequences card animations properly
|
// AnimationQueue - Sequences card animations properly
|
||||||
// Ensures animations play in order without overlap
|
// Ensures animations play in order without overlap
|
||||||
|
|
||||||
@ -11,27 +12,37 @@ 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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add movements to the queue and start processing
|
// Add movements to the queue and start processing.
|
||||||
|
// The onComplete callback only fires after the LAST movement in this batch —
|
||||||
|
// intermediate movements don't trigger it. This is intentional: callers want
|
||||||
|
// to know when the whole sequence is done, not each individual step.
|
||||||
async enqueue(movements, onComplete) {
|
async enqueue(movements, onComplete) {
|
||||||
if (!movements || movements.length === 0) {
|
if (!movements || movements.length === 0) {
|
||||||
if (onComplete) onComplete();
|
if (onComplete) onComplete();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add completion callback to last movement
|
// Attach callback to last movement only
|
||||||
const movementsWithCallback = movements.map((m, i) => ({
|
const movementsWithCallback = movements.map((m, i) => ({
|
||||||
...m,
|
...m,
|
||||||
onComplete: i === movements.length - 1 ? onComplete : null
|
onComplete: i === movements.length - 1 ? onComplete : null
|
||||||
@ -124,7 +135,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 +147,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 +159,56 @@ 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.
|
||||||
|
// 150ms is short enough to feel instant but long enough for the eye to
|
||||||
|
// register the transition. Shorter looks like a glitch, longer looks laggy.
|
||||||
|
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 +336,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
8
client/anime.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
3389
client/app.js
3389
client/app.js
File diff suppressed because it is too large
Load Diff
1784
client/card-animations.js
Normal file
1784
client/card-animations.js
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,3 +1,4 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
// CardManager - Manages persistent card DOM elements
|
// CardManager - Manages persistent card DOM elements
|
||||||
// Cards are REAL elements that exist in ONE place and move between locations
|
// Cards are REAL elements that exist in ONE place and move between locations
|
||||||
|
|
||||||
@ -52,7 +53,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 +65,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 +101,19 @@ class CardManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get the deck color class for a card based on its deck_id.
|
||||||
|
// Reads from window.currentDeckColors, which app.js sets from game state.
|
||||||
|
// This global coupling is intentional — card-manager shouldn't know about
|
||||||
|
// game state directly, and passing it through every call site isn't worth it.
|
||||||
|
getDeckColorClass(cardData) {
|
||||||
|
if (!cardData || cardData.deck_id === undefined || cardData.deck_id === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
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] || '';
|
||||||
}
|
}
|
||||||
@ -103,8 +129,19 @@ class CardManager {
|
|||||||
cardEl.style.width = `${rect.width}px`;
|
cardEl.style.width = `${rect.width}px`;
|
||||||
cardEl.style.height = `${rect.height}px`;
|
cardEl.style.height = `${rect.height}px`;
|
||||||
|
|
||||||
|
// On mobile, scale font proportional to card width so rank/suit fit.
|
||||||
|
// This must stay in sync with the CSS .card font-size on desktop — if CSS
|
||||||
|
// sets a fixed size and we set an inline style, the inline wins. Clearing
|
||||||
|
// fontSize on desktop lets the CSS rule take over.
|
||||||
|
if (document.body.classList.contains('mobile-portrait')) {
|
||||||
|
cardEl.style.fontSize = `${rect.width * 0.35}px`;
|
||||||
|
} else {
|
||||||
|
cardEl.style.fontSize = '';
|
||||||
|
}
|
||||||
|
|
||||||
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 +167,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 +199,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,17 +237,21 @@ 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.
|
||||||
|
// The +50ms buffer accounts for CSS transition timing jitter — without it,
|
||||||
|
// we occasionally remove the 'moving' class before the transition finishes.
|
||||||
cardEl.classList.add('moving');
|
cardEl.classList.add('moving');
|
||||||
this.positionCard(cardEl, discardRect);
|
this.positionCard(cardEl, discardRect);
|
||||||
await this.delay(duration + 50);
|
await this.delay(duration + 50);
|
||||||
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';
|
||||||
|
|||||||
@ -59,9 +59,9 @@
|
|||||||
<!-- Outer edge highlight -->
|
<!-- Outer edge highlight -->
|
||||||
<circle cx="50" cy="44" r="46" fill="none" stroke="#ffffff" stroke-width="0.5" opacity="0.5"/>
|
<circle cx="50" cy="44" r="46" fill="none" stroke="#ffffff" stroke-width="0.5" opacity="0.5"/>
|
||||||
|
|
||||||
<!-- Card suits - single row, larger -->
|
<!-- Card suits - 2x2 grid -->
|
||||||
<text x="22" y="51" font-family="Arial, sans-serif" font-size="32" font-weight="bold" fill="#1a1a1a" text-anchor="middle">♣</text>
|
<text x="36" y="40" font-family="Arial, sans-serif" font-size="28" font-weight="bold" fill="#1a1a1a" text-anchor="middle">♣</text>
|
||||||
<text x="41" y="51" font-family="Arial, sans-serif" font-size="32" font-weight="bold" fill="#cc0000" text-anchor="middle">♦</text>
|
<text x="64" y="40" font-family="Arial, sans-serif" font-size="28" font-weight="bold" fill="#cc0000" text-anchor="middle">♦</text>
|
||||||
<text x="59" y="51" font-family="Arial, sans-serif" font-size="32" font-weight="bold" fill="#1a1a1a" text-anchor="middle">♠</text>
|
<text x="36" y="64" font-family="Arial, sans-serif" font-size="28" font-weight="bold" fill="#cc0000" text-anchor="middle">♥</text>
|
||||||
<text x="77" y="51" font-family="Arial, sans-serif" font-size="32" font-weight="bold" fill="#cc0000" text-anchor="middle">♥</text>
|
<text x="64" y="64" font-family="Arial, sans-serif" font-size="28" font-weight="bold" fill="#1a1a1a" text-anchor="middle">♠</text>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.8 KiB |
@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||||
<title>Golf Card Game</title>
|
<title>Golf Card Game</title>
|
||||||
<link rel="stylesheet" href="style.css">
|
<link rel="stylesheet" href="style.css">
|
||||||
</head>
|
</head>
|
||||||
@ -16,36 +16,57 @@
|
|||||||
|
|
||||||
<!-- Lobby Screen -->
|
<!-- Lobby Screen -->
|
||||||
<div id="lobby-screen" class="screen active">
|
<div id="lobby-screen" class="screen active">
|
||||||
<h1><img src="golfball-logo.svg" alt="" class="golfball-logo"><span class="golfer-swing">🏌️</span><span class="kicked-ball">⚪</span> <span class="golf-title">Golf</span></h1>
|
<h1><span class="logo-row"><img src="golfball-logo.svg" alt="" class="golfball-logo"><span class="golfer-container"><span class="golfer-swing">🏌️</span><span class="kicked-ball">⚪</span></span></span> <span class="golf-title">GolfCards<span class="golf-title-tld">.club</span></span></h1>
|
||||||
<p class="subtitle">6-Card Golf Card Game <button id="rules-btn" class="btn btn-small btn-rules">Rules</button> <button id="leaderboard-btn" class="btn btn-small leaderboard-btn">Leaderboard</button></p>
|
<p class="subtitle">6-Card Golf Card Game <button id="rules-btn" class="btn btn-small btn-rules">Rules</button> <button id="leaderboard-btn" class="btn btn-small leaderboard-btn">Leaderboard</button></p>
|
||||||
|
|
||||||
<!-- Auth buttons for guests (hidden until auth check confirms not logged in) -->
|
<div class="alpha-banner">Beta Testing - Bear with us while, stuff.</div>
|
||||||
<div id="auth-buttons" class="auth-buttons hidden">
|
|
||||||
<button id="login-btn" class="btn btn-small">Login</button>
|
|
||||||
<button id="signup-btn" class="btn btn-small btn-primary">Sign Up</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="player-name">Your Name</label>
|
|
||||||
<input type="text" id="player-name" placeholder="Enter your name" maxlength="12">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<!-- Auth prompt for unauthenticated users -->
|
||||||
|
<div id="auth-prompt" class="auth-prompt">
|
||||||
|
<p>Log in or sign up to play.</p>
|
||||||
<div class="button-group">
|
<div class="button-group">
|
||||||
<button id="create-room-btn" class="btn btn-primary">Create Room</button>
|
<button id="login-btn" class="btn btn-primary">Login</button>
|
||||||
|
<button id="signup-btn" class="btn btn-secondary">Sign Up</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Game controls (shown only when authenticated) -->
|
||||||
|
<div id="lobby-game-controls" class="hidden">
|
||||||
|
<div class="button-group">
|
||||||
|
<button id="find-game-btn" class="btn btn-primary">Find Game</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="divider">or</div>
|
<div class="divider">or</div>
|
||||||
|
|
||||||
|
<div class="button-group">
|
||||||
|
<button id="create-room-btn" class="btn btn-secondary">Create Private Room</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="room-code">Room Code</label>
|
<label for="room-code">Join Private Room</label>
|
||||||
<input type="text" id="room-code" placeholder="ABCD" maxlength="4">
|
<input type="text" id="room-code" placeholder="ABCD" maxlength="4">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="button-group">
|
<div class="button-group">
|
||||||
<button id="join-room-btn" class="btn btn-secondary">Join Room</button>
|
<button id="join-room-btn" class="btn btn-secondary">Join Room</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p id="lobby-error" class="error"></p>
|
<p id="lobby-error" class="error"></p>
|
||||||
|
|
||||||
|
<footer class="app-footer">v3.1.6 © Aaron D. Lee</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Matchmaking Screen -->
|
||||||
|
<div id="matchmaking-screen" class="screen">
|
||||||
|
<h2>Finding Game...</h2>
|
||||||
|
<div class="matchmaking-spinner"></div>
|
||||||
|
<p id="matchmaking-status">Searching for opponents...</p>
|
||||||
|
<p id="matchmaking-time" class="matchmaking-timer">0:00</p>
|
||||||
|
<p id="matchmaking-queue-info" class="matchmaking-info"></p>
|
||||||
|
<div class="button-group">
|
||||||
|
<button id="cancel-matchmaking-btn" class="btn btn-danger">Cancel</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Waiting Room Screen -->
|
<!-- Waiting Room Screen -->
|
||||||
@ -61,16 +82,16 @@
|
|||||||
<div class="waiting-layout">
|
<div class="waiting-layout">
|
||||||
<div class="waiting-left-col">
|
<div class="waiting-left-col">
|
||||||
<div class="players-list">
|
<div class="players-list">
|
||||||
|
<div class="players-list-header">
|
||||||
<h3>Players</h3>
|
<h3>Players</h3>
|
||||||
|
<div id="cpu-controls-section" class="cpu-controls hidden">
|
||||||
|
<span class="cpu-controls-label">CPU:</span>
|
||||||
|
<button id="remove-cpu-btn" class="cpu-ctrl-btn btn-danger" title="Remove CPU">−</button>
|
||||||
|
<button id="add-cpu-btn" class="cpu-ctrl-btn btn-success" title="Add CPU">+</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<ul id="players-list"></ul>
|
<ul id="players-list"></ul>
|
||||||
</div>
|
</div>
|
||||||
<div id="cpu-controls-section" class="cpu-controls-section hidden">
|
|
||||||
<h4>Add CPU Opponents</h4>
|
|
||||||
<div class="cpu-controls">
|
|
||||||
<button id="remove-cpu-btn" class="btn btn-small btn-danger" title="Remove last CPU">−</button>
|
|
||||||
<button id="add-cpu-btn" class="btn btn-small btn-success" title="Add CPU player">+</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button id="leave-room-btn" class="btn btn-danger">Leave Room</button>
|
<button id="leave-room-btn" class="btn btn-danger">Leave Room</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -78,12 +99,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 +116,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,11 +280,14 @@
|
|||||||
</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>
|
||||||
|
|
||||||
<p id="waiting-message" class="info">Waiting for host to start the game...</p>
|
<p id="waiting-message" class="info">Waiting for host to start the game...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<footer class="app-footer">v3.1.6 © Aaron D. Lee</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Game Screen -->
|
<!-- Game Screen -->
|
||||||
@ -258,7 +306,10 @@
|
|||||||
</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>
|
||||||
|
</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 +332,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<span class="held-label">Holding</span>
|
<span class="held-label">Holding</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="deck" class="card card-back">
|
<div class="pile-wrapper">
|
||||||
<span>?</span>
|
<span class="pile-label">DRAW</span>
|
||||||
|
<div id="deck" class="card card-back"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="pile-wrapper">
|
||||||
|
<span class="pile-label">DISCARD</span>
|
||||||
<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>
|
||||||
@ -298,6 +352,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="player-section">
|
<div class="player-section">
|
||||||
<div class="player-area">
|
<div class="player-area">
|
||||||
@ -312,14 +367,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 +390,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>
|
||||||
@ -352,15 +408,32 @@
|
|||||||
<tbody></tbody>
|
<tbody></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile bottom bar (hidden on desktop) -->
|
||||||
|
<div id="mobile-bottom-bar">
|
||||||
|
<div class="mobile-round-info">Hole <span id="mobile-current-round">1</span>/<span id="mobile-total-rounds">9</span></div>
|
||||||
|
<button class="mobile-bar-btn mobile-rules-btn" id="mobile-rules-btn" data-drawer="rules-drawer"><span id="mobile-rules-icon">RULES</span></button>
|
||||||
|
<button class="mobile-bar-btn" data-drawer="standings-panel">Scorecard</button>
|
||||||
|
<button id="mobile-leave-btn" class="mobile-bar-btn mobile-leave-btn">End Game</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile rules drawer -->
|
||||||
|
<div id="rules-drawer" class="side-panel rules-drawer-panel">
|
||||||
|
<h4>Active Rules</h4>
|
||||||
|
<div id="mobile-rules-content"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Drawer backdrop for mobile -->
|
||||||
|
<div id="drawer-backdrop" class="drawer-backdrop"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Rules Screen -->
|
<!-- Rules Screen -->
|
||||||
<div id="rules-screen" class="screen">
|
<div id="rules-screen" class="screen">
|
||||||
<div class="rules-container">
|
<div class="rules-container">
|
||||||
<button id="rules-back-btn" class="btn rules-back-btn">« Back</button>
|
|
||||||
|
|
||||||
<div class="rules-header">
|
<div class="rules-header">
|
||||||
|
<button id="rules-back-btn" class="btn rules-back-btn">« Back</button>
|
||||||
<h1><span class="golfer-logo">🏌️</span> <span class="golf-title">Golf Rules</span></h1>
|
<h1><span class="golfer-logo">🏌️</span> <span class="golf-title">Golf Rules</span></h1>
|
||||||
<p class="rules-subtitle">6-Card Golf Card Game - Complete Guide</p>
|
<p class="rules-subtitle">6-Card Golf Card Game - Complete Guide</p>
|
||||||
</div>
|
</div>
|
||||||
@ -520,12 +593,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 +607,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 +626,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 +641,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 +670,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>
|
||||||
@ -676,9 +749,8 @@ TOTAL: 0 + 8 + 16 = 24 points</pre>
|
|||||||
<!-- Leaderboard Screen -->
|
<!-- Leaderboard Screen -->
|
||||||
<div id="leaderboard-screen" class="screen">
|
<div id="leaderboard-screen" class="screen">
|
||||||
<div class="leaderboard-container">
|
<div class="leaderboard-container">
|
||||||
<button id="leaderboard-back-btn" class="btn leaderboard-back-btn">« Back</button>
|
|
||||||
|
|
||||||
<div class="leaderboard-header">
|
<div class="leaderboard-header">
|
||||||
|
<button id="leaderboard-back-btn" class="btn leaderboard-back-btn">« Back</button>
|
||||||
<h1>Leaderboard</h1>
|
<h1>Leaderboard</h1>
|
||||||
<p class="leaderboard-subtitle">Top players ranked by performance</p>
|
<p class="leaderboard-subtitle">Top players ranked by performance</p>
|
||||||
</div>
|
</div>
|
||||||
@ -689,6 +761,7 @@ TOTAL: 0 + 8 + 16 = 24 points</pre>
|
|||||||
<button class="leaderboard-tab" data-metric="avg_score">Avg Score</button>
|
<button class="leaderboard-tab" data-metric="avg_score">Avg Score</button>
|
||||||
<button class="leaderboard-tab" data-metric="knockouts">Knockouts</button>
|
<button class="leaderboard-tab" data-metric="knockouts">Knockouts</button>
|
||||||
<button class="leaderboard-tab" data-metric="streak">Best Streak</button>
|
<button class="leaderboard-tab" data-metric="streak">Best Streak</button>
|
||||||
|
<button class="leaderboard-tab" data-metric="rating">Rating</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="leaderboard-content">
|
<div id="leaderboard-content">
|
||||||
@ -782,12 +855,48 @@ TOTAL: 0 + 8 + 16 = 24 points</pre>
|
|||||||
<button type="submit" class="btn btn-primary btn-full">Login</button>
|
<button type="submit" class="btn btn-primary btn-full">Login</button>
|
||||||
</form>
|
</form>
|
||||||
<p class="auth-switch">Don't have an account? <a href="#" id="show-signup">Sign up</a></p>
|
<p class="auth-switch">Don't have an account? <a href="#" id="show-signup">Sign up</a></p>
|
||||||
|
<p class="auth-switch"><a href="#" id="show-forgot">Forgot password?</a></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Forgot Password Form -->
|
||||||
|
<div id="forgot-form-container" class="hidden">
|
||||||
|
<h3>Reset Password</h3>
|
||||||
|
<p class="auth-hint">Enter your email and we'll send you a reset link.</p>
|
||||||
|
<form id="forgot-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<input type="email" id="forgot-email" placeholder="Email" required>
|
||||||
|
</div>
|
||||||
|
<p id="forgot-error" class="error"></p>
|
||||||
|
<p id="forgot-success" class="success"></p>
|
||||||
|
<button type="submit" class="btn btn-primary btn-full">Send Reset Link</button>
|
||||||
|
</form>
|
||||||
|
<p class="auth-switch"><a href="#" id="forgot-back-login">Back to login</a></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reset Password Form (from email link) -->
|
||||||
|
<div id="reset-form-container" class="hidden">
|
||||||
|
<h3>Set New Password</h3>
|
||||||
|
<form id="reset-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<input type="password" id="reset-password" placeholder="New password" required minlength="8">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<input type="password" id="reset-password-confirm" placeholder="Confirm password" required minlength="8">
|
||||||
|
</div>
|
||||||
|
<p id="reset-error" class="error"></p>
|
||||||
|
<p id="reset-success" class="success"></p>
|
||||||
|
<button type="submit" class="btn btn-primary btn-full">Reset Password</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Signup Form -->
|
<!-- Signup Form -->
|
||||||
<div id="signup-form-container" class="hidden">
|
<div id="signup-form-container" class="hidden">
|
||||||
<h3>Sign Up</h3>
|
<h3>Sign Up</h3>
|
||||||
<form id="signup-form">
|
<form id="signup-form">
|
||||||
|
<div class="form-group" id="invite-code-group">
|
||||||
|
<input type="text" id="signup-invite-code" placeholder="Invite Code">
|
||||||
|
<small id="invite-code-hint" class="form-hint"></small>
|
||||||
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<input type="text" id="signup-username" placeholder="Username" required minlength="3" maxlength="20">
|
<input type="text" id="signup-username" placeholder="Username" required minlength="3" maxlength="20">
|
||||||
</div>
|
</div>
|
||||||
@ -805,6 +914,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>
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
/**
|
/**
|
||||||
* Leaderboard component for Golf game.
|
* Leaderboard component for Golf game.
|
||||||
* Handles leaderboard display, metric switching, and player stats modal.
|
* Handles leaderboard display, metric switching, and player stats modal.
|
||||||
@ -26,6 +27,7 @@ class LeaderboardComponent {
|
|||||||
avg_score: 'Avg Score',
|
avg_score: 'Avg Score',
|
||||||
knockouts: 'Knockouts',
|
knockouts: 'Knockouts',
|
||||||
streak: 'Best Streak',
|
streak: 'Best Streak',
|
||||||
|
rating: 'Rating',
|
||||||
};
|
};
|
||||||
|
|
||||||
this.metricFormats = {
|
this.metricFormats = {
|
||||||
@ -34,6 +36,7 @@ class LeaderboardComponent {
|
|||||||
avg_score: (v) => v.toFixed(1),
|
avg_score: (v) => v.toFixed(1),
|
||||||
knockouts: (v) => v.toLocaleString(),
|
knockouts: (v) => v.toLocaleString(),
|
||||||
streak: (v) => v.toLocaleString(),
|
streak: (v) => v.toLocaleString(),
|
||||||
|
rating: (v) => Math.round(v).toLocaleString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
this.init();
|
this.init();
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
// Golf Card Game - Replay Viewer
|
// Golf Card Game - Replay Viewer
|
||||||
|
|
||||||
class ReplayViewer {
|
class ReplayViewer {
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
// StateDiffer - Detects what changed between game states
|
// StateDiffer - Detects what changed between game states
|
||||||
// Generates movement instructions for the animation queue
|
// Generates movement instructions for the animation queue
|
||||||
|
|
||||||
@ -114,7 +115,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
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
2302
client/style.css
2302
client/style.css
File diff suppressed because it is too large
Load Diff
176
client/timing-config.js
Normal file
176
client/timing-config.js
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// 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: {
|
||||||
|
lastPlayPause: 2000, // Pause after last play animation before reveals
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Turn pulse (deck shake)
|
||||||
|
turnPulse: {
|
||||||
|
initialDelay: 5000, // Delay before first shake
|
||||||
|
interval: 5400, // Time between shakes
|
||||||
|
duration: 300, // Shake animation duration
|
||||||
|
},
|
||||||
|
|
||||||
|
// V3_17: Knock notification
|
||||||
|
knock: {
|
||||||
|
statusDuration: 2500, // How long the knock status message persists
|
||||||
|
},
|
||||||
|
|
||||||
|
// V3_17: Scoresheet modal
|
||||||
|
scoresheet: {
|
||||||
|
playerStagger: 150, // Delay between player row animations
|
||||||
|
columnStagger: 80, // Delay between column animations within a row
|
||||||
|
pairGlowDelay: 200, // Delay before paired columns glow
|
||||||
|
},
|
||||||
|
|
||||||
|
// Player swap animation steps - smooth continuous motion
|
||||||
|
playerSwap: {
|
||||||
|
flipToReveal: 400, // Initial flip to show card
|
||||||
|
pauseAfterReveal: 50, // Tiny beat to register the card
|
||||||
|
moveToDiscard: 400, // Move old card to discard
|
||||||
|
pulseBeforeSwap: 0, // No pulse - just flow
|
||||||
|
completePause: 50, // Tiny settle
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to get beat duration with variance
|
||||||
|
function getBeatDuration() {
|
||||||
|
const base = TIMING.beat.base;
|
||||||
|
const variance = TIMING.beat.variance;
|
||||||
|
return base + (Math.random() * variance * 2 - variance);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export for module systems, also attach to window for direct use
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = TIMING;
|
||||||
|
}
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.TIMING = TIMING;
|
||||||
|
window.getBeatDuration = getBeatDuration;
|
||||||
|
}
|
||||||
@ -17,49 +17,71 @@
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
|
restart: unless-stopped
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
environment:
|
environment:
|
||||||
- POSTGRES_URL=postgresql://golf:${DB_PASSWORD}@postgres:5432/golf
|
- POSTGRES_URL=postgresql://golf:${DB_PASSWORD}@postgres:5432/golf
|
||||||
|
- DATABASE_URL=postgresql://golf:${DB_PASSWORD}@postgres:5432/golf
|
||||||
- REDIS_URL=redis://redis:6379
|
- REDIS_URL=redis://redis:6379
|
||||||
- SECRET_KEY=${SECRET_KEY}
|
- SECRET_KEY=${SECRET_KEY}
|
||||||
- RESEND_API_KEY=${RESEND_API_KEY:-}
|
- RESEND_API_KEY=${RESEND_API_KEY:-}
|
||||||
|
- EMAIL_FROM=${EMAIL_FROM:-Golf Cards <noreply@contact.golfcards.club>}
|
||||||
- SENTRY_DSN=${SENTRY_DSN:-}
|
- SENTRY_DSN=${SENTRY_DSN:-}
|
||||||
- ENVIRONMENT=production
|
- ENVIRONMENT=${ENVIRONMENT:-production}
|
||||||
- LOG_LEVEL=INFO
|
- LOG_LEVEL=${LOG_LEVEL:-WARNING}
|
||||||
|
- LOG_LEVEL_GAME=${LOG_LEVEL_GAME:-}
|
||||||
|
- LOG_LEVEL_AI=${LOG_LEVEL_AI:-}
|
||||||
|
- LOG_LEVEL_HANDLERS=${LOG_LEVEL_HANDLERS:-}
|
||||||
|
- LOG_LEVEL_ROOM=${LOG_LEVEL_ROOM:-}
|
||||||
|
- LOG_LEVEL_AUTH=${LOG_LEVEL_AUTH:-}
|
||||||
|
- LOG_LEVEL_STORES=${LOG_LEVEL_STORES:-}
|
||||||
- BASE_URL=${BASE_URL:-https://golf.example.com}
|
- BASE_URL=${BASE_URL:-https://golf.example.com}
|
||||||
- RATE_LIMIT_ENABLED=true
|
- RATE_LIMIT_ENABLED=true
|
||||||
|
- INVITE_ONLY=true
|
||||||
|
- DAILY_OPEN_SIGNUPS=${DAILY_OPEN_SIGNUPS:-0}
|
||||||
|
- DAILY_SIGNUPS_PER_IP=${DAILY_SIGNUPS_PER_IP:-3}
|
||||||
|
- BOOTSTRAP_ADMIN_USERNAME=${BOOTSTRAP_ADMIN_USERNAME:-}
|
||||||
|
- BOOTSTRAP_ADMIN_PASSWORD=${BOOTSTRAP_ADMIN_PASSWORD:-}
|
||||||
|
- MATCHMAKING_ENABLED=true
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
deploy:
|
deploy:
|
||||||
replicas: 2
|
|
||||||
restart_policy:
|
|
||||||
condition: on-failure
|
|
||||||
max_attempts: 3
|
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
memory: 512M
|
|
||||||
reservations:
|
|
||||||
memory: 256M
|
memory: 256M
|
||||||
|
reservations:
|
||||||
|
memory: 64M
|
||||||
networks:
|
networks:
|
||||||
- internal
|
- internal
|
||||||
- web
|
- web
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
|
- "traefik.docker.network=traefik_web"
|
||||||
- "traefik.http.routers.golf.rule=Host(`${DOMAIN:-golf.example.com}`)"
|
- "traefik.http.routers.golf.rule=Host(`${DOMAIN:-golf.example.com}`)"
|
||||||
- "traefik.http.routers.golf.entrypoints=websecure"
|
- "traefik.http.routers.golf.entrypoints=websecure"
|
||||||
- "traefik.http.routers.golf.tls=true"
|
- "traefik.http.routers.golf.tls=true"
|
||||||
- "traefik.http.routers.golf.tls.certresolver=letsencrypt"
|
- "traefik.http.routers.golf.tls.certresolver=letsencrypt"
|
||||||
|
# www -> bare domain redirect
|
||||||
|
- "traefik.http.routers.golf-www.rule=Host(`www.${DOMAIN:-golf.example.com}`)"
|
||||||
|
- "traefik.http.routers.golf-www.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.golf-www.tls=true"
|
||||||
|
- "traefik.http.routers.golf-www.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.routers.golf-www.middlewares=www-redirect"
|
||||||
|
- "traefik.http.middlewares.www-redirect.redirectregex.regex=^https://www\\.(.+)"
|
||||||
|
- "traefik.http.middlewares.www-redirect.redirectregex.replacement=https://$${1}"
|
||||||
|
- "traefik.http.middlewares.www-redirect.redirectregex.permanent=true"
|
||||||
- "traefik.http.services.golf.loadbalancer.server.port=8000"
|
- "traefik.http.services.golf.loadbalancer.server.port=8000"
|
||||||
# WebSocket sticky sessions
|
# WebSocket sticky sessions
|
||||||
- "traefik.http.services.golf.loadbalancer.sticky.cookie=true"
|
- "traefik.http.services.golf.loadbalancer.sticky.cookie=true"
|
||||||
- "traefik.http.services.golf.loadbalancer.sticky.cookie.name=golf_server"
|
- "traefik.http.services.golf.loadbalancer.sticky.cookie.name=golf_server"
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
|
restart: unless-stopped
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: golf
|
POSTGRES_DB: golf
|
||||||
@ -77,13 +99,14 @@ services:
|
|||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
memory: 512M
|
memory: 192M
|
||||||
reservations:
|
reservations:
|
||||||
memory: 256M
|
memory: 64M
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
|
restart: unless-stopped
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
command: redis-server --appendonly yes --maxmemory 128mb --maxmemory-policy allkeys-lru
|
command: redis-server --appendonly yes --maxmemory 32mb --maxmemory-policy allkeys-lru
|
||||||
volumes:
|
volumes:
|
||||||
- redis_data:/data
|
- redis_data:/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@ -96,44 +119,18 @@ services:
|
|||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
memory: 192M
|
|
||||||
reservations:
|
|
||||||
memory: 64M
|
memory: 64M
|
||||||
|
reservations:
|
||||||
|
memory: 16M
|
||||||
|
|
||||||
traefik:
|
|
||||||
image: traefik:v2.10
|
|
||||||
command:
|
|
||||||
- "--api.dashboard=true"
|
|
||||||
- "--providers.docker=true"
|
|
||||||
- "--providers.docker.exposedbydefault=false"
|
|
||||||
- "--entrypoints.web.address=:80"
|
|
||||||
- "--entrypoints.web.http.redirections.entryPoint.to=websecure"
|
|
||||||
- "--entrypoints.web.http.redirections.entryPoint.scheme=https"
|
|
||||||
- "--entrypoints.websecure.address=:443"
|
|
||||||
- "--certificatesresolvers.letsencrypt.acme.httpchallenge=true"
|
|
||||||
- "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"
|
|
||||||
- "--certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL}"
|
|
||||||
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
|
|
||||||
ports:
|
|
||||||
- "80:80"
|
|
||||||
- "443:443"
|
|
||||||
volumes:
|
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
|
||||||
- letsencrypt:/letsencrypt
|
|
||||||
networks:
|
|
||||||
- web
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
memory: 128M
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
redis_data:
|
redis_data:
|
||||||
letsencrypt:
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
internal:
|
internal:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
web:
|
web:
|
||||||
driver: bridge
|
name: traefik_web
|
||||||
|
external: true
|
||||||
|
|||||||
148
docker-compose.staging.yml
Normal file
148
docker-compose.staging.yml
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
# Staging Docker Compose for Golf Card Game
|
||||||
|
#
|
||||||
|
# Mirrors production but with reduced memory limits for 512MB droplet.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# docker compose -f docker-compose.staging.yml up -d --build
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
environment:
|
||||||
|
- POSTGRES_URL=postgresql://golf:${DB_PASSWORD}@postgres:5432/golf
|
||||||
|
- DATABASE_URL=postgresql://golf:${DB_PASSWORD}@postgres:5432/golf
|
||||||
|
- REDIS_URL=redis://redis:6379
|
||||||
|
- SECRET_KEY=${SECRET_KEY}
|
||||||
|
- RESEND_API_KEY=${RESEND_API_KEY:-}
|
||||||
|
- EMAIL_FROM=${EMAIL_FROM:-Golf Cards <noreply@contact.golfcards.club>}
|
||||||
|
- SENTRY_DSN=${SENTRY_DSN:-}
|
||||||
|
- ENVIRONMENT=${ENVIRONMENT:-staging}
|
||||||
|
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||||
|
- LOG_LEVEL_GAME=${LOG_LEVEL_GAME:-}
|
||||||
|
- LOG_LEVEL_AI=${LOG_LEVEL_AI:-}
|
||||||
|
- LOG_LEVEL_HANDLERS=${LOG_LEVEL_HANDLERS:-}
|
||||||
|
- LOG_LEVEL_ROOM=${LOG_LEVEL_ROOM:-}
|
||||||
|
- LOG_LEVEL_AUTH=${LOG_LEVEL_AUTH:-}
|
||||||
|
- LOG_LEVEL_STORES=${LOG_LEVEL_STORES:-}
|
||||||
|
- BASE_URL=${BASE_URL:-https://staging.golfcards.club}
|
||||||
|
- RATE_LIMIT_ENABLED=false
|
||||||
|
- INVITE_ONLY=true
|
||||||
|
- DAILY_OPEN_SIGNUPS=${DAILY_OPEN_SIGNUPS:-0}
|
||||||
|
- DAILY_SIGNUPS_PER_IP=${DAILY_SIGNUPS_PER_IP:-3}
|
||||||
|
- BOOTSTRAP_ADMIN_USERNAME=${BOOTSTRAP_ADMIN_USERNAME:-}
|
||||||
|
- BOOTSTRAP_ADMIN_PASSWORD=${BOOTSTRAP_ADMIN_PASSWORD:-}
|
||||||
|
- MATCHMAKING_ENABLED=true
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
deploy:
|
||||||
|
replicas: 1
|
||||||
|
restart_policy:
|
||||||
|
condition: on-failure
|
||||||
|
max_attempts: 3
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 128M
|
||||||
|
reservations:
|
||||||
|
memory: 48M
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
- web
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.docker.network=golfgame_web"
|
||||||
|
- "traefik.http.routers.golf.rule=Host(`${DOMAIN:-staging.golfcards.club}`)"
|
||||||
|
- "traefik.http.routers.golf.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.golf.tls=true"
|
||||||
|
- "traefik.http.routers.golf.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.services.golf.loadbalancer.server.port=8000"
|
||||||
|
- "traefik.http.services.golf.loadbalancer.sticky.cookie=true"
|
||||||
|
- "traefik.http.services.golf.loadbalancer.sticky.cookie.name=golf_server"
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: golf
|
||||||
|
POSTGRES_USER: golf
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U golf -d golf"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 96M
|
||||||
|
reservations:
|
||||||
|
memory: 48M
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
command: redis-server --appendonly yes --maxmemory 16mb --maxmemory-policy allkeys-lru
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 32M
|
||||||
|
reservations:
|
||||||
|
memory: 16M
|
||||||
|
|
||||||
|
traefik:
|
||||||
|
image: traefik:v3.6
|
||||||
|
environment:
|
||||||
|
- DOCKER_API_VERSION=1.44
|
||||||
|
command:
|
||||||
|
- "--api.dashboard=true"
|
||||||
|
- "--api.insecure=true"
|
||||||
|
- "--accesslog=true"
|
||||||
|
- "--log.level=WARN"
|
||||||
|
- "--providers.docker=true"
|
||||||
|
- "--providers.docker.exposedbydefault=false"
|
||||||
|
- "--entrypoints.web.address=:80"
|
||||||
|
- "--entrypoints.web.http.redirections.entryPoint.to=websecure"
|
||||||
|
- "--entrypoints.web.http.redirections.entryPoint.scheme=https"
|
||||||
|
- "--entrypoints.websecure.address=:443"
|
||||||
|
- "--certificatesresolvers.letsencrypt.acme.httpchallenge=true"
|
||||||
|
- "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"
|
||||||
|
- "--certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL}"
|
||||||
|
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
- letsencrypt:/letsencrypt
|
||||||
|
networks:
|
||||||
|
- web
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 48M
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
redis_data:
|
||||||
|
letsencrypt:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
internal:
|
||||||
|
driver: bridge
|
||||||
|
web:
|
||||||
|
driver: bridge
|
||||||
616
docs/ANIMATION-FLOWS.md
Normal file
616
docs/ANIMATION-FLOWS.md
Normal file
@ -0,0 +1,616 @@
|
|||||||
|
# Animation Flow Reference
|
||||||
|
|
||||||
|
Complete reference for how card animations are triggered, sequenced, and cleaned up.
|
||||||
|
All animations use anime.js via the `CardAnimations` class (`client/card-animations.js`).
|
||||||
|
Timing is configured in `client/timing-config.js`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Architecture Overview](#architecture-overview)
|
||||||
|
2. [Animation Flags](#animation-flags)
|
||||||
|
3. [Flow 1: Local Player Draws from Deck](#flow-1-local-player-draws-from-deck)
|
||||||
|
4. [Flow 2: Local Player Draws from Discard](#flow-2-local-player-draws-from-discard)
|
||||||
|
5. [Flow 3: Local Player Swaps](#flow-3-local-player-swaps)
|
||||||
|
6. [Flow 4: Local Player Discards](#flow-4-local-player-discards)
|
||||||
|
7. [Flow 5: Opponent Draws from Deck then Swaps](#flow-5-opponent-draws-from-deck-then-swaps)
|
||||||
|
8. [Flow 6: Opponent Draws from Deck then Discards](#flow-6-opponent-draws-from-deck-then-discards)
|
||||||
|
9. [Flow 7: Opponent Draws from Discard then Swaps](#flow-7-opponent-draws-from-discard-then-swaps)
|
||||||
|
10. [Flow 8: Initial Card Flip](#flow-8-initial-card-flip)
|
||||||
|
11. [Flow 9: Deal Animation](#flow-9-deal-animation)
|
||||||
|
12. [Flow 10: Round End Reveal](#flow-10-round-end-reveal)
|
||||||
|
13. [Flag Lifecycle Summary](#flag-lifecycle-summary)
|
||||||
|
14. [Safety Clears](#safety-clears)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ app.js │
|
||||||
|
│ │
|
||||||
|
│ User Click / WebSocket ──► triggerAnimationsForStateChange │
|
||||||
|
│ │ │ │
|
||||||
|
│ ▼ ▼ │
|
||||||
|
│ Set flags ──────────────► CardAnimations method │
|
||||||
|
│ │ │ │
|
||||||
|
│ ▼ ▼ │
|
||||||
|
│ renderGame() skips anime.js timeline runs │
|
||||||
|
│ flagged elements │ │
|
||||||
|
│ │ ▼ │
|
||||||
|
│ │ Callback fires │
|
||||||
|
│ │ │ │
|
||||||
|
│ ▼ ▼ │
|
||||||
|
│ Flags cleared ◄──────── renderGame() called │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ Normal rendering resumes │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key principle:** Flags block `renderGame()` from updating the DOM while animations are in flight. The animation callback clears flags and triggers a fresh render.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Animation Flags
|
||||||
|
|
||||||
|
Flags in `app.js` that prevent `renderGame()` from updating the discard pile or held card during animations:
|
||||||
|
|
||||||
|
| Flag | Type | Blocks | Purpose |
|
||||||
|
|------|------|--------|---------|
|
||||||
|
| `isDrawAnimating` | bool | Discard pile, held card | Draw animation in progress |
|
||||||
|
| `localDiscardAnimating` | bool | Discard pile | Local player discarding drawn card |
|
||||||
|
| `opponentDiscardAnimating` | bool | Discard pile | Opponent discarding without swap |
|
||||||
|
| `opponentSwapAnimation` | object/null | Discard pile, turn indicator | Opponent swap `{ playerId, position }` |
|
||||||
|
| `dealAnimationInProgress` | bool | Flip prompts | Deal animation running |
|
||||||
|
| `swapAnimationInProgress` | bool | Game state application | Local swap — defers incoming state |
|
||||||
|
|
||||||
|
**renderGame() skip logic:**
|
||||||
|
```
|
||||||
|
if (localDiscardAnimating OR opponentSwapAnimation OR
|
||||||
|
opponentDiscardAnimating OR isDrawAnimating):
|
||||||
|
→ skip discard pile update
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Flow 1: Local Player Draws from Deck
|
||||||
|
|
||||||
|
**Trigger:** User clicks deck
|
||||||
|
|
||||||
|
```
|
||||||
|
User clicks deck
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
drawFromDeck()
|
||||||
|
├─ Validate: isMyTurn(), no drawnCard
|
||||||
|
└─ Send: { type: 'draw', source: 'deck' }
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Server responds: 'card_drawn'
|
||||||
|
├─ Store drawnCard, drawnFromDiscard=false
|
||||||
|
├─ Clear stale flags (opponentSwap, opponentDiscard)
|
||||||
|
├─ SET isDrawAnimating = true
|
||||||
|
└─ hideDrawnCard()
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
cardAnimations.animateDrawDeck(card, callback)
|
||||||
|
│
|
||||||
|
├─ Pulse deck (gold ring)
|
||||||
|
├─ Wait pulseDelay (200ms)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
_animateDrawDeckCard() timeline:
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ 1. Lift off deck (120ms, lift ease) │
|
||||||
|
│ translateY: -15, rotate wobble │
|
||||||
|
│ │
|
||||||
|
│ 2. Move to hold pos (250ms, move ease) │
|
||||||
|
│ left/top to holdingRect │
|
||||||
|
│ │
|
||||||
|
│ 3. Brief pause (80ms) │
|
||||||
|
│ │
|
||||||
|
│ 4. Flip to reveal (320ms, flip ease) │
|
||||||
|
│ rotateY: 180→0, play flip sound │
|
||||||
|
│ │
|
||||||
|
│ 5. View pause (120ms) │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Callback:
|
||||||
|
├─ CLEAR isDrawAnimating = false
|
||||||
|
├─ displayHeldCard(card) with popIn
|
||||||
|
├─ renderGame()
|
||||||
|
└─ Show toast: "Swap with a card or discard"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Total animation time:** ~200 + 120 + 250 + 80 + 320 + 120 = ~1090ms
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Flow 2: Local Player Draws from Discard
|
||||||
|
|
||||||
|
**Trigger:** User clicks discard pile
|
||||||
|
|
||||||
|
```
|
||||||
|
User clicks discard
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
drawFromDiscard()
|
||||||
|
├─ Validate: isMyTurn(), no drawnCard, discard_top exists
|
||||||
|
└─ Send: { type: 'draw', source: 'discard' }
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Server responds: 'card_drawn'
|
||||||
|
├─ Store drawnCard, drawnFromDiscard=true
|
||||||
|
├─ Clear stale flags
|
||||||
|
├─ SET isDrawAnimating = true
|
||||||
|
└─ hideDrawnCard()
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
cardAnimations.animateDrawDiscard(card, callback)
|
||||||
|
│
|
||||||
|
├─ Pulse discard (gold ring)
|
||||||
|
├─ Wait pulseDelay (200ms)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
_animateDrawDiscardCard() timeline:
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ Hide actual discard pile (opacity: 0) │
|
||||||
|
│ │
|
||||||
|
│ 1. Quick lift (80ms, lift ease) │
|
||||||
|
│ translateY: -12, scale: 1.05 │
|
||||||
|
│ │
|
||||||
|
│ 2. Move to hold pos (200ms, move ease) │
|
||||||
|
│ left/top to holdingRect │
|
||||||
|
│ │
|
||||||
|
│ 3. Brief settle (60ms) │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Callback:
|
||||||
|
├─ Restore discard pile opacity
|
||||||
|
├─ CLEAR isDrawAnimating = false
|
||||||
|
├─ displayHeldCard(card) with popIn
|
||||||
|
├─ renderGame()
|
||||||
|
└─ Show toast: "Swap with a card or discard"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Total animation time:** ~200 + 80 + 200 + 60 = ~540ms
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Flow 3: Local Player Swaps
|
||||||
|
|
||||||
|
**Trigger:** User clicks hand card while holding a drawn card
|
||||||
|
|
||||||
|
```
|
||||||
|
User clicks hand card (position N)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
handleCardClick(position)
|
||||||
|
└─ drawnCard exists → animateSwap(position)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
animateSwap(position)
|
||||||
|
├─ SET swapAnimationInProgress = true
|
||||||
|
├─ Hide originals (swap-out class, visibility:hidden)
|
||||||
|
├─ Store drawnCard, clear this.drawnCard
|
||||||
|
├─ SET skipNextDiscardFlip = true
|
||||||
|
└─ Send: { type: 'swap', position }
|
||||||
|
│
|
||||||
|
├──────────────────────────────────┐
|
||||||
|
│ Face-up card? │ Face-down card?
|
||||||
|
▼ ▼
|
||||||
|
Card data known Store pendingSwapData
|
||||||
|
immediately Wait for server response
|
||||||
|
│ │
|
||||||
|
│ ▼
|
||||||
|
│ Server: 'game_state'
|
||||||
|
│ ├─ Detect swapAnimationInProgress
|
||||||
|
│ ├─ Store pendingGameState
|
||||||
|
│ └─ updateSwapAnimation(discard_top)
|
||||||
|
│ │
|
||||||
|
▼──────────────────────────────────▼
|
||||||
|
cardAnimations.animateUnifiedSwap()
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
_doArcSwap() timeline:
|
||||||
|
┌───────────────────────────────────────────┐
|
||||||
|
│ (If face-down: flip first, 320ms) │
|
||||||
|
│ │
|
||||||
|
│ 1. Lift both cards (100ms, lift ease) │
|
||||||
|
│ translateY: -10, scale: 1.03 │
|
||||||
|
│ │
|
||||||
|
│ 2a. Hand card arcs (320ms, arc ease) │
|
||||||
|
│ → discard pile │
|
||||||
|
│ │
|
||||||
|
│ 2b. Held card arcs (320ms, arc ease) │ ← parallel
|
||||||
|
│ → hand slot │ with 2a
|
||||||
|
│ │
|
||||||
|
│ 3. Settle (100ms, settle ease)│
|
||||||
|
│ scale: 1.02→1 (gentle overshoot) │
|
||||||
|
└───────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Callback → completeSwapAnimation()
|
||||||
|
├─ Clean up animation state, remove classes
|
||||||
|
├─ CLEAR swapAnimationInProgress = false
|
||||||
|
├─ Apply pendingGameState if exists
|
||||||
|
└─ renderGame()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Total animation time:** ~100 + 320 + 100 = ~520ms (face-up), ~840ms (face-down)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Flow 4: Local Player Discards
|
||||||
|
|
||||||
|
**Trigger:** User clicks discard button while holding a drawn card
|
||||||
|
|
||||||
|
```
|
||||||
|
User clicks discard button
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
discardDrawn()
|
||||||
|
├─ Store discardedCard
|
||||||
|
├─ Send: { type: 'discard' }
|
||||||
|
├─ Clear drawnCard, hide toast/button
|
||||||
|
├─ Get heldRect (position of floating card)
|
||||||
|
├─ Hide floating held card
|
||||||
|
├─ SET skipNextDiscardFlip = true
|
||||||
|
└─ SET localDiscardAnimating = true
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
cardAnimations.animateHeldToDiscard(card, heldRect, callback)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Timeline:
|
||||||
|
┌───────────────────────────────────────────┐
|
||||||
|
│ 1. Lift (100ms, lift ease) │
|
||||||
|
│ translateY: -8, scale: 1.02 │
|
||||||
|
│ │
|
||||||
|
│ 2. Arc to discard (320ms, arc ease) │
|
||||||
|
│ left/top with arc peak above │
|
||||||
|
│ │
|
||||||
|
│ 3. Settle (100ms, settle ease)│
|
||||||
|
│ scale: 1.02→1 │
|
||||||
|
└───────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Callback:
|
||||||
|
├─ updateDiscardPileDisplay(card)
|
||||||
|
├─ pulseDiscardLand()
|
||||||
|
├─ SET skipNextDiscardFlip = true
|
||||||
|
└─ CLEAR localDiscardAnimating = false
|
||||||
|
```
|
||||||
|
|
||||||
|
**Total animation time:** ~100 + 320 + 100 = ~520ms
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Flow 5: Opponent Draws from Deck then Swaps
|
||||||
|
|
||||||
|
**Trigger:** State change detected via WebSocket `game_state` update
|
||||||
|
|
||||||
|
```
|
||||||
|
Server sends game_state (opponent drew + swapped)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
triggerAnimationsForStateChange(old, new)
|
||||||
|
│
|
||||||
|
├─── STEP 1: Draw Detection ───────────────────────┐
|
||||||
|
│ drawn_card: null → something │
|
||||||
|
│ drawn_player_id != local player │
|
||||||
|
│ Discard unchanged → drew from DECK │
|
||||||
|
│ │
|
||||||
|
│ ├─ Clear stale opponent flags │
|
||||||
|
│ ├─ SET isDrawAnimating = true │
|
||||||
|
│ └─ animateDrawDeck(null, callback) │
|
||||||
|
│ │ │
|
||||||
|
│ └─ Callback: CLEAR isDrawAnimating │
|
||||||
|
│ │
|
||||||
|
├─── STEP 2: Swap Detection ───────────────────────┐
|
||||||
|
│ discard_top changed │
|
||||||
|
│ Previous player's hand has different card │
|
||||||
|
│ NOT justDetectedDraw (skip guard) │
|
||||||
|
│ │
|
||||||
|
│ └─ fireSwapAnimation(playerId, card, pos) │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ SET opponentSwapAnimation = { playerId, pos } │
|
||||||
|
│ Hide source card (swap-out) │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ animateUnifiedSwap() → _doArcSwap() │
|
||||||
|
│ (same timeline as Flow 3) │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ Callback: │
|
||||||
|
│ ├─ Restore source card │
|
||||||
|
│ ├─ CLEAR opponentSwapAnimation = null │
|
||||||
|
│ └─ renderGame() │
|
||||||
|
└───────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** STEP 1 and STEP 2 are detected in the same `triggerAnimationsForStateChange` call. The draw animation fires first; the swap animation fires after (may overlap slightly depending on timing).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Flow 6: Opponent Draws from Deck then Discards
|
||||||
|
|
||||||
|
**Trigger:** State change — opponent drew from deck but didn't swap (discarded drawn card)
|
||||||
|
|
||||||
|
```
|
||||||
|
Server sends game_state (opponent drew + discarded)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
triggerAnimationsForStateChange(old, new)
|
||||||
|
│
|
||||||
|
├─── STEP 1: Draw Detection ──────────────────┐
|
||||||
|
│ (Same as Flow 5 — draw from deck) │
|
||||||
|
│ SET isDrawAnimating = true │
|
||||||
|
│ animateDrawDeck(null, callback) │
|
||||||
|
│ │
|
||||||
|
├─── STEP 2: Discard Detection ────────────────┐
|
||||||
|
│ discard_top changed │
|
||||||
|
│ No hand position changed (no swap) │
|
||||||
|
│ │
|
||||||
|
│ └─ fireDiscardAnimation(card, playerId) │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ SET opponentDiscardAnimating = true │
|
||||||
|
│ SET skipNextDiscardFlip = true │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ animateOpponentDiscard(card, callback) │
|
||||||
|
│ │
|
||||||
|
│ Timeline: │
|
||||||
|
│ ┌────────────────────────────────────────┐ │
|
||||||
|
│ │ (Wait for draw overlay to clear) │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ 1. Lift (100ms, lift ease) │ │
|
||||||
|
│ │ 2. Arc→discard (320ms, arc ease) │ │
|
||||||
|
│ │ 3. Settle (100ms, settle ease) │ │
|
||||||
|
│ └────────────────────────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ Callback: │
|
||||||
|
│ ├─ CLEAR opponentDiscardAnimating = false │
|
||||||
|
│ ├─ updateDiscardPileDisplay(card) │
|
||||||
|
│ └─ pulseDiscardLand() │
|
||||||
|
└───────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Flow 7: Opponent Draws from Discard then Swaps
|
||||||
|
|
||||||
|
**Trigger:** State change — opponent took from discard pile and swapped
|
||||||
|
|
||||||
|
```
|
||||||
|
Server sends game_state (opponent drew from discard + swapped)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
triggerAnimationsForStateChange(old, new)
|
||||||
|
│
|
||||||
|
├─── STEP 1: Draw Detection ──────────────────┐
|
||||||
|
│ drawn_card: null → something │
|
||||||
|
│ Discard top CHANGED → drew from DISCARD │
|
||||||
|
│ │
|
||||||
|
│ ├─ Clear stale opponent flags │
|
||||||
|
│ ├─ SET isDrawAnimating = true │
|
||||||
|
│ └─ animateDrawDiscard(card, callback) │
|
||||||
|
│ │
|
||||||
|
├─── STEP 2: Skip Guard ───────────────────────┐
|
||||||
|
│ justDetectedDraw AND discard changed? │
|
||||||
|
│ YES → SKIP STEP 2 │
|
||||||
|
│ │
|
||||||
|
│ The discard change was from REMOVING a │
|
||||||
|
│ card (draw), not ADDING one (discard). │
|
||||||
|
│ The swap detection comes from a LATER │
|
||||||
|
│ state update when the turn completes. │
|
||||||
|
└───────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
(Next state update detects the swap via STEP 2)
|
||||||
|
└─ fireSwapAnimation() — same as Flow 5
|
||||||
|
```
|
||||||
|
|
||||||
|
**Critical:** The skip guard (`!justDetectedDraw`) prevents double-animating when an opponent draws from the discard pile. Without it, the discard change would trigger both a draw animation AND a discard animation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Flow 8: Initial Card Flip
|
||||||
|
|
||||||
|
**Trigger:** User clicks face-down card during the initial flip phase (start of round)
|
||||||
|
|
||||||
|
```
|
||||||
|
User clicks face-down card (position N)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
handleCardClick(position)
|
||||||
|
├─ Check: waiting_for_initial_flip
|
||||||
|
├─ Validate: card is face-down, not already tracked
|
||||||
|
├─ Add to locallyFlippedCards set
|
||||||
|
├─ Add to selectedCards array
|
||||||
|
└─ fireLocalFlipAnimation(position, card)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
fireLocalFlipAnimation()
|
||||||
|
├─ Add to animatingPositions set (prevent overlap)
|
||||||
|
└─ cardAnimations.animateInitialFlip(cardEl, card, callback)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Timeline:
|
||||||
|
┌──────────────────────────────────┐
|
||||||
|
│ Create overlay at card position │
|
||||||
|
│ Hide original (opacity: 0) │
|
||||||
|
│ │
|
||||||
|
│ 1. Flip (320ms, flip) │
|
||||||
|
│ rotateY: 180→0 │
|
||||||
|
│ Play flip sound │
|
||||||
|
└──────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Callback:
|
||||||
|
├─ Remove overlay, restore original
|
||||||
|
└─ Remove from animatingPositions
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
renderGame() (called after click)
|
||||||
|
└─ Shows flipped state immediately (optimistic)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
(If all required flips selected)
|
||||||
|
└─ Send: { type: 'flip_cards', positions: [...] }
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Server confirms → clear locallyFlippedCards
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Flow 9: Deal Animation
|
||||||
|
|
||||||
|
**Trigger:** `game_started` or `round_started` WebSocket message
|
||||||
|
|
||||||
|
```
|
||||||
|
Server: 'game_started' / 'round_started'
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Reset all state, cancel animations
|
||||||
|
SET dealAnimationInProgress = true
|
||||||
|
renderGame() — layout card slots
|
||||||
|
Hide player/opponent cards (visibility: hidden)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
cardAnimations.animateDealing(gameState, getPlayerRect, callback)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ Shuffle pause (400ms) │
|
||||||
|
│ │
|
||||||
|
│ For each deal round (6 total): │
|
||||||
|
│ For each player (dealer's left first): │
|
||||||
|
│ ┌─────────────────────────────────────┐ │
|
||||||
|
│ │ Create overlay at deck position │ │
|
||||||
|
│ │ Fly to player card slot (150ms) │ │
|
||||||
|
│ │ Play card sound │ │
|
||||||
|
│ │ Stagger delay (80ms) │ │
|
||||||
|
│ └─────────────────────────────────────┘ │
|
||||||
|
│ Round pause (50ms) │
|
||||||
|
│ │
|
||||||
|
│ Wait for last cards to land │
|
||||||
|
│ Flip discard card (200ms delay + flip sound) │
|
||||||
|
│ Clean up all overlays │
|
||||||
|
└─────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Callback:
|
||||||
|
├─ CLEAR dealAnimationInProgress = false
|
||||||
|
├─ Show real cards (visibility: visible)
|
||||||
|
├─ renderGame()
|
||||||
|
└─ animateOpponentInitialFlips()
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ For each opponent: │
|
||||||
|
│ Random delay (500-2500ms window) │
|
||||||
|
│ For each face-up card: │
|
||||||
|
│ Temporarily show as face-down │
|
||||||
|
│ animateOpponentFlip() (320ms) │
|
||||||
|
│ Stagger (400ms between cards) │
|
||||||
|
└─────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Total deal time:** ~400 + (6 rounds x players x 230ms) + 350ms flip
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Flow 10: Round End Reveal
|
||||||
|
|
||||||
|
**Trigger:** `round_over` WebSocket message after round ends
|
||||||
|
|
||||||
|
```
|
||||||
|
Server: 'game_state' (phase → 'round_over')
|
||||||
|
├─ Detect roundJustEnded
|
||||||
|
├─ Save pre/post reveal states
|
||||||
|
└─ Update gameState but DON'T render
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Server: 'round_over' (scores, rankings)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
runRoundEndReveal(scores, rankings)
|
||||||
|
├─ SET revealAnimationInProgress = true
|
||||||
|
├─ renderGame() — show current layout
|
||||||
|
├─ Compute cardsToReveal (face-down → face-up)
|
||||||
|
└─ Get reveal order (knocker first, then clockwise)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────────┐
|
||||||
|
│ For each player (in reveal order): │
|
||||||
|
│ Highlight player area │
|
||||||
|
│ Pause (200ms) │
|
||||||
|
│ │
|
||||||
|
│ For each face-down card: │
|
||||||
|
│ animateRevealFlip(id, pos, card) │
|
||||||
|
│ ├─ Local: animateInitialFlip (320ms) │
|
||||||
|
│ └─ Opponent: animateOpponentFlip │
|
||||||
|
│ Stagger (100ms) │
|
||||||
|
│ │
|
||||||
|
│ Wait for last flip + pause │
|
||||||
|
│ Remove highlight │
|
||||||
|
└──────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
CLEAR revealAnimationInProgress = false
|
||||||
|
renderGame()
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Run score tally animation
|
||||||
|
Show scoreboard
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Flag Lifecycle Summary
|
||||||
|
|
||||||
|
Every flag follows the same pattern: **SET before animation, CLEAR in callback**.
|
||||||
|
|
||||||
|
```
|
||||||
|
SET flag ──► Animation runs ──► Callback fires ──► CLEAR flag
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
renderGame()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Where each flag is cleared
|
||||||
|
|
||||||
|
| Flag | Normal Clear | Safety Clears |
|
||||||
|
|------|-------------|---------------|
|
||||||
|
| `isDrawAnimating` | Draw animation callback | — |
|
||||||
|
| `localDiscardAnimating` | Discard animation callback | Fallback path |
|
||||||
|
| `opponentDiscardAnimating` | Opponent discard callback | `your_turn`, `card_drawn`, before opponent draw |
|
||||||
|
| `opponentSwapAnimation` | Swap animation callback | `your_turn`, `card_drawn`, before opponent draw, new round |
|
||||||
|
| `dealAnimationInProgress` | Deal complete callback | — |
|
||||||
|
| `swapAnimationInProgress` | `completeSwapAnimation()` | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Safety Clears
|
||||||
|
|
||||||
|
Stale flags can freeze the UI. Multiple locations clear opponent flags as a safety net:
|
||||||
|
|
||||||
|
| Location | Clears | When |
|
||||||
|
|----------|--------|------|
|
||||||
|
| `your_turn` message handler | `opponentSwapAnimation`, `opponentDiscardAnimating` | Player's turn starts |
|
||||||
|
| `card_drawn` handler (deck) | `opponentSwapAnimation`, `opponentDiscardAnimating` | Local player draws |
|
||||||
|
| `card_drawn` handler (discard) | `opponentSwapAnimation`, `opponentDiscardAnimating` | Local player draws |
|
||||||
|
| Before opponent draw animation | `opponentSwapAnimation`, `opponentDiscardAnimating` | New opponent animation starts |
|
||||||
|
| `game_started`/`round_started` | All flags | New round resets everything |
|
||||||
|
|
||||||
|
**Rule:** If you add a new animation flag, add safety clears in the `your_turn` handler and at round start.
|
||||||
77
docs/BUG-kicked-ball-position.md
Normal file
77
docs/BUG-kicked-ball-position.md
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
# BUG: Kicked ball animation starts from golfer's back foot
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The `⚪` kicked ball animation (`.kicked-ball`) appears to launch from the golfer's **back foot** (left side) instead of the **front foot** (right side). The golfer faces right in both landscape (two-row) and mobile (single-line) views due to `scaleX(-1)`.
|
||||||
|
|
||||||
|
## What we want
|
||||||
|
|
||||||
|
The ball should appear at the golfer's front foot (right side) and arc up and to the right — matching the "good" landscape behavior seen at wide desktop widths (~1100px+).
|
||||||
|
|
||||||
|
## Good reference
|
||||||
|
|
||||||
|
- Video: `good.mp4` (landscape wide view)
|
||||||
|
- Extracted frames: `/tmp/golf-frames-good/`
|
||||||
|
- Frame 025: Ball clearly appears to the RIGHT of the golfer, arcing up-right
|
||||||
|
|
||||||
|
## Bad behavior
|
||||||
|
|
||||||
|
- Videos: `Screencast_20260224_005555.mp4`, `Screencast_20260224_013326.mp4`
|
||||||
|
- The ball appears to the LEFT of the golfer (between the golf ball logo and golfer emoji)
|
||||||
|
- Happens at the user's phone viewport width (two-row layout, inline-grid)
|
||||||
|
|
||||||
|
## Root cause analysis
|
||||||
|
|
||||||
|
### The scaleX(-1) offset problem
|
||||||
|
|
||||||
|
The golfer emoji (`.golfer-swing`) has `transform: scaleX(-1)` which flips it visually. This means:
|
||||||
|
- The golfer's **layout box** occupies the same inline flow position
|
||||||
|
- But the **visual** left/right is flipped — the front foot (visually on the right) is at the LEFT edge of the layout box
|
||||||
|
- The `.kicked-ball` span comes right after `.golfer-swing` in inline flow, so its natural position is at the **right edge** of the golfer's layout box
|
||||||
|
- But due to `scaleX(-1)`, the right edge of the layout box is the golfer's **visual back** (left side)
|
||||||
|
- So `translate(0, 0)` places the ball at the golfer's back, not front
|
||||||
|
|
||||||
|
### CSS translate values tested
|
||||||
|
|
||||||
|
| Start X | Result |
|
||||||
|
|---------|--------|
|
||||||
|
| `-30px` (original) | Ball appears way behind golfer (further left) |
|
||||||
|
| `+20px` | Ball still appears to LEFT of golfer, but slightly closer |
|
||||||
|
| `+80px` | Not confirmed (staging 404 during test) |
|
||||||
|
|
||||||
|
### Key finding: The kicked-ball's natural position needs ~60-80px positive X offset to reach the golfer's visual front foot
|
||||||
|
|
||||||
|
The golfer emoji is roughly 30-40px wide at this viewport. Since `scaleX(-1)` flips the visual, the ball needs to translate **past the entire emoji width** to reach the visual front.
|
||||||
|
|
||||||
|
### Media query issues encountered
|
||||||
|
|
||||||
|
1. First attempt: Added `ball-kicked-mobile` keyframes with `@media (max-width: 500px)` override
|
||||||
|
2. **CSS source order bug**: The mobile override at line 144 was being overridden by the base `.kicked-ball` rule at line 216 (later = higher priority at equal specificity)
|
||||||
|
3. Moved override after base rule — still didn't work
|
||||||
|
4. Added `!important` — still didn't work
|
||||||
|
5. Raised breakpoint from 500px to 768px, then 1200px — still no visible change
|
||||||
|
6. **Breakthrough**: Added `outline: 3px solid red; background: yellow` debug styles to base `.kicked-ball` — these DID appear, confirming CSS was loading
|
||||||
|
7. Changed base `ball-kicked` keyframes from `-30px` to `+20px` — ball DID move, confirming the base keyframes are what's being used
|
||||||
|
8. The mobile override keyframes may never have been applied (unclear if `ball-kicked-mobile` was actually used)
|
||||||
|
|
||||||
|
### What the Chrome extension Claude analysis said
|
||||||
|
|
||||||
|
> "The breakpoint is 500px, but the viewport is above 500px. At 700px+, ball-kicked-mobile never kicks in — it still uses the desktop ball-kicked animation. But the layout at this width has already shifted to a more centered layout which changes where .kicked-ball is positioned relative to the golfer."
|
||||||
|
|
||||||
|
## Suggested fix approach
|
||||||
|
|
||||||
|
1. **Don't use separate mobile keyframes** — just fix the base `ball-kicked` to work at all viewport widths
|
||||||
|
2. The starting X needs to be **much larger positive** (60-80px) to account for `scaleX(-1)` placing the natural position at the golfer's visual back
|
||||||
|
3. Alternatively, restructure the HTML: move `.kicked-ball` BEFORE `.golfer-swing` in the DOM, so its natural inline position is at the golfer's visual front (since scaleX(-1) flips left/right)
|
||||||
|
4. Or use `position: absolute` on `.kicked-ball` and position it relative to the golfer container explicitly
|
||||||
|
|
||||||
|
## Files involved
|
||||||
|
|
||||||
|
- `client/style.css` — `.kicked-ball`, `@keyframes ball-kicked`, `.golfer-swing`
|
||||||
|
- `client/index.html` — line 19: `<span class="golfer-swing">🏌️</span><span class="kicked-ball">⚪</span>`
|
||||||
|
|
||||||
|
## Resolution (v3.1.6)
|
||||||
|
|
||||||
|
**Fixed** by wrapping `.golfer-swing` + `.kicked-ball` in a `.golfer-container` span with `position: relative`, and changing `.kicked-ball` from `position: relative` to `position: absolute; right: -8px; bottom: 30%`. This anchors the ball to the golfer's front foot regardless of viewport width or inline flow layout.
|
||||||
|
|
||||||
|
Also fixed a **CSS source order bug** where the base `.golfer-container` rule was defined after the `@media (max-width: 500px)` override, clobbering the mobile margin-left value.
|
||||||
317
docs/v2/V2_08_GAME_LOGGING.md
Normal file
317
docs/v2/V2_08_GAME_LOGGING.md
Normal file
@ -0,0 +1,317 @@
|
|||||||
|
# V2-08: Unified Game Logging
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document covers the unified PostgreSQL game logging system that replaces
|
||||||
|
the legacy SQLite `game_log.py`. All game events and AI decisions are logged
|
||||||
|
to PostgreSQL for analysis, replay, and cloud deployment.
|
||||||
|
|
||||||
|
**Dependencies:** V2-01 (Event Sourcing), V2-02 (Persistence)
|
||||||
|
**Dependents:** Game Analyzer, Stats Dashboard
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. Consolidate all game data in PostgreSQL (drop SQLite dependency)
|
||||||
|
2. Preserve AI decision context for analysis
|
||||||
|
3. Maintain compatibility with existing services (Stats, Replay, Recovery)
|
||||||
|
4. Enable efficient queries for game analysis
|
||||||
|
5. Support cloud deployment without local file dependencies
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐
|
||||||
|
│ Game Server │
|
||||||
|
│ (main.py) │
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
┌───────────────────┼───────────────────┐
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
|
||||||
|
│ GameLogger │ │ EventStore │ │ StatsService │
|
||||||
|
│ Service │ │ (events) │ │ ReplayService │
|
||||||
|
└───────┬───────┘ └───────────────┘ └───────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌───────────────────────────────────────────────────┐
|
||||||
|
│ PostgreSQL │
|
||||||
|
│ ┌─────────┐ ┌───────────┐ ┌──────────────┐ │
|
||||||
|
│ │ games_v2│ │ events │ │ moves │ │
|
||||||
|
│ │ metadata│ │ (actions) │ │ (AI context) │ │
|
||||||
|
│ └─────────┘ └───────────┘ └──────────────┘ │
|
||||||
|
└───────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
### moves Table (New)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS moves (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
game_id UUID NOT NULL,
|
||||||
|
sequence_num INT NOT NULL,
|
||||||
|
timestamp TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
player_id VARCHAR(50) NOT NULL,
|
||||||
|
player_name VARCHAR(100),
|
||||||
|
is_cpu BOOLEAN DEFAULT FALSE,
|
||||||
|
|
||||||
|
-- Action details
|
||||||
|
action VARCHAR(30) NOT NULL, -- draw_deck, take_discard, swap, discard, flip, etc.
|
||||||
|
card_rank VARCHAR(5),
|
||||||
|
card_suit VARCHAR(10),
|
||||||
|
position INT,
|
||||||
|
|
||||||
|
-- AI context (JSONB for flexibility)
|
||||||
|
hand_state JSONB, -- Player's hand at decision time
|
||||||
|
discard_top JSONB, -- Top of discard pile
|
||||||
|
visible_opponents JSONB, -- Face-up cards of opponents
|
||||||
|
decision_reason TEXT, -- AI reasoning
|
||||||
|
|
||||||
|
UNIQUE(game_id, sequence_num)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_moves_game ON moves(game_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_moves_action ON moves(action);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_moves_is_cpu ON moves(is_cpu);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_moves_player ON moves(player_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Action Types
|
||||||
|
|
||||||
|
| Action | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `draw_deck` | Player drew from deck |
|
||||||
|
| `take_discard` | Player took top of discard pile |
|
||||||
|
| `swap` | Player swapped drawn card with hand card |
|
||||||
|
| `discard` | Player discarded drawn card |
|
||||||
|
| `flip` | Player flipped a card after discarding |
|
||||||
|
| `skip_flip` | Player skipped optional flip (endgame) |
|
||||||
|
| `flip_as_action` | Player used flip-as-action house rule |
|
||||||
|
| `knock_early` | Player knocked to end round early |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GameLogger Service
|
||||||
|
|
||||||
|
**Location:** `/server/services/game_logger.py`
|
||||||
|
|
||||||
|
### API
|
||||||
|
|
||||||
|
```python
|
||||||
|
class GameLogger:
|
||||||
|
"""Logs game events and moves to PostgreSQL."""
|
||||||
|
|
||||||
|
def __init__(self, event_store: EventStore):
|
||||||
|
"""Initialize with event store instance."""
|
||||||
|
|
||||||
|
def log_game_start(
|
||||||
|
self,
|
||||||
|
room_code: str,
|
||||||
|
num_players: int,
|
||||||
|
options: GameOptions,
|
||||||
|
) -> str:
|
||||||
|
"""Log game start, returns game_id."""
|
||||||
|
|
||||||
|
def log_move(
|
||||||
|
self,
|
||||||
|
game_id: str,
|
||||||
|
player: Player,
|
||||||
|
is_cpu: bool,
|
||||||
|
action: str,
|
||||||
|
card: Optional[Card] = None,
|
||||||
|
position: Optional[int] = None,
|
||||||
|
game: Optional[Game] = None,
|
||||||
|
decision_reason: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Log a move with AI context."""
|
||||||
|
|
||||||
|
def log_game_end(self, game_id: str) -> None:
|
||||||
|
"""Mark game as ended."""
|
||||||
|
```
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
```python
|
||||||
|
# In main.py lifespan
|
||||||
|
from services.game_logger import GameLogger, set_logger
|
||||||
|
|
||||||
|
_event_store = await get_event_store(config.POSTGRES_URL)
|
||||||
|
_game_logger = GameLogger(_event_store)
|
||||||
|
set_logger(_game_logger)
|
||||||
|
|
||||||
|
# In handlers
|
||||||
|
from services.game_logger import get_logger
|
||||||
|
|
||||||
|
game_logger = get_logger()
|
||||||
|
if game_logger:
|
||||||
|
game_logger.log_move(
|
||||||
|
game_id=room.game_log_id,
|
||||||
|
player=player,
|
||||||
|
is_cpu=False,
|
||||||
|
action="swap",
|
||||||
|
card=drawn_card,
|
||||||
|
position=position,
|
||||||
|
game=room.game,
|
||||||
|
decision_reason="swapped 5 into position 2",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Query Patterns
|
||||||
|
|
||||||
|
### Find Suspicious Discards
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Using EventStore
|
||||||
|
blunders = await event_store.find_suspicious_discards(limit=50)
|
||||||
|
```
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Direct SQL
|
||||||
|
SELECT m.*, g.room_code
|
||||||
|
FROM moves m
|
||||||
|
JOIN games_v2 g ON m.game_id = g.id
|
||||||
|
WHERE m.action = 'discard'
|
||||||
|
AND m.card_rank IN ('A', '2', 'K')
|
||||||
|
AND m.is_cpu = TRUE
|
||||||
|
ORDER BY m.timestamp DESC
|
||||||
|
LIMIT 50;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Player Decisions
|
||||||
|
|
||||||
|
```python
|
||||||
|
moves = await event_store.get_player_decisions(game_id, player_name)
|
||||||
|
```
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT * FROM moves
|
||||||
|
WHERE game_id = $1 AND player_name = $2
|
||||||
|
ORDER BY sequence_num;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Recent Games with Stats
|
||||||
|
|
||||||
|
```python
|
||||||
|
games = await event_store.get_recent_games_with_stats(limit=10)
|
||||||
|
```
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT g.*, COUNT(m.id) as total_moves
|
||||||
|
FROM games_v2 g
|
||||||
|
LEFT JOIN moves m ON g.id = m.game_id
|
||||||
|
GROUP BY g.id
|
||||||
|
ORDER BY g.created_at DESC
|
||||||
|
LIMIT 10;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration from SQLite
|
||||||
|
|
||||||
|
### Removed Files
|
||||||
|
|
||||||
|
- `/server/game_log.py` - Replaced by `/server/services/game_logger.py`
|
||||||
|
- `/server/games.db` - Data now in PostgreSQL
|
||||||
|
|
||||||
|
### Updated Files
|
||||||
|
|
||||||
|
| File | Changes |
|
||||||
|
|------|---------|
|
||||||
|
| `main.py` | Import from `services.game_logger`, init in lifespan |
|
||||||
|
| `ai.py` | Import from `services.game_logger` |
|
||||||
|
| `simulate.py` | Removed logging, uses in-memory SimulationStats only |
|
||||||
|
| `game_analyzer.py` | CLI updated for PostgreSQL, class deprecated |
|
||||||
|
| `stores/event_store.py` | Added `moves` table and query methods |
|
||||||
|
|
||||||
|
### Simulation Mode
|
||||||
|
|
||||||
|
Simulations (`simulate.py`) no longer write to the database. They use in-memory
|
||||||
|
`SimulationStats` for analysis. This keeps simulations fast and avoids flooding
|
||||||
|
the database with bulk test runs.
|
||||||
|
|
||||||
|
For simulation analysis:
|
||||||
|
```bash
|
||||||
|
python simulate.py 100 --preset baseline
|
||||||
|
# Stats printed to console
|
||||||
|
```
|
||||||
|
|
||||||
|
For production game analysis:
|
||||||
|
```bash
|
||||||
|
python game_analyzer.py blunders 20
|
||||||
|
python game_analyzer.py recent 10
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
1. **PostgreSQL Integration**
|
||||||
|
- [x] moves table created with proper indexes
|
||||||
|
- [x] All game actions logged to PostgreSQL via GameLogger
|
||||||
|
- [x] EventStore has append_move() and query methods
|
||||||
|
|
||||||
|
2. **Service Compatibility**
|
||||||
|
- [x] StatsService still works (uses events table)
|
||||||
|
- [x] ReplayService still works (uses events table)
|
||||||
|
- [x] RecoveryService still works (uses events table)
|
||||||
|
|
||||||
|
3. **Simulation Mode**
|
||||||
|
- [x] simulate.py works without PostgreSQL
|
||||||
|
- [x] In-memory SimulationStats provides analysis
|
||||||
|
|
||||||
|
4. **SQLite Removal**
|
||||||
|
- [x] game_log.py can be deleted
|
||||||
|
- [x] games.db can be deleted
|
||||||
|
- [x] No sqlite3 imports in main game code
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
### Async/Sync Bridging
|
||||||
|
|
||||||
|
The GameLogger provides sync methods (`log_move`, `log_game_start`) that
|
||||||
|
internally fire async tasks. This allows existing sync code paths to call
|
||||||
|
the logger without blocking:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def log_move(self, game_id, ...):
|
||||||
|
if not game_id:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
asyncio.create_task(self.log_move_async(...))
|
||||||
|
except RuntimeError:
|
||||||
|
# Not in async context - skip (simulations)
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fire-and-Forget Logging
|
||||||
|
|
||||||
|
Move logging uses fire-and-forget async tasks to avoid blocking game logic.
|
||||||
|
This means:
|
||||||
|
- Logging failures don't crash the game
|
||||||
|
- Slight delay between action and database write is acceptable
|
||||||
|
- No acknowledgment that log succeeded
|
||||||
|
|
||||||
|
For critical data, use the events table which is the source of truth.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes for Developers
|
||||||
|
|
||||||
|
- The `moves` table is denormalized for efficient queries
|
||||||
|
- The `events` table remains the source of truth for game replay
|
||||||
|
- GameLogger is None when PostgreSQL is not configured (no logging)
|
||||||
|
- Always check `if game_logger:` before calling methods
|
||||||
|
- For quick development testing, use simulate.py without database
|
||||||
291
docs/v3/V3_00_MASTER_PLAN.md
Normal file
291
docs/v3/V3_00_MASTER_PLAN.md
Normal file
@ -0,0 +1,291 @@
|
|||||||
|
# Golf Card Game - V3 Master Plan
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Transform the current Golf card game into a more natural, physical-feeling experience through enhanced animations, visual feedback, and gameplay flow improvements. The goal is to make the digital game feel as satisfying as playing with real cards.
|
||||||
|
|
||||||
|
**Theme:** "Make it feel like a real card game"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Document Structure (VDD)
|
||||||
|
|
||||||
|
This plan is split into independent vertical slices ordered by priority and impact. Each document is self-contained and can be worked on by a separate agent.
|
||||||
|
|
||||||
|
| Document | Scope | Priority | Effort | Dependencies |
|
||||||
|
|----------|-------|----------|--------|--------------|
|
||||||
|
| `V3_01_DEALER_ROTATION.md` | Rotate dealer/first player each round | High | Low | None (server change) |
|
||||||
|
| `V3_02_DEALING_ANIMATION.md` | Animated card dealing at round start | High | Medium | 01 |
|
||||||
|
| `V3_03_ROUND_END_REVEAL.md` | Dramatic sequential card reveal | High | Medium | None |
|
||||||
|
| `V3_04_COLUMN_PAIR_CELEBRATION.md` | Visual feedback for matching pairs | High | Low | None |
|
||||||
|
| `V3_05_FINAL_TURN_URGENCY.md` | Enhanced final turn visual tension | High | Low | None |
|
||||||
|
| `V3_06_OPPONENT_THINKING.md` | Visible opponent consideration phase | Medium | Low | None |
|
||||||
|
| `V3_07_SCORE_TALLYING.md` | Animated score counting | Medium | Medium | 03 |
|
||||||
|
| `V3_08_CARD_HOVER_SELECTION.md` | Enhanced card selection preview | Medium | Low | None |
|
||||||
|
| `V3_09_KNOCK_EARLY_DRAMA.md` | Dramatic knock early presentation | Medium | Low | None |
|
||||||
|
| `V3_10_COLUMN_PAIR_INDICATOR.md` | Visual connector for paired columns | Medium | Low | 04 |
|
||||||
|
| `V3_11_SWAP_ANIMATION_IMPROVEMENTS.md` | More physical swap motion | Medium | Medium | None |
|
||||||
|
| `V3_12_DRAW_SOURCE_DISTINCTION.md` | Visual distinction deck vs discard draw | Low | Low | None |
|
||||||
|
| `V3_13_CARD_VALUE_TOOLTIPS.md` | Long-press card value display | Low | Medium | None |
|
||||||
|
| `V3_14_ACTIVE_RULES_CONTEXT.md` | Contextual rule highlighting | Low | Low | None |
|
||||||
|
| `V3_15_DISCARD_PILE_HISTORY.md` | Show recent discards fanned | Low | Medium | None |
|
||||||
|
| `V3_16_REALISTIC_CARD_SOUNDS.md` | Improved audio feedback | Nice | Medium | None |
|
||||||
|
| `V3_17_MOBILE_PORTRAIT_LAYOUT.md` | Full mobile portrait layout + animation fixes | High | High | 02, 11 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current State (V2)
|
||||||
|
|
||||||
|
```
|
||||||
|
Client (Vanilla JS)
|
||||||
|
├── app.js - Main game logic (2500+ lines)
|
||||||
|
├── card-manager.js - DOM card element management (3D flip structure)
|
||||||
|
├── animation-queue.js - Sequential animation processing
|
||||||
|
├── card-animations.js - Unified anime.js animation system (replaces draw-animations.js)
|
||||||
|
├── state-differ.js - State change detection
|
||||||
|
├── timing-config.js - Centralized animation timing + anime.js easing config
|
||||||
|
├── anime.min.js - Anime.js library for all animations
|
||||||
|
└── style.css - Minimal CSS, mostly layout
|
||||||
|
```
|
||||||
|
|
||||||
|
**What works well:**
|
||||||
|
- **Unified anime.js system** - All card animations use `window.cardAnimations` (CardAnimations class)
|
||||||
|
- State diffing detects changes and triggers appropriate animations
|
||||||
|
- Animation queue ensures sequential, non-overlapping animations
|
||||||
|
- Centralized timing config with anime.js easing presets (`TIMING.anime.easing`)
|
||||||
|
- Sound effects via Web Audio API
|
||||||
|
- CardAnimations provides: draw, flip, swap, discard, ambient loops (turn pulse, CPU thinking)
|
||||||
|
- Opponent turn visibility with CPU action announcements
|
||||||
|
|
||||||
|
**Limitations:**
|
||||||
|
- Cards appear instantly at round start (no dealing animation)
|
||||||
|
- Round end reveals all cards simultaneously
|
||||||
|
- No visual celebration for column pairs
|
||||||
|
- Final turn phase lacks urgency/tension
|
||||||
|
- Swap animation uses crossfade rather than physical motion
|
||||||
|
- Limited feedback during card selection
|
||||||
|
- Discard pile shows only top card
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## V3 Target Experience
|
||||||
|
|
||||||
|
### Physical Card Game Feel Checklist
|
||||||
|
|
||||||
|
| Aspect | Physical Game | Current Digital | V3 Target |
|
||||||
|
|--------|---------------|-----------------|-----------|
|
||||||
|
| **Dealer Rotation** | Deal passes clockwise each round | Always starts with host | Rotating dealer/first player |
|
||||||
|
| **Dealing** | Cards dealt one at a time | Cards appear instantly | Animated dealing sequence |
|
||||||
|
| **Drawing** | Card lifts, player considers | Card pops in | Source-appropriate pickup |
|
||||||
|
| **Swapping** | Old card slides out, new slides in | Teleport swap | Cross-over motion |
|
||||||
|
| **Pairing** | "Nice!" moment when match noticed | No feedback | Visual celebration |
|
||||||
|
| **Round End** | Dramatic reveal, one player at a time | All cards flip at once | Staggered reveal |
|
||||||
|
| **Scoring** | Count card by card | Score appears | Animated tally |
|
||||||
|
| **Final Turn** | Tension in the room | Badge shows | Visual urgency |
|
||||||
|
| **Sounds** | Shuffle, flip, slap | Synth beeps | Realistic card sounds |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tech Approach
|
||||||
|
|
||||||
|
### Animation Strategy
|
||||||
|
|
||||||
|
All **card animations** use the unified `CardAnimations` class (`card-animations.js`):
|
||||||
|
- **Anime.js timelines** for all card animations (flip, swap, draw, discard)
|
||||||
|
- **CardAnimations methods** - `animateDrawDeck()`, `animateFlip()`, `animateSwap()`, etc.
|
||||||
|
- **Ambient loops** - `startTurnPulse()`, `startCpuThinking()`, `startInitialFlipPulse()`
|
||||||
|
- **One-shot effects** - `pulseDiscard()`, `pulseSwap()`, `popIn()`
|
||||||
|
- **Animation queue** for sequencing multi-step animations
|
||||||
|
- **State differ** to trigger animations on state changes
|
||||||
|
|
||||||
|
**When to use CSS vs anime.js:**
|
||||||
|
- **Anime.js (CardAnimations)**: Card movements, flips, swaps, draws - anything involving card elements
|
||||||
|
- **CSS keyframes/transitions**: Simple UI feedback (button hover, badge entrance, status message fades) - non-card elements
|
||||||
|
|
||||||
|
**General rule:** If it moves a card, use anime.js. If it's UI chrome, CSS is fine.
|
||||||
|
|
||||||
|
### Timing Philosophy
|
||||||
|
|
||||||
|
From `timing-config.js`:
|
||||||
|
```javascript
|
||||||
|
// Current values - animations are smooth but quick
|
||||||
|
card: {
|
||||||
|
flip: 400, // Card flip duration
|
||||||
|
move: 400, // Card movement
|
||||||
|
},
|
||||||
|
pause: {
|
||||||
|
afterFlip: 0, // No pause - flow into next action
|
||||||
|
betweenAnimations: 0, // No gaps
|
||||||
|
},
|
||||||
|
// Anime.js easing presets
|
||||||
|
anime: {
|
||||||
|
easing: {
|
||||||
|
flip: 'easeInOutQuad',
|
||||||
|
move: 'easeOutCubic',
|
||||||
|
lift: 'easeOutQuad',
|
||||||
|
pulse: 'easeInOutSine',
|
||||||
|
},
|
||||||
|
loop: {
|
||||||
|
turnPulse: { duration: 2000 },
|
||||||
|
cpuThinking: { duration: 1500 },
|
||||||
|
initialFlipGlow: { duration: 1500 },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
V3 will introduce **optional pauses for drama** without slowing normal gameplay:
|
||||||
|
- Quick pauses at key moments (pair formed, round end)
|
||||||
|
- Staggered timing for dealing/reveal (perceived faster than actual)
|
||||||
|
- User preference for animation speed (future consideration)
|
||||||
|
|
||||||
|
### Sound Strategy
|
||||||
|
|
||||||
|
Current sounds are oscillator-based (Web Audio API synthesis). V3 options:
|
||||||
|
1. **Enhanced synthesis** - More realistic waveforms, envelopes
|
||||||
|
2. **Audio sprites** - Short recordings of real card sounds
|
||||||
|
3. **Hybrid** - Synthesis for some, samples for others
|
||||||
|
|
||||||
|
Recommendation: Start with enhanced synthesis (no asset loading), consider audio sprites later.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phases & Milestones
|
||||||
|
|
||||||
|
### Phase 1: Core Feel (High Priority)
|
||||||
|
**Goal:** Make the game feel noticeably more physical
|
||||||
|
|
||||||
|
| Item | Description | Document |
|
||||||
|
|------|-------------|----------|
|
||||||
|
| Dealer rotation | First player rotates each round (like real cards) | 01 |
|
||||||
|
| Dealing animation | Cards dealt sequentially at round start | 02 |
|
||||||
|
| Round end reveal | Dramatic staggered flip at round end | 03 |
|
||||||
|
| Column pair celebration | Glow/pulse when pairs form | 04 |
|
||||||
|
| Final turn urgency | Visual tension enhancement | 05 |
|
||||||
|
|
||||||
|
### Phase 2: Turn Polish (Medium Priority)
|
||||||
|
**Goal:** Improve the feel of individual turns
|
||||||
|
|
||||||
|
| Item | Description | Document |
|
||||||
|
|------|-------------|----------|
|
||||||
|
| Opponent thinking | Visible consideration phase | 06 |
|
||||||
|
| Score tallying | Animated counting | 07 |
|
||||||
|
| Card hover/selection | Better swap preview | 08 |
|
||||||
|
| Knock early drama | Dramatic knock presentation | 09 |
|
||||||
|
| Column pair indicator | Visual pair connector | 10 |
|
||||||
|
| Swap improvements | Physical swap motion | 11 |
|
||||||
|
|
||||||
|
### Phase 3: Polish & Extras (Low Priority)
|
||||||
|
**Goal:** Nice-to-have improvements
|
||||||
|
|
||||||
|
| Item | Description | Document |
|
||||||
|
|------|-------------|----------|
|
||||||
|
| Draw distinction | Deck vs discard visual difference | 12 |
|
||||||
|
| Card value tooltips | Long-press to see points | 13 |
|
||||||
|
| Active rules context | Highlight relevant rules | 14 |
|
||||||
|
| Discard history | Show fanned recent cards | 15 |
|
||||||
|
| Realistic sounds | Better audio feedback | 16 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure (Changes)
|
||||||
|
|
||||||
|
```
|
||||||
|
server/
|
||||||
|
├── game.py # Add dealer rotation logic (V3_01)
|
||||||
|
|
||||||
|
client/
|
||||||
|
├── app.js # Enhance existing methods
|
||||||
|
├── timing-config.js # Add new timing values + anime.js config
|
||||||
|
├── card-animations.js # Extend with new animation methods
|
||||||
|
├── animation-queue.js # Add new animation types
|
||||||
|
├── style.css # Minimal additions (mostly layout)
|
||||||
|
└── sounds/ # OPTIONAL: Audio sprites
|
||||||
|
├── shuffle.mp3
|
||||||
|
├── deal.mp3
|
||||||
|
└── flip.mp3
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** All new animations should be added to `CardAnimations` class in `card-animations.js`. Do not add CSS keyframe animations for card movements.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance Criteria (V3 Complete)
|
||||||
|
|
||||||
|
1. **Dealer rotates properly** - First player advances clockwise each round
|
||||||
|
2. **Dealing feels physical** - Cards dealt one by one with shuffle sound
|
||||||
|
3. **Round end is dramatic** - Staggered reveal with tension pause
|
||||||
|
4. **Pairs are satisfying** - Visual celebration when columns match
|
||||||
|
5. **Final turn has urgency** - Clear visual indication of tension
|
||||||
|
6. **Swaps look natural** - Cards appear to exchange positions
|
||||||
|
7. **No performance regression** - Animations run at 60fps on mobile
|
||||||
|
8. **Timing is tunable** - All values in timing-config.js
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design Principles
|
||||||
|
|
||||||
|
### 1. Enhance, Don't Slow Down
|
||||||
|
Animations should make the game feel better without making it slower. Use perceived timing tricks:
|
||||||
|
- Start next animation before previous fully completes
|
||||||
|
- Stagger start times, not end times
|
||||||
|
- Quick movements with slight ease-out
|
||||||
|
|
||||||
|
### 2. Respect the Player's Time
|
||||||
|
- First-time experience: full animations
|
||||||
|
- Repeat plays: consider faster mode option
|
||||||
|
- Never block input unnecessarily
|
||||||
|
|
||||||
|
### 3. Clear Visual Hierarchy
|
||||||
|
- Active player highlighted
|
||||||
|
- Current action obvious
|
||||||
|
- Next expected action hinted
|
||||||
|
|
||||||
|
### 4. Consistent Feedback
|
||||||
|
- Same action = same animation
|
||||||
|
- Similar duration for similar actions
|
||||||
|
- Predictable timing helps player flow
|
||||||
|
|
||||||
|
### 5. Graceful Degradation
|
||||||
|
- Animations enhance but aren't required
|
||||||
|
- State updates should work without animations
|
||||||
|
- Handle animation interruption gracefully
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How to Use These Documents
|
||||||
|
|
||||||
|
Each `V3_XX_*.md` document is designed to be:
|
||||||
|
|
||||||
|
1. **Self-contained** - Has all context needed to implement that feature
|
||||||
|
2. **Agent-ready** - Can be given to a Claude agent as the primary context
|
||||||
|
3. **Testable** - Includes visual verification criteria
|
||||||
|
4. **Incremental** - Can be implemented and shipped independently
|
||||||
|
|
||||||
|
**Workflow:**
|
||||||
|
1. Pick a document based on current priority
|
||||||
|
2. Start a new Claude session with that document as context
|
||||||
|
3. Implement the feature
|
||||||
|
4. Verify against acceptance criteria
|
||||||
|
5. Test on mobile and desktop
|
||||||
|
6. Merge and move to next
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes for Implementation
|
||||||
|
|
||||||
|
- **Don't break existing functionality** - All current animations must still work
|
||||||
|
- **Use existing infrastructure** - Build on animation-queue, timing-config
|
||||||
|
- **Test on mobile** - Animations must run smoothly on phones
|
||||||
|
- **Consider reduced motion** - Respect `prefers-reduced-motion` media query
|
||||||
|
- **Keep it vanilla** - No new frameworks, Anime.js is sufficient
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Success Metrics
|
||||||
|
|
||||||
|
After V3 implementation, the game should:
|
||||||
|
- Feel noticeably more satisfying to play
|
||||||
|
- Get positive feedback on "polish" or "feel"
|
||||||
|
- Not feel slower despite more animations
|
||||||
|
- Work smoothly on all devices
|
||||||
|
- Be easy to tune timing via config
|
||||||
286
docs/v3/V3_01_DEALER_ROTATION.md
Normal file
286
docs/v3/V3_01_DEALER_ROTATION.md
Normal file
@ -0,0 +1,286 @@
|
|||||||
|
# V3-01: Dealer/Starting Player Rotation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
In physical card games, the deal rotates clockwise after each hand. The player who deals also typically plays last (or the player to their left plays first). Currently, our game always starts with the host/first player each round.
|
||||||
|
|
||||||
|
**Dependencies:** None (server-side foundation)
|
||||||
|
**Dependents:** V3_02 (Dealing Animation needs to know who is dealing)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. Track the current dealer position across rounds
|
||||||
|
2. Rotate dealer clockwise after each round
|
||||||
|
3. First player to act is to the left of the dealer (next in order)
|
||||||
|
4. Communicate dealer position to clients
|
||||||
|
5. Visual indicator of current dealer (client-side, prep for V3_02)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
From `server/game.py`, round start logic:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def start_next_round(self):
|
||||||
|
"""Start the next round."""
|
||||||
|
self.current_round += 1
|
||||||
|
# ... deal cards ...
|
||||||
|
# Current player is always index 0 (host/first joiner)
|
||||||
|
self.current_player_idx = 0
|
||||||
|
```
|
||||||
|
|
||||||
|
The `player_order` list is set once at game start and never changes. The first player is always `player_order[0]`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Server Changes
|
||||||
|
|
||||||
|
#### New State Fields
|
||||||
|
|
||||||
|
```python
|
||||||
|
# In Game class __init__
|
||||||
|
self.dealer_idx = 0 # Index into player_order of current dealer
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Round Start Logic
|
||||||
|
|
||||||
|
```python
|
||||||
|
def start_next_round(self):
|
||||||
|
"""Start the next round."""
|
||||||
|
self.current_round += 1
|
||||||
|
|
||||||
|
# Rotate dealer clockwise (next player in order)
|
||||||
|
if self.current_round > 1:
|
||||||
|
self.dealer_idx = (self.dealer_idx + 1) % len(self.player_order)
|
||||||
|
|
||||||
|
# First player is to the LEFT of dealer (next after dealer)
|
||||||
|
self.current_player_idx = (self.dealer_idx + 1) % len(self.player_order)
|
||||||
|
|
||||||
|
# ... rest of dealing logic ...
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Game State Response
|
||||||
|
|
||||||
|
Add dealer info to the game state sent to clients:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def get_state(self, for_player_id: str) -> dict:
|
||||||
|
return {
|
||||||
|
# ... existing fields ...
|
||||||
|
"dealer_id": self.player_order[self.dealer_idx] if self.player_order else None,
|
||||||
|
"dealer_idx": self.dealer_idx,
|
||||||
|
# current_player_id already exists
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Client Changes
|
||||||
|
|
||||||
|
#### State Handling
|
||||||
|
|
||||||
|
In `app.js`, the `gameState` will now include:
|
||||||
|
- `dealer_id` - The player ID of the current dealer
|
||||||
|
- `dealer_idx` - Index for ordering
|
||||||
|
|
||||||
|
#### Visual Indicator
|
||||||
|
|
||||||
|
Add a dealer chip/badge to the current dealer's area:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// In renderGame() or opponent rendering
|
||||||
|
const isDealer = player.id === this.gameState.dealer_id;
|
||||||
|
if (isDealer) {
|
||||||
|
div.classList.add('is-dealer');
|
||||||
|
// Add dealer chip element
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### CSS
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Dealer indicator */
|
||||||
|
.is-dealer::before {
|
||||||
|
content: "D";
|
||||||
|
position: absolute;
|
||||||
|
top: -8px;
|
||||||
|
left: -8px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background: #f4a460;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #1a1a2e;
|
||||||
|
border: 2px solid #fff;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Or use a chip emoji/icon */
|
||||||
|
.dealer-chip {
|
||||||
|
position: absolute;
|
||||||
|
top: -10px;
|
||||||
|
right: -10px;
|
||||||
|
font-size: 1.2em;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Edge Cases
|
||||||
|
|
||||||
|
### Player Leaves Mid-Game
|
||||||
|
|
||||||
|
If the current dealer leaves:
|
||||||
|
- Dealer position should stay at the same index
|
||||||
|
- If that index is now out of bounds, wrap to 0
|
||||||
|
- The show must go on
|
||||||
|
|
||||||
|
```python
|
||||||
|
def remove_player(self, player_id: str):
|
||||||
|
# ... existing removal logic ...
|
||||||
|
|
||||||
|
# Adjust dealer_idx if needed
|
||||||
|
if self.dealer_idx >= len(self.player_order):
|
||||||
|
self.dealer_idx = 0
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2-Player Game
|
||||||
|
|
||||||
|
With 2 players, dealer alternates each round:
|
||||||
|
- Round 1: Player A deals, Player B plays first
|
||||||
|
- Round 2: Player B deals, Player A plays first
|
||||||
|
- This works naturally with the modulo logic
|
||||||
|
|
||||||
|
### Game Start (Round 1)
|
||||||
|
|
||||||
|
For round 1:
|
||||||
|
- Dealer is the host (player_order[0])
|
||||||
|
- First player is player_order[1] (or player_order[0] in solo/test)
|
||||||
|
|
||||||
|
Option: Could randomize initial dealer, but host-as-first-dealer is traditional.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Cases
|
||||||
|
|
||||||
|
```python
|
||||||
|
# server/tests/test_dealer_rotation.py
|
||||||
|
|
||||||
|
def test_dealer_starts_as_host():
|
||||||
|
"""First round dealer is the host (first player)."""
|
||||||
|
game = create_game_with_players(["Alice", "Bob", "Carol"])
|
||||||
|
game.start_game()
|
||||||
|
|
||||||
|
assert game.dealer_idx == 0
|
||||||
|
assert game.get_dealer_id() == "Alice"
|
||||||
|
# First player is to dealer's left
|
||||||
|
assert game.current_player_idx == 1
|
||||||
|
assert game.get_current_player_id() == "Bob"
|
||||||
|
|
||||||
|
def test_dealer_rotates_each_round():
|
||||||
|
"""Dealer advances clockwise after each round."""
|
||||||
|
game = create_game_with_players(["Alice", "Bob", "Carol"])
|
||||||
|
game.start_game()
|
||||||
|
|
||||||
|
# Round 1: Alice deals, Bob plays first
|
||||||
|
assert game.dealer_idx == 0
|
||||||
|
|
||||||
|
complete_round(game)
|
||||||
|
game.start_next_round()
|
||||||
|
|
||||||
|
# Round 2: Bob deals, Carol plays first
|
||||||
|
assert game.dealer_idx == 1
|
||||||
|
assert game.current_player_idx == 2
|
||||||
|
|
||||||
|
complete_round(game)
|
||||||
|
game.start_next_round()
|
||||||
|
|
||||||
|
# Round 3: Carol deals, Alice plays first
|
||||||
|
assert game.dealer_idx == 2
|
||||||
|
assert game.current_player_idx == 0
|
||||||
|
|
||||||
|
def test_dealer_wraps_around():
|
||||||
|
"""Dealer wraps to first player after last player deals."""
|
||||||
|
game = create_game_with_players(["Alice", "Bob"])
|
||||||
|
game.start_game()
|
||||||
|
|
||||||
|
# Round 1: Alice deals
|
||||||
|
assert game.dealer_idx == 0
|
||||||
|
|
||||||
|
complete_round(game)
|
||||||
|
game.start_next_round()
|
||||||
|
|
||||||
|
# Round 2: Bob deals
|
||||||
|
assert game.dealer_idx == 1
|
||||||
|
|
||||||
|
complete_round(game)
|
||||||
|
game.start_next_round()
|
||||||
|
|
||||||
|
# Round 3: Back to Alice
|
||||||
|
assert game.dealer_idx == 0
|
||||||
|
|
||||||
|
def test_dealer_adjustment_on_player_leave():
|
||||||
|
"""Dealer index adjusts when players leave."""
|
||||||
|
game = create_game_with_players(["Alice", "Bob", "Carol"])
|
||||||
|
game.start_game()
|
||||||
|
|
||||||
|
complete_round(game)
|
||||||
|
game.start_next_round()
|
||||||
|
# Bob is now dealer (idx 1)
|
||||||
|
|
||||||
|
game.remove_player("Carol") # Remove last player
|
||||||
|
# Dealer idx should still be valid
|
||||||
|
assert game.dealer_idx == 1
|
||||||
|
assert game.dealer_idx < len(game.player_order)
|
||||||
|
|
||||||
|
def test_state_includes_dealer_info():
|
||||||
|
"""Game state includes dealer information."""
|
||||||
|
game = create_game_with_players(["Alice", "Bob"])
|
||||||
|
game.start_game()
|
||||||
|
|
||||||
|
state = game.get_state("Alice")
|
||||||
|
assert "dealer_id" in state
|
||||||
|
assert state["dealer_id"] == "Alice"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
1. Add `dealer_idx` field to Game class
|
||||||
|
2. Modify `start_game()` to set initial dealer
|
||||||
|
3. Modify `start_next_round()` to rotate dealer
|
||||||
|
4. Modify `get_state()` to include dealer info
|
||||||
|
5. Handle edge case: player leaves
|
||||||
|
6. Add tests for dealer rotation
|
||||||
|
7. Client: Add dealer visual indicator
|
||||||
|
8. Client: Style the dealer chip/badge
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Round 1 dealer is the host (first player in order)
|
||||||
|
- [ ] Dealer rotates clockwise after each round
|
||||||
|
- [ ] First player to act is always left of dealer
|
||||||
|
- [ ] Dealer info included in game state sent to clients
|
||||||
|
- [ ] Dealer position survives player departure
|
||||||
|
- [ ] Visual indicator shows current dealer
|
||||||
|
- [ ] All existing tests still pass
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes for Agent
|
||||||
|
|
||||||
|
- The `player_order` list is established at game start and defines clockwise order
|
||||||
|
- Keep backward compatibility - games in progress shouldn't break
|
||||||
|
- The dealer indicator is prep work for V3_02 (dealing animation)
|
||||||
|
- Consider: Should dealer deal to themselves last? (Traditional, but not gameplay-affecting)
|
||||||
|
- The visual dealer chip will become important when dealing animation shows cards coming FROM the dealer
|
||||||
406
docs/v3/V3_02_DEALING_ANIMATION.md
Normal file
406
docs/v3/V3_02_DEALING_ANIMATION.md
Normal file
@ -0,0 +1,406 @@
|
|||||||
|
# V3-02: Dealing Animation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
In physical card games, cards are dealt one at a time from the dealer to each player in turn. Currently, cards appear instantly when a round starts. This feature adds an animated dealing sequence that mimics the physical ritual.
|
||||||
|
|
||||||
|
**Dependencies:** V3_01 (Dealer Rotation - need to know who is dealing)
|
||||||
|
**Dependents:** None
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. Animate cards being dealt from a central deck position
|
||||||
|
2. Deal one card at a time to each player in clockwise order
|
||||||
|
3. Play shuffle sound before dealing begins
|
||||||
|
4. Play card sound as each card lands
|
||||||
|
5. Maintain quick perceived pace (stagger start times, not end times)
|
||||||
|
6. Show dealing from dealer's position (or center as fallback)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
From `app.js`, when `game_started` or `round_started` message received:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
case 'game_started':
|
||||||
|
case 'round_started':
|
||||||
|
this.gameState = data.game_state;
|
||||||
|
this.playSound('shuffle');
|
||||||
|
this.showGameScreen();
|
||||||
|
this.renderGame(); // Cards appear instantly
|
||||||
|
break;
|
||||||
|
```
|
||||||
|
|
||||||
|
Cards are rendered immediately via `renderGame()` which populates the card grids.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Animation Sequence
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Shuffle sound plays
|
||||||
|
2. Brief pause (300ms) - deck appears to shuffle
|
||||||
|
3. Deal round 1: One card to each player (clockwise from dealer's left)
|
||||||
|
4. Deal round 2-6: Repeat until all 6 cards dealt to each player
|
||||||
|
5. Flip discard pile top card
|
||||||
|
6. Initial flip phase begins (or game starts if initial_flips=0)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Visual Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
[Deck]
|
||||||
|
|
|
||||||
|
┌─────────────────┼─────────────────┐
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ ▼
|
||||||
|
[Opponent 1] [Opponent 2] [Opponent 3]
|
||||||
|
|
|
||||||
|
▼
|
||||||
|
[Local Player]
|
||||||
|
```
|
||||||
|
|
||||||
|
Cards fly from deck position to each player's card slot, face-down.
|
||||||
|
|
||||||
|
### Timing
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// New timing values in timing-config.js
|
||||||
|
dealing: {
|
||||||
|
shufflePause: 400, // Pause after shuffle sound
|
||||||
|
cardFlyTime: 150, // Time for card to fly to destination
|
||||||
|
cardStagger: 80, // Delay between cards (overlap for speed)
|
||||||
|
roundPause: 50, // Brief pause between deal rounds
|
||||||
|
discardFlipDelay: 200, // Pause before flipping discard
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Total time for 4-player game (24 cards):
|
||||||
|
- 400ms shuffle + 24 cards × 80ms stagger + 200ms discard = ~2.5 seconds
|
||||||
|
|
||||||
|
This feels unhurried but not slow.
|
||||||
|
|
||||||
|
### Implementation Approach
|
||||||
|
|
||||||
|
#### Option A: Overlay Animation (Recommended)
|
||||||
|
|
||||||
|
Create temporary card elements that animate from deck to destinations, then remove them and show the real cards.
|
||||||
|
|
||||||
|
Pros:
|
||||||
|
- Clean separation from game state
|
||||||
|
- Easy to skip/interrupt
|
||||||
|
- No complex state management
|
||||||
|
|
||||||
|
Cons:
|
||||||
|
- Brief flash when swapping to real cards (mitigate with timing)
|
||||||
|
|
||||||
|
#### Option B: Animate Real Cards
|
||||||
|
|
||||||
|
Start with cards at deck position, animate to final positions.
|
||||||
|
|
||||||
|
Pros:
|
||||||
|
- No element swap
|
||||||
|
- More "real"
|
||||||
|
|
||||||
|
Cons:
|
||||||
|
- Complex coordination with renderGame()
|
||||||
|
- State management issues
|
||||||
|
|
||||||
|
**Recommendation:** Option A - overlay animation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
### Add to `card-animations.js`
|
||||||
|
|
||||||
|
Add the dealing animation as a method on the existing `CardAnimations` class:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Add to CardAnimations class in card-animations.js
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the dealing animation using anime.js timelines
|
||||||
|
* @param {Object} gameState - The game state with players and their cards
|
||||||
|
* @param {Function} getPlayerRect - Function(playerId, cardIdx) => {left, top, width, height}
|
||||||
|
* @param {Function} onComplete - Callback when animation completes
|
||||||
|
*/
|
||||||
|
async animateDealing(gameState, getPlayerRect, onComplete) {
|
||||||
|
const T = window.TIMING?.dealing || {
|
||||||
|
shufflePause: 400,
|
||||||
|
cardFlyTime: 150,
|
||||||
|
cardStagger: 80,
|
||||||
|
roundPause: 50,
|
||||||
|
discardFlipDelay: 200,
|
||||||
|
};
|
||||||
|
|
||||||
|
const deckRect = this.getDeckRect();
|
||||||
|
const discardRect = this.getDiscardRect();
|
||||||
|
if (!deckRect) {
|
||||||
|
if (onComplete) onComplete();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get player order starting from dealer's left
|
||||||
|
const dealerIdx = gameState.dealer_idx || 0;
|
||||||
|
const playerOrder = this.getDealOrder(gameState.players, dealerIdx);
|
||||||
|
|
||||||
|
// Create container for animation cards
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.className = 'deal-animation-container';
|
||||||
|
container.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;pointer-events:none;z-index:1000;';
|
||||||
|
document.body.appendChild(container);
|
||||||
|
|
||||||
|
// Shuffle sound and pause
|
||||||
|
this.playSound('shuffle');
|
||||||
|
await this.delay(T.shufflePause);
|
||||||
|
|
||||||
|
// Deal 6 rounds of cards using anime.js
|
||||||
|
const allCards = [];
|
||||||
|
for (let cardIdx = 0; cardIdx < 6; cardIdx++) {
|
||||||
|
for (const player of playerOrder) {
|
||||||
|
const targetRect = getPlayerRect(player.id, cardIdx);
|
||||||
|
if (!targetRect) continue;
|
||||||
|
|
||||||
|
// Create card at deck position
|
||||||
|
const deckColor = this.getDeckColor();
|
||||||
|
const card = this.createAnimCard(deckRect, true, deckColor);
|
||||||
|
card.classList.add('deal-anim-card');
|
||||||
|
container.appendChild(card);
|
||||||
|
allCards.push({ card, targetRect });
|
||||||
|
|
||||||
|
// Animate using anime.js
|
||||||
|
anime({
|
||||||
|
targets: card,
|
||||||
|
left: targetRect.left,
|
||||||
|
top: targetRect.top,
|
||||||
|
width: targetRect.width,
|
||||||
|
height: targetRect.height,
|
||||||
|
duration: T.cardFlyTime,
|
||||||
|
easing: this.getEasing('move'),
|
||||||
|
});
|
||||||
|
|
||||||
|
this.playSound('card');
|
||||||
|
await this.delay(T.cardStagger);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Brief pause between rounds
|
||||||
|
if (cardIdx < 5) {
|
||||||
|
await this.delay(T.roundPause);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for last cards to land
|
||||||
|
await this.delay(T.cardFlyTime);
|
||||||
|
|
||||||
|
// Flip discard pile card
|
||||||
|
if (discardRect && gameState.discard_top) {
|
||||||
|
await this.delay(T.discardFlipDelay);
|
||||||
|
this.playSound('flip');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
container.remove();
|
||||||
|
if (onComplete) onComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
getDealOrder(players, dealerIdx) {
|
||||||
|
// Rotate so dealing starts to dealer's left
|
||||||
|
const order = [...players];
|
||||||
|
const startIdx = (dealerIdx + 1) % order.length;
|
||||||
|
return [...order.slice(startIdx), ...order.slice(0, startIdx)];
|
||||||
|
}
|
||||||
|
|
||||||
|
delay(ms) {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### CSS for Deal Animation
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* In style.css - minimal, anime.js handles all animation */
|
||||||
|
|
||||||
|
/* Deal animation container */
|
||||||
|
.deal-animation-container {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Deal cards inherit from .draw-anim-card (already exists in card-animations.js) */
|
||||||
|
.deal-anim-card {
|
||||||
|
/* Uses same structure as createAnimCard() */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration in app.js
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// In handleMessage, game_started/round_started case:
|
||||||
|
|
||||||
|
case 'game_started':
|
||||||
|
case 'round_started':
|
||||||
|
this.clearNextHoleCountdown();
|
||||||
|
this.nextRoundBtn.classList.remove('waiting');
|
||||||
|
this.roundWinnerNames = new Set();
|
||||||
|
this.gameState = data.game_state;
|
||||||
|
this.previousState = JSON.parse(JSON.stringify(data.game_state));
|
||||||
|
this.locallyFlippedCards = new Set();
|
||||||
|
this.selectedCards = [];
|
||||||
|
this.animatingPositions = new Set();
|
||||||
|
this.opponentSwapAnimation = null;
|
||||||
|
|
||||||
|
this.showGameScreen();
|
||||||
|
|
||||||
|
// NEW: Run deal animation using CardAnimations
|
||||||
|
this.runDealAnimation(() => {
|
||||||
|
this.renderGame();
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
// New method using CardAnimations
|
||||||
|
runDealAnimation(onComplete) {
|
||||||
|
// Hide cards initially
|
||||||
|
this.playerCards.style.visibility = 'hidden';
|
||||||
|
this.opponentsRow.style.visibility = 'hidden';
|
||||||
|
|
||||||
|
// Use the global cardAnimations instance
|
||||||
|
window.cardAnimations.animateDealing(
|
||||||
|
this.gameState,
|
||||||
|
(playerId, cardIdx) => this.getCardSlotRect(playerId, cardIdx),
|
||||||
|
() => {
|
||||||
|
// Show real cards
|
||||||
|
this.playerCards.style.visibility = 'visible';
|
||||||
|
this.opponentsRow.style.visibility = 'visible';
|
||||||
|
onComplete();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to get card slot position
|
||||||
|
getCardSlotRect(playerId, cardIdx) {
|
||||||
|
if (playerId === this.playerId) {
|
||||||
|
// Local player
|
||||||
|
const cards = this.playerCards.querySelectorAll('.card');
|
||||||
|
return cards[cardIdx]?.getBoundingClientRect();
|
||||||
|
} else {
|
||||||
|
// Opponent
|
||||||
|
const opponentAreas = this.opponentsRow.querySelectorAll('.opponent-area');
|
||||||
|
for (const area of opponentAreas) {
|
||||||
|
if (area.dataset.playerId === playerId) {
|
||||||
|
const cards = area.querySelectorAll('.card');
|
||||||
|
return cards[cardIdx]?.getBoundingClientRect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Timing Tuning
|
||||||
|
|
||||||
|
### Perceived Speed Tricks
|
||||||
|
|
||||||
|
1. **Overlap card flights** - Start next card before previous lands
|
||||||
|
2. **Ease-out timing** - Cards decelerate into position (feels snappier)
|
||||||
|
3. **Batch by round** - 6 deal rounds feels rhythmic
|
||||||
|
4. **Quick stagger** - 80ms between cards feels like rapid dealing
|
||||||
|
|
||||||
|
### Accessibility
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Respect reduced motion preference
|
||||||
|
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||||
|
// Skip animation, just show cards
|
||||||
|
this.renderGame();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Edge Cases
|
||||||
|
|
||||||
|
### Animation Interrupted
|
||||||
|
|
||||||
|
If player disconnects or game state changes during dealing:
|
||||||
|
- Cancel animation
|
||||||
|
- Show cards immediately
|
||||||
|
- Continue with normal game flow
|
||||||
|
|
||||||
|
### Varying Player Counts
|
||||||
|
|
||||||
|
2-6 players supported:
|
||||||
|
- Fewer players = faster deal (fewer cards per round)
|
||||||
|
- 2 players: 12 cards total, ~1.5 seconds
|
||||||
|
- 6 players: 36 cards total, ~3.5 seconds
|
||||||
|
|
||||||
|
### Opponent Areas Not Ready
|
||||||
|
|
||||||
|
If opponent areas haven't rendered yet:
|
||||||
|
- Fall back to animating to center positions
|
||||||
|
- Or skip animation for that player
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Scenarios
|
||||||
|
|
||||||
|
1. **2-player game** - Dealing alternates correctly
|
||||||
|
2. **6-player game** - All players receive cards in order
|
||||||
|
3. **Quick tap through** - Animation can be interrupted
|
||||||
|
4. **Round 2+** - Dealing starts from correct dealer position
|
||||||
|
5. **Mobile** - Animation runs smoothly at 60fps
|
||||||
|
6. **Reduced motion** - Animation skipped appropriately
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Cards animate from deck to player positions
|
||||||
|
- [ ] Deal order follows clockwise from dealer's left
|
||||||
|
- [ ] Shuffle sound plays before dealing
|
||||||
|
- [ ] Card sound plays as each card lands
|
||||||
|
- [ ] Animation completes in < 4 seconds for 6 players
|
||||||
|
- [ ] Real cards appear after animation (no flash)
|
||||||
|
- [ ] Reduced motion preference respected
|
||||||
|
- [ ] Works on mobile (60fps)
|
||||||
|
- [ ] Can be interrupted without breaking game
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
1. Add timing values to `timing-config.js`
|
||||||
|
2. Create `deal-animation.js` with DealAnimation class
|
||||||
|
3. Add CSS for deal animation cards
|
||||||
|
4. Add `data-player-id` to opponent areas for targeting
|
||||||
|
5. Add `getCardSlotRect()` helper method
|
||||||
|
6. Integrate animation in game_started/round_started handler
|
||||||
|
7. Test with various player counts
|
||||||
|
8. Add reduced motion support
|
||||||
|
9. Tune timing for best feel
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes for Agent
|
||||||
|
|
||||||
|
- Add `animateDealing()` as a method on the existing `CardAnimations` class
|
||||||
|
- Use `createAnimCard()` to create deal cards (already exists, handles 3D structure)
|
||||||
|
- Use anime.js for all card movements, not CSS transitions
|
||||||
|
- The existing `CardManager` handles persistent cards - don't modify it
|
||||||
|
- Timing values should all be in `timing-config.js` under `dealing` key
|
||||||
|
- Consider: Show dealer's hands actually dealing? (complex, skip for V3)
|
||||||
|
- The shuffle sound already exists - reuse it via `playSound('shuffle')`
|
||||||
|
- Cards should deal face-down (use `createAnimCard(rect, true, deckColor)`)
|
||||||
532
docs/v3/V3_03_ROUND_END_REVEAL.md
Normal file
532
docs/v3/V3_03_ROUND_END_REVEAL.md
Normal file
@ -0,0 +1,532 @@
|
|||||||
|
# V3-03: Round End Dramatic Reveal
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
When a round ends, all face-down cards must be revealed for scoring. In physical games, this is a dramatic moment - each player flips their hidden cards one at a time while others watch. Currently, all cards flip simultaneously which lacks drama.
|
||||||
|
|
||||||
|
**Dependencies:** None
|
||||||
|
**Dependents:** V3_07 (Score Tallying can follow the reveal)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. Reveal cards sequentially, one player at a time
|
||||||
|
2. Within each player, reveal cards with slight stagger
|
||||||
|
3. Pause briefly between players for dramatic effect
|
||||||
|
4. Start with the player who triggered final turn (the "knocker")
|
||||||
|
5. End with visible score tally moment
|
||||||
|
6. Play flip sounds for each reveal
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
When round ends, the server sends a `round_over` message and clients receive a `game_state` update where all cards are now `face_up: true`. The state differ detects the changes but doesn't sequence the animations - they happen together.
|
||||||
|
|
||||||
|
From `showScoreboard()` in app.js:
|
||||||
|
```javascript
|
||||||
|
showScoreboard(scores, isFinal, rankings) {
|
||||||
|
// Cards are already revealed by state update
|
||||||
|
// Scoreboard appears immediately
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Reveal Sequence
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Round ends - "Hole Complete!" message
|
||||||
|
2. VOLUNTARY FLIP WINDOW (4 seconds):
|
||||||
|
- Players can tap their own face-down cards to peek/flip
|
||||||
|
- Countdown timer shows remaining time
|
||||||
|
- "Tap to reveal your cards" prompt
|
||||||
|
3. AUTO-REVEAL (after timeout or all flipped):
|
||||||
|
- Knocker's cards reveal first (they went out)
|
||||||
|
- For each other player (clockwise from knocker):
|
||||||
|
a. Player area highlights
|
||||||
|
b. Face-down cards flip with stagger (100ms between)
|
||||||
|
c. Brief pause to see the reveal (400ms)
|
||||||
|
4. Score tallying animation (see V3_07)
|
||||||
|
5. Scoreboard appears
|
||||||
|
```
|
||||||
|
|
||||||
|
### Voluntary Flip Window
|
||||||
|
|
||||||
|
Before the dramatic reveal sequence, players get a chance to flip their own hidden cards:
|
||||||
|
- **Duration:** 4 seconds (configurable)
|
||||||
|
- **Purpose:** Let players see their own cards before everyone else does
|
||||||
|
- **UI:** Countdown timer, "Tap your cards to reveal" message
|
||||||
|
- **Skip:** If all players flip their cards, proceed immediately
|
||||||
|
|
||||||
|
### Visual Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Timeline:
|
||||||
|
0ms - Round ends, pause
|
||||||
|
500ms - Knocker highlight, first card flips
|
||||||
|
600ms - Knocker second card flips (if any)
|
||||||
|
700ms - Knocker third card flips (if any)
|
||||||
|
1100ms - Pause to see knocker's hand
|
||||||
|
1500ms - Player 2 highlight
|
||||||
|
1600ms - Player 2 cards flip...
|
||||||
|
...continue for all players...
|
||||||
|
Final - Scoreboard appears
|
||||||
|
```
|
||||||
|
|
||||||
|
### Timing Configuration
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// In timing-config.js
|
||||||
|
reveal: {
|
||||||
|
voluntaryWindow: 4000, // Time for players to flip their own cards
|
||||||
|
initialPause: 500, // Pause before auto-reveals start
|
||||||
|
cardStagger: 100, // Between cards in same hand
|
||||||
|
playerPause: 400, // Pause after each player's reveal
|
||||||
|
highlightDuration: 200, // Player area highlight fade-in
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
### Approach: Intercept State Update
|
||||||
|
|
||||||
|
Instead of letting `renderGame()` show all cards instantly, intercept the round_over state and run a reveal sequence.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// In handleMessage, game_state case:
|
||||||
|
|
||||||
|
case 'game_state':
|
||||||
|
const oldState = this.gameState;
|
||||||
|
const newState = data.game_state;
|
||||||
|
|
||||||
|
// Check for round end transition
|
||||||
|
const roundJustEnded = oldState?.phase !== 'round_over' &&
|
||||||
|
newState.phase === 'round_over';
|
||||||
|
|
||||||
|
if (roundJustEnded) {
|
||||||
|
// Don't update state yet - run reveal animation first
|
||||||
|
this.runRoundEndReveal(oldState, newState, () => {
|
||||||
|
this.gameState = newState;
|
||||||
|
this.renderGame();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal state update
|
||||||
|
this.gameState = newState;
|
||||||
|
this.renderGame();
|
||||||
|
break;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Voluntary Flip Window Implementation
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async runVoluntaryFlipWindow(oldState, newState) {
|
||||||
|
const T = window.TIMING?.reveal || {};
|
||||||
|
const windowDuration = T.voluntaryWindow || 4000;
|
||||||
|
|
||||||
|
// Find which of MY cards need flipping
|
||||||
|
const myOldCards = oldState?.players?.find(p => p.id === this.playerId)?.cards || [];
|
||||||
|
const myNewCards = newState?.players?.find(p => p.id === this.playerId)?.cards || [];
|
||||||
|
const myHiddenPositions = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
if (!myOldCards[i]?.face_up && myNewCards[i]?.face_up) {
|
||||||
|
myHiddenPositions.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If I have no hidden cards, skip window
|
||||||
|
if (myHiddenPositions.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show prompt and countdown
|
||||||
|
this.showRevealPrompt(windowDuration);
|
||||||
|
|
||||||
|
// Enable clicking on my hidden cards
|
||||||
|
this.voluntaryFlipMode = true;
|
||||||
|
this.voluntaryFlipPositions = new Set(myHiddenPositions);
|
||||||
|
this.renderGame(); // Re-render to make cards clickable
|
||||||
|
|
||||||
|
// Wait for timeout or all cards flipped
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const checkComplete = () => {
|
||||||
|
if (this.voluntaryFlipPositions.size === 0) {
|
||||||
|
this.hideRevealPrompt();
|
||||||
|
this.voluntaryFlipMode = false;
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set up interval to check completion
|
||||||
|
const checkInterval = setInterval(checkComplete, 100);
|
||||||
|
|
||||||
|
// Timeout after window duration
|
||||||
|
setTimeout(() => {
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
this.hideRevealPrompt();
|
||||||
|
this.voluntaryFlipMode = false;
|
||||||
|
resolve();
|
||||||
|
}, windowDuration);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
showRevealPrompt(duration) {
|
||||||
|
// Create countdown overlay
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.id = 'reveal-prompt';
|
||||||
|
overlay.className = 'reveal-prompt';
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="reveal-prompt-text">Tap your cards to reveal</div>
|
||||||
|
<div class="reveal-prompt-countdown">${Math.ceil(duration / 1000)}</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
// Countdown timer
|
||||||
|
const countdownEl = overlay.querySelector('.reveal-prompt-countdown');
|
||||||
|
let remaining = duration;
|
||||||
|
this.countdownInterval = setInterval(() => {
|
||||||
|
remaining -= 100;
|
||||||
|
countdownEl.textContent = Math.ceil(remaining / 1000);
|
||||||
|
if (remaining <= 0) {
|
||||||
|
clearInterval(this.countdownInterval);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
hideRevealPrompt() {
|
||||||
|
clearInterval(this.countdownInterval);
|
||||||
|
const overlay = document.getElementById('reveal-prompt');
|
||||||
|
if (overlay) {
|
||||||
|
overlay.classList.add('fading');
|
||||||
|
setTimeout(() => overlay.remove(), 300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modify handleCardClick to handle voluntary flips
|
||||||
|
handleCardClick(position) {
|
||||||
|
// ... existing code ...
|
||||||
|
|
||||||
|
// Voluntary flip during reveal window
|
||||||
|
if (this.voluntaryFlipMode && this.voluntaryFlipPositions?.has(position)) {
|
||||||
|
const myData = this.getMyPlayerData();
|
||||||
|
const card = myData?.cards[position];
|
||||||
|
if (card) {
|
||||||
|
this.playSound('flip');
|
||||||
|
this.fireLocalFlipAnimation(position, card);
|
||||||
|
this.voluntaryFlipPositions.delete(position);
|
||||||
|
// Update local state to show card flipped
|
||||||
|
this.locallyFlippedCards.add(position);
|
||||||
|
this.renderGame();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ... rest of existing code ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reveal Animation Method
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async runRoundEndReveal(oldState, newState, onComplete) {
|
||||||
|
const T = window.TIMING?.reveal || {};
|
||||||
|
|
||||||
|
// STEP 1: Voluntary flip window - let players peek at their own cards
|
||||||
|
this.setStatus('Reveal your hidden cards!', 'reveal-window');
|
||||||
|
await this.runVoluntaryFlipWindow(oldState, newState);
|
||||||
|
|
||||||
|
// STEP 2: Auto-reveal remaining hidden cards
|
||||||
|
// Recalculate what needs flipping (some may have been voluntarily revealed)
|
||||||
|
const revealsByPlayer = this.getCardsToReveal(oldState, newState);
|
||||||
|
|
||||||
|
// Get reveal order: knocker first, then clockwise
|
||||||
|
const knockerId = newState.finisher_id;
|
||||||
|
const revealOrder = this.getRevealOrder(newState.players, knockerId);
|
||||||
|
|
||||||
|
// Initial dramatic pause before auto-reveals
|
||||||
|
this.setStatus('Revealing cards...', 'reveal');
|
||||||
|
await this.delay(T.initialPause || 500);
|
||||||
|
|
||||||
|
// Reveal each player's cards
|
||||||
|
for (const player of revealOrder) {
|
||||||
|
const cardsToFlip = revealsByPlayer.get(player.id) || [];
|
||||||
|
if (cardsToFlip.length === 0) continue;
|
||||||
|
|
||||||
|
// Highlight player area
|
||||||
|
this.highlightPlayerArea(player.id, true);
|
||||||
|
await this.delay(T.highlightDuration || 200);
|
||||||
|
|
||||||
|
// Flip each card with stagger
|
||||||
|
for (const { position, card } of cardsToFlip) {
|
||||||
|
this.animateRevealFlip(player.id, position, card);
|
||||||
|
await this.delay(T.cardStagger || 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for last flip to complete + pause
|
||||||
|
await this.delay(400 + (T.playerPause || 400));
|
||||||
|
|
||||||
|
// Remove highlight
|
||||||
|
this.highlightPlayerArea(player.id, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// All revealed
|
||||||
|
onComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
getCardsToReveal(oldState, newState) {
|
||||||
|
const reveals = new Map();
|
||||||
|
|
||||||
|
for (const newPlayer of newState.players) {
|
||||||
|
const oldPlayer = oldState.players.find(p => p.id === newPlayer.id);
|
||||||
|
if (!oldPlayer) continue;
|
||||||
|
|
||||||
|
const cardsToFlip = [];
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
const wasHidden = !oldPlayer.cards[i]?.face_up;
|
||||||
|
const nowVisible = newPlayer.cards[i]?.face_up;
|
||||||
|
|
||||||
|
if (wasHidden && nowVisible) {
|
||||||
|
cardsToFlip.push({
|
||||||
|
position: i,
|
||||||
|
card: newPlayer.cards[i]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cardsToFlip.length > 0) {
|
||||||
|
reveals.set(newPlayer.id, cardsToFlip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return reveals;
|
||||||
|
}
|
||||||
|
|
||||||
|
getRevealOrder(players, knockerId) {
|
||||||
|
// Knocker first
|
||||||
|
const knocker = players.find(p => p.id === knockerId);
|
||||||
|
const others = players.filter(p => p.id !== knockerId);
|
||||||
|
|
||||||
|
// Others in clockwise order (already sorted by player_order)
|
||||||
|
if (knocker) {
|
||||||
|
return [knocker, ...others];
|
||||||
|
}
|
||||||
|
return others;
|
||||||
|
}
|
||||||
|
|
||||||
|
highlightPlayerArea(playerId, highlight) {
|
||||||
|
if (playerId === this.playerId) {
|
||||||
|
this.playerArea.classList.toggle('revealing', highlight);
|
||||||
|
} else {
|
||||||
|
const area = this.opponentsRow.querySelector(
|
||||||
|
`.opponent-area[data-player-id="${playerId}"]`
|
||||||
|
);
|
||||||
|
if (area) {
|
||||||
|
area.classList.toggle('revealing', highlight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
animateRevealFlip(playerId, position, cardData) {
|
||||||
|
// Reuse existing flip animation
|
||||||
|
if (playerId === this.playerId) {
|
||||||
|
this.fireLocalFlipAnimation(position, cardData);
|
||||||
|
} else {
|
||||||
|
this.fireFlipAnimation(playerId, position, cardData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### CSS for Reveal Prompt and Highlights
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Voluntary reveal prompt */
|
||||||
|
.reveal-prompt {
|
||||||
|
position: fixed;
|
||||||
|
top: 20%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: linear-gradient(135deg, #f4a460 0%, #d4845a 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 15px 30px;
|
||||||
|
border-radius: 12px;
|
||||||
|
text-align: center;
|
||||||
|
z-index: 200;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||||
|
animation: prompt-entrance 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reveal-prompt.fading {
|
||||||
|
animation: prompt-fade 0.3s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes prompt-entrance {
|
||||||
|
0% { transform: translateX(-50%) translateY(-20px); opacity: 0; }
|
||||||
|
100% { transform: translateX(-50%) translateY(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes prompt-fade {
|
||||||
|
0% { opacity: 1; }
|
||||||
|
100% { opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.reveal-prompt-text {
|
||||||
|
font-size: 1.1em;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reveal-prompt-countdown {
|
||||||
|
font-size: 2em;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cards clickable during voluntary reveal */
|
||||||
|
.player-area.voluntary-flip .card.can-flip {
|
||||||
|
cursor: pointer;
|
||||||
|
animation: flip-hint 0.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes flip-hint {
|
||||||
|
0%, 100% { transform: scale(1); }
|
||||||
|
50% { transform: scale(1.03); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Player area highlight during reveal */
|
||||||
|
.player-area.revealing,
|
||||||
|
.opponent-area.revealing {
|
||||||
|
animation: reveal-highlight 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes reveal-highlight {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(244, 164, 96, 0);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 20px 10px rgba(244, 164, 96, 0.4);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 10px 5px rgba(244, 164, 96, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keep highlight while revealing */
|
||||||
|
.player-area.revealing,
|
||||||
|
.opponent-area.revealing {
|
||||||
|
box-shadow: 0 0 10px 5px rgba(244, 164, 96, 0.2);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Special Cases
|
||||||
|
|
||||||
|
### All Cards Already Face-Up
|
||||||
|
|
||||||
|
If a player has no face-down cards (they knocked or flipped everything):
|
||||||
|
- Skip their reveal in the sequence
|
||||||
|
- Don't highlight their area
|
||||||
|
|
||||||
|
### Player Disconnected
|
||||||
|
|
||||||
|
If a player left before round end:
|
||||||
|
- Their cards still need to reveal for scoring
|
||||||
|
- Handle missing player areas gracefully
|
||||||
|
|
||||||
|
### Single Player (Debug/Test)
|
||||||
|
|
||||||
|
If only one player remains:
|
||||||
|
- Still do the reveal animation for their cards
|
||||||
|
- Feels consistent
|
||||||
|
|
||||||
|
### Quick Mode (Future)
|
||||||
|
|
||||||
|
Consider a setting to skip reveal animation:
|
||||||
|
```javascript
|
||||||
|
if (this.settings.quickMode) {
|
||||||
|
this.gameState = newState;
|
||||||
|
this.renderGame();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Timing Tuning
|
||||||
|
|
||||||
|
The reveal should feel dramatic but not tedious:
|
||||||
|
|
||||||
|
| Scenario | Cards to Reveal | Approximate Duration |
|
||||||
|
|----------|----------------|---------------------|
|
||||||
|
| 2 players, 2 hidden each | 4 cards | ~2 seconds |
|
||||||
|
| 4 players, 3 hidden each | 12 cards | ~4 seconds |
|
||||||
|
| 6 players, 4 hidden each | 24 cards | ~7 seconds |
|
||||||
|
|
||||||
|
If too slow, reduce:
|
||||||
|
- `cardStagger`: 100ms → 60ms
|
||||||
|
- `playerPause`: 400ms → 250ms
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Scenarios
|
||||||
|
|
||||||
|
1. **Normal round end** - Knocker reveals first, others follow
|
||||||
|
2. **Knocker has no hidden cards** - Skip knocker, start with next player
|
||||||
|
3. **All players have hidden cards** - Full reveal sequence
|
||||||
|
4. **Some players have no hidden cards** - Skip them gracefully
|
||||||
|
5. **Player disconnected** - Handle gracefully
|
||||||
|
6. **2-player game** - Both players reveal in order
|
||||||
|
7. **Quick succession** - Multiple round ends don't overlap
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] **Voluntary flip window:** 4-second window for players to flip their own cards
|
||||||
|
- [ ] Countdown timer shows remaining time
|
||||||
|
- [ ] Players can tap their face-down cards to reveal early
|
||||||
|
- [ ] Auto-reveal starts after timeout (or if all cards flipped)
|
||||||
|
- [ ] Cards reveal sequentially during auto-reveal, not all at once
|
||||||
|
- [ ] Knocker (finisher) reveals first
|
||||||
|
- [ ] Other players reveal clockwise after knocker
|
||||||
|
- [ ] Cards within a hand have slight stagger
|
||||||
|
- [ ] Pause between players for drama
|
||||||
|
- [ ] Player area highlights during their reveal
|
||||||
|
- [ ] Flip sound plays for each card
|
||||||
|
- [ ] Reveal completes before scoreboard appears
|
||||||
|
- [ ] Handles players with no hidden cards
|
||||||
|
- [ ] Animation can be interrupted if needed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
1. Add reveal timing to `timing-config.js`
|
||||||
|
2. Add `data-player-id` to opponent areas (if not done in V3_02)
|
||||||
|
3. Implement `getCardsToReveal()` method
|
||||||
|
4. Implement `getRevealOrder()` method
|
||||||
|
5. Implement `highlightPlayerArea()` method
|
||||||
|
6. Implement `runRoundEndReveal()` method
|
||||||
|
7. Intercept round_over state transition
|
||||||
|
8. Add reveal highlight CSS
|
||||||
|
9. Test with various player counts and card states
|
||||||
|
10. Tune timing for best dramatic effect
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes for Agent
|
||||||
|
|
||||||
|
- Use `window.cardAnimations.animateFlip()` or `animateOpponentFlip()` for reveals
|
||||||
|
- The existing CardAnimations class has all flip animation methods ready
|
||||||
|
- Don't forget to set `finisher_id` in game state (server may already do this)
|
||||||
|
- The reveal order should match the physical clockwise order
|
||||||
|
- Consider: Add a "drum roll" sound before reveals? (Nice to have)
|
||||||
|
- The scoreboard should NOT appear until all reveals complete
|
||||||
|
- State update is deferred until animation completes - ensure no race conditions
|
||||||
|
- All animations use anime.js timelines internally - no CSS keyframes needed
|
||||||
354
docs/v3/V3_04_COLUMN_PAIR_CELEBRATION.md
Normal file
354
docs/v3/V3_04_COLUMN_PAIR_CELEBRATION.md
Normal file
@ -0,0 +1,354 @@
|
|||||||
|
# V3-04: Column Pair Celebration
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Matching cards in a column (positions 0+3, 1+4, or 2+5) score 0 points - a key strategic mechanic. In physical games, players often exclaim when they make a pair. Currently, there's no visual feedback when a pair is formed, missing a satisfying moment.
|
||||||
|
|
||||||
|
**Dependencies:** None
|
||||||
|
**Dependents:** V3_10 (Column Pair Indicator builds on this)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. Detect when a swap creates a new column pair
|
||||||
|
2. Play satisfying visual celebration on both cards
|
||||||
|
3. Play a distinct "pair matched" sound
|
||||||
|
4. Brief but noticeable - shouldn't slow gameplay
|
||||||
|
5. Works for both local player and opponent swaps
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
Column pairs are calculated during scoring but there's no visual indication when a pair forms during play.
|
||||||
|
|
||||||
|
From the rules (RULES.md):
|
||||||
|
```
|
||||||
|
Column 0: positions (0, 3)
|
||||||
|
Column 1: positions (1, 4)
|
||||||
|
Column 2: positions (2, 5)
|
||||||
|
```
|
||||||
|
|
||||||
|
A pair is formed when both cards in a column are face-up and have the same rank.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Detection
|
||||||
|
|
||||||
|
After any swap or flip, check if a new pair was formed:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function detectNewPair(oldCards, newCards) {
|
||||||
|
const columns = [[0, 3], [1, 4], [2, 5]];
|
||||||
|
|
||||||
|
for (const [top, bottom] of columns) {
|
||||||
|
const wasPaired = isPaired(oldCards, top, bottom);
|
||||||
|
const nowPaired = isPaired(newCards, top, bottom);
|
||||||
|
|
||||||
|
if (!wasPaired && nowPaired) {
|
||||||
|
return { column: columns.indexOf([top, bottom]), positions: [top, bottom] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPaired(cards, pos1, pos2) {
|
||||||
|
const card1 = cards[pos1];
|
||||||
|
const card2 = cards[pos2];
|
||||||
|
return card1?.face_up && card2?.face_up &&
|
||||||
|
card1?.rank && card2?.rank &&
|
||||||
|
card1.rank === card2.rank;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Celebration Animation
|
||||||
|
|
||||||
|
When a pair forms:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Both cards pulse/glow simultaneously
|
||||||
|
2. Brief sparkle effect (optional)
|
||||||
|
3. "Pair!" sound plays
|
||||||
|
4. Animation lasts ~400ms
|
||||||
|
5. Cards return to normal
|
||||||
|
```
|
||||||
|
|
||||||
|
### Visual Effect Options
|
||||||
|
|
||||||
|
**Option A: Anime.js Glow Pulse** (Recommended - matches existing animation system)
|
||||||
|
```javascript
|
||||||
|
// Add to CardAnimations class
|
||||||
|
celebratePair(cardElement1, cardElement2) {
|
||||||
|
this.playSound('pair');
|
||||||
|
|
||||||
|
const duration = window.TIMING?.celebration?.pairDuration || 400;
|
||||||
|
|
||||||
|
[cardElement1, cardElement2].forEach(el => {
|
||||||
|
anime({
|
||||||
|
targets: el,
|
||||||
|
boxShadow: [
|
||||||
|
'0 0 0 0 rgba(255, 215, 0, 0)',
|
||||||
|
'0 0 15px 8px rgba(255, 215, 0, 0.5)',
|
||||||
|
'0 0 0 0 rgba(255, 215, 0, 0)'
|
||||||
|
],
|
||||||
|
scale: [1, 1.05, 1],
|
||||||
|
duration: duration,
|
||||||
|
easing: 'easeOutQuad'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option B: Scale Bounce**
|
||||||
|
```javascript
|
||||||
|
anime({
|
||||||
|
targets: [cardElement1, cardElement2],
|
||||||
|
scale: [1, 1.1, 1],
|
||||||
|
duration: 400,
|
||||||
|
easing: 'easeOutQuad'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option C: Connecting Line**
|
||||||
|
Draw a brief line connecting the paired cards (more complex).
|
||||||
|
|
||||||
|
**Recommendation:** Option A - anime.js glow pulse matches the existing animation system.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
### Timing Configuration
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// In timing-config.js
|
||||||
|
celebration: {
|
||||||
|
pairDuration: 400, // Celebration animation length
|
||||||
|
pairDelay: 50, // Slight delay before celebration (let swap settle)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sound
|
||||||
|
|
||||||
|
Add a new sound type for pairs:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// In playSound() method
|
||||||
|
} else if (type === 'pair') {
|
||||||
|
// Two-tone "ding-ding" for pair match
|
||||||
|
const osc1 = ctx.createOscillator();
|
||||||
|
const osc2 = ctx.createOscillator();
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
|
||||||
|
osc1.connect(gain);
|
||||||
|
osc2.connect(gain);
|
||||||
|
gain.connect(ctx.destination);
|
||||||
|
|
||||||
|
osc1.frequency.setValueAtTime(880, ctx.currentTime); // A5
|
||||||
|
osc2.frequency.setValueAtTime(1108, ctx.currentTime); // C#6
|
||||||
|
|
||||||
|
gain.gain.setValueAtTime(0.1, ctx.currentTime);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.3);
|
||||||
|
|
||||||
|
osc1.start(ctx.currentTime);
|
||||||
|
osc2.start(ctx.currentTime);
|
||||||
|
osc1.stop(ctx.currentTime + 0.3);
|
||||||
|
osc2.stop(ctx.currentTime + 0.3);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Detection Integration
|
||||||
|
|
||||||
|
In the state differ or after swap animations:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// In triggerAnimationsForStateChange() or after swap completes
|
||||||
|
|
||||||
|
checkForNewPairs(oldState, newState, playerId) {
|
||||||
|
const oldPlayer = oldState?.players?.find(p => p.id === playerId);
|
||||||
|
const newPlayer = newState?.players?.find(p => p.id === playerId);
|
||||||
|
|
||||||
|
if (!oldPlayer || !newPlayer) return;
|
||||||
|
|
||||||
|
const columns = [[0, 3], [1, 4], [2, 5]];
|
||||||
|
|
||||||
|
for (const [top, bottom] of columns) {
|
||||||
|
const wasPaired = this.isPaired(oldPlayer.cards, top, bottom);
|
||||||
|
const nowPaired = this.isPaired(newPlayer.cards, top, bottom);
|
||||||
|
|
||||||
|
if (!wasPaired && nowPaired) {
|
||||||
|
// New pair formed!
|
||||||
|
setTimeout(() => {
|
||||||
|
this.celebratePair(playerId, top, bottom);
|
||||||
|
}, window.TIMING?.celebration?.pairDelay || 50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isPaired(cards, pos1, pos2) {
|
||||||
|
const c1 = cards[pos1];
|
||||||
|
const c2 = cards[pos2];
|
||||||
|
return c1?.face_up && c2?.face_up && c1?.rank === c2?.rank;
|
||||||
|
}
|
||||||
|
|
||||||
|
celebratePair(playerId, pos1, pos2) {
|
||||||
|
const cards = this.getCardElements(playerId, pos1, pos2);
|
||||||
|
if (cards.length === 0) return;
|
||||||
|
|
||||||
|
// Use CardAnimations to animate (or add method to CardAnimations)
|
||||||
|
window.cardAnimations.celebratePair(cards[0], cards[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to CardAnimations class in card-animations.js:
|
||||||
|
celebratePair(cardElement1, cardElement2) {
|
||||||
|
this.playSound('pair');
|
||||||
|
|
||||||
|
const duration = window.TIMING?.celebration?.pairDuration || 400;
|
||||||
|
|
||||||
|
[cardElement1, cardElement2].forEach(el => {
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
// Temporarily raise z-index so glow shows above adjacent cards
|
||||||
|
el.style.zIndex = '10';
|
||||||
|
|
||||||
|
anime({
|
||||||
|
targets: el,
|
||||||
|
boxShadow: [
|
||||||
|
'0 0 0 0 rgba(255, 215, 0, 0)',
|
||||||
|
'0 0 15px 8px rgba(255, 215, 0, 0.5)',
|
||||||
|
'0 0 0 0 rgba(255, 215, 0, 0)'
|
||||||
|
],
|
||||||
|
scale: [1, 1.05, 1],
|
||||||
|
duration: duration,
|
||||||
|
easing: 'easeOutQuad',
|
||||||
|
complete: () => {
|
||||||
|
el.style.zIndex = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getCardElements(playerId, ...positions) {
|
||||||
|
const elements = [];
|
||||||
|
|
||||||
|
if (playerId === this.playerId) {
|
||||||
|
const cards = this.playerCards.querySelectorAll('.card');
|
||||||
|
for (const pos of positions) {
|
||||||
|
if (cards[pos]) elements.push(cards[pos]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const area = this.opponentsRow.querySelector(
|
||||||
|
`.opponent-area[data-player-id="${playerId}"]`
|
||||||
|
);
|
||||||
|
if (area) {
|
||||||
|
const cards = area.querySelectorAll('.card');
|
||||||
|
for (const pos of positions) {
|
||||||
|
if (cards[pos]) elements.push(cards[pos]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return elements;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### CSS
|
||||||
|
|
||||||
|
No CSS keyframes needed - all animation is handled by anime.js in `CardAnimations.celebratePair()`.
|
||||||
|
|
||||||
|
The animation temporarily sets `z-index: 10` on cards during celebration to ensure the glow shows above adjacent cards. For opponent pairs, you can pass a different color parameter:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Optional: Different color for opponent pairs
|
||||||
|
celebratePair(cardElement1, cardElement2, isOpponent = false) {
|
||||||
|
const color = isOpponent
|
||||||
|
? 'rgba(100, 200, 255, 0.4)' // Blue for opponents
|
||||||
|
: 'rgba(255, 215, 0, 0.5)'; // Gold for local player
|
||||||
|
|
||||||
|
// ... anime.js animation with color ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Edge Cases
|
||||||
|
|
||||||
|
### Pair Broken Then Reformed
|
||||||
|
|
||||||
|
If a swap breaks one pair and creates another:
|
||||||
|
- Only celebrate the new pair
|
||||||
|
- Don't mourn the broken pair (no negative feedback)
|
||||||
|
|
||||||
|
### Multiple Pairs in One Move
|
||||||
|
|
||||||
|
Theoretically possible (swap creates pairs in adjacent columns):
|
||||||
|
- Celebrate all new pairs simultaneously
|
||||||
|
- Same sound, same animation on all involved cards
|
||||||
|
|
||||||
|
### Pair at Round Start (Initial Flip)
|
||||||
|
|
||||||
|
If initial flip creates a pair:
|
||||||
|
- Yes, celebrate it! Early luck deserves recognition
|
||||||
|
|
||||||
|
### Negative Card Pairs (2s, Jokers)
|
||||||
|
|
||||||
|
Pairing 2s or Jokers is strategically bad (wastes -2 value), but:
|
||||||
|
- Still celebrate the pair (it's mechanically correct)
|
||||||
|
- Player will learn the strategy over time
|
||||||
|
- Consider: different sound/color for "bad" pairs? (Too complex for V3)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Scenarios
|
||||||
|
|
||||||
|
1. **Local player creates pair** - Both cards glow, sound plays
|
||||||
|
2. **Opponent creates pair** - Their cards glow, sound plays
|
||||||
|
3. **Initial flip creates pair** - Celebration after flip animation
|
||||||
|
4. **Swap breaks one pair, creates another** - Only new pair celebrates
|
||||||
|
5. **No pair formed** - No celebration
|
||||||
|
6. **Face-down card in column** - No false celebration
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Swap that creates a pair triggers celebration
|
||||||
|
- [ ] Flip that creates a pair triggers celebration
|
||||||
|
- [ ] Both paired cards animate simultaneously
|
||||||
|
- [ ] Distinct "pair" sound plays
|
||||||
|
- [ ] Animation is brief (~400ms)
|
||||||
|
- [ ] Works for local player and opponents
|
||||||
|
- [ ] No celebration when pair isn't formed
|
||||||
|
- [ ] No celebration for already-existing pairs
|
||||||
|
- [ ] Animation doesn't block gameplay
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
1. Add `pair` sound to `playSound()` method
|
||||||
|
2. Add celebration timing to `timing-config.js`
|
||||||
|
3. Implement `isPaired()` helper method
|
||||||
|
4. Implement `checkForNewPairs()` method
|
||||||
|
5. Implement `celebratePair()` method
|
||||||
|
6. Implement `getCardElements()` helper
|
||||||
|
7. Add CSS animation for pair celebration
|
||||||
|
8. Integrate into state change detection
|
||||||
|
9. Test all pair formation scenarios
|
||||||
|
10. Tune sound and timing for satisfaction
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes for Agent
|
||||||
|
|
||||||
|
- Add `celebratePair()` method to the existing `CardAnimations` class
|
||||||
|
- Use anime.js for all animation - no CSS keyframes
|
||||||
|
- Keep the celebration brief - shouldn't slow down fast players
|
||||||
|
- The glow color (gold) suggests "success" - matches golf scoring concept
|
||||||
|
- Consider accessibility: animation should be visible but not overwhelming
|
||||||
|
- The existing swap animation completes before pair check runs
|
||||||
|
- Don't celebrate pairs that already existed before the action
|
||||||
|
- Opponent celebration can use slightly different color (optional parameter)
|
||||||
411
docs/v3/V3_05_FINAL_TURN_URGENCY.md
Normal file
411
docs/v3/V3_05_FINAL_TURN_URGENCY.md
Normal file
@ -0,0 +1,411 @@
|
|||||||
|
# V3-05: Final Turn Urgency
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
When a player reveals all their cards, the round enters "final turn" phase - each other player gets one last turn. This is a tense moment in physical games. Currently, only a small badge shows "Final Turn" which lacks urgency.
|
||||||
|
|
||||||
|
**Dependencies:** None
|
||||||
|
**Dependents:** None
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. Create visual tension when final turn begins
|
||||||
|
2. Show who triggered final turn (the knocker)
|
||||||
|
3. Indicate how many players still need to act
|
||||||
|
4. Make each remaining turn feel consequential
|
||||||
|
5. Countdown feeling as players take their last turns
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
From `app.js`:
|
||||||
|
```javascript
|
||||||
|
// Final turn badge exists but is minimal
|
||||||
|
if (isFinalTurn) {
|
||||||
|
this.finalTurnBadge.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
this.finalTurnBadge.classList.add('hidden');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The badge just shows "FINAL TURN" text - no countdown, no urgency indicator.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Visual Elements
|
||||||
|
|
||||||
|
1. **Pulsing Border** - Game area gets subtle pulsing red/orange border
|
||||||
|
2. **Enhanced Badge** - Larger badge with countdown
|
||||||
|
3. **Knocker Indicator** - Show who triggered final turn
|
||||||
|
4. **Turn Counter** - "2 players remaining" style indicator
|
||||||
|
|
||||||
|
### Badge Enhancement
|
||||||
|
|
||||||
|
```
|
||||||
|
Current: [FINAL TURN]
|
||||||
|
|
||||||
|
Enhanced: [⚠️ FINAL TURN]
|
||||||
|
[Player 2 of 3]
|
||||||
|
```
|
||||||
|
|
||||||
|
Or more dramatic:
|
||||||
|
```
|
||||||
|
[🔔 LAST CHANCE!]
|
||||||
|
[2 turns left]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Color Scheme
|
||||||
|
|
||||||
|
- Normal play: Green felt background
|
||||||
|
- Final turn: Subtle warm/orange tint or border pulse
|
||||||
|
- Not overwhelming, but noticeable shift
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
### Enhanced Final Turn Badge
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- Enhanced badge structure -->
|
||||||
|
<div id="final-turn-badge" class="hidden">
|
||||||
|
<div class="final-turn-icon">⚡</div>
|
||||||
|
<div class="final-turn-text">FINAL TURN</div>
|
||||||
|
<div class="final-turn-remaining">2 turns left</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### CSS Enhancements
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Enhanced final turn badge */
|
||||||
|
#final-turn-badge {
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
background: linear-gradient(135deg, #ff6b35 0%, #d63031 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-radius: 12px;
|
||||||
|
text-align: center;
|
||||||
|
z-index: 100;
|
||||||
|
box-shadow: 0 4px 20px rgba(214, 48, 49, 0.4);
|
||||||
|
animation: final-turn-pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
#final-turn-badge.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.final-turn-icon {
|
||||||
|
font-size: 1.5em;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.final-turn-text {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 1.2em;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.final-turn-remaining {
|
||||||
|
font-size: 0.9em;
|
||||||
|
opacity: 0.9;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes final-turn-pulse {
|
||||||
|
0%, 100% {
|
||||||
|
transform: translate(-50%, -50%) scale(1);
|
||||||
|
box-shadow: 0 4px 20px rgba(214, 48, 49, 0.4);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translate(-50%, -50%) scale(1.02);
|
||||||
|
box-shadow: 0 4px 30px rgba(214, 48, 49, 0.6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Game area border pulse during final turn */
|
||||||
|
#game-screen.final-turn-active {
|
||||||
|
animation: game-area-urgency 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes game-area-urgency {
|
||||||
|
0%, 100% {
|
||||||
|
box-shadow: inset 0 0 0 0 rgba(255, 107, 53, 0);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: inset 0 0 30px 0 rgba(255, 107, 53, 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Knocker highlight */
|
||||||
|
.player-area.is-knocker,
|
||||||
|
.opponent-area.is-knocker {
|
||||||
|
border: 2px solid #ff6b35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.knocker-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -10px;
|
||||||
|
right: -10px;
|
||||||
|
background: #ff6b35;
|
||||||
|
color: white;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.7em;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### JavaScript Updates
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// In renderGame() or dedicated method
|
||||||
|
|
||||||
|
updateFinalTurnDisplay() {
|
||||||
|
const isFinalTurn = this.gameState?.phase === 'final_turn';
|
||||||
|
const finisherId = this.gameState?.finisher_id;
|
||||||
|
|
||||||
|
// Toggle game area class
|
||||||
|
this.gameScreen.classList.toggle('final-turn-active', isFinalTurn);
|
||||||
|
|
||||||
|
if (isFinalTurn) {
|
||||||
|
// Calculate remaining turns
|
||||||
|
const remaining = this.countRemainingTurns();
|
||||||
|
|
||||||
|
// Update badge content
|
||||||
|
this.finalTurnBadge.querySelector('.final-turn-remaining').textContent =
|
||||||
|
remaining === 1 ? '1 turn left' : `${remaining} turns left`;
|
||||||
|
|
||||||
|
// Show badge with entrance animation
|
||||||
|
this.finalTurnBadge.classList.remove('hidden');
|
||||||
|
this.finalTurnBadge.classList.add('entering');
|
||||||
|
setTimeout(() => {
|
||||||
|
this.finalTurnBadge.classList.remove('entering');
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
// Mark knocker
|
||||||
|
this.markKnocker(finisherId);
|
||||||
|
|
||||||
|
// Play alert sound on first appearance
|
||||||
|
if (!this.finalTurnAnnounced) {
|
||||||
|
this.playSound('alert');
|
||||||
|
this.finalTurnAnnounced = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.finalTurnBadge.classList.add('hidden');
|
||||||
|
this.gameScreen.classList.remove('final-turn-active');
|
||||||
|
this.finalTurnAnnounced = false;
|
||||||
|
this.clearKnockerMark();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
countRemainingTurns() {
|
||||||
|
if (!this.gameState || this.gameState.phase !== 'final_turn') return 0;
|
||||||
|
|
||||||
|
const finisherId = this.gameState.finisher_id;
|
||||||
|
const currentIdx = this.gameState.players.findIndex(
|
||||||
|
p => p.id === this.gameState.current_player_id
|
||||||
|
);
|
||||||
|
const finisherIdx = this.gameState.players.findIndex(
|
||||||
|
p => p.id === finisherId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (currentIdx === -1 || finisherIdx === -1) return 0;
|
||||||
|
|
||||||
|
// Count players between current and finisher (not including finisher)
|
||||||
|
let count = 0;
|
||||||
|
let idx = currentIdx;
|
||||||
|
const numPlayers = this.gameState.players.length;
|
||||||
|
|
||||||
|
while (idx !== finisherIdx) {
|
||||||
|
count++;
|
||||||
|
idx = (idx + 1) % numPlayers;
|
||||||
|
}
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
markKnocker(knockerId) {
|
||||||
|
// Add knocker badge to the player who triggered final turn
|
||||||
|
this.clearKnockerMark();
|
||||||
|
|
||||||
|
if (!knockerId) return;
|
||||||
|
|
||||||
|
if (knockerId === this.playerId) {
|
||||||
|
this.playerArea.classList.add('is-knocker');
|
||||||
|
// Add badge element
|
||||||
|
const badge = document.createElement('div');
|
||||||
|
badge.className = 'knocker-badge';
|
||||||
|
badge.textContent = 'OUT';
|
||||||
|
this.playerArea.appendChild(badge);
|
||||||
|
} else {
|
||||||
|
const area = this.opponentsRow.querySelector(
|
||||||
|
`.opponent-area[data-player-id="${knockerId}"]`
|
||||||
|
);
|
||||||
|
if (area) {
|
||||||
|
area.classList.add('is-knocker');
|
||||||
|
const badge = document.createElement('div');
|
||||||
|
badge.className = 'knocker-badge';
|
||||||
|
badge.textContent = 'OUT';
|
||||||
|
area.appendChild(badge);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clearKnockerMark() {
|
||||||
|
// Remove all knocker indicators
|
||||||
|
document.querySelectorAll('.is-knocker').forEach(el => {
|
||||||
|
el.classList.remove('is-knocker');
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.knocker-badge').forEach(el => {
|
||||||
|
el.remove();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Alert Sound
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// In playSound() method
|
||||||
|
} else if (type === 'alert') {
|
||||||
|
// Attention-getting sound for final turn
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(ctx.destination);
|
||||||
|
|
||||||
|
osc.type = 'triangle';
|
||||||
|
osc.frequency.setValueAtTime(523, ctx.currentTime); // C5
|
||||||
|
osc.frequency.setValueAtTime(659, ctx.currentTime + 0.1); // E5
|
||||||
|
osc.frequency.setValueAtTime(784, ctx.currentTime + 0.2); // G5
|
||||||
|
|
||||||
|
gain.gain.setValueAtTime(0.15, ctx.currentTime);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.4);
|
||||||
|
|
||||||
|
osc.start(ctx.currentTime);
|
||||||
|
osc.stop(ctx.currentTime + 0.4);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Entrance Animation
|
||||||
|
|
||||||
|
When final turn starts, badge should appear dramatically:
|
||||||
|
|
||||||
|
```css
|
||||||
|
#final-turn-badge.entering {
|
||||||
|
animation: badge-entrance 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes badge-entrance {
|
||||||
|
0% {
|
||||||
|
transform: translate(-50%, -50%) scale(0.5);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
transform: translate(-50%, -50%) scale(1.1);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translate(-50%, -50%) scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Turn Countdown Update
|
||||||
|
|
||||||
|
Each time a player takes their final turn, update the counter:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// In state change detection
|
||||||
|
if (newState.phase === 'final_turn') {
|
||||||
|
const oldRemaining = this.lastRemainingTurns;
|
||||||
|
const newRemaining = this.countRemainingTurns();
|
||||||
|
|
||||||
|
if (oldRemaining !== newRemaining) {
|
||||||
|
this.updateFinalTurnCounter(newRemaining);
|
||||||
|
this.lastRemainingTurns = newRemaining;
|
||||||
|
|
||||||
|
// Pulse the badge on update
|
||||||
|
this.finalTurnBadge.classList.add('counter-updated');
|
||||||
|
setTimeout(() => {
|
||||||
|
this.finalTurnBadge.classList.remove('counter-updated');
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```css
|
||||||
|
#final-turn-badge.counter-updated {
|
||||||
|
animation: counter-pulse 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes counter-pulse {
|
||||||
|
0% { transform: translate(-50%, -50%) scale(1); }
|
||||||
|
50% { transform: translate(-50%, -50%) scale(1.05); }
|
||||||
|
100% { transform: translate(-50%, -50%) scale(1); }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Scenarios
|
||||||
|
|
||||||
|
1. **Enter final turn** - Badge appears with animation, sound plays
|
||||||
|
2. **Turn counter decrements** - Shows "2 turns left" → "1 turn left"
|
||||||
|
3. **Last turn** - Shows "1 turn left", extra urgency
|
||||||
|
4. **Round ends** - Badge disappears, border pulse stops
|
||||||
|
5. **Knocker marked** - OUT badge on player who triggered
|
||||||
|
6. **Multiple rounds** - Badge resets between rounds
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Final turn badge appears when phase is `final_turn`
|
||||||
|
- [ ] Badge shows remaining turns count
|
||||||
|
- [ ] Count updates as players take turns
|
||||||
|
- [ ] Game area has subtle urgency visual
|
||||||
|
- [ ] Knocker is marked with badge
|
||||||
|
- [ ] Alert sound plays when final turn starts
|
||||||
|
- [ ] Badge has entrance animation
|
||||||
|
- [ ] All visuals reset when round ends
|
||||||
|
- [ ] Not overwhelming - tension without annoyance
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
1. Update HTML structure for enhanced badge
|
||||||
|
2. Add CSS for badge, urgency border, knocker indicator
|
||||||
|
3. Implement `countRemainingTurns()` method
|
||||||
|
4. Implement `updateFinalTurnDisplay()` method
|
||||||
|
5. Implement `markKnocker()` and `clearKnockerMark()`
|
||||||
|
6. Add alert sound to `playSound()`
|
||||||
|
7. Integrate into `renderGame()` or state change handler
|
||||||
|
8. Add entrance animation
|
||||||
|
9. Add counter update pulse
|
||||||
|
10. Test all scenarios
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes for Agent
|
||||||
|
|
||||||
|
- The urgency should enhance tension, not frustrate players
|
||||||
|
- Keep the pulsing subtle - not distracting during play
|
||||||
|
- The knocker badge helps players understand game state
|
||||||
|
- Consider mobile: badge should fit on small screens
|
||||||
|
- The remaining turns count helps players plan their last move
|
||||||
|
- Reset all state between rounds (finalTurnAnnounced flag)
|
||||||
376
docs/v3/V3_06_OPPONENT_THINKING.md
Normal file
376
docs/v3/V3_06_OPPONENT_THINKING.md
Normal file
@ -0,0 +1,376 @@
|
|||||||
|
# V3-06: Opponent Thinking Phase
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
In physical card games, you watch opponents pick up a card, consider it, and decide. Currently, CPU turns happen quickly with minimal visual indication that they're "thinking." This feature adds visible consideration time.
|
||||||
|
|
||||||
|
**Dependencies:** None
|
||||||
|
**Dependents:** None
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. Show when an opponent is considering their move
|
||||||
|
2. Highlight which pile they're considering (deck vs discard)
|
||||||
|
3. Add brief thinking pause before CPU actions
|
||||||
|
4. Make CPU feel more like a real player
|
||||||
|
5. Human opponents should also show consideration state
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
From `app.js` and `card-animations.js`:
|
||||||
|
```javascript
|
||||||
|
// In app.js
|
||||||
|
updateCpuConsideringState() {
|
||||||
|
const currentPlayer = this.gameState.players.find(
|
||||||
|
p => p.id === this.gameState.current_player_id
|
||||||
|
);
|
||||||
|
const isCpuTurn = currentPlayer && currentPlayer.is_cpu;
|
||||||
|
const hasNotDrawn = !this.gameState.has_drawn_card;
|
||||||
|
|
||||||
|
if (isCpuTurn && hasNotDrawn) {
|
||||||
|
this.discard.classList.add('cpu-considering');
|
||||||
|
} else {
|
||||||
|
this.discard.classList.remove('cpu-considering');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CardAnimations already has CPU thinking glow:
|
||||||
|
startCpuThinking(element) {
|
||||||
|
anime({
|
||||||
|
targets: element,
|
||||||
|
boxShadow: [
|
||||||
|
'0 4px 12px rgba(0,0,0,0.3)',
|
||||||
|
'0 4px 12px rgba(0,0,0,0.3), 0 0 18px rgba(59, 130, 246, 0.5)',
|
||||||
|
'0 4px 12px rgba(0,0,0,0.3)'
|
||||||
|
],
|
||||||
|
duration: 1500,
|
||||||
|
easing: 'easeInOutSine',
|
||||||
|
loop: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The existing `startCpuThinking()` method in CardAnimations provides a looping glow animation. This feature enhances visibility further.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Enhanced Consideration Display
|
||||||
|
|
||||||
|
1. **Opponent area highlight** - Active player's area glows
|
||||||
|
2. **"Thinking" indicator** - Small animation near their name
|
||||||
|
3. **Deck/discard highlight** - Show which pile they're eyeing
|
||||||
|
4. **Held card consideration** - After draw, show they're deciding
|
||||||
|
|
||||||
|
### States
|
||||||
|
|
||||||
|
```
|
||||||
|
1. WAITING_TO_DRAW
|
||||||
|
- Player area highlighted
|
||||||
|
- Deck and discard both subtly available
|
||||||
|
- Brief pause before action (CPU)
|
||||||
|
|
||||||
|
2. CONSIDERING_DISCARD
|
||||||
|
- Player looks at discard pile
|
||||||
|
- Discard pile pulses brighter
|
||||||
|
- "Eye" indicator on discard
|
||||||
|
|
||||||
|
3. DREW_CARD
|
||||||
|
- Held card visible (existing)
|
||||||
|
- Player area still highlighted
|
||||||
|
|
||||||
|
4. CONSIDERING_SWAP
|
||||||
|
- Player deciding which card to swap
|
||||||
|
- Their hand cards subtly indicate options
|
||||||
|
```
|
||||||
|
|
||||||
|
### Timing (CPU only)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// In timing-config.js
|
||||||
|
cpuThinking: {
|
||||||
|
beforeDraw: 800, // Pause before CPU draws
|
||||||
|
discardConsider: 400, // Extra pause when looking at discard
|
||||||
|
beforeSwap: 500, // Pause before CPU swaps
|
||||||
|
beforeDiscard: 300, // Pause before CPU discards drawn card
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Human players don't need artificial pauses - their actual thinking provides the delay.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
### Thinking Indicator
|
||||||
|
|
||||||
|
Add a small animated indicator near the current player's name:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- In opponent area -->
|
||||||
|
<div class="opponent-area" data-player-id="...">
|
||||||
|
<h4>
|
||||||
|
<span class="thinking-indicator hidden">🤔</span>
|
||||||
|
<span class="opponent-name">Sofia</span>
|
||||||
|
...
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### CSS and Animations
|
||||||
|
|
||||||
|
Most animations should use anime.js via CardAnimations class for consistency:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// In CardAnimations class - the startCpuThinking method already exists
|
||||||
|
// Add similar methods for other thinking states:
|
||||||
|
|
||||||
|
startOpponentThinking(opponentArea) {
|
||||||
|
const id = `opponentThinking-${opponentArea.dataset.playerId}`;
|
||||||
|
this.stopOpponentThinking(opponentArea);
|
||||||
|
|
||||||
|
anime({
|
||||||
|
targets: opponentArea,
|
||||||
|
boxShadow: [
|
||||||
|
'0 0 15px rgba(244, 164, 96, 0.4)',
|
||||||
|
'0 0 25px rgba(244, 164, 96, 0.6)',
|
||||||
|
'0 0 15px rgba(244, 164, 96, 0.4)'
|
||||||
|
],
|
||||||
|
duration: 1500,
|
||||||
|
easing: 'easeInOutSine',
|
||||||
|
loop: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
stopOpponentThinking(opponentArea) {
|
||||||
|
anime.remove(opponentArea);
|
||||||
|
opponentArea.style.boxShadow = '';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Minimal CSS for layout only:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Thinking indicator - simple show/hide */
|
||||||
|
.thinking-indicator {
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking-indicator.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Current turn highlight base (animation handled by anime.js) */
|
||||||
|
.opponent-area.current-turn {
|
||||||
|
border-color: #f4a460;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Eye indicator positioning */
|
||||||
|
.pile-eye-indicator {
|
||||||
|
position: absolute;
|
||||||
|
top: -15px;
|
||||||
|
right: -10px;
|
||||||
|
font-size: 1.2em;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For the thinking indicator bobbing, use anime.js:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Animate emoji indicator
|
||||||
|
startThinkingIndicator(element) {
|
||||||
|
anime({
|
||||||
|
targets: element,
|
||||||
|
translateY: [0, -3, 0],
|
||||||
|
duration: 800,
|
||||||
|
easing: 'easeInOutSine',
|
||||||
|
loop: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### JavaScript Updates
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Enhanced consideration state management
|
||||||
|
|
||||||
|
updateConsiderationState() {
|
||||||
|
const currentPlayer = this.gameState?.players?.find(
|
||||||
|
p => p.id === this.gameState.current_player_id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!currentPlayer || currentPlayer.id === this.playerId) {
|
||||||
|
this.clearConsiderationState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasDrawn = this.gameState.has_drawn_card;
|
||||||
|
const isCpu = currentPlayer.is_cpu;
|
||||||
|
|
||||||
|
// Find opponent area
|
||||||
|
const area = this.opponentsRow.querySelector(
|
||||||
|
`.opponent-area[data-player-id="${currentPlayer.id}"]`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!area) return;
|
||||||
|
|
||||||
|
// Show thinking indicator for CPUs
|
||||||
|
const indicator = area.querySelector('.thinking-indicator');
|
||||||
|
if (indicator) {
|
||||||
|
indicator.classList.toggle('hidden', !isCpu || hasDrawn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add thinking class to area
|
||||||
|
area.classList.toggle('thinking', !hasDrawn);
|
||||||
|
|
||||||
|
// Show which pile they might be considering
|
||||||
|
if (!hasDrawn && isCpu) {
|
||||||
|
// CPU AI hint: check if discard is attractive
|
||||||
|
const discardValue = this.getDiscardValue();
|
||||||
|
if (discardValue !== null && discardValue <= 4) {
|
||||||
|
this.discard.classList.add('being-considered');
|
||||||
|
this.deck.classList.remove('being-considered');
|
||||||
|
} else {
|
||||||
|
this.deck.classList.add('being-considered');
|
||||||
|
this.discard.classList.remove('being-considered');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.deck.classList.remove('being-considered');
|
||||||
|
this.discard.classList.remove('being-considered');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clearConsiderationState() {
|
||||||
|
// Remove all consideration indicators
|
||||||
|
this.opponentsRow.querySelectorAll('.thinking-indicator').forEach(el => {
|
||||||
|
el.classList.add('hidden');
|
||||||
|
});
|
||||||
|
this.opponentsRow.querySelectorAll('.opponent-area').forEach(el => {
|
||||||
|
el.classList.remove('thinking');
|
||||||
|
});
|
||||||
|
this.deck.classList.remove('being-considered');
|
||||||
|
this.discard.classList.remove('being-considered');
|
||||||
|
}
|
||||||
|
|
||||||
|
getDiscardValue() {
|
||||||
|
const card = this.gameState?.discard_top;
|
||||||
|
if (!card) return null;
|
||||||
|
|
||||||
|
const values = this.gameState?.card_values || {
|
||||||
|
'A': 1, '2': -2, '3': 3, '4': 4, '5': 5, '6': 6, '7': 7,
|
||||||
|
'8': 8, '9': 9, '10': 10, 'J': 10, 'Q': 10, 'K': 0, '★': -2
|
||||||
|
};
|
||||||
|
|
||||||
|
return values[card.rank] ?? 10;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Server-Side CPU Thinking Delay
|
||||||
|
|
||||||
|
The server should add pauses for CPU thinking (or the client can delay rendering):
|
||||||
|
|
||||||
|
```python
|
||||||
|
# In ai.py or game.py, after CPU makes decision
|
||||||
|
|
||||||
|
async def cpu_take_turn(self, game, player_id):
|
||||||
|
thinking_time = self.profile.get_thinking_time() # 500-1500ms based on profile
|
||||||
|
|
||||||
|
# Pre-draw consideration
|
||||||
|
await asyncio.sleep(thinking_time * 0.5)
|
||||||
|
|
||||||
|
# Make draw decision
|
||||||
|
source = self.decide_draw_source(game, player_id)
|
||||||
|
|
||||||
|
# Broadcast "considering" state
|
||||||
|
await self.broadcast_cpu_considering(game, player_id, source)
|
||||||
|
await asyncio.sleep(thinking_time * 0.3)
|
||||||
|
|
||||||
|
# Execute draw
|
||||||
|
game.draw_card(player_id, source)
|
||||||
|
|
||||||
|
# Post-draw consideration
|
||||||
|
await asyncio.sleep(thinking_time * 0.4)
|
||||||
|
|
||||||
|
# Make swap/discard decision
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternatively, handle all delays on the client side by adding pauses before rendering CPU actions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CPU Personality Integration
|
||||||
|
|
||||||
|
Different AI profiles could have different thinking patterns:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Thinking time variance by personality (from ai.py profiles)
|
||||||
|
const thinkingProfiles = {
|
||||||
|
'Sofia': { baseTime: 1200, variance: 200 }, // Calculated & Patient
|
||||||
|
'Maya': { baseTime: 600, variance: 100 }, // Aggressive Closer
|
||||||
|
'Priya': { baseTime: 1000, variance: 300 }, // Pair Hunter (considers more)
|
||||||
|
'Marcus': { baseTime: 800, variance: 150 }, // Steady Eddie
|
||||||
|
'Kenji': { baseTime: 500, variance: 200 }, // Risk Taker (quick)
|
||||||
|
'Diego': { baseTime: 700, variance: 400 }, // Chaotic Gambler (variable)
|
||||||
|
'River': { baseTime: 900, variance: 250 }, // Adaptive Strategist
|
||||||
|
'Sage': { baseTime: 1100, variance: 150 }, // Sneaky Finisher
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Scenarios
|
||||||
|
|
||||||
|
1. **CPU turn starts** - Area highlights, thinking indicator shows
|
||||||
|
2. **CPU considering discard** - Discard pile glows if valuable card
|
||||||
|
3. **CPU draws** - Thinking indicator changes to held card state
|
||||||
|
4. **CPU swaps** - Brief consideration before swap
|
||||||
|
5. **Human opponent turn** - Area highlights but no thinking indicator
|
||||||
|
6. **Local player turn** - No consideration UI (they know what they're doing)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Current opponent's area highlights during their turn
|
||||||
|
- [ ] CPU players show thinking indicator (emoji)
|
||||||
|
- [ ] Deck/discard shows which pile CPU is considering
|
||||||
|
- [ ] Brief pause before CPU actions (feels like thinking)
|
||||||
|
- [ ] Different CPU personalities have different timing
|
||||||
|
- [ ] Human opponents highlight without thinking indicator
|
||||||
|
- [ ] All indicators clear when turn ends
|
||||||
|
- [ ] Doesn't slow down the game significantly
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
1. Add thinking indicator element to opponent areas
|
||||||
|
2. Add CSS for thinking animations
|
||||||
|
3. Implement `updateConsiderationState()` method
|
||||||
|
4. Implement `clearConsiderationState()` method
|
||||||
|
5. Add pile consideration highlighting
|
||||||
|
6. Integrate CPU thinking delays (server or client)
|
||||||
|
7. Test with various CPU profiles
|
||||||
|
8. Tune timing for natural feel
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes for Agent
|
||||||
|
|
||||||
|
- Use existing CardAnimations methods: `startCpuThinking()`, `stopCpuThinking()`
|
||||||
|
- Add new methods to CardAnimations for opponent area glow
|
||||||
|
- Use anime.js for all looping animations, not CSS keyframes
|
||||||
|
- Keep thinking pauses short enough to not frustrate players
|
||||||
|
- The goal is to make CPUs feel more human, not slow
|
||||||
|
- Different profiles should feel distinct in their play speed
|
||||||
|
- Human players don't need artificial delays
|
||||||
|
- Consider: Option to speed up CPU thinking? (Future setting)
|
||||||
|
- The "being considered" pile indicator is a subtle hint at AI logic
|
||||||
|
- Track animations in `activeAnimations` for proper cleanup
|
||||||
484
docs/v3/V3_07_SCORE_TALLYING.md
Normal file
484
docs/v3/V3_07_SCORE_TALLYING.md
Normal file
@ -0,0 +1,484 @@
|
|||||||
|
# V3-07: Animated Score Tallying
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
In physical card games, scoring involves counting cards one by one, noting pairs, and calculating the total. Currently, scores just appear in the scoreboard. This feature adds animated score counting that highlights each card's contribution.
|
||||||
|
|
||||||
|
**Dependencies:** V3_03 (Round End Reveal should complete before tallying)
|
||||||
|
**Dependents:** None
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. Animate score counting card-by-card
|
||||||
|
2. Highlight each card as its value is added
|
||||||
|
3. Show column pairs canceling to zero
|
||||||
|
4. Running total builds up visibly
|
||||||
|
5. Special effect for negative cards and pairs
|
||||||
|
6. Satisfying "final score" reveal
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
From `showScoreboard()` in app.js:
|
||||||
|
```javascript
|
||||||
|
showScoreboard(scores, isFinal, rankings) {
|
||||||
|
// Scores appear instantly in table
|
||||||
|
// No animation of how score was calculated
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The server calculates scores and sends them. The client just displays them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Tally Sequence
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Round end reveal completes (V3_03)
|
||||||
|
2. Brief pause (300ms)
|
||||||
|
3. For each player (starting with knocker):
|
||||||
|
a. Highlight player area
|
||||||
|
b. Count through each column:
|
||||||
|
- Highlight top card, show value
|
||||||
|
- Highlight bottom card, show value
|
||||||
|
- If pair: show "PAIR! +0" effect
|
||||||
|
- If not pair: add values to running total
|
||||||
|
c. Show final score with flourish
|
||||||
|
d. Move to next player
|
||||||
|
4. Scoreboard slides in with all scores
|
||||||
|
```
|
||||||
|
|
||||||
|
### Visual Elements
|
||||||
|
|
||||||
|
- **Card value overlay** - Temporary badge showing card's point value
|
||||||
|
- **Running total** - Animated counter near player area
|
||||||
|
- **Pair effect** - Special animation when column pair cancels
|
||||||
|
- **Final score** - Large number with celebration effect
|
||||||
|
|
||||||
|
### Timing
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// In timing-config.js
|
||||||
|
tally: {
|
||||||
|
initialPause: 300, // After reveal, before tally
|
||||||
|
cardHighlight: 200, // Duration to show each card value
|
||||||
|
columnPause: 150, // Between columns
|
||||||
|
pairCelebration: 400, // Pair cancel effect
|
||||||
|
playerPause: 500, // Between players
|
||||||
|
finalScoreReveal: 600, // Final score animation
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
### Card Value Overlay
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Create temporary overlay showing card value
|
||||||
|
showCardValue(cardElement, value, isNegative) {
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'card-value-overlay';
|
||||||
|
if (isNegative) overlay.classList.add('negative');
|
||||||
|
if (value === 0) overlay.classList.add('zero');
|
||||||
|
|
||||||
|
const sign = value > 0 ? '+' : '';
|
||||||
|
overlay.textContent = `${sign}${value}`;
|
||||||
|
|
||||||
|
// Position over the card
|
||||||
|
const rect = cardElement.getBoundingClientRect();
|
||||||
|
overlay.style.left = `${rect.left + rect.width / 2}px`;
|
||||||
|
overlay.style.top = `${rect.top + rect.height / 2}px`;
|
||||||
|
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
// Animate in
|
||||||
|
overlay.classList.add('visible');
|
||||||
|
|
||||||
|
return overlay;
|
||||||
|
}
|
||||||
|
|
||||||
|
hideCardValue(overlay) {
|
||||||
|
overlay.classList.remove('visible');
|
||||||
|
setTimeout(() => overlay.remove(), 200);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### CSS for Overlays
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Card value overlay */
|
||||||
|
.card-value-overlay {
|
||||||
|
position: fixed;
|
||||||
|
transform: translate(-50%, -50%) scale(0.5);
|
||||||
|
background: rgba(30, 30, 46, 0.9);
|
||||||
|
color: white;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1.4em;
|
||||||
|
font-weight: bold;
|
||||||
|
opacity: 0;
|
||||||
|
transition: transform 0.2s ease-out, opacity 0.2s ease-out;
|
||||||
|
z-index: 200;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-value-overlay.visible {
|
||||||
|
transform: translate(-50%, -50%) scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-value-overlay.negative {
|
||||||
|
background: linear-gradient(135deg, #27ae60 0%, #1e8449 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-value-overlay.zero {
|
||||||
|
background: linear-gradient(135deg, #f4a460 0%, #d4845a 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Running total */
|
||||||
|
.running-total {
|
||||||
|
position: absolute;
|
||||||
|
bottom: -30px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
color: white;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 15px;
|
||||||
|
font-size: 1.2em;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.running-total.updating {
|
||||||
|
animation: total-bounce 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes total-bounce {
|
||||||
|
0% { transform: translateX(-50%) scale(1); }
|
||||||
|
50% { transform: translateX(-50%) scale(1.1); }
|
||||||
|
100% { transform: translateX(-50%) scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pair cancel effect */
|
||||||
|
.pair-cancel-overlay {
|
||||||
|
position: fixed;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
font-size: 1.2em;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #f4a460;
|
||||||
|
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||||
|
animation: pair-cancel 0.6s ease-out forwards;
|
||||||
|
z-index: 200;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pair-cancel {
|
||||||
|
0% {
|
||||||
|
transform: translate(-50%, -50%) scale(0.5);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
30% {
|
||||||
|
transform: translate(-50%, -50%) scale(1.2);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translate(-50%, -60%) scale(1);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card highlight during tally */
|
||||||
|
.card.tallying {
|
||||||
|
box-shadow: 0 0 15px rgba(244, 164, 96, 0.6);
|
||||||
|
transform: scale(1.05);
|
||||||
|
transition: box-shadow 0.1s, transform 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Final score reveal */
|
||||||
|
.final-score-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%) scale(0);
|
||||||
|
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 20px 40px;
|
||||||
|
border-radius: 15px;
|
||||||
|
text-align: center;
|
||||||
|
z-index: 250;
|
||||||
|
animation: final-score-reveal 0.6s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.final-score-overlay .player-name {
|
||||||
|
font-size: 1em;
|
||||||
|
opacity: 0.8;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.final-score-overlay .score-value {
|
||||||
|
font-size: 3em;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.final-score-overlay .score-value.negative {
|
||||||
|
color: #27ae60;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes final-score-reveal {
|
||||||
|
0% {
|
||||||
|
transform: translate(-50%, -50%) scale(0);
|
||||||
|
}
|
||||||
|
60% {
|
||||||
|
transform: translate(-50%, -50%) scale(1.1);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translate(-50%, -50%) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Main Tally Logic
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async runScoreTally(players, onComplete) {
|
||||||
|
const T = window.TIMING?.tally || {};
|
||||||
|
|
||||||
|
// Initial pause after reveal
|
||||||
|
await this.delay(T.initialPause || 300);
|
||||||
|
|
||||||
|
// Get card values from game state
|
||||||
|
const cardValues = this.gameState?.card_values || this.getDefaultCardValues();
|
||||||
|
|
||||||
|
// Tally each player
|
||||||
|
for (const player of players) {
|
||||||
|
const area = this.getPlayerArea(player.id);
|
||||||
|
if (!area) continue;
|
||||||
|
|
||||||
|
// Highlight player area
|
||||||
|
area.classList.add('tallying-player');
|
||||||
|
|
||||||
|
// Create running total display
|
||||||
|
const runningTotal = document.createElement('div');
|
||||||
|
runningTotal.className = 'running-total';
|
||||||
|
runningTotal.textContent = '0';
|
||||||
|
area.appendChild(runningTotal);
|
||||||
|
|
||||||
|
let total = 0;
|
||||||
|
const cards = area.querySelectorAll('.card');
|
||||||
|
|
||||||
|
// Process each column
|
||||||
|
const columns = [[0, 3], [1, 4], [2, 5]];
|
||||||
|
|
||||||
|
for (const [topIdx, bottomIdx] of columns) {
|
||||||
|
const topCard = cards[topIdx];
|
||||||
|
const bottomCard = cards[bottomIdx];
|
||||||
|
const topData = player.cards[topIdx];
|
||||||
|
const bottomData = player.cards[bottomIdx];
|
||||||
|
|
||||||
|
// Highlight top card
|
||||||
|
topCard.classList.add('tallying');
|
||||||
|
const topValue = cardValues[topData.rank] ?? 0;
|
||||||
|
const topOverlay = this.showCardValue(topCard, topValue, topValue < 0);
|
||||||
|
await this.delay(T.cardHighlight || 200);
|
||||||
|
|
||||||
|
// Highlight bottom card
|
||||||
|
bottomCard.classList.add('tallying');
|
||||||
|
const bottomValue = cardValues[bottomData.rank] ?? 0;
|
||||||
|
const bottomOverlay = this.showCardValue(bottomCard, bottomValue, bottomValue < 0);
|
||||||
|
await this.delay(T.cardHighlight || 200);
|
||||||
|
|
||||||
|
// Check for pair
|
||||||
|
if (topData.rank === bottomData.rank) {
|
||||||
|
// Pair! Show cancel effect
|
||||||
|
this.hideCardValue(topOverlay);
|
||||||
|
this.hideCardValue(bottomOverlay);
|
||||||
|
this.showPairCancel(topCard, bottomCard);
|
||||||
|
await this.delay(T.pairCelebration || 400);
|
||||||
|
} else {
|
||||||
|
// Add values to total
|
||||||
|
total += topValue + bottomValue;
|
||||||
|
this.updateRunningTotal(runningTotal, total);
|
||||||
|
this.hideCardValue(topOverlay);
|
||||||
|
this.hideCardValue(bottomOverlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear card highlights
|
||||||
|
topCard.classList.remove('tallying');
|
||||||
|
bottomCard.classList.remove('tallying');
|
||||||
|
|
||||||
|
await this.delay(T.columnPause || 150);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show final score for this player
|
||||||
|
await this.showFinalScore(player.name, total);
|
||||||
|
await this.delay(T.finalScoreReveal || 600);
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
runningTotal.remove();
|
||||||
|
area.classList.remove('tallying-player');
|
||||||
|
|
||||||
|
await this.delay(T.playerPause || 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
onComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
showPairCancel(card1, card2) {
|
||||||
|
// Position between the two cards
|
||||||
|
const rect1 = card1.getBoundingClientRect();
|
||||||
|
const rect2 = card2.getBoundingClientRect();
|
||||||
|
const centerX = (rect1.left + rect1.right + rect2.left + rect2.right) / 4;
|
||||||
|
const centerY = (rect1.top + rect1.bottom + rect2.top + rect2.bottom) / 4;
|
||||||
|
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'pair-cancel-overlay';
|
||||||
|
overlay.textContent = 'PAIR! +0';
|
||||||
|
overlay.style.left = `${centerX}px`;
|
||||||
|
overlay.style.top = `${centerY}px`;
|
||||||
|
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
// Pulse both cards
|
||||||
|
card1.classList.add('pair-matched');
|
||||||
|
card2.classList.add('pair-matched');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
overlay.remove();
|
||||||
|
card1.classList.remove('pair-matched');
|
||||||
|
card2.classList.remove('pair-matched');
|
||||||
|
}, 600);
|
||||||
|
|
||||||
|
this.playSound('pair');
|
||||||
|
}
|
||||||
|
|
||||||
|
updateRunningTotal(element, value) {
|
||||||
|
element.textContent = value >= 0 ? value : value;
|
||||||
|
element.classList.add('updating');
|
||||||
|
setTimeout(() => element.classList.remove('updating'), 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
async showFinalScore(playerName, score) {
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'final-score-overlay';
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="player-name">${playerName}</div>
|
||||||
|
<div class="score-value ${score < 0 ? 'negative' : ''}">${score}</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
this.playSound(score < 0 ? 'success' : 'card');
|
||||||
|
|
||||||
|
await this.delay(800);
|
||||||
|
|
||||||
|
overlay.style.opacity = '0';
|
||||||
|
overlay.style.transition = 'opacity 0.3s';
|
||||||
|
await this.delay(300);
|
||||||
|
overlay.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
getDefaultCardValues() {
|
||||||
|
return {
|
||||||
|
'A': 1, '2': -2, '3': 3, '4': 4, '5': 5, '6': 6, '7': 7,
|
||||||
|
'8': 8, '9': 9, '10': 10, 'J': 10, 'Q': 10, 'K': 0, '★': -2
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Integration with Round End
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// In runRoundEndReveal completion callback
|
||||||
|
|
||||||
|
async runRoundEndReveal(oldState, newState, onComplete) {
|
||||||
|
// ... existing reveal logic ...
|
||||||
|
|
||||||
|
// After all reveals complete
|
||||||
|
await this.runScoreTally(newState.players, () => {
|
||||||
|
// Now show the scoreboard
|
||||||
|
onComplete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Simplified Mode
|
||||||
|
|
||||||
|
For faster games, offer a simplified tally that just shows final scores:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
if (this.settings.quickTally) {
|
||||||
|
// Just flash the final scores, skip card-by-card
|
||||||
|
for (const player of players) {
|
||||||
|
const score = this.calculateScore(player.cards);
|
||||||
|
await this.showFinalScore(player.name, score);
|
||||||
|
await this.delay(400);
|
||||||
|
}
|
||||||
|
onComplete();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Scenarios
|
||||||
|
|
||||||
|
1. **Normal hand** - Values add up correctly
|
||||||
|
2. **Paired column** - Shows "PAIR! +0" effect
|
||||||
|
3. **All pairs** - Total is 0, multiple pair celebrations
|
||||||
|
4. **Negative cards** - Green highlight, reduces total
|
||||||
|
5. **Multiple players** - Tallies sequentially
|
||||||
|
6. **Various scores** - Positive, negative, zero
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Cards highlight as they're counted
|
||||||
|
- [ ] Point values show as temporary overlays
|
||||||
|
- [ ] Running total updates with each card
|
||||||
|
- [ ] Paired columns show cancel effect
|
||||||
|
- [ ] Final score has celebration animation
|
||||||
|
- [ ] Tally order: knocker first, then clockwise
|
||||||
|
- [ ] Sound effects enhance the experience
|
||||||
|
- [ ] Total time under 10 seconds for 4 players
|
||||||
|
- [ ] Scoreboard appears after tally completes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
1. Add tally timing to `timing-config.js`
|
||||||
|
2. Create CSS for all overlays and animations
|
||||||
|
3. Implement `showCardValue()` and `hideCardValue()`
|
||||||
|
4. Implement `showPairCancel()`
|
||||||
|
5. Implement `updateRunningTotal()`
|
||||||
|
6. Implement `showFinalScore()`
|
||||||
|
7. Implement main `runScoreTally()` method
|
||||||
|
8. Integrate with round end reveal
|
||||||
|
9. Test various scoring scenarios
|
||||||
|
10. Add quick tally option
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes for Agent
|
||||||
|
|
||||||
|
- **CSS vs anime.js**: Use CSS for UI overlays (value badges, running total). Use anime.js for card highlight effects.
|
||||||
|
- Card highlighting can use `window.cardAnimations` methods or simple anime.js calls
|
||||||
|
- The tally should feel satisfying, not tedious
|
||||||
|
- Keep individual card highlight times short
|
||||||
|
- Pair cancellation is a highlight moment - give it emphasis
|
||||||
|
- Consider accessibility: values should be readable
|
||||||
|
- The running total helps players follow the math
|
||||||
|
- Don't forget to handle house rules affecting card values (use `gameState.card_values`)
|
||||||
343
docs/v3/V3_08_CARD_HOVER_SELECTION.md
Normal file
343
docs/v3/V3_08_CARD_HOVER_SELECTION.md
Normal file
@ -0,0 +1,343 @@
|
|||||||
|
# V3-08: Card Hover/Selection Enhancement
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
When holding a drawn card, players must choose which card to swap with. Currently, clicking a card immediately swaps. This feature adds better hover feedback showing the potential swap before committing.
|
||||||
|
|
||||||
|
**Dependencies:** None
|
||||||
|
**Dependents:** None
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. Clear visual preview of the swap before clicking
|
||||||
|
2. Show where the held card will go
|
||||||
|
3. Show where the hand card will go (discard)
|
||||||
|
4. Distinct hover states for face-up vs face-down cards
|
||||||
|
5. Mobile-friendly (no hover, but clear tap targets)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
From `app.js`:
|
||||||
|
```javascript
|
||||||
|
handleCardClick(position) {
|
||||||
|
// ... if holding drawn card ...
|
||||||
|
if (this.drawnCard) {
|
||||||
|
this.animateSwap(position); // Immediately swaps
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Cards have basic hover effects in CSS but no swap preview.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Desktop Hover Preview
|
||||||
|
|
||||||
|
When hovering over a hand card while holding a drawn card:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Hovered card lifts slightly and dims
|
||||||
|
2. Ghost of held card appears in that slot (semi-transparent)
|
||||||
|
3. Arrow or line hints at the swap direction
|
||||||
|
4. "Click to swap" tooltip (optional)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mobile Tap Preview
|
||||||
|
|
||||||
|
Since mobile has no hover:
|
||||||
|
- First tap = select/highlight the card
|
||||||
|
- Second tap = confirm swap
|
||||||
|
- Or: long-press shows preview, release to swap
|
||||||
|
|
||||||
|
**Recommendation:** Immediate swap on tap (current behavior) is fine for mobile. Focus on desktop hover preview.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
### CSS Hover Enhancements
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Card hover when holding drawn card */
|
||||||
|
.player-area.can-swap .card {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.15s, box-shadow 0.15s, opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-area.can-swap .card:hover {
|
||||||
|
transform: translateY(-5px) scale(1.02);
|
||||||
|
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dimmed state showing "this will be replaced" */
|
||||||
|
.player-area.can-swap .card:hover::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border-radius: inherit;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ghost preview of incoming card */
|
||||||
|
.card-ghost-preview {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0.6;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: scale(0.95);
|
||||||
|
z-index: 5;
|
||||||
|
border: 2px dashed rgba(244, 164, 96, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Swap indicator arrow */
|
||||||
|
.swap-indicator {
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 10;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-area.can-swap .card:hover ~ .swap-indicator {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Different highlight for face-down cards */
|
||||||
|
.player-area.can-swap .card.card-back:hover {
|
||||||
|
box-shadow: 0 8px 20px rgba(244, 164, 96, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* "Unknown" indicator for face-down hover */
|
||||||
|
.card.card-back:hover::before {
|
||||||
|
content: '?';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
font-size: 2em;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### JavaScript Implementation
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Add swap preview functionality
|
||||||
|
setupSwapPreview() {
|
||||||
|
this.ghostPreview = document.createElement('div');
|
||||||
|
this.ghostPreview.className = 'card-ghost-preview hidden';
|
||||||
|
this.playerCards.appendChild(this.ghostPreview);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call during render when player is holding a card
|
||||||
|
updateSwapPreviewState() {
|
||||||
|
const canSwap = this.drawnCard && this.isMyTurn();
|
||||||
|
|
||||||
|
this.playerArea.classList.toggle('can-swap', canSwap);
|
||||||
|
|
||||||
|
if (!canSwap) {
|
||||||
|
this.ghostPreview?.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up ghost preview content
|
||||||
|
if (this.drawnCard && this.ghostPreview) {
|
||||||
|
this.ghostPreview.className = 'card-ghost-preview card card-front hidden';
|
||||||
|
|
||||||
|
if (this.drawnCard.rank === '★') {
|
||||||
|
this.ghostPreview.classList.add('joker');
|
||||||
|
} else if (this.isRedSuit(this.drawnCard.suit)) {
|
||||||
|
this.ghostPreview.classList.add('red');
|
||||||
|
} else {
|
||||||
|
this.ghostPreview.classList.add('black');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ghostPreview.innerHTML = this.renderCardContent(this.drawnCard);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bind hover events to cards
|
||||||
|
bindCardHoverEvents() {
|
||||||
|
const cards = this.playerCards.querySelectorAll('.card');
|
||||||
|
|
||||||
|
cards.forEach((card, index) => {
|
||||||
|
card.addEventListener('mouseenter', () => {
|
||||||
|
if (!this.drawnCard || !this.isMyTurn()) return;
|
||||||
|
this.showSwapPreview(card, index);
|
||||||
|
});
|
||||||
|
|
||||||
|
card.addEventListener('mouseleave', () => {
|
||||||
|
this.hideSwapPreview();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
showSwapPreview(targetCard, position) {
|
||||||
|
if (!this.ghostPreview) return;
|
||||||
|
|
||||||
|
// Position ghost at target card location
|
||||||
|
const rect = targetCard.getBoundingClientRect();
|
||||||
|
const containerRect = this.playerCards.getBoundingClientRect();
|
||||||
|
|
||||||
|
this.ghostPreview.style.left = `${rect.left - containerRect.left}px`;
|
||||||
|
this.ghostPreview.style.top = `${rect.top - containerRect.top}px`;
|
||||||
|
this.ghostPreview.style.width = `${rect.width}px`;
|
||||||
|
this.ghostPreview.style.height = `${rect.height}px`;
|
||||||
|
|
||||||
|
this.ghostPreview.classList.remove('hidden');
|
||||||
|
|
||||||
|
// Highlight target card
|
||||||
|
targetCard.classList.add('swap-target');
|
||||||
|
|
||||||
|
// Show what will happen
|
||||||
|
this.setStatus(`Swap with position ${position + 1}`, 'swap-preview');
|
||||||
|
}
|
||||||
|
|
||||||
|
hideSwapPreview() {
|
||||||
|
this.ghostPreview?.classList.add('hidden');
|
||||||
|
|
||||||
|
// Remove target highlight
|
||||||
|
this.playerCards.querySelectorAll('.card').forEach(card => {
|
||||||
|
card.classList.remove('swap-target');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore normal status
|
||||||
|
this.updateStatusFromGameState();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Card Position Labels (Optional Enhancement)
|
||||||
|
|
||||||
|
Show position numbers on cards during swap selection:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.player-area.can-swap .card::before {
|
||||||
|
content: attr(data-position);
|
||||||
|
position: absolute;
|
||||||
|
top: -8px;
|
||||||
|
left: -8px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 11px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// In renderGame, add data-position to cards
|
||||||
|
const cards = this.playerCards.querySelectorAll('.card');
|
||||||
|
cards.forEach((card, i) => {
|
||||||
|
card.dataset.position = i + 1;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Visual Preview Options
|
||||||
|
|
||||||
|
### Option A: Ghost Card (Recommended)
|
||||||
|
|
||||||
|
Semi-transparent copy of the held card appears over the target slot.
|
||||||
|
|
||||||
|
### Option B: Arrow Indicator
|
||||||
|
|
||||||
|
Arrow from held card to target slot, and from target to discard.
|
||||||
|
|
||||||
|
### Option C: Split Preview
|
||||||
|
|
||||||
|
Show both cards side-by-side with swap arrows.
|
||||||
|
|
||||||
|
**Recommendation:** Option A is simplest and most intuitive.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Face-Down Card Interaction
|
||||||
|
|
||||||
|
When swapping with a face-down card, player is taking a risk:
|
||||||
|
|
||||||
|
- Show "?" indicator to emphasize unknown
|
||||||
|
- Maybe show estimated value range? (Too complex for V3)
|
||||||
|
- Different hover color (orange = warning)
|
||||||
|
|
||||||
|
```css
|
||||||
|
.player-area.can-swap .card.card-back:hover {
|
||||||
|
border: 2px solid #f4a460;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-area.can-swap .card.card-back:hover::after {
|
||||||
|
content: 'Unknown';
|
||||||
|
position: absolute;
|
||||||
|
bottom: -20px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
font-size: 0.7em;
|
||||||
|
color: #f4a460;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Scenarios
|
||||||
|
|
||||||
|
1. **Hover over face-up card** - Shows preview, card lifts
|
||||||
|
2. **Hover over face-down card** - Shows warning styling
|
||||||
|
3. **Move between cards** - Preview updates smoothly
|
||||||
|
4. **Mouse leaves card area** - Preview disappears
|
||||||
|
5. **Not holding card** - No special hover effects
|
||||||
|
6. **Not my turn** - No hover effects
|
||||||
|
7. **Mobile tap** - Works without preview (existing behavior)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Cards lift on hover when holding drawn card
|
||||||
|
- [ ] Ghost preview shows incoming card
|
||||||
|
- [ ] Face-down cards have distinct hover (unknown warning)
|
||||||
|
- [ ] Preview disappears on mouse leave
|
||||||
|
- [ ] No effects when not holding card
|
||||||
|
- [ ] No effects when not your turn
|
||||||
|
- [ ] Mobile tap still works normally
|
||||||
|
- [ ] Smooth transitions, no jank
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
1. Add `can-swap` class toggle to player area
|
||||||
|
2. Add CSS for hover lift effect
|
||||||
|
3. Create ghost preview element
|
||||||
|
4. Implement `showSwapPreview()` method
|
||||||
|
5. Implement `hideSwapPreview()` method
|
||||||
|
6. Bind mouseenter/mouseleave events
|
||||||
|
7. Add face-down card distinct styling
|
||||||
|
8. Test on desktop and mobile
|
||||||
|
9. Optional: Add position labels
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes for Agent
|
||||||
|
|
||||||
|
- **CSS vs anime.js**: CSS is appropriate for simple hover effects (performant, no JS overhead)
|
||||||
|
- Keep hover effects performant (CSS transforms preferred)
|
||||||
|
- Don't break existing click-to-swap behavior
|
||||||
|
- Mobile should work exactly as before (immediate swap)
|
||||||
|
- Consider reduced motion preferences
|
||||||
|
- The ghost preview should match the actual card appearance
|
||||||
|
- Position labels help new players understand the grid
|
||||||
451
docs/v3/V3_09_KNOCK_EARLY_DRAMA.md
Normal file
451
docs/v3/V3_09_KNOCK_EARLY_DRAMA.md
Normal file
@ -0,0 +1,451 @@
|
|||||||
|
# V3-09: Knock Early Drama
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The "Knock Early" house rule lets players flip all remaining face-down cards (if 2 or fewer) to immediately trigger final turn. This is a high-risk, high-reward move that deserves dramatic presentation.
|
||||||
|
|
||||||
|
**Dependencies:** None
|
||||||
|
**Dependents:** None
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. Make knock early feel dramatic and consequential
|
||||||
|
2. Show confirmation dialog (optional - it's risky!)
|
||||||
|
3. Dramatic animation when knock happens
|
||||||
|
4. Clear feedback showing the decision
|
||||||
|
5. Other players see "Player X knocked early!"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
From `app.js`:
|
||||||
|
```javascript
|
||||||
|
knockEarly() {
|
||||||
|
if (!this.gameState || !this.gameState.knock_early) return;
|
||||||
|
this.send({ type: 'knock_early' });
|
||||||
|
this.hideToast();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The knock early button exists but there's no special visual treatment.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Knock Early Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Player clicks "Knock Early" button
|
||||||
|
2. Confirmation prompt: "Reveal your hidden cards and go out?"
|
||||||
|
3. If confirmed:
|
||||||
|
a. Dramatic sound effect
|
||||||
|
b. Player's hidden cards flip rapidly in sequence
|
||||||
|
c. "KNOCK!" banner appears
|
||||||
|
d. Final turn badge triggers
|
||||||
|
4. Other players see announcement
|
||||||
|
```
|
||||||
|
|
||||||
|
### Visual Elements
|
||||||
|
|
||||||
|
- **Confirmation dialog** - "Are you sure?" with preview
|
||||||
|
- **Rapid flip animation** - Cards flip faster than normal
|
||||||
|
- **"KNOCK!" banner** - Large dramatic announcement
|
||||||
|
- **Screen shake** (subtle) - Impact feeling
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
### Confirmation Dialog
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
knockEarly() {
|
||||||
|
if (!this.gameState || !this.gameState.knock_early) return;
|
||||||
|
|
||||||
|
// Count hidden cards
|
||||||
|
const myData = this.getMyPlayerData();
|
||||||
|
const hiddenCards = myData.cards.filter(c => !c.face_up);
|
||||||
|
|
||||||
|
if (hiddenCards.length === 0 || hiddenCards.length > 2) {
|
||||||
|
return; // Can't knock
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show confirmation
|
||||||
|
this.showKnockConfirmation(hiddenCards.length, () => {
|
||||||
|
this.executeKnockEarly();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
showKnockConfirmation(hiddenCount, onConfirm) {
|
||||||
|
// Create modal
|
||||||
|
const modal = document.createElement('div');
|
||||||
|
modal.className = 'knock-confirm-modal';
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="knock-confirm-content">
|
||||||
|
<div class="knock-confirm-icon">⚡</div>
|
||||||
|
<h3>Knock Early?</h3>
|
||||||
|
<p>You'll reveal ${hiddenCount} hidden card${hiddenCount > 1 ? 's' : ''} and trigger final turn.</p>
|
||||||
|
<p class="knock-warning">This cannot be undone!</p>
|
||||||
|
<div class="knock-confirm-buttons">
|
||||||
|
<button class="btn btn-secondary knock-cancel">Cancel</button>
|
||||||
|
<button class="btn btn-primary knock-confirm">Knock!</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
|
||||||
|
// Bind events
|
||||||
|
modal.querySelector('.knock-cancel').addEventListener('click', () => {
|
||||||
|
this.playSound('click');
|
||||||
|
modal.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
modal.querySelector('.knock-confirm').addEventListener('click', () => {
|
||||||
|
this.playSound('click');
|
||||||
|
modal.remove();
|
||||||
|
onConfirm();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click outside to cancel
|
||||||
|
modal.addEventListener('click', (e) => {
|
||||||
|
if (e.target === modal) {
|
||||||
|
modal.remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async executeKnockEarly() {
|
||||||
|
// Play dramatic sound
|
||||||
|
this.playSound('knock');
|
||||||
|
|
||||||
|
// Get positions of hidden cards
|
||||||
|
const myData = this.getMyPlayerData();
|
||||||
|
const hiddenPositions = myData.cards
|
||||||
|
.map((card, i) => ({ card, position: i }))
|
||||||
|
.filter(({ card }) => !card.face_up)
|
||||||
|
.map(({ position }) => position);
|
||||||
|
|
||||||
|
// Start rapid flip animation
|
||||||
|
await this.animateKnockFlips(hiddenPositions);
|
||||||
|
|
||||||
|
// Show KNOCK banner
|
||||||
|
this.showKnockBanner();
|
||||||
|
|
||||||
|
// Send to server
|
||||||
|
this.send({ type: 'knock_early' });
|
||||||
|
this.hideToast();
|
||||||
|
}
|
||||||
|
|
||||||
|
async animateKnockFlips(positions) {
|
||||||
|
// Rapid sequential flips
|
||||||
|
const flipDelay = 150; // Faster than normal
|
||||||
|
|
||||||
|
for (const position of positions) {
|
||||||
|
const myData = this.getMyPlayerData();
|
||||||
|
const card = myData.cards[position];
|
||||||
|
this.fireLocalFlipAnimation(position, card);
|
||||||
|
this.playSound('flip');
|
||||||
|
await this.delay(flipDelay);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for last flip
|
||||||
|
await this.delay(300);
|
||||||
|
}
|
||||||
|
|
||||||
|
showKnockBanner() {
|
||||||
|
const banner = document.createElement('div');
|
||||||
|
banner.className = 'knock-banner';
|
||||||
|
banner.innerHTML = '<span>KNOCK!</span>';
|
||||||
|
document.body.appendChild(banner);
|
||||||
|
|
||||||
|
// Screen shake effect
|
||||||
|
document.body.classList.add('screen-shake');
|
||||||
|
|
||||||
|
// Remove after animation
|
||||||
|
setTimeout(() => {
|
||||||
|
banner.classList.add('fading');
|
||||||
|
document.body.classList.remove('screen-shake');
|
||||||
|
}, 800);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
banner.remove();
|
||||||
|
}, 1100);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### CSS
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Knock confirmation modal */
|
||||||
|
.knock-confirm-modal {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 300;
|
||||||
|
animation: modal-fade-in 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modal-fade-in {
|
||||||
|
0% { opacity: 0; }
|
||||||
|
100% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.knock-confirm-content {
|
||||||
|
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||||
|
padding: 30px;
|
||||||
|
border-radius: 15px;
|
||||||
|
text-align: center;
|
||||||
|
max-width: 320px;
|
||||||
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
|
||||||
|
animation: modal-scale-in 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modal-scale-in {
|
||||||
|
0% { transform: scale(0.9); }
|
||||||
|
100% { transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.knock-confirm-icon {
|
||||||
|
font-size: 3em;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.knock-confirm-content h3 {
|
||||||
|
margin: 0 0 15px;
|
||||||
|
color: #f4a460;
|
||||||
|
}
|
||||||
|
|
||||||
|
.knock-confirm-content p {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.knock-warning {
|
||||||
|
color: #e74c3c !important;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.knock-confirm-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.knock-confirm-buttons .btn {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* KNOCK banner */
|
||||||
|
.knock-banner {
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%) scale(0);
|
||||||
|
z-index: 400;
|
||||||
|
pointer-events: none;
|
||||||
|
animation: knock-banner-in 0.3s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.knock-banner span {
|
||||||
|
display: block;
|
||||||
|
font-size: 4em;
|
||||||
|
font-weight: 900;
|
||||||
|
color: #f4a460;
|
||||||
|
text-shadow:
|
||||||
|
0 0 20px rgba(244, 164, 96, 0.8),
|
||||||
|
0 0 40px rgba(244, 164, 96, 0.4),
|
||||||
|
2px 2px 0 #1a1a2e;
|
||||||
|
letter-spacing: 0.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes knock-banner-in {
|
||||||
|
0% {
|
||||||
|
transform: translate(-50%, -50%) scale(0);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translate(-50%, -50%) scale(1.2);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translate(-50%, -50%) scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.knock-banner.fading {
|
||||||
|
animation: knock-banner-out 0.3s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes knock-banner-out {
|
||||||
|
0% {
|
||||||
|
transform: translate(-50%, -50%) scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translate(-50%, -50%) scale(0.8);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Screen shake effect */
|
||||||
|
@keyframes screen-shake {
|
||||||
|
0%, 100% { transform: translateX(0); }
|
||||||
|
20% { transform: translateX(-3px); }
|
||||||
|
40% { transform: translateX(3px); }
|
||||||
|
60% { transform: translateX(-2px); }
|
||||||
|
80% { transform: translateX(2px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
body.screen-shake {
|
||||||
|
animation: screen-shake 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Enhanced knock early button */
|
||||||
|
#knock-early-btn {
|
||||||
|
background: linear-gradient(135deg, #ff6b35 0%, #d63031 100%);
|
||||||
|
animation: knock-btn-pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes knock-btn-pulse {
|
||||||
|
0%, 100% {
|
||||||
|
box-shadow: 0 2px 10px rgba(214, 48, 49, 0.3);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 2px 20px rgba(214, 48, 49, 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Knock Sound
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// In playSound() method
|
||||||
|
} else if (type === 'knock') {
|
||||||
|
// Dramatic "knock" sound - low thud
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(ctx.destination);
|
||||||
|
|
||||||
|
osc.type = 'sine';
|
||||||
|
osc.frequency.setValueAtTime(80, ctx.currentTime);
|
||||||
|
osc.frequency.exponentialRampToValueAtTime(40, ctx.currentTime + 0.15);
|
||||||
|
|
||||||
|
gain.gain.setValueAtTime(0.4, ctx.currentTime);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.2);
|
||||||
|
|
||||||
|
osc.start(ctx.currentTime);
|
||||||
|
osc.stop(ctx.currentTime + 0.2);
|
||||||
|
|
||||||
|
// Secondary impact
|
||||||
|
setTimeout(() => {
|
||||||
|
const osc2 = ctx.createOscillator();
|
||||||
|
const gain2 = ctx.createGain();
|
||||||
|
osc2.connect(gain2);
|
||||||
|
gain2.connect(ctx.destination);
|
||||||
|
osc2.type = 'sine';
|
||||||
|
osc2.frequency.setValueAtTime(60, ctx.currentTime);
|
||||||
|
gain2.gain.setValueAtTime(0.2, ctx.currentTime);
|
||||||
|
gain2.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.1);
|
||||||
|
osc2.start(ctx.currentTime);
|
||||||
|
osc2.stop(ctx.currentTime + 0.1);
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Opponent Sees Knock
|
||||||
|
|
||||||
|
When another player knocks, show announcement:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// In state change detection or game_state handler
|
||||||
|
|
||||||
|
if (newState.phase === 'final_turn' && oldState?.phase !== 'final_turn') {
|
||||||
|
const knocker = newState.players.find(p => p.id === newState.finisher_id);
|
||||||
|
if (knocker && knocker.id !== this.playerId) {
|
||||||
|
// Someone else knocked
|
||||||
|
this.showOpponentKnockAnnouncement(knocker.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showOpponentKnockAnnouncement(playerName) {
|
||||||
|
this.playSound('alert');
|
||||||
|
|
||||||
|
const banner = document.createElement('div');
|
||||||
|
banner.className = 'opponent-knock-banner';
|
||||||
|
banner.innerHTML = `<span>${playerName} knocked!</span>`;
|
||||||
|
document.body.appendChild(banner);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
banner.classList.add('fading');
|
||||||
|
}, 1500);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
banner.remove();
|
||||||
|
}, 1800);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Scenarios
|
||||||
|
|
||||||
|
1. **Knock with 1 hidden card** - Single flip, then knock banner
|
||||||
|
2. **Knock with 2 hidden cards** - Rapid double flip
|
||||||
|
3. **Cancel confirmation** - Modal closes, no action
|
||||||
|
4. **Opponent knocks** - See announcement
|
||||||
|
5. **Can't knock (3+ hidden)** - Button disabled
|
||||||
|
6. **Can't knock (all face-up)** - Button disabled
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Confirmation dialog appears before knock
|
||||||
|
- [ ] Dialog shows number of cards to reveal
|
||||||
|
- [ ] Cancel button works
|
||||||
|
- [ ] Knock triggers rapid flip animation
|
||||||
|
- [ ] "KNOCK!" banner appears with fanfare
|
||||||
|
- [ ] Subtle screen shake effect
|
||||||
|
- [ ] Other players see announcement
|
||||||
|
- [ ] Final turn triggers after knock
|
||||||
|
- [ ] Sound effects enhance the drama
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
1. Add knock sound to `playSound()`
|
||||||
|
2. Implement `showKnockConfirmation()` method
|
||||||
|
3. Implement `executeKnockEarly()` method
|
||||||
|
4. Implement `animateKnockFlips()` method
|
||||||
|
5. Implement `showKnockBanner()` method
|
||||||
|
6. Add CSS for modal and banner
|
||||||
|
7. Implement opponent knock announcement
|
||||||
|
8. Add screen shake effect
|
||||||
|
9. Test all scenarios
|
||||||
|
10. Tune timing for maximum drama
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes for Agent
|
||||||
|
|
||||||
|
- **CSS vs anime.js**: CSS is fine for modal/button animations (UI chrome). Screen shake can use anime.js for precision.
|
||||||
|
- The confirmation prevents accidental knocks (it's irreversible)
|
||||||
|
- Keep animation fast - drama without delay
|
||||||
|
- The screen shake should be subtle (accessibility)
|
||||||
|
- Consider: skip confirmation option for experienced players?
|
||||||
|
- Make sure knock works even if animations fail
|
||||||
394
docs/v3/V3_10_COLUMN_PAIR_INDICATOR.md
Normal file
394
docs/v3/V3_10_COLUMN_PAIR_INDICATOR.md
Normal file
@ -0,0 +1,394 @@
|
|||||||
|
# V3-10: Column Pair Indicator
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
When two cards in a column match (forming a pair that scores 0), there's currently no persistent visual indicator. This feature adds a subtle connector showing paired columns at a glance.
|
||||||
|
|
||||||
|
**Dependencies:** V3_04 (Column Pair Celebration - this builds on that)
|
||||||
|
**Dependents:** None
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. Show which columns are currently paired
|
||||||
|
2. Visual connector between paired cards
|
||||||
|
3. Score indicator showing "+0" or "locked"
|
||||||
|
4. Don't clutter the interface
|
||||||
|
5. Help new players understand pairing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
After V3_04 (celebration), pairs get a brief animation when formed. But after that animation, there's no indication which columns are paired. Players must remember or scan visually.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Visual Options
|
||||||
|
|
||||||
|
**Option A: Connecting Line**
|
||||||
|
Draw a subtle line or bracket connecting paired cards.
|
||||||
|
|
||||||
|
**Option B: Shared Glow**
|
||||||
|
Both cards have a subtle shared glow color.
|
||||||
|
|
||||||
|
**Option C: Zero Badge**
|
||||||
|
Small "0" badge on the column.
|
||||||
|
|
||||||
|
**Option D: Lock Icon**
|
||||||
|
Small lock icon indicating "locked in" pair.
|
||||||
|
|
||||||
|
**Recommendation:** Option A (line) + Option C (badge) - clear and informative.
|
||||||
|
|
||||||
|
### Visual Treatment
|
||||||
|
|
||||||
|
```
|
||||||
|
Normal columns: Paired column:
|
||||||
|
┌───┐ ┌───┐ ┌───┐ ─┐
|
||||||
|
│ K │ │ 7 │ │ 5 │ │ [0]
|
||||||
|
└───┘ └───┘ └───┘ │
|
||||||
|
│
|
||||||
|
┌───┐ ┌───┐ ┌───┐ ─┘
|
||||||
|
│ Q │ │ 3 │ │ 5 │
|
||||||
|
└───┘ └───┘ └───┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
### Detecting Pairs
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
getColumnPairs(cards) {
|
||||||
|
const pairs = [];
|
||||||
|
const columns = [[0, 3], [1, 4], [2, 5]];
|
||||||
|
|
||||||
|
for (let i = 0; i < columns.length; i++) {
|
||||||
|
const [top, bottom] = columns[i];
|
||||||
|
const topCard = cards[top];
|
||||||
|
const bottomCard = cards[bottom];
|
||||||
|
|
||||||
|
if (topCard?.face_up && bottomCard?.face_up &&
|
||||||
|
topCard?.rank && topCard.rank === bottomCard?.rank) {
|
||||||
|
pairs.push({
|
||||||
|
column: i,
|
||||||
|
topPosition: top,
|
||||||
|
bottomPosition: bottom,
|
||||||
|
rank: topCard.rank
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pairs;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rendering Pair Indicators
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
renderPairIndicators(playerId, cards) {
|
||||||
|
const pairs = this.getColumnPairs(cards);
|
||||||
|
const container = this.getPairIndicatorContainer(playerId);
|
||||||
|
|
||||||
|
// Clear existing indicators
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
if (pairs.length === 0) return;
|
||||||
|
|
||||||
|
const cardElements = this.getCardElements(playerId);
|
||||||
|
|
||||||
|
for (const pair of pairs) {
|
||||||
|
const topCard = cardElements[pair.topPosition];
|
||||||
|
const bottomCard = cardElements[pair.bottomPosition];
|
||||||
|
|
||||||
|
if (!topCard || !bottomCard) continue;
|
||||||
|
|
||||||
|
// Create connector line
|
||||||
|
const connector = this.createPairConnector(topCard, bottomCard, pair.column);
|
||||||
|
container.appendChild(connector);
|
||||||
|
|
||||||
|
// Add paired class to cards
|
||||||
|
topCard.classList.add('paired');
|
||||||
|
bottomCard.classList.add('paired');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createPairConnector(topCard, bottomCard, columnIndex) {
|
||||||
|
const connector = document.createElement('div');
|
||||||
|
connector.className = 'pair-connector';
|
||||||
|
connector.dataset.column = columnIndex;
|
||||||
|
|
||||||
|
// Calculate position
|
||||||
|
const topRect = topCard.getBoundingClientRect();
|
||||||
|
const bottomRect = bottomCard.getBoundingClientRect();
|
||||||
|
const containerRect = topCard.closest('.card-grid').getBoundingClientRect();
|
||||||
|
|
||||||
|
// Position connector to the right of the column
|
||||||
|
const x = topRect.right - containerRect.left + 5;
|
||||||
|
const y = topRect.top - containerRect.top;
|
||||||
|
const height = bottomRect.bottom - topRect.top;
|
||||||
|
|
||||||
|
connector.style.cssText = `
|
||||||
|
left: ${x}px;
|
||||||
|
top: ${y}px;
|
||||||
|
height: ${height}px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Add zero badge
|
||||||
|
const badge = document.createElement('div');
|
||||||
|
badge.className = 'pair-badge';
|
||||||
|
badge.textContent = '0';
|
||||||
|
connector.appendChild(badge);
|
||||||
|
|
||||||
|
return connector;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPairIndicatorContainer(playerId) {
|
||||||
|
// Get or create indicator container
|
||||||
|
const area = playerId === this.playerId
|
||||||
|
? this.playerCards
|
||||||
|
: this.opponentsRow.querySelector(`[data-player-id="${playerId}"] .card-grid`);
|
||||||
|
|
||||||
|
if (!area) return document.createElement('div'); // Fallback
|
||||||
|
|
||||||
|
let container = area.querySelector('.pair-indicators');
|
||||||
|
if (!container) {
|
||||||
|
container = document.createElement('div');
|
||||||
|
container.className = 'pair-indicators';
|
||||||
|
area.style.position = 'relative';
|
||||||
|
area.appendChild(container);
|
||||||
|
}
|
||||||
|
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### CSS
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Pair indicators container */
|
||||||
|
.pair-indicators {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Connector line */
|
||||||
|
.pair-connector {
|
||||||
|
position: absolute;
|
||||||
|
width: 3px;
|
||||||
|
background: linear-gradient(180deg,
|
||||||
|
rgba(244, 164, 96, 0.6) 0%,
|
||||||
|
rgba(244, 164, 96, 0.8) 50%,
|
||||||
|
rgba(244, 164, 96, 0.6) 100%
|
||||||
|
);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bracket style alternative */
|
||||||
|
.pair-connector::before,
|
||||||
|
.pair-connector::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
width: 8px;
|
||||||
|
height: 3px;
|
||||||
|
background: rgba(244, 164, 96, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pair-connector::before {
|
||||||
|
top: 0;
|
||||||
|
border-radius: 2px 0 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pair-connector::after {
|
||||||
|
bottom: 0;
|
||||||
|
border-radius: 0 0 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Zero badge */
|
||||||
|
.pair-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
background: #f4a460;
|
||||||
|
color: #1a1a2e;
|
||||||
|
font-size: 0.7em;
|
||||||
|
font-weight: bold;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 10px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Paired card subtle highlight */
|
||||||
|
.card.paired {
|
||||||
|
box-shadow: 0 0 8px rgba(244, 164, 96, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Opponent paired cards - smaller/subtler */
|
||||||
|
.opponent-area .pair-connector {
|
||||||
|
width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opponent-area .pair-badge {
|
||||||
|
font-size: 0.6em;
|
||||||
|
padding: 1px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opponent-area .card.paired {
|
||||||
|
box-shadow: 0 0 5px rgba(244, 164, 96, 0.2);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration with renderGame
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// In renderGame(), after rendering cards
|
||||||
|
renderGame() {
|
||||||
|
// ... existing rendering ...
|
||||||
|
|
||||||
|
// Update pair indicators for all players
|
||||||
|
for (const player of this.gameState.players) {
|
||||||
|
this.renderPairIndicators(player.id, player.cards);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Handling Window Resize
|
||||||
|
|
||||||
|
Pair connectors are positioned absolutely, so they need updating on resize:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
constructor() {
|
||||||
|
// ... existing constructor ...
|
||||||
|
|
||||||
|
// Debounced resize handler for pair indicators
|
||||||
|
window.addEventListener('resize', this.debounce(() => {
|
||||||
|
if (this.gameState) {
|
||||||
|
for (const player of this.gameState.players) {
|
||||||
|
this.renderPairIndicators(player.id, player.cards);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
debounce(fn, delay) {
|
||||||
|
let timeout;
|
||||||
|
return (...args) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
timeout = setTimeout(() => fn.apply(this, args), delay);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Alternative: CSS-Only Approach
|
||||||
|
|
||||||
|
Simpler approach using only CSS classes:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// In renderGame, just add classes
|
||||||
|
for (const player of this.gameState.players) {
|
||||||
|
const pairs = this.getColumnPairs(player.cards);
|
||||||
|
const cards = this.getCardElements(player.id);
|
||||||
|
|
||||||
|
// Clear previous
|
||||||
|
cards.forEach(c => c.classList.remove('paired', 'pair-top', 'pair-bottom'));
|
||||||
|
|
||||||
|
for (const pair of pairs) {
|
||||||
|
cards[pair.topPosition]?.classList.add('paired', 'pair-top');
|
||||||
|
cards[pair.bottomPosition]?.classList.add('paired', 'pair-bottom');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* CSS-only pair indication */
|
||||||
|
.card.pair-top {
|
||||||
|
border-bottom: 3px solid #f4a460;
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
border-bottom-right-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.pair-bottom {
|
||||||
|
border-top: 3px solid #f4a460;
|
||||||
|
border-top-left-radius: 0;
|
||||||
|
border-top-right-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.paired::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
right: -10px;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 3px;
|
||||||
|
background: rgba(244, 164, 96, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.pair-bottom::after {
|
||||||
|
top: -100%; /* Extend up to connect */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Recommendation:** Start with CSS-only approach. Add connector elements if more visual clarity needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Scenarios
|
||||||
|
|
||||||
|
1. **Single pair** - One column shows indicator
|
||||||
|
2. **Multiple pairs** - Multiple indicators (rare but possible)
|
||||||
|
3. **No pairs** - No indicators
|
||||||
|
4. **Pair broken** - Indicator disappears
|
||||||
|
5. **Pair formed** - Indicator appears (after celebration)
|
||||||
|
6. **Face-down card in column** - No indicator
|
||||||
|
7. **Opponent pairs** - Smaller indicators visible
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Paired columns show visual connector
|
||||||
|
- [ ] "0" badge indicates the score contribution
|
||||||
|
- [ ] Indicators update when cards change
|
||||||
|
- [ ] Works for local player and opponents
|
||||||
|
- [ ] Smaller/subtler for opponents
|
||||||
|
- [ ] Handles window resize
|
||||||
|
- [ ] Doesn't clutter interface
|
||||||
|
- [ ] Helps new players understand pairing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
1. Implement `getColumnPairs()` method
|
||||||
|
2. Choose approach: CSS-only or connector elements
|
||||||
|
3. If connector: implement `createPairConnector()`
|
||||||
|
4. Add CSS for indicators
|
||||||
|
5. Integrate into `renderGame()`
|
||||||
|
6. Add resize handling
|
||||||
|
7. Test various pair scenarios
|
||||||
|
8. Adjust styling for opponents
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes for Agent
|
||||||
|
|
||||||
|
- **CSS vs anime.js**: CSS is appropriate for static indicators (not animated elements)
|
||||||
|
- Keep indicators subtle - informative not distracting
|
||||||
|
- Opponent indicators should be smaller/lighter
|
||||||
|
- CSS-only approach is simpler to maintain
|
||||||
|
- The badge helps players learning the scoring system
|
||||||
|
- Consider: toggle option to hide indicators? (For experienced players)
|
||||||
|
- Make sure indicators don't overlap cards on mobile
|
||||||
317
docs/v3/V3_11_SWAP_ANIMATION_IMPROVEMENTS.md
Normal file
317
docs/v3/V3_11_SWAP_ANIMATION_IMPROVEMENTS.md
Normal file
@ -0,0 +1,317 @@
|
|||||||
|
# V3-11: Swap Animation Improvements
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
When swapping a drawn card with a hand card, the current animation uses a "flip in place + teleport" approach. Physical card games have cards that slide past each other. This feature improves the swap animation to feel more physical.
|
||||||
|
|
||||||
|
**Dependencies:** None
|
||||||
|
**Dependents:** None
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. Cards visibly exchange positions (not teleport)
|
||||||
|
2. Old card slides toward discard
|
||||||
|
3. New card slides into hand slot
|
||||||
|
4. Brief "crossing" moment visible
|
||||||
|
5. Smooth, performant animation
|
||||||
|
6. Works for both face-up and face-down swaps
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
From `card-animations.js` (CardAnimations class):
|
||||||
|
```javascript
|
||||||
|
// Current swap uses anime.js with pulse effect for face-up swaps
|
||||||
|
// and flip animation for face-down swaps
|
||||||
|
|
||||||
|
animateSwap(position, oldCard, newCard, handCardElement, onComplete) {
|
||||||
|
if (isAlreadyFaceUp) {
|
||||||
|
// Face-up swap: subtle pulse, no flip needed
|
||||||
|
this._animateFaceUpSwap(handCardElement, onComplete);
|
||||||
|
} else {
|
||||||
|
// Face-down swap: flip reveal then swap
|
||||||
|
this._animateFaceDownSwap(position, oldCard, handCardElement, onComplete);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_animateFaceUpSwap(handCardElement, onComplete) {
|
||||||
|
anime({
|
||||||
|
targets: handCardElement,
|
||||||
|
scale: [1, 0.92, 1.08, 1],
|
||||||
|
filter: ['brightness(1)', 'brightness(0.85)', 'brightness(1.15)', 'brightness(1)'],
|
||||||
|
duration: 400,
|
||||||
|
easing: 'easeOutQuad'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The current animation uses a pulse effect for face-up swaps and a flip reveal for face-down swaps. It works but lacks the physical feeling of cards moving past each other.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Animation Sequence
|
||||||
|
|
||||||
|
```
|
||||||
|
1. If face-down: Flip hand card to reveal (existing)
|
||||||
|
2. Lift both cards slightly (z-index, shadow)
|
||||||
|
3. Hand card arcs toward discard pile
|
||||||
|
4. Held card arcs toward hand slot
|
||||||
|
5. Cards cross paths visually (middle of arc)
|
||||||
|
6. Cards land at destinations
|
||||||
|
7. Landing pulse effect
|
||||||
|
```
|
||||||
|
|
||||||
|
### Arc Paths
|
||||||
|
|
||||||
|
Instead of straight lines, cards follow curved paths:
|
||||||
|
|
||||||
|
```
|
||||||
|
Hand card path
|
||||||
|
╭─────────────────╮
|
||||||
|
│ │
|
||||||
|
[Hand] [Discard]
|
||||||
|
│ │
|
||||||
|
╰─────────────────╯
|
||||||
|
Held card path
|
||||||
|
```
|
||||||
|
|
||||||
|
The curves create a visual "exchange" moment.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
### Enhanced Swap Animation (Add to CardAnimations class)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// In card-animations.js - enhance the existing animateSwap method
|
||||||
|
|
||||||
|
async animatePhysicalSwap(handCardEl, heldCardEl, handRect, discardRect, holdingRect, onComplete) {
|
||||||
|
const T = window.TIMING?.swap || {
|
||||||
|
lift: 80,
|
||||||
|
arc: 280,
|
||||||
|
settle: 60,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create animation elements that will travel
|
||||||
|
const travelingHandCard = this.createTravelingCard(handCardEl);
|
||||||
|
const travelingHeldCard = this.createTravelingCard(heldCardEl);
|
||||||
|
|
||||||
|
document.body.appendChild(travelingHandCard);
|
||||||
|
document.body.appendChild(travelingHeldCard);
|
||||||
|
|
||||||
|
// Position at start
|
||||||
|
this.positionAt(travelingHandCard, handRect);
|
||||||
|
this.positionAt(travelingHeldCard, holdingRect || discardRect);
|
||||||
|
|
||||||
|
// Hide originals
|
||||||
|
handCardEl.style.visibility = 'hidden';
|
||||||
|
heldCardEl.style.visibility = 'hidden';
|
||||||
|
|
||||||
|
this.playSound('card');
|
||||||
|
|
||||||
|
// Use anime.js timeline for coordinated arc movement
|
||||||
|
const timeline = anime.timeline({
|
||||||
|
easing: this.getEasing('move'),
|
||||||
|
complete: () => {
|
||||||
|
travelingHandCard.remove();
|
||||||
|
travelingHeldCard.remove();
|
||||||
|
handCardEl.style.visibility = 'visible';
|
||||||
|
heldCardEl.style.visibility = 'visible';
|
||||||
|
this.pulseDiscard();
|
||||||
|
if (onComplete) onComplete();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate arc midpoints
|
||||||
|
const midY1 = (handRect.top + discardRect.top) / 2 - 40; // Arc up
|
||||||
|
const midY2 = ((holdingRect || discardRect).top + handRect.top) / 2 + 40; // Arc down
|
||||||
|
|
||||||
|
// Step 1: Lift both cards with shadow increase
|
||||||
|
timeline.add({
|
||||||
|
targets: [travelingHandCard, travelingHeldCard],
|
||||||
|
translateY: -10,
|
||||||
|
boxShadow: '0 8px 30px rgba(0, 0, 0, 0.5)',
|
||||||
|
scale: 1.02,
|
||||||
|
duration: T.lift,
|
||||||
|
easing: this.getEasing('lift')
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 2: Hand card arcs to discard
|
||||||
|
timeline.add({
|
||||||
|
targets: travelingHandCard,
|
||||||
|
left: discardRect.left,
|
||||||
|
top: [
|
||||||
|
{ value: midY1, duration: T.arc / 2 },
|
||||||
|
{ value: discardRect.top, duration: T.arc / 2 }
|
||||||
|
],
|
||||||
|
rotate: [0, -5, 0],
|
||||||
|
duration: T.arc,
|
||||||
|
}, `-=${T.lift / 2}`);
|
||||||
|
|
||||||
|
// Held card arcs to hand (in parallel)
|
||||||
|
timeline.add({
|
||||||
|
targets: travelingHeldCard,
|
||||||
|
left: handRect.left,
|
||||||
|
top: [
|
||||||
|
{ value: midY2, duration: T.arc / 2 },
|
||||||
|
{ value: handRect.top, duration: T.arc / 2 }
|
||||||
|
],
|
||||||
|
rotate: [0, 5, 0],
|
||||||
|
duration: T.arc,
|
||||||
|
}, `-=${T.arc + T.lift / 2}`);
|
||||||
|
|
||||||
|
// Step 3: Settle
|
||||||
|
timeline.add({
|
||||||
|
targets: [travelingHandCard, travelingHeldCard],
|
||||||
|
translateY: 0,
|
||||||
|
boxShadow: '0 2px 10px rgba(0, 0, 0, 0.3)',
|
||||||
|
scale: 1,
|
||||||
|
duration: T.settle,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.activeAnimations.set('physicalSwap', timeline);
|
||||||
|
}
|
||||||
|
|
||||||
|
createTravelingCard(sourceCard) {
|
||||||
|
const clone = sourceCard.cloneNode(true);
|
||||||
|
clone.className = 'traveling-card';
|
||||||
|
clone.style.position = 'fixed';
|
||||||
|
clone.style.pointerEvents = 'none';
|
||||||
|
clone.style.zIndex = '1000';
|
||||||
|
clone.style.borderRadius = '6px';
|
||||||
|
return clone;
|
||||||
|
}
|
||||||
|
|
||||||
|
positionAt(element, rect) {
|
||||||
|
element.style.left = `${rect.left}px`;
|
||||||
|
element.style.top = `${rect.top}px`;
|
||||||
|
element.style.width = `${rect.width}px`;
|
||||||
|
element.style.height = `${rect.height}px`;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### CSS for Traveling Cards
|
||||||
|
|
||||||
|
Minimal CSS needed - anime.js handles all animation properties including box-shadow and scale:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Traveling card during swap - base styles only */
|
||||||
|
.traveling-card {
|
||||||
|
position: fixed;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
|
||||||
|
/* All animation handled by anime.js */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Timing Configuration
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// In timing-config.js
|
||||||
|
swap: {
|
||||||
|
lift: 80, // Time to lift cards
|
||||||
|
arc: 280, // Time for arc travel
|
||||||
|
settle: 60, // Time to settle into place
|
||||||
|
// Total: ~420ms (similar to current)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Note on Animation Approach
|
||||||
|
|
||||||
|
All swap animations use anime.js timelines, not CSS transitions or Web Animations API. This provides:
|
||||||
|
- Better coordination between multiple elements
|
||||||
|
- Consistent with rest of animation system
|
||||||
|
- Easier timing control via `window.TIMING`
|
||||||
|
- Proper animation cancellation via `activeAnimations` tracking
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Integration Points
|
||||||
|
|
||||||
|
### For Local Player Swap
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// In animateSwap() method
|
||||||
|
animateSwap(position) {
|
||||||
|
const cardElements = this.playerCards.querySelectorAll('.card');
|
||||||
|
const handCardEl = cardElements[position];
|
||||||
|
|
||||||
|
// Get positions
|
||||||
|
const handRect = handCardEl.getBoundingClientRect();
|
||||||
|
const discardRect = this.discard.getBoundingClientRect();
|
||||||
|
const holdingRect = this.getHoldingRect();
|
||||||
|
|
||||||
|
// If face-down, flip first (existing logic)
|
||||||
|
// ...
|
||||||
|
|
||||||
|
// Then do physical swap
|
||||||
|
this.animatePhysicalSwap(
|
||||||
|
handCardEl,
|
||||||
|
this.heldCardFloating,
|
||||||
|
handRect,
|
||||||
|
discardRect,
|
||||||
|
holdingRect
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### For Opponent Swap
|
||||||
|
|
||||||
|
The opponent swap animation in `fireSwapAnimation()` can use similar arc logic for the visible card traveling to discard.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Scenarios
|
||||||
|
|
||||||
|
1. **Swap face-up card** - Direct arc exchange
|
||||||
|
2. **Swap face-down card** - Flip first, then arc
|
||||||
|
3. **Fast repeated swaps** - No animation overlap
|
||||||
|
4. **Mobile** - Animation performs at 60fps
|
||||||
|
5. **Different screen sizes** - Arcs scale appropriately
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Cards visibly travel to new positions (not teleport)
|
||||||
|
- [ ] Arc paths create "crossing" visual
|
||||||
|
- [ ] Lift and settle effects enhance physicality
|
||||||
|
- [ ] Animation total time ~400ms (not slower than current)
|
||||||
|
- [ ] Works for face-up and face-down cards
|
||||||
|
- [ ] Performant on mobile (60fps)
|
||||||
|
- [ ] Landing effect on discard pile
|
||||||
|
- [ ] Opponent swaps also improved
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
1. Add swap timing to `timing-config.js`
|
||||||
|
2. Implement `createTravelingCard()` helper
|
||||||
|
3. Implement `animateArc()` with Web Animations API
|
||||||
|
4. Implement `animatePhysicalSwap()` method
|
||||||
|
5. Add CSS for traveling cards
|
||||||
|
6. Integrate with local player swap
|
||||||
|
7. Integrate with opponent swap animation
|
||||||
|
8. Test on various devices
|
||||||
|
9. Tune arc height and timing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes for Agent
|
||||||
|
|
||||||
|
- Add `animatePhysicalSwap()` to the existing CardAnimations class
|
||||||
|
- Use anime.js timelines for coordinated multi-element animation
|
||||||
|
- Arc height should scale with card distance
|
||||||
|
- The "crossing" moment is the key visual improvement
|
||||||
|
- Keep total animation time similar to current (~400ms)
|
||||||
|
- Track animation in `activeAnimations` for proper cancellation
|
||||||
|
- Consider: option for "fast mode" with simpler animations?
|
||||||
|
- Make sure sound timing aligns with visual (card leaving hand)
|
||||||
|
- Existing `animateSwap()` can call this new method internally
|
||||||
279
docs/v3/V3_12_DRAW_SOURCE_DISTINCTION.md
Normal file
279
docs/v3/V3_12_DRAW_SOURCE_DISTINCTION.md
Normal file
@ -0,0 +1,279 @@
|
|||||||
|
# V3-12: Draw Source Distinction
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Drawing from the deck (face-down, unknown) vs discard (face-up, known) should feel different. Currently both animations are similar. This feature enhances the visual distinction.
|
||||||
|
|
||||||
|
**Dependencies:** None
|
||||||
|
**Dependents:** None
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. Deck draw: Card emerges face-down, then flips
|
||||||
|
2. Discard draw: Card lifts straight up (already visible)
|
||||||
|
3. Different sound for each source
|
||||||
|
4. Visual hint about the strategic difference
|
||||||
|
5. Help new players understand the two options
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
From `card-animations.js` (CardAnimations class):
|
||||||
|
```javascript
|
||||||
|
// Deck draw: suspenseful pause + flip reveal
|
||||||
|
animateDrawDeck(cardData, onComplete) {
|
||||||
|
// Pulse deck, lift card face-down, move to holding, suspense pause, flip
|
||||||
|
timeline.add({ targets: inner, rotateY: 0, duration: 245 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discard draw: quick decisive grab
|
||||||
|
animateDrawDiscard(cardData, onComplete) {
|
||||||
|
// Pulse discard, quick lift, direct move to holding (no flip needed)
|
||||||
|
timeline.add({ targets: animCard, translateY: -12, scale: 1.05, duration: 42 });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The distinction exists and is already fairly pronounced. This feature enhances it further with:
|
||||||
|
- More distinct sounds for each source
|
||||||
|
- Visual "shuffleDeckVisual" effect when drawing from deck
|
||||||
|
- Better timing contrast
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Deck Draw (Unknown)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Deck "shuffles" slightly (optional)
|
||||||
|
2. Top card lifts off deck
|
||||||
|
3. Card floats to holding position (face-down)
|
||||||
|
4. Brief suspense pause
|
||||||
|
5. Card flips to reveal
|
||||||
|
6. Sound: "mysterious" flip sound
|
||||||
|
```
|
||||||
|
|
||||||
|
### Discard Draw (Known)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Card lifts directly (quick)
|
||||||
|
2. No flip needed - already visible
|
||||||
|
3. Moves to holding position
|
||||||
|
4. "Picked up" visual on discard pile
|
||||||
|
5. Sound: quick "pick" sound
|
||||||
|
```
|
||||||
|
|
||||||
|
### Visual Distinction
|
||||||
|
|
||||||
|
| Aspect | Deck Draw | Discard Draw |
|
||||||
|
|--------|-----------|--------------|
|
||||||
|
| Card state | Face-down → Face-up | Face-up entire time |
|
||||||
|
| Motion | Float + flip | Direct lift |
|
||||||
|
| Sound | Suspenseful flip | Quick pick |
|
||||||
|
| Duration | Longer (suspense) | Shorter (decisive) |
|
||||||
|
| Deck visual | Cards shuffle | N/A |
|
||||||
|
| Discard visual | N/A | "Picked up" state |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
### Enhanced Deck Draw
|
||||||
|
|
||||||
|
The existing `animateDrawDeck()` in `card-animations.js` already has most of this functionality. Enhancements to add:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// In card-animations.js - enhance existing animateDrawDeck
|
||||||
|
|
||||||
|
// The current implementation already:
|
||||||
|
// - Pulses deck before drawing (startDrawPulse)
|
||||||
|
// - Lifts card with wobble
|
||||||
|
// - Adds suspense pause before flip
|
||||||
|
// - Flips to reveal with sound
|
||||||
|
|
||||||
|
// Add distinct sound for deck draws:
|
||||||
|
animateDrawDeck(cardData, onComplete) {
|
||||||
|
// ... existing code ...
|
||||||
|
|
||||||
|
// Change sound from 'card' to 'draw-deck' for more mysterious feel
|
||||||
|
this.playSound('draw-deck'); // Instead of 'card'
|
||||||
|
|
||||||
|
// ... rest of existing code ...
|
||||||
|
}
|
||||||
|
|
||||||
|
// The shuffleDeckVisual already exists as startDrawPulse:
|
||||||
|
startDrawPulse(element) {
|
||||||
|
if (!element) return;
|
||||||
|
element.classList.add('draw-pulse');
|
||||||
|
setTimeout(() => {
|
||||||
|
element.classList.remove('draw-pulse');
|
||||||
|
}, 450);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key existing features:**
|
||||||
|
- `startDrawPulse()` - gold ring pulse effect
|
||||||
|
- Suspense pause of 200ms before flip
|
||||||
|
- Flip duration 245ms with `easeInOutQuad` easing
|
||||||
|
|
||||||
|
### Enhanced Discard Draw
|
||||||
|
|
||||||
|
The existing `animateDrawDiscard()` in `card-animations.js` already has quick, decisive animation:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Current implementation already does:
|
||||||
|
// - Pulses discard before picking up (startDrawPulse)
|
||||||
|
// - Quick lift (42ms) with scale
|
||||||
|
// - Direct move (126ms) - much faster than deck draw
|
||||||
|
// - No flip needed (card already face-up)
|
||||||
|
|
||||||
|
// Enhancement: Add distinct sound for discard draws
|
||||||
|
_animateDrawDiscardCard(cardData, discardRect, holdingRect, onComplete) {
|
||||||
|
// ... existing code ...
|
||||||
|
|
||||||
|
// Change sound from 'card' to 'draw-discard' for decisive feel
|
||||||
|
this.playSound('draw-discard'); // Instead of 'card'
|
||||||
|
|
||||||
|
// ... rest of existing code ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Current timing comparison (already implemented):**
|
||||||
|
|
||||||
|
| Phase | Deck Draw | Discard Draw |
|
||||||
|
|-------|-----------|--------------|
|
||||||
|
| Pulse delay | 250ms | 200ms |
|
||||||
|
| Lift | 105ms | 42ms |
|
||||||
|
| Travel | 175ms | 126ms |
|
||||||
|
| Suspense | 200ms | 0ms |
|
||||||
|
| Flip | 245ms | 0ms |
|
||||||
|
| Settle | 150ms | 80ms |
|
||||||
|
| **Total** | **~1125ms** | **~448ms** |
|
||||||
|
|
||||||
|
The distinction is already pronounced - discard draw is ~2.5x faster.
|
||||||
|
|
||||||
|
### Deck Visual Effects
|
||||||
|
|
||||||
|
The `draw-pulse` class already exists with a CSS animation (gold ring expanding). For additional deck depth effect, use CSS only:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Deck "depth" visual - multiple card shadows */
|
||||||
|
#deck {
|
||||||
|
box-shadow:
|
||||||
|
1px 1px 0 0 rgba(0, 0, 0, 0.1),
|
||||||
|
2px 2px 0 0 rgba(0, 0, 0, 0.1),
|
||||||
|
3px 3px 0 0 rgba(0, 0, 0, 0.1),
|
||||||
|
4px 4px 8px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Existing draw-pulse animation handles the visual feedback */
|
||||||
|
.draw-pulse {
|
||||||
|
/* Already defined in style.css */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Distinct Sounds
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// In playSound() method
|
||||||
|
|
||||||
|
} else if (type === 'draw-deck') {
|
||||||
|
// Mysterious "what's this?" sound
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(ctx.destination);
|
||||||
|
|
||||||
|
osc.type = 'triangle';
|
||||||
|
osc.frequency.setValueAtTime(300, ctx.currentTime);
|
||||||
|
osc.frequency.exponentialRampToValueAtTime(500, ctx.currentTime + 0.1);
|
||||||
|
osc.frequency.exponentialRampToValueAtTime(350, ctx.currentTime + 0.15);
|
||||||
|
|
||||||
|
gain.gain.setValueAtTime(0.08, ctx.currentTime);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.2);
|
||||||
|
|
||||||
|
osc.start(ctx.currentTime);
|
||||||
|
osc.stop(ctx.currentTime + 0.2);
|
||||||
|
|
||||||
|
} else if (type === 'draw-discard') {
|
||||||
|
// Quick decisive "grab" sound
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(ctx.destination);
|
||||||
|
|
||||||
|
osc.type = 'square';
|
||||||
|
osc.frequency.setValueAtTime(600, ctx.currentTime);
|
||||||
|
osc.frequency.exponentialRampToValueAtTime(300, ctx.currentTime + 0.05);
|
||||||
|
|
||||||
|
gain.gain.setValueAtTime(0.08, ctx.currentTime);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.06);
|
||||||
|
|
||||||
|
osc.start(ctx.currentTime);
|
||||||
|
osc.stop(ctx.currentTime + 0.06);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Timing Comparison
|
||||||
|
|
||||||
|
| Phase | Deck Draw | Discard Draw |
|
||||||
|
|-------|-----------|--------------|
|
||||||
|
| Lift | 150ms | 80ms |
|
||||||
|
| Travel | 250ms | 200ms |
|
||||||
|
| Suspense | 200ms | 0ms |
|
||||||
|
| Flip | 350ms | 0ms |
|
||||||
|
| Settle | 150ms | 80ms |
|
||||||
|
| **Total** | **~1100ms** | **~360ms** |
|
||||||
|
|
||||||
|
Deck draw is intentionally longer to build suspense.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Scenarios
|
||||||
|
|
||||||
|
1. **Draw from deck** - Longer animation with flip
|
||||||
|
2. **Draw from discard** - Quick decisive grab
|
||||||
|
3. **Rapid alternating draws** - Animations don't conflict
|
||||||
|
4. **CPU draws** - Same visual distinction
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Deck draw has suspenseful pause before flip
|
||||||
|
- [ ] Discard draw is quick and direct
|
||||||
|
- [ ] Different sounds for each source
|
||||||
|
- [ ] Deck shows visual "dealing" effect
|
||||||
|
- [ ] Timing difference is noticeable but not tedious
|
||||||
|
- [ ] Both animations complete cleanly
|
||||||
|
- [ ] Works for both local player and opponents
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
1. Add distinct sounds to `playSound()`
|
||||||
|
2. Enhance `animateDrawDeck()` with suspense
|
||||||
|
3. Enhance `animateDrawDiscard()` for quick grab
|
||||||
|
4. Add deck visual effects (CSS)
|
||||||
|
5. Add `shuffleDeckVisual()` method
|
||||||
|
6. Test both draw types
|
||||||
|
7. Tune timing for feel
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes for Agent
|
||||||
|
|
||||||
|
- Most of this is already implemented in `card-animations.js`
|
||||||
|
- Main enhancement is adding distinct sounds (`draw-deck` vs `draw-discard`)
|
||||||
|
- The existing timing difference (1125ms vs 448ms) is already significant
|
||||||
|
- Deck draw suspense shouldn't be annoying, just noticeable
|
||||||
|
- Discard draw being faster reflects the strategic advantage (you know what you're getting)
|
||||||
|
- Consider: Show deck count visual changing? (Nice to have)
|
||||||
|
- Sound design matters here - different tones communicate different meanings
|
||||||
|
- Mobile performance should still be smooth
|
||||||
399
docs/v3/V3_13_CARD_VALUE_TOOLTIPS.md
Normal file
399
docs/v3/V3_13_CARD_VALUE_TOOLTIPS.md
Normal file
@ -0,0 +1,399 @@
|
|||||||
|
# V3-13: Card Value Tooltips
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
New players often forget card values, especially special cards (2=-2, K=0, Joker=-2). This feature adds tooltips showing card point values on long-press or hover.
|
||||||
|
|
||||||
|
**Dependencies:** None
|
||||||
|
**Dependents:** None
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. Show card point value on long-press (mobile) or hover (desktop)
|
||||||
|
2. Especially helpful for special value cards
|
||||||
|
3. Show house rule modified values if active
|
||||||
|
4. Don't interfere with normal gameplay
|
||||||
|
5. Optional: disable for experienced players
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
No card value tooltips exist. Players must remember:
|
||||||
|
- Standard values: A=1, 2-10=face, J/Q=10, K=0
|
||||||
|
- Special values: 2=-2, Joker=-2
|
||||||
|
- House rules: super_kings=-2, ten_penny=1, etc.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Tooltip Content
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────┐
|
||||||
|
│ K │ ← Normal card display
|
||||||
|
│ ♠ │
|
||||||
|
└─────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌───────┐
|
||||||
|
│ 0 pts │ ← Tooltip on hover/long-press
|
||||||
|
└───────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
For special cards:
|
||||||
|
```
|
||||||
|
┌────────────┐
|
||||||
|
│ -2 pts │
|
||||||
|
│ (negative!)│
|
||||||
|
└────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Activation
|
||||||
|
|
||||||
|
- **Desktop:** Hover for 500ms (not instant to avoid cluttering)
|
||||||
|
- **Mobile:** Long-press (300ms threshold)
|
||||||
|
- **Dismiss:** Mouse leave / touch release
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
### JavaScript
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Card tooltip system
|
||||||
|
|
||||||
|
initCardTooltips() {
|
||||||
|
this.tooltip = document.createElement('div');
|
||||||
|
this.tooltip.className = 'card-value-tooltip hidden';
|
||||||
|
document.body.appendChild(this.tooltip);
|
||||||
|
|
||||||
|
this.tooltipTimeout = null;
|
||||||
|
this.currentTooltipTarget = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
bindCardTooltipEvents(cardElement, cardData) {
|
||||||
|
// Desktop hover
|
||||||
|
cardElement.addEventListener('mouseenter', () => {
|
||||||
|
this.scheduleTooltip(cardElement, cardData);
|
||||||
|
});
|
||||||
|
|
||||||
|
cardElement.addEventListener('mouseleave', () => {
|
||||||
|
this.hideCardTooltip();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mobile long-press
|
||||||
|
let pressTimer = null;
|
||||||
|
|
||||||
|
cardElement.addEventListener('touchstart', (e) => {
|
||||||
|
pressTimer = setTimeout(() => {
|
||||||
|
this.showCardTooltip(cardElement, cardData);
|
||||||
|
// Prevent triggering card click
|
||||||
|
e.preventDefault();
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
cardElement.addEventListener('touchend', () => {
|
||||||
|
clearTimeout(pressTimer);
|
||||||
|
this.hideCardTooltip();
|
||||||
|
});
|
||||||
|
|
||||||
|
cardElement.addEventListener('touchmove', () => {
|
||||||
|
clearTimeout(pressTimer);
|
||||||
|
this.hideCardTooltip();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleTooltip(cardElement, cardData) {
|
||||||
|
this.hideCardTooltip();
|
||||||
|
|
||||||
|
if (!cardData?.face_up || !cardData?.rank) return;
|
||||||
|
|
||||||
|
this.tooltipTimeout = setTimeout(() => {
|
||||||
|
this.showCardTooltip(cardElement, cardData);
|
||||||
|
}, 500); // 500ms delay on desktop
|
||||||
|
}
|
||||||
|
|
||||||
|
showCardTooltip(cardElement, cardData) {
|
||||||
|
if (!cardData?.face_up || !cardData?.rank) return;
|
||||||
|
|
||||||
|
const value = this.getCardPointValue(cardData);
|
||||||
|
const special = this.getCardSpecialNote(cardData);
|
||||||
|
|
||||||
|
// Build tooltip content
|
||||||
|
let content = `<span class="tooltip-value ${value < 0 ? 'negative' : ''}">${value} pts</span>`;
|
||||||
|
if (special) {
|
||||||
|
content += `<span class="tooltip-note">${special}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.tooltip.innerHTML = content;
|
||||||
|
|
||||||
|
// Position tooltip
|
||||||
|
const rect = cardElement.getBoundingClientRect();
|
||||||
|
const tooltipRect = this.tooltip.getBoundingClientRect();
|
||||||
|
|
||||||
|
let left = rect.left + rect.width / 2;
|
||||||
|
let top = rect.bottom + 8;
|
||||||
|
|
||||||
|
// Keep on screen
|
||||||
|
if (left + tooltipRect.width / 2 > window.innerWidth) {
|
||||||
|
left = window.innerWidth - tooltipRect.width / 2 - 10;
|
||||||
|
}
|
||||||
|
if (left - tooltipRect.width / 2 < 0) {
|
||||||
|
left = tooltipRect.width / 2 + 10;
|
||||||
|
}
|
||||||
|
if (top + tooltipRect.height > window.innerHeight) {
|
||||||
|
top = rect.top - tooltipRect.height - 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.tooltip.style.left = `${left}px`;
|
||||||
|
this.tooltip.style.top = `${top}px`;
|
||||||
|
this.tooltip.classList.remove('hidden');
|
||||||
|
|
||||||
|
this.currentTooltipTarget = cardElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
hideCardTooltip() {
|
||||||
|
clearTimeout(this.tooltipTimeout);
|
||||||
|
this.tooltip.classList.add('hidden');
|
||||||
|
this.currentTooltipTarget = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCardPointValue(cardData) {
|
||||||
|
const values = this.gameState?.card_values || {
|
||||||
|
'A': 1, '2': -2, '3': 3, '4': 4, '5': 5, '6': 6, '7': 7,
|
||||||
|
'8': 8, '9': 9, '10': 10, 'J': 10, 'Q': 10, 'K': 0, '★': -2
|
||||||
|
};
|
||||||
|
|
||||||
|
return values[cardData.rank] ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCardSpecialNote(cardData) {
|
||||||
|
const rank = cardData.rank;
|
||||||
|
const value = this.getCardPointValue(cardData);
|
||||||
|
|
||||||
|
// Special notes for notable cards
|
||||||
|
if (value < 0) {
|
||||||
|
return 'Negative - keep it!';
|
||||||
|
}
|
||||||
|
if (rank === 'K' && value === 0) {
|
||||||
|
return 'Safe card';
|
||||||
|
}
|
||||||
|
if (rank === 'K' && value === -2) {
|
||||||
|
return 'Super King!';
|
||||||
|
}
|
||||||
|
if (rank === '10' && value === 1) {
|
||||||
|
return 'Ten Penny rule';
|
||||||
|
}
|
||||||
|
if (rank === 'J' || rank === 'Q') {
|
||||||
|
return 'High - replace if possible';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### CSS
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Card value tooltip */
|
||||||
|
.card-value-tooltip {
|
||||||
|
position: fixed;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: rgba(26, 26, 46, 0.95);
|
||||||
|
color: white;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
text-align: center;
|
||||||
|
z-index: 500;
|
||||||
|
pointer-events: none;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-value-tooltip.hidden {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-value-tooltip::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -6px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
border: 6px solid transparent;
|
||||||
|
border-bottom-color: rgba(26, 26, 46, 0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-value {
|
||||||
|
display: block;
|
||||||
|
font-size: 1.2em;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-value.negative {
|
||||||
|
color: #27ae60;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-note {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Visual indicator that tooltip is available */
|
||||||
|
.card[data-has-tooltip]:hover {
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration with renderGame
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// In renderGame, after creating card elements
|
||||||
|
renderPlayerCards() {
|
||||||
|
// ... existing card rendering ...
|
||||||
|
|
||||||
|
const cards = this.playerCards.querySelectorAll('.card');
|
||||||
|
const myData = this.getMyPlayerData();
|
||||||
|
|
||||||
|
cards.forEach((cardEl, i) => {
|
||||||
|
const cardData = myData?.cards[i];
|
||||||
|
if (cardData?.face_up) {
|
||||||
|
cardEl.dataset.hasTooltip = 'true';
|
||||||
|
this.bindCardTooltipEvents(cardEl, cardData);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Similar for opponent cards
|
||||||
|
renderOpponentCards(player, container) {
|
||||||
|
// ... existing card rendering ...
|
||||||
|
|
||||||
|
const cards = container.querySelectorAll('.card');
|
||||||
|
player.cards.forEach((cardData, i) => {
|
||||||
|
if (cardData?.face_up && cards[i]) {
|
||||||
|
cards[i].dataset.hasTooltip = 'true';
|
||||||
|
this.bindCardTooltipEvents(cards[i], cardData);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## House Rule Awareness
|
||||||
|
|
||||||
|
Tooltip values should reflect active house rules:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
getCardPointValue(cardData) {
|
||||||
|
// Use server-provided values which include house rules
|
||||||
|
if (this.gameState?.card_values) {
|
||||||
|
return this.gameState.card_values[cardData.rank] ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to defaults
|
||||||
|
return DEFAULT_CARD_VALUES[cardData.rank] ?? 0;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The server already provides `card_values` in game state that accounts for:
|
||||||
|
- `super_kings` (K = -2)
|
||||||
|
- `ten_penny` (10 = 1)
|
||||||
|
- `lucky_swing` (Joker = -5)
|
||||||
|
- etc.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
- Only bind tooltip events to face-up cards
|
||||||
|
- Remove tooltip events when cards re-render
|
||||||
|
- Use event delegation if performance becomes an issue
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Event delegation approach
|
||||||
|
this.playerCards.addEventListener('mouseenter', (e) => {
|
||||||
|
const card = e.target.closest('.card');
|
||||||
|
if (card && card.dataset.hasTooltip) {
|
||||||
|
const cardData = this.getCardDataForElement(card);
|
||||||
|
this.scheduleTooltip(card, cardData);
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Settings Option (Optional)
|
||||||
|
|
||||||
|
Let players disable tooltips:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// In settings
|
||||||
|
this.showCardTooltips = localStorage.getItem('showCardTooltips') !== 'false';
|
||||||
|
|
||||||
|
// Check before showing
|
||||||
|
showCardTooltip(cardElement, cardData) {
|
||||||
|
if (!this.showCardTooltips) return;
|
||||||
|
// ... rest of method
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Scenarios
|
||||||
|
|
||||||
|
1. **Hover on face-up card** - Tooltip appears after delay
|
||||||
|
2. **Long-press on mobile** - Tooltip appears
|
||||||
|
3. **Move mouse away** - Tooltip disappears
|
||||||
|
4. **Face-down card** - No tooltip
|
||||||
|
5. **Special cards (K, 2, Joker)** - Show special note
|
||||||
|
6. **House rules active** - Modified values shown
|
||||||
|
7. **Rapid card changes** - No stale tooltips
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Hover (500ms delay) shows tooltip on desktop
|
||||||
|
- [ ] Long-press (300ms) shows tooltip on mobile
|
||||||
|
- [ ] Tooltip shows point value
|
||||||
|
- [ ] Negative values highlighted green
|
||||||
|
- [ ] Special notes for notable cards
|
||||||
|
- [ ] House rule modified values displayed
|
||||||
|
- [ ] Tooltips don't interfere with gameplay
|
||||||
|
- [ ] Tooltips position correctly (stay on screen)
|
||||||
|
- [ ] Face-down cards have no tooltip
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
1. Create tooltip element and basic CSS
|
||||||
|
2. Implement `showCardTooltip()` method
|
||||||
|
3. Implement `hideCardTooltip()` method
|
||||||
|
4. Add desktop hover events
|
||||||
|
5. Add mobile long-press events
|
||||||
|
6. Integrate with `renderGame()`
|
||||||
|
7. Add house rule awareness
|
||||||
|
8. Test on mobile and desktop
|
||||||
|
9. Optional: Add settings toggle
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes for Agent
|
||||||
|
|
||||||
|
- **CSS vs anime.js**: CSS is appropriate for tooltip show/hide transitions (simple UI)
|
||||||
|
- The 500ms delay prevents tooltips appearing during normal play
|
||||||
|
- Mobile long-press should be discoverable but not intrusive
|
||||||
|
- Use server-provided `card_values` for house rule accuracy
|
||||||
|
- Consider: Quick reference card in rules screen? (Separate feature)
|
||||||
|
- Don't show tooltip during swap animation
|
||||||
332
docs/v3/V3_14_ACTIVE_RULES_CONTEXT.md
Normal file
332
docs/v3/V3_14_ACTIVE_RULES_CONTEXT.md
Normal file
@ -0,0 +1,332 @@
|
|||||||
|
# V3-14: Active Rules Context
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The active rules bar shows which house rules are in effect, but doesn't highlight when a rule is relevant to the current action. This feature adds contextual highlighting to help players understand rule effects.
|
||||||
|
|
||||||
|
**Dependencies:** None
|
||||||
|
**Dependents:** None
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. Highlight relevant rules during specific actions
|
||||||
|
2. Brief explanatory tooltip when rule affects play
|
||||||
|
3. Help players learn how rules work
|
||||||
|
4. Don't clutter the interface
|
||||||
|
5. Fade after the moment passes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
From `app.js`:
|
||||||
|
```javascript
|
||||||
|
updateActiveRulesBar() {
|
||||||
|
const rules = this.gameState.active_rules || [];
|
||||||
|
if (rules.length === 0) {
|
||||||
|
this.activeRulesList.innerHTML = '<span class="rule-tag standard">Standard</span>';
|
||||||
|
} else {
|
||||||
|
// Show rule tags
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules are listed but never highlighted contextually.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Contextual Highlighting Moments
|
||||||
|
|
||||||
|
| Moment | Relevant Rule(s) | Highlight Text |
|
||||||
|
|--------|------------------|----------------|
|
||||||
|
| Discard from deck | flip_mode: always | "Must flip a card!" |
|
||||||
|
| Player knocks | knock_penalty | "+10 if not lowest!" |
|
||||||
|
| Player knocks | knock_bonus | "-5 for going out first" |
|
||||||
|
| Pair negative cards | negative_pairs_keep_value | "Pairs keep -4!" |
|
||||||
|
| Draw Joker | lucky_swing | "Worth -5!" |
|
||||||
|
| Round end | underdog_bonus | "-3 for lowest score" |
|
||||||
|
| Score = 21 | blackjack | "Blackjack! Score → 0" |
|
||||||
|
| Four Jacks | wolfpack | "-20 Wolfpack bonus!" |
|
||||||
|
|
||||||
|
### Visual Treatment
|
||||||
|
|
||||||
|
```
|
||||||
|
Normal: [Speed Golf] [Knock Penalty]
|
||||||
|
|
||||||
|
Highlighted: [Speed Golf ← Must flip!] [Knock Penalty]
|
||||||
|
↑
|
||||||
|
Pulsing, expanded
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
### Rule Highlight Method
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
highlightRule(ruleKey, message, duration = 3000) {
|
||||||
|
const ruleTag = this.activeRulesList.querySelector(
|
||||||
|
`[data-rule="${ruleKey}"]`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!ruleTag) return;
|
||||||
|
|
||||||
|
// Add highlight class
|
||||||
|
ruleTag.classList.add('rule-highlighted');
|
||||||
|
|
||||||
|
// Add message
|
||||||
|
const messageEl = document.createElement('span');
|
||||||
|
messageEl.className = 'rule-message';
|
||||||
|
messageEl.textContent = message;
|
||||||
|
ruleTag.appendChild(messageEl);
|
||||||
|
|
||||||
|
// Remove after duration
|
||||||
|
setTimeout(() => {
|
||||||
|
ruleTag.classList.remove('rule-highlighted');
|
||||||
|
messageEl.remove();
|
||||||
|
}, duration);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration Points
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// In handleMessage or state change handlers
|
||||||
|
|
||||||
|
// 1. Speed Golf - must flip after discard
|
||||||
|
case 'can_flip':
|
||||||
|
if (!data.optional && this.gameState.flip_mode === 'always') {
|
||||||
|
this.highlightRule('flip_mode', 'Must flip a card!');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
// 2. Knock penalty warning
|
||||||
|
knockEarly() {
|
||||||
|
if (this.gameState.knock_penalty) {
|
||||||
|
this.highlightRule('knock_penalty', '+10 if not lowest!', 4000);
|
||||||
|
}
|
||||||
|
// ... rest of knock logic
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Lucky swing Joker
|
||||||
|
case 'card_drawn':
|
||||||
|
if (data.card.rank === '★' && this.gameState.lucky_swing) {
|
||||||
|
this.highlightRule('lucky_swing', 'Worth -5!');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
// 4. Blackjack at round end
|
||||||
|
showScoreboard(scores, isFinal, rankings) {
|
||||||
|
// Check for blackjack
|
||||||
|
for (const [playerId, score] of Object.entries(scores)) {
|
||||||
|
if (score === 0 && this.wasOriginallyBlackjack(playerId)) {
|
||||||
|
this.highlightRule('blackjack', 'Blackjack! 21 → 0');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ... rest of scoreboard logic
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update Rule Rendering
|
||||||
|
|
||||||
|
Add data attributes for targeting:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
updateActiveRulesBar() {
|
||||||
|
const rules = this.gameState.active_rules || [];
|
||||||
|
|
||||||
|
if (rules.length === 0) {
|
||||||
|
this.activeRulesList.innerHTML = '<span class="rule-tag standard">Standard</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.activeRulesList.innerHTML = rules
|
||||||
|
.map(rule => {
|
||||||
|
const key = this.getRuleKey(rule);
|
||||||
|
return `<span class="rule-tag" data-rule="${key}">${rule}</span>`;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
getRuleKey(ruleName) {
|
||||||
|
// Convert display name to key
|
||||||
|
const mapping = {
|
||||||
|
'Speed Golf': 'flip_mode',
|
||||||
|
'Endgame Flip': 'flip_mode',
|
||||||
|
'Knock Penalty': 'knock_penalty',
|
||||||
|
'Knock Bonus': 'knock_bonus',
|
||||||
|
'Super Kings': 'super_kings',
|
||||||
|
'Ten Penny': 'ten_penny',
|
||||||
|
'Lucky Swing': 'lucky_swing',
|
||||||
|
'Eagle Eye': 'eagle_eye',
|
||||||
|
'Underdog': 'underdog_bonus',
|
||||||
|
'Tied Shame': 'tied_shame',
|
||||||
|
'Blackjack': 'blackjack',
|
||||||
|
'Wolfpack': 'wolfpack',
|
||||||
|
'Flip Action': 'flip_as_action',
|
||||||
|
'4 of a Kind': 'four_of_a_kind',
|
||||||
|
'Negative Pairs': 'negative_pairs_keep_value',
|
||||||
|
'One-Eyed Jacks': 'one_eyed_jacks',
|
||||||
|
'Knock Early': 'knock_early',
|
||||||
|
};
|
||||||
|
return mapping[ruleName] || ruleName.toLowerCase().replace(/\s+/g, '_');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### CSS
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Rule tag base */
|
||||||
|
.rule-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.8em;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Highlighted rule */
|
||||||
|
.rule-tag.rule-highlighted {
|
||||||
|
background: rgba(244, 164, 96, 0.3);
|
||||||
|
box-shadow: 0 0 10px rgba(244, 164, 96, 0.4);
|
||||||
|
animation: rule-pulse 0.5s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rule-pulse {
|
||||||
|
0% { transform: scale(1); }
|
||||||
|
50% { transform: scale(1.05); }
|
||||||
|
100% { transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Message that appears */
|
||||||
|
.rule-message {
|
||||||
|
margin-left: 8px;
|
||||||
|
padding-left: 8px;
|
||||||
|
border-left: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
|
font-weight: bold;
|
||||||
|
color: #f4a460;
|
||||||
|
animation: message-fade-in 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes message-fade-in {
|
||||||
|
0% { opacity: 0; transform: translateX(-5px); }
|
||||||
|
100% { opacity: 1; transform: translateX(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure bar is visible when highlighted */
|
||||||
|
#active-rules-bar:has(.rule-highlighted) {
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rule-Specific Triggers
|
||||||
|
|
||||||
|
### Flip Mode (Speed Golf/Endgame)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// When player must flip
|
||||||
|
if (this.waitingForFlip && !this.flipIsOptional) {
|
||||||
|
this.highlightRule('flip_mode', 'Flip a face-down card!');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Knock Penalty/Bonus
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// When someone triggers final turn
|
||||||
|
if (newState.phase === 'final_turn' && oldState?.phase !== 'final_turn') {
|
||||||
|
if (this.gameState.knock_penalty) {
|
||||||
|
this.highlightRule('knock_penalty', '+10 if beaten!');
|
||||||
|
}
|
||||||
|
if (this.gameState.knock_bonus) {
|
||||||
|
this.highlightRule('knock_bonus', '-5 for going out!');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Negative Pairs
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// When pair of 2s or Jokers is formed
|
||||||
|
checkForNewPairs(oldState, newState, playerId) {
|
||||||
|
// ... pair detection ...
|
||||||
|
if (nowPaired && this.gameState.negative_pairs_keep_value) {
|
||||||
|
const isNegativePair = cardRank === '2' || cardRank === '★';
|
||||||
|
if (isNegativePair) {
|
||||||
|
this.highlightRule('negative_pairs_keep_value', 'Keeps -4!');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Score Bonuses (Round End)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// In showScoreboard
|
||||||
|
if (this.gameState.underdog_bonus) {
|
||||||
|
const lowestPlayer = findLowest(scores);
|
||||||
|
this.highlightRule('underdog_bonus', `${lowestPlayer} gets -3!`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.gameState.tied_shame) {
|
||||||
|
const ties = findTies(scores);
|
||||||
|
if (ties.length > 0) {
|
||||||
|
this.highlightRule('tied_shame', '+5 for ties!');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Scenarios
|
||||||
|
|
||||||
|
1. **Speed Golf mode** - "Must flip" highlighted when discarding
|
||||||
|
2. **Knock with penalty** - Warning shown
|
||||||
|
3. **Draw Lucky Swing Joker** - "-5" highlighted
|
||||||
|
4. **Blackjack score** - Celebration when 21 → 0
|
||||||
|
5. **No active rules** - No highlights
|
||||||
|
6. **Multiple rules trigger** - All relevant ones highlight
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Rules have data attributes for targeting
|
||||||
|
- [ ] Relevant rule highlights during specific actions
|
||||||
|
- [ ] Highlight message explains the effect
|
||||||
|
- [ ] Highlight auto-fades after duration
|
||||||
|
- [ ] Multiple rules can highlight simultaneously
|
||||||
|
- [ ] Works for all major house rules
|
||||||
|
- [ ] Doesn't interfere with gameplay flow
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
1. Add `data-rule` attributes to rule tags
|
||||||
|
2. Implement `getRuleKey()` mapping
|
||||||
|
3. Implement `highlightRule()` method
|
||||||
|
4. Add CSS for highlight animation
|
||||||
|
5. Add trigger points for each major rule
|
||||||
|
6. Test with various rule combinations
|
||||||
|
7. Tune timing and messaging
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes for Agent
|
||||||
|
|
||||||
|
- **CSS vs anime.js**: CSS is appropriate for rule tag highlights (simple UI feedback)
|
||||||
|
- Keep highlight messages very short (3-5 words)
|
||||||
|
- Don't highlight on every single action, just key moments
|
||||||
|
- The goal is education, not distraction
|
||||||
|
- Consider: First-time highlight only? (Too complex for V3)
|
||||||
|
- Make sure the bar is visible when highlighting (expand if collapsed)
|
||||||
384
docs/v3/V3_15_DISCARD_PILE_HISTORY.md
Normal file
384
docs/v3/V3_15_DISCARD_PILE_HISTORY.md
Normal file
@ -0,0 +1,384 @@
|
|||||||
|
# V3-15: Discard Pile History
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
In physical card games, you can see the top few cards of the discard pile fanned out slightly. This provides memory aid and context for recent play. Currently our discard pile shows only the top card.
|
||||||
|
|
||||||
|
**Dependencies:** None
|
||||||
|
**Dependents:** None
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. Show 2-3 recent discards visually fanned
|
||||||
|
2. Help players track what's been discarded recently
|
||||||
|
3. Subtle visual depth without cluttering
|
||||||
|
4. Optional: expandable full discard view
|
||||||
|
5. Authentic card game feel
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
From `app.js` and CSS:
|
||||||
|
```javascript
|
||||||
|
// Only shows the top card
|
||||||
|
updateDiscard(cardData) {
|
||||||
|
this.discard.innerHTML = this.createCardHTML(cardData);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The discard pile is a single card element with no history visualization.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Visual Treatment
|
||||||
|
|
||||||
|
```
|
||||||
|
Current: With history:
|
||||||
|
┌─────┐ ┌─────┐
|
||||||
|
│ 7 │ │ 7 │ ← Top card (clickable)
|
||||||
|
│ ♥ │ ╱└─────┘
|
||||||
|
└─────┘ ╱ └─────┘ ← Previous (faded, offset)
|
||||||
|
└─────┘ ← Older (more faded)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fan Layout
|
||||||
|
|
||||||
|
- Top card: Full visibility, normal position
|
||||||
|
- Previous card: Offset 3-4px left and up, 50% opacity
|
||||||
|
- Older card: Offset 6-8px left and up, 25% opacity
|
||||||
|
- Maximum 3 visible cards (performance + clarity)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
### Track Discard History
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// In app.js constructor
|
||||||
|
this.discardHistory = [];
|
||||||
|
this.maxVisibleHistory = 3;
|
||||||
|
|
||||||
|
// Update when discard changes
|
||||||
|
updateDiscardHistory(newCard) {
|
||||||
|
if (!newCard) {
|
||||||
|
this.discardHistory = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new card to front
|
||||||
|
this.discardHistory.unshift(newCard);
|
||||||
|
|
||||||
|
// Keep only recent cards
|
||||||
|
if (this.discardHistory.length > this.maxVisibleHistory) {
|
||||||
|
this.discardHistory = this.discardHistory.slice(0, this.maxVisibleHistory);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called from state differ or handleMessage
|
||||||
|
onDiscardChange(newCard, oldCard) {
|
||||||
|
// Only add if it's a new card (not initial state)
|
||||||
|
if (oldCard && newCard && oldCard.rank !== newCard.rank) {
|
||||||
|
this.updateDiscardHistory(newCard);
|
||||||
|
} else if (newCard && !oldCard) {
|
||||||
|
this.updateDiscardHistory(newCard);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.renderDiscardPile();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Render Fanned Pile
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
renderDiscardPile() {
|
||||||
|
const container = this.discard;
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
if (this.discardHistory.length === 0) {
|
||||||
|
container.innerHTML = '<div class="card empty">Empty</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render from oldest to newest (back to front)
|
||||||
|
const cards = [...this.discardHistory].reverse();
|
||||||
|
|
||||||
|
cards.forEach((cardData, index) => {
|
||||||
|
const reverseIndex = cards.length - 1 - index;
|
||||||
|
const card = this.createDiscardCard(cardData, reverseIndex);
|
||||||
|
container.appendChild(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createDiscardCard(cardData, depthIndex) {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'card discard-card';
|
||||||
|
card.dataset.depth = depthIndex;
|
||||||
|
|
||||||
|
// Only top card is interactive
|
||||||
|
if (depthIndex === 0) {
|
||||||
|
card.classList.add('top-card');
|
||||||
|
card.addEventListener('click', () => this.handleDiscardClick());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set card content
|
||||||
|
card.innerHTML = this.createCardContentHTML(cardData);
|
||||||
|
|
||||||
|
// Apply offset based on depth
|
||||||
|
const offset = depthIndex * 4;
|
||||||
|
card.style.setProperty('--depth-offset', `${offset}px`);
|
||||||
|
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### CSS Styling
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Discard pile container */
|
||||||
|
#discard {
|
||||||
|
position: relative;
|
||||||
|
width: var(--card-width);
|
||||||
|
height: var(--card-height);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stacked discard cards */
|
||||||
|
.discard-card {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
transition: transform 0.2s, opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Depth-based styling */
|
||||||
|
.discard-card[data-depth="0"] {
|
||||||
|
z-index: 3;
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate(0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.discard-card[data-depth="1"] {
|
||||||
|
z-index: 2;
|
||||||
|
opacity: 0.5;
|
||||||
|
transform: translate(-4px, -4px);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discard-card[data-depth="2"] {
|
||||||
|
z-index: 1;
|
||||||
|
opacity: 0.25;
|
||||||
|
transform: translate(-8px, -8px);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Using CSS variable for dynamic offset */
|
||||||
|
.discard-card:not(.top-card) {
|
||||||
|
transform: translate(
|
||||||
|
calc(var(--depth-offset, 0px) * -1),
|
||||||
|
calc(var(--depth-offset, 0px) * -1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover to expand history slightly */
|
||||||
|
#discard:hover .discard-card[data-depth="1"] {
|
||||||
|
opacity: 0.7;
|
||||||
|
transform: translate(-8px, -8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
#discard:hover .discard-card[data-depth="2"] {
|
||||||
|
opacity: 0.4;
|
||||||
|
transform: translate(-16px, -16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animation when new card is discarded */
|
||||||
|
@keyframes discard-land {
|
||||||
|
0% {
|
||||||
|
transform: translate(0, -20px) scale(1.05);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translate(0, 0) scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.discard-card.top-card.just-landed {
|
||||||
|
animation: discard-land 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shift animation for cards moving back */
|
||||||
|
@keyframes shift-back {
|
||||||
|
0% { transform: translate(0, 0); }
|
||||||
|
100% { transform: translate(var(--depth-offset) * -1, var(--depth-offset) * -1); }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration with State Changes
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// In state-differ.js or wherever discard changes are detected
|
||||||
|
detectDiscardChange(oldState, newState) {
|
||||||
|
const oldDiscard = oldState?.discard_pile?.[oldState.discard_pile.length - 1];
|
||||||
|
const newDiscard = newState?.discard_pile?.[newState.discard_pile.length - 1];
|
||||||
|
|
||||||
|
if (this.cardsDifferent(oldDiscard, newDiscard)) {
|
||||||
|
return {
|
||||||
|
type: 'discard_change',
|
||||||
|
oldCard: oldDiscard,
|
||||||
|
newCard: newDiscard
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle the change
|
||||||
|
handleDiscardChange(change) {
|
||||||
|
this.onDiscardChange(change.newCard, change.oldCard);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Round/Game Reset
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Clear history at start of new round
|
||||||
|
onNewRound() {
|
||||||
|
this.discardHistory = [];
|
||||||
|
this.renderDiscardPile();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Or when deck is reshuffled (if that's a game mechanic)
|
||||||
|
onDeckReshuffle() {
|
||||||
|
this.discardHistory = [];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Optional: Expandable Full History
|
||||||
|
|
||||||
|
For players who want to see all discards:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Toggle full discard view
|
||||||
|
showDiscardHistory() {
|
||||||
|
const modal = document.getElementById('discard-history-modal');
|
||||||
|
modal.innerHTML = this.buildFullDiscardView();
|
||||||
|
modal.classList.add('visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFullDiscardView() {
|
||||||
|
// Show all cards in discard pile from game state
|
||||||
|
const discards = this.gameState.discard_pile || [];
|
||||||
|
return discards.map(card =>
|
||||||
|
`<div class="card mini">${this.createCardContentHTML(card)}</div>`
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```css
|
||||||
|
#discard-history-modal {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 80px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: rgba(26, 26, 46, 0.95);
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: none;
|
||||||
|
max-width: 90vw;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#discard-history-modal.visible {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#discard-history-modal .card.mini {
|
||||||
|
width: 40px;
|
||||||
|
height: 56px;
|
||||||
|
font-size: 0.7em;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mobile Considerations
|
||||||
|
|
||||||
|
On smaller screens, reduce the fan offset:
|
||||||
|
|
||||||
|
```css
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.discard-card[data-depth="1"] {
|
||||||
|
transform: translate(-2px, -2px);
|
||||||
|
}
|
||||||
|
.discard-card[data-depth="2"] {
|
||||||
|
transform: translate(-4px, -4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Skip hover expansion on touch */
|
||||||
|
#discard:hover .discard-card {
|
||||||
|
transform: translate(
|
||||||
|
calc(var(--depth-offset, 0px) * -0.5),
|
||||||
|
calc(var(--depth-offset, 0px) * -0.5)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Scenarios
|
||||||
|
|
||||||
|
1. **First discard** - Single card shows
|
||||||
|
2. **Second discard** - Two cards fanned
|
||||||
|
3. **Third+ discards** - Three cards max, oldest drops off
|
||||||
|
4. **New round** - History clears
|
||||||
|
5. **Draw from discard** - Top card removed, others shift forward
|
||||||
|
6. **Hover interaction** - Cards fan out slightly more
|
||||||
|
7. **Mobile view** - Smaller offset, still visible
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Recent 2-3 discards visible in fanned pile
|
||||||
|
- [ ] Older cards progressively more faded
|
||||||
|
- [ ] Only top card is interactive
|
||||||
|
- [ ] History updates smoothly when cards change
|
||||||
|
- [ ] History clears on new round
|
||||||
|
- [ ] Hover expands fan slightly (desktop)
|
||||||
|
- [ ] Works on mobile with smaller offsets
|
||||||
|
- [ ] Optional: expandable full history view
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
1. Add `discardHistory` array tracking
|
||||||
|
2. Implement `renderDiscardPile()` method
|
||||||
|
3. Add CSS for fanned stack
|
||||||
|
4. Integrate with state change detection
|
||||||
|
5. Add round reset handling
|
||||||
|
6. Add hover expansion effect
|
||||||
|
7. Test on various screen sizes
|
||||||
|
8. Optional: Add full history modal
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes for Agent
|
||||||
|
|
||||||
|
- **CSS vs anime.js**: CSS is appropriate for static fan layout. If adding "landing" animation for new discards, use anime.js.
|
||||||
|
- Keep visible history small (3 cards max) for clarity
|
||||||
|
- The fan offset should be subtle, not dramatic
|
||||||
|
- History helps players remember what was recently played
|
||||||
|
- Consider: Should drawing from discard affect history display?
|
||||||
|
- Mobile: smaller offset but still visible
|
||||||
|
- Don't overcomplicate - this is a nice-to-have feature
|
||||||
632
docs/v3/V3_16_REALISTIC_CARD_SOUNDS.md
Normal file
632
docs/v3/V3_16_REALISTIC_CARD_SOUNDS.md
Normal file
@ -0,0 +1,632 @@
|
|||||||
|
# V3-16: Realistic Card Sounds
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Current sounds use simple Web Audio oscillator beeps. Real card games have distinct sounds: shuffling, dealing, flipping, placing. This feature improves audio feedback to feel more physical.
|
||||||
|
|
||||||
|
**Dependencies:** None
|
||||||
|
**Dependents:** None
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. Distinct sounds for each card action
|
||||||
|
2. Variation to avoid repetition fatigue
|
||||||
|
3. Physical "card" quality (paper, snap, thunk)
|
||||||
|
4. Volume control and mute option
|
||||||
|
5. Performant (Web Audio API synthesis or small samples)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
From `app.js` and `card-animations.js`:
|
||||||
|
```javascript
|
||||||
|
// app.js has the main playSound method
|
||||||
|
playSound(type) {
|
||||||
|
const ctx = new AudioContext();
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
// Simple beep tones for different actions
|
||||||
|
}
|
||||||
|
|
||||||
|
// CardAnimations routes to app.js via window.game.playSound()
|
||||||
|
playSound(type) {
|
||||||
|
if (window.game && typeof window.game.playSound === 'function') {
|
||||||
|
window.game.playSound(type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Sounds are functional but feel digital/arcade rather than physical. The existing sound types include:
|
||||||
|
- `card` - general card movement
|
||||||
|
- `flip` - card flip
|
||||||
|
- `shuffle` - deck shuffle
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Sound Palette
|
||||||
|
|
||||||
|
| Action | Sound Character | Notes |
|
||||||
|
|--------|-----------------|-------|
|
||||||
|
| Card flip | Sharp snap | Paper/cardboard flip |
|
||||||
|
| Card place | Soft thunk | Card landing on table |
|
||||||
|
| Card draw | Slide + lift | Taking from pile |
|
||||||
|
| Card shuffle | Multiple snaps | Riffle texture |
|
||||||
|
| Pair formed | Satisfying click | Success feedback |
|
||||||
|
| Knock | Table tap | Knuckle on table |
|
||||||
|
| Deal | Quick sequence | Multiple snaps |
|
||||||
|
| Turn notification | Subtle chime | Alert without jarring |
|
||||||
|
| Round end | Flourish | Resolution feel |
|
||||||
|
|
||||||
|
### Synthesis vs Samples
|
||||||
|
|
||||||
|
**Option A: Synthesized sounds (current approach, enhanced)**
|
||||||
|
- No external files needed
|
||||||
|
- Smaller bundle size
|
||||||
|
- More control over variations
|
||||||
|
- Can sound artificial
|
||||||
|
|
||||||
|
**Option B: Audio samples**
|
||||||
|
- More realistic
|
||||||
|
- Larger file size (small samples ~5-10KB each)
|
||||||
|
- Need to handle loading
|
||||||
|
- Can use Web Audio for variations
|
||||||
|
|
||||||
|
**Recommendation:** Hybrid - synthesized base with sample layering for key sounds.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
### Enhanced Sound System
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// sound-system.js
|
||||||
|
|
||||||
|
class SoundSystem {
|
||||||
|
constructor() {
|
||||||
|
this.ctx = null;
|
||||||
|
this.enabled = true;
|
||||||
|
this.volume = 0.5;
|
||||||
|
this.samples = {};
|
||||||
|
this.initialized = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
if (this.initialized) return;
|
||||||
|
|
||||||
|
this.ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
this.masterGain = this.ctx.createGain();
|
||||||
|
this.masterGain.connect(this.ctx.destination);
|
||||||
|
this.masterGain.gain.value = this.volume;
|
||||||
|
|
||||||
|
// Load settings
|
||||||
|
this.enabled = localStorage.getItem('soundEnabled') !== 'false';
|
||||||
|
this.volume = parseFloat(localStorage.getItem('soundVolume') || '0.5');
|
||||||
|
|
||||||
|
this.initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
setVolume(value) {
|
||||||
|
this.volume = Math.max(0, Math.min(1, value));
|
||||||
|
if (this.masterGain) {
|
||||||
|
this.masterGain.gain.value = this.volume;
|
||||||
|
}
|
||||||
|
localStorage.setItem('soundVolume', this.volume.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
setEnabled(enabled) {
|
||||||
|
this.enabled = enabled;
|
||||||
|
localStorage.setItem('soundEnabled', enabled.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
async play(type) {
|
||||||
|
if (!this.enabled) return;
|
||||||
|
if (!this.ctx || this.ctx.state === 'suspended') {
|
||||||
|
await this.ctx?.resume();
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = this.ctx.currentTime;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'flip':
|
||||||
|
this.playFlip(now);
|
||||||
|
break;
|
||||||
|
case 'place':
|
||||||
|
case 'discard':
|
||||||
|
this.playPlace(now);
|
||||||
|
break;
|
||||||
|
case 'draw-deck':
|
||||||
|
this.playDrawDeck(now);
|
||||||
|
break;
|
||||||
|
case 'draw-discard':
|
||||||
|
this.playDrawDiscard(now);
|
||||||
|
break;
|
||||||
|
case 'pair':
|
||||||
|
this.playPair(now);
|
||||||
|
break;
|
||||||
|
case 'knock':
|
||||||
|
this.playKnock(now);
|
||||||
|
break;
|
||||||
|
case 'deal':
|
||||||
|
this.playDeal(now);
|
||||||
|
break;
|
||||||
|
case 'shuffle':
|
||||||
|
this.playShuffle(now);
|
||||||
|
break;
|
||||||
|
case 'turn':
|
||||||
|
this.playTurn(now);
|
||||||
|
break;
|
||||||
|
case 'round-end':
|
||||||
|
this.playRoundEnd(now);
|
||||||
|
break;
|
||||||
|
case 'win':
|
||||||
|
this.playWin(now);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
this.playGeneric(now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Card flip - sharp snap
|
||||||
|
playFlip(now) {
|
||||||
|
// White noise burst for paper snap
|
||||||
|
const noise = this.createNoiseBurst(0.03, 0.02);
|
||||||
|
|
||||||
|
// High frequency click
|
||||||
|
const click = this.ctx.createOscillator();
|
||||||
|
const clickGain = this.ctx.createGain();
|
||||||
|
click.connect(clickGain);
|
||||||
|
clickGain.connect(this.masterGain);
|
||||||
|
|
||||||
|
click.type = 'square';
|
||||||
|
click.frequency.setValueAtTime(2000 + Math.random() * 500, now);
|
||||||
|
click.frequency.exponentialRampToValueAtTime(800, now + 0.02);
|
||||||
|
|
||||||
|
clickGain.gain.setValueAtTime(0.15, now);
|
||||||
|
clickGain.gain.exponentialRampToValueAtTime(0.001, now + 0.05);
|
||||||
|
|
||||||
|
click.start(now);
|
||||||
|
click.stop(now + 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Card place - soft thunk
|
||||||
|
playPlace(now) {
|
||||||
|
// Low thump
|
||||||
|
const thump = this.ctx.createOscillator();
|
||||||
|
const thumpGain = this.ctx.createGain();
|
||||||
|
thump.connect(thumpGain);
|
||||||
|
thumpGain.connect(this.masterGain);
|
||||||
|
|
||||||
|
thump.type = 'sine';
|
||||||
|
thump.frequency.setValueAtTime(150 + Math.random() * 30, now);
|
||||||
|
thump.frequency.exponentialRampToValueAtTime(80, now + 0.08);
|
||||||
|
|
||||||
|
thumpGain.gain.setValueAtTime(0.2, now);
|
||||||
|
thumpGain.gain.exponentialRampToValueAtTime(0.001, now + 0.1);
|
||||||
|
|
||||||
|
thump.start(now);
|
||||||
|
thump.stop(now + 0.1);
|
||||||
|
|
||||||
|
// Soft noise
|
||||||
|
this.createNoiseBurst(0.02, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw from deck - mysterious slide + flip
|
||||||
|
playDrawDeck(now) {
|
||||||
|
// Slide sound
|
||||||
|
const slide = this.ctx.createOscillator();
|
||||||
|
const slideGain = this.ctx.createGain();
|
||||||
|
slide.connect(slideGain);
|
||||||
|
slideGain.connect(this.masterGain);
|
||||||
|
|
||||||
|
slide.type = 'triangle';
|
||||||
|
slide.frequency.setValueAtTime(200, now);
|
||||||
|
slide.frequency.exponentialRampToValueAtTime(400, now + 0.1);
|
||||||
|
|
||||||
|
slideGain.gain.setValueAtTime(0.08, now);
|
||||||
|
slideGain.gain.exponentialRampToValueAtTime(0.001, now + 0.12);
|
||||||
|
|
||||||
|
slide.start(now);
|
||||||
|
slide.stop(now + 0.12);
|
||||||
|
|
||||||
|
// Delayed flip
|
||||||
|
setTimeout(() => this.playFlip(this.ctx.currentTime), 150);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw from discard - quick grab
|
||||||
|
playDrawDiscard(now) {
|
||||||
|
const grab = this.ctx.createOscillator();
|
||||||
|
const grabGain = this.ctx.createGain();
|
||||||
|
grab.connect(grabGain);
|
||||||
|
grabGain.connect(this.masterGain);
|
||||||
|
|
||||||
|
grab.type = 'square';
|
||||||
|
grab.frequency.setValueAtTime(600, now);
|
||||||
|
grab.frequency.exponentialRampToValueAtTime(300, now + 0.04);
|
||||||
|
|
||||||
|
grabGain.gain.setValueAtTime(0.1, now);
|
||||||
|
grabGain.gain.exponentialRampToValueAtTime(0.001, now + 0.05);
|
||||||
|
|
||||||
|
grab.start(now);
|
||||||
|
grab.stop(now + 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pair formed - satisfying double click
|
||||||
|
playPair(now) {
|
||||||
|
// Two quick clicks
|
||||||
|
for (let i = 0; i < 2; i++) {
|
||||||
|
const click = this.ctx.createOscillator();
|
||||||
|
const gain = this.ctx.createGain();
|
||||||
|
click.connect(gain);
|
||||||
|
gain.connect(this.masterGain);
|
||||||
|
|
||||||
|
click.type = 'triangle';
|
||||||
|
click.frequency.setValueAtTime(800 + i * 200, now + i * 0.08);
|
||||||
|
|
||||||
|
gain.gain.setValueAtTime(0.15, now + i * 0.08);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, now + i * 0.08 + 0.06);
|
||||||
|
|
||||||
|
click.start(now + i * 0.08);
|
||||||
|
click.stop(now + i * 0.08 + 0.06);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Knock - table tap
|
||||||
|
playKnock(now) {
|
||||||
|
// Low woody thunk
|
||||||
|
const knock = this.ctx.createOscillator();
|
||||||
|
const knockGain = this.ctx.createGain();
|
||||||
|
knock.connect(knockGain);
|
||||||
|
knockGain.connect(this.masterGain);
|
||||||
|
|
||||||
|
knock.type = 'sine';
|
||||||
|
knock.frequency.setValueAtTime(120, now);
|
||||||
|
knock.frequency.exponentialRampToValueAtTime(60, now + 0.1);
|
||||||
|
|
||||||
|
knockGain.gain.setValueAtTime(0.3, now);
|
||||||
|
knockGain.gain.exponentialRampToValueAtTime(0.001, now + 0.15);
|
||||||
|
|
||||||
|
knock.start(now);
|
||||||
|
knock.stop(now + 0.15);
|
||||||
|
|
||||||
|
// Resonance
|
||||||
|
const resonance = this.ctx.createOscillator();
|
||||||
|
const resGain = this.ctx.createGain();
|
||||||
|
resonance.connect(resGain);
|
||||||
|
resGain.connect(this.masterGain);
|
||||||
|
|
||||||
|
resonance.type = 'triangle';
|
||||||
|
resonance.frequency.setValueAtTime(180, now);
|
||||||
|
|
||||||
|
resGain.gain.setValueAtTime(0.1, now);
|
||||||
|
resGain.gain.exponentialRampToValueAtTime(0.001, now + 0.2);
|
||||||
|
|
||||||
|
resonance.start(now);
|
||||||
|
resonance.stop(now + 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deal - rapid card sequence
|
||||||
|
playDeal(now) {
|
||||||
|
// Multiple quick snaps
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
setTimeout(() => {
|
||||||
|
const snap = this.ctx.createOscillator();
|
||||||
|
const gain = this.ctx.createGain();
|
||||||
|
snap.connect(gain);
|
||||||
|
gain.connect(this.masterGain);
|
||||||
|
|
||||||
|
snap.type = 'square';
|
||||||
|
snap.frequency.setValueAtTime(1500 + Math.random() * 300, this.ctx.currentTime);
|
||||||
|
|
||||||
|
gain.gain.setValueAtTime(0.08, this.ctx.currentTime);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.03);
|
||||||
|
|
||||||
|
snap.start(this.ctx.currentTime);
|
||||||
|
snap.stop(this.ctx.currentTime + 0.03);
|
||||||
|
}, i * 80);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shuffle - riffle texture
|
||||||
|
playShuffle(now) {
|
||||||
|
// Many tiny clicks with frequency variation
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.createNoiseBurst(0.01, 0.01 + Math.random() * 0.02);
|
||||||
|
}, i * 40 + Math.random() * 20);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Turn notification - gentle chime
|
||||||
|
playTurn(now) {
|
||||||
|
const freqs = [523, 659]; // C5, E5
|
||||||
|
|
||||||
|
freqs.forEach((freq, i) => {
|
||||||
|
const osc = this.ctx.createOscillator();
|
||||||
|
const gain = this.ctx.createGain();
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(this.masterGain);
|
||||||
|
|
||||||
|
osc.type = 'sine';
|
||||||
|
osc.frequency.setValueAtTime(freq, now + i * 0.1);
|
||||||
|
|
||||||
|
gain.gain.setValueAtTime(0.1, now + i * 0.1);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, now + i * 0.1 + 0.3);
|
||||||
|
|
||||||
|
osc.start(now + i * 0.1);
|
||||||
|
osc.stop(now + i * 0.1 + 0.3);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Round end - resolution flourish
|
||||||
|
playRoundEnd(now) {
|
||||||
|
const freqs = [392, 494, 587, 784]; // G4, B4, D5, G5
|
||||||
|
|
||||||
|
freqs.forEach((freq, i) => {
|
||||||
|
const osc = this.ctx.createOscillator();
|
||||||
|
const gain = this.ctx.createGain();
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(this.masterGain);
|
||||||
|
|
||||||
|
osc.type = 'triangle';
|
||||||
|
osc.frequency.setValueAtTime(freq, now + i * 0.08);
|
||||||
|
|
||||||
|
gain.gain.setValueAtTime(0.12, now + i * 0.08);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, now + i * 0.08 + 0.4);
|
||||||
|
|
||||||
|
osc.start(now + i * 0.08);
|
||||||
|
osc.stop(now + i * 0.08 + 0.4);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Win celebration
|
||||||
|
playWin(now) {
|
||||||
|
const freqs = [523, 659, 784, 1047]; // C5, E5, G5, C6
|
||||||
|
|
||||||
|
freqs.forEach((freq, i) => {
|
||||||
|
const osc = this.ctx.createOscillator();
|
||||||
|
const gain = this.ctx.createGain();
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(this.masterGain);
|
||||||
|
|
||||||
|
osc.type = 'sine';
|
||||||
|
osc.frequency.setValueAtTime(freq, now + i * 0.12);
|
||||||
|
|
||||||
|
gain.gain.setValueAtTime(0.15, now + i * 0.12);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, now + i * 0.12 + 0.5);
|
||||||
|
|
||||||
|
osc.start(now + i * 0.12);
|
||||||
|
osc.stop(now + i * 0.12 + 0.5);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic click
|
||||||
|
playGeneric(now) {
|
||||||
|
const osc = this.ctx.createOscillator();
|
||||||
|
const gain = this.ctx.createGain();
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(this.masterGain);
|
||||||
|
|
||||||
|
osc.type = 'triangle';
|
||||||
|
osc.frequency.setValueAtTime(440, now);
|
||||||
|
|
||||||
|
gain.gain.setValueAtTime(0.1, now);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.1);
|
||||||
|
|
||||||
|
osc.start(now);
|
||||||
|
osc.stop(now + 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: Create white noise burst for paper/snap sounds
|
||||||
|
createNoiseBurst(volume, duration) {
|
||||||
|
const bufferSize = this.ctx.sampleRate * duration;
|
||||||
|
const buffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate);
|
||||||
|
const output = buffer.getChannelData(0);
|
||||||
|
|
||||||
|
for (let i = 0; i < bufferSize; i++) {
|
||||||
|
output[i] = Math.random() * 2 - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const noise = this.ctx.createBufferSource();
|
||||||
|
noise.buffer = buffer;
|
||||||
|
|
||||||
|
const noiseGain = this.ctx.createGain();
|
||||||
|
noise.connect(noiseGain);
|
||||||
|
noiseGain.connect(this.masterGain);
|
||||||
|
|
||||||
|
const now = this.ctx.currentTime;
|
||||||
|
noiseGain.gain.setValueAtTime(volume, now);
|
||||||
|
noiseGain.gain.exponentialRampToValueAtTime(0.001, now + duration);
|
||||||
|
|
||||||
|
noise.start(now);
|
||||||
|
noise.stop(now + duration);
|
||||||
|
|
||||||
|
return noise;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export singleton
|
||||||
|
const soundSystem = new SoundSystem();
|
||||||
|
export default soundSystem;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration with App
|
||||||
|
|
||||||
|
The SoundSystem can replace the existing `playSound()` method in `app.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// In app.js - replace the existing playSound method
|
||||||
|
// Option 1: Direct integration (no import needed for non-module setup)
|
||||||
|
|
||||||
|
// Create global instance
|
||||||
|
window.soundSystem = new SoundSystem();
|
||||||
|
|
||||||
|
// Initialize on first interaction
|
||||||
|
document.addEventListener('click', async () => {
|
||||||
|
await window.soundSystem.init();
|
||||||
|
}, { once: true });
|
||||||
|
|
||||||
|
// Replace existing playSound calls
|
||||||
|
playSound(type) {
|
||||||
|
window.soundSystem.play(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
// CardAnimations already routes through window.game.playSound()
|
||||||
|
// so no changes needed in card-animations.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sound Variation
|
||||||
|
|
||||||
|
Add slight randomization to prevent repetitive sounds:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
playFlip(now) {
|
||||||
|
// Random variation
|
||||||
|
const pitchVariation = 1 + (Math.random() - 0.5) * 0.1;
|
||||||
|
const volumeVariation = 1 + (Math.random() - 0.5) * 0.2;
|
||||||
|
|
||||||
|
// Apply to sound...
|
||||||
|
click.frequency.setValueAtTime(2000 * pitchVariation, now);
|
||||||
|
clickGain.gain.setValueAtTime(0.15 * volumeVariation, now);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Settings UI
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// In settings panel
|
||||||
|
renderSoundSettings() {
|
||||||
|
return `
|
||||||
|
<div class="setting-group">
|
||||||
|
<label class="setting-toggle">
|
||||||
|
<input type="checkbox" id="sound-enabled"
|
||||||
|
${soundSystem.enabled ? 'checked' : ''}>
|
||||||
|
<span>Sound Effects</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="setting-slider" ${!soundSystem.enabled ? 'style="opacity: 0.5"' : ''}>
|
||||||
|
<span>Volume</span>
|
||||||
|
<input type="range" id="sound-volume"
|
||||||
|
min="0" max="1" step="0.1"
|
||||||
|
value="${soundSystem.volume}"
|
||||||
|
${!soundSystem.enabled ? 'disabled' : ''}>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event handlers
|
||||||
|
document.getElementById('sound-enabled').addEventListener('change', (e) => {
|
||||||
|
soundSystem.setEnabled(e.target.checked);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('sound-volume').addEventListener('input', (e) => {
|
||||||
|
soundSystem.setVolume(parseFloat(e.target.value));
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CSS for Settings
|
||||||
|
|
||||||
|
```css
|
||||||
|
.setting-group {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-slider {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-slider input[type="range"] {
|
||||||
|
flex: 1;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-slider input[type="range"]::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
background: #f4a460;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Scenarios
|
||||||
|
|
||||||
|
1. **Card flip** - Sharp snap sound
|
||||||
|
2. **Card place/discard** - Soft thunk
|
||||||
|
3. **Draw from deck** - Slide + flip sequence
|
||||||
|
4. **Draw from discard** - Quick grab
|
||||||
|
5. **Pair formed** - Double click satisfaction
|
||||||
|
6. **Knock** - Table tap
|
||||||
|
7. **Deal sequence** - Rapid snaps
|
||||||
|
8. **Volume control** - Adjusts all sounds
|
||||||
|
9. **Mute toggle** - Silences all sounds
|
||||||
|
10. **Settings persist** - Reload maintains preferences
|
||||||
|
11. **First interaction** - AudioContext initializes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Distinct sounds for each card action
|
||||||
|
- [ ] Sounds feel physical (not arcade beeps)
|
||||||
|
- [ ] Variation prevents repetition fatigue
|
||||||
|
- [ ] Volume slider works
|
||||||
|
- [ ] Mute toggle works
|
||||||
|
- [ ] Settings persist in localStorage
|
||||||
|
- [ ] AudioContext handles browser restrictions
|
||||||
|
- [ ] No sound glitches or overlaps
|
||||||
|
- [ ] Performant (no audio lag)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
1. Create SoundSystem class with basic structure
|
||||||
|
2. Implement individual sound methods
|
||||||
|
3. Add noise burst helper for paper sounds
|
||||||
|
4. Add volume/enabled controls
|
||||||
|
5. Integrate with existing playSound calls
|
||||||
|
6. Add variation to prevent repetition
|
||||||
|
7. Add settings UI
|
||||||
|
8. Test on various browsers
|
||||||
|
9. Fine-tune sound character
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes for Agent
|
||||||
|
|
||||||
|
- Replaces existing `playSound()` method in `app.js`
|
||||||
|
- CardAnimations already routes through `window.game.playSound()` - no changes needed there
|
||||||
|
- Web Audio API has good browser support
|
||||||
|
- AudioContext must be created after user interaction
|
||||||
|
- Noise bursts add realistic texture to card sounds
|
||||||
|
- Keep sounds short (<200ms) to stay responsive
|
||||||
|
- Volume variation and pitch variation prevent fatigue
|
||||||
|
- Test with headphones - sounds should be pleasant, not jarring
|
||||||
|
- Consider: different sound "themes"? (Classic, Minimal, Fun)
|
||||||
|
- Mobile: test performance impact of audio synthesis
|
||||||
|
- Settings should persist in localStorage
|
||||||
117
docs/v3/V3_17_MOBILE_PORTRAIT_LAYOUT.md
Normal file
117
docs/v3/V3_17_MOBILE_PORTRAIT_LAYOUT.md
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
# V3.17: Mobile Portrait Layout
|
||||||
|
|
||||||
|
**Version:** 3.1.6
|
||||||
|
**Commits:** `4fcdf13`, `fb3bd53`
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Full mobile portrait layout for phones, triggered by JS `matchMedia` on narrow portrait screens (`max-width: 500px`, `orientation: portrait`). The desktop layout is completely untouched — all mobile rules are scoped under `body.mobile-portrait`.
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
### Responsive Game Layout
|
||||||
|
- Viewport fills 100dvh with no scroll; `overscroll-behavior: contain` prevents pull-to-refresh
|
||||||
|
- Game screen uses flexbox column: compact header → opponents row → player row → bottom bar
|
||||||
|
- Safe-area insets respected for notched devices (`env(safe-area-inset-top/bottom)`)
|
||||||
|
|
||||||
|
### Compact Header
|
||||||
|
- Single-row header with reduced font sizes (0.75rem) and tight gaps
|
||||||
|
- Non-essential items hidden on mobile: username display, logout button, active rules bar
|
||||||
|
- Status message, round info, final turn badge, and leave button all use `white-space: nowrap` with ellipsis overflow
|
||||||
|
|
||||||
|
### Opponent Cards
|
||||||
|
- Flat horizontal strip (no arch rotation) with horizontal scroll for 4+ opponents
|
||||||
|
- Cards scaled to 32x45px with 0.6rem font (26x36px on short screens)
|
||||||
|
- Dealer chip scaled from 38px to 20px diameter to fit compact opponent areas
|
||||||
|
- Showing score badge sized proportionally
|
||||||
|
|
||||||
|
### Deck/Discard Area
|
||||||
|
- Deck and discard cards match player card size (72x101px) for visual consistency
|
||||||
|
- Held card floating matches player card size with proportional font scaling
|
||||||
|
|
||||||
|
### Player Cards
|
||||||
|
- Fixed 72x101px cards with 1.5rem font in 3-column grid
|
||||||
|
- 60x84px with 1.3rem font on short screens (max-height: 600px)
|
||||||
|
- Font size set inline by `card-manager.js` proportional to card width (0.35x ratio on mobile)
|
||||||
|
|
||||||
|
### Side Panels as Bottom Drawers
|
||||||
|
- Standings and scoreboard panels slide up as bottom drawers from a mobile bottom bar
|
||||||
|
- Drawer backdrop overlay with tap-to-dismiss
|
||||||
|
- Drag handle visual indicator on each drawer
|
||||||
|
- Drawers auto-close on screen change or layout change back to desktop
|
||||||
|
|
||||||
|
### Short Screen Fallback
|
||||||
|
- `@media (max-height: 600px)` reduces all card sizes, gaps, and padding
|
||||||
|
- Opponent cards: 26x36px, deck/discard: 60x84px, player cards: 60x84px
|
||||||
|
|
||||||
|
## Animation Fixes
|
||||||
|
|
||||||
|
### Deal Animation Guard
|
||||||
|
- `renderGame()` returns early when `dealAnimationInProgress` is true
|
||||||
|
- Prevents WebSocket state updates from destroying card slot DOM elements mid-deal animation
|
||||||
|
- Cards were piling up at (0,0) because `getCardSlotRect()` read stale/null positions after `innerHTML = ''`
|
||||||
|
|
||||||
|
### Animation Overlay Card Sizing
|
||||||
|
- **Root cause:** Base `.card` CSS (`width: clamp(65px, 5.5vw, 100px)`) was leaking into animation overlay elements (`.draw-anim-front.card`), overriding the intended `width: 100%` inherited from the overlay container
|
||||||
|
- **Effect:** Opponent flip overlays appeared at 65px instead of 32px (too big); deck/discard draw overlays appeared at 65px instead of 72px (too small)
|
||||||
|
- **Fix:** Added `!important` to `.draw-anim-front/.draw-anim-back` `width` and `height` rules to ensure animation overlays always match their parent container's inline dimensions from JavaScript
|
||||||
|
|
||||||
|
### Opponent Swap Held Card Sizing
|
||||||
|
- `fireSwapAnimation()` now passes a `heldRect` sized to match the opponent card (32px) positioned at the holding location, instead of defaulting to deck dimensions (72px)
|
||||||
|
- The traveling held card no longer appears oversized relative to opponent cards during the swap arc
|
||||||
|
|
||||||
|
### Font Size Consistency
|
||||||
|
- `cardFontSize()` helper in `CardAnimations` uses 0.35x width ratio on mobile (vs 0.5x desktop)
|
||||||
|
- Applied consistently across all animation paths: `createAnimCard`, `createCardFromData`, and arc swap font transitions
|
||||||
|
- Held card floating gets inline font-size scaled to card width on mobile
|
||||||
|
|
||||||
|
## CSS Architecture
|
||||||
|
|
||||||
|
All mobile rules use the `body.mobile-portrait` scope:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Applied by JS matchMedia, not CSS media query */
|
||||||
|
body.mobile-portrait .selector { ... }
|
||||||
|
|
||||||
|
/* Short screen fallback uses both */
|
||||||
|
@media (max-height: 600px) {
|
||||||
|
body.mobile-portrait .selector { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Card sizing uses `!important` to override base `.card` clamp values:
|
||||||
|
```css
|
||||||
|
body.mobile-portrait .opponent-area .card {
|
||||||
|
width: 32px !important;
|
||||||
|
height: 45px !important;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Animation overlays use `!important` to override base `.card` leaking:
|
||||||
|
```css
|
||||||
|
.draw-anim-front,
|
||||||
|
.draw-anim-back {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
|
||||||
|
| File | Changes |
|
||||||
|
|------|---------|
|
||||||
|
| `client/style.css` | ~470 lines of mobile portrait CSS added at end of file |
|
||||||
|
| `client/app.js` | Mobile detection, drawer management, `renderGame()` guard, swap heldRect sizing, held card font scaling |
|
||||||
|
| `client/card-animations.js` | `cardFontSize()` helper, consistent font scaling across all animation paths |
|
||||||
|
| `client/card-manager.js` | Inline font-size on mobile for `updateCardElement()` |
|
||||||
|
| `client/index.html` | Mobile bottom bar, drawer backdrop, viewport-fit=cover |
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- **Desktop:** No visual changes — all rules scoped under `body.mobile-portrait`
|
||||||
|
- **Mobile portrait:** Verify game fits 100dvh, no scroll, cards properly sized
|
||||||
|
- **Deal animation:** Cards fly to correct grid positions (not piling up)
|
||||||
|
- **Draw/discard:** Animation overlay matches source card size
|
||||||
|
- **Opponent swap:** Flip and arc animations use opponent card dimensions
|
||||||
|
- **Short screens (iPhone SE):** All elements fit with reduced sizes
|
||||||
|
- **Orientation change:** Layout switches cleanly between mobile and desktop
|
||||||
57
docs/v3/V3_18_POSTGRES_STORAGE_EFFICIENCY.md
Normal file
57
docs/v3/V3_18_POSTGRES_STORAGE_EFFICIENCY.md
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
# V3.18: PostgreSQL Game Data Storage Efficiency
|
||||||
|
|
||||||
|
**Status:** Planning
|
||||||
|
**Priority:** Medium
|
||||||
|
**Category:** Infrastructure / Performance
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Per-move game logging stores full `hand_state` and `visible_opponents` JSONB on every move. For a typical 6-player, 9-hole game this generates significant redundant data since most of each player's hand doesn't change between moves.
|
||||||
|
|
||||||
|
## Areas to Investigate
|
||||||
|
|
||||||
|
### 1. Delta Encoding for Move Data
|
||||||
|
|
||||||
|
Store only what changed from the previous move instead of full state snapshots.
|
||||||
|
|
||||||
|
- First move of each round stores full state (baseline)
|
||||||
|
- Subsequent moves store only changed positions (e.g., `{"player_0": {"pos_2": "5H"}}`)
|
||||||
|
- Replay reconstruction applies deltas sequentially
|
||||||
|
- Trade-off: simpler queries vs. storage savings
|
||||||
|
|
||||||
|
### 2. PostgreSQL TOAST and Compression
|
||||||
|
|
||||||
|
- TOAST already compresses large JSONB values automatically
|
||||||
|
- Measure actual on-disk size vs. logical size for typical game data
|
||||||
|
- Consider whether explicit compression (e.g., storing gzipped blobs) adds meaningful savings over TOAST
|
||||||
|
|
||||||
|
### 3. Retention Policy
|
||||||
|
|
||||||
|
- Archive completed games older than N days to a separate table or cold storage
|
||||||
|
- Configurable retention period via env var (e.g., `GAME_LOG_RETENTION_DAYS`)
|
||||||
|
- Keep aggregate stats even after pruning raw move data
|
||||||
|
|
||||||
|
### 4. Move Logging Toggle
|
||||||
|
|
||||||
|
- Env var `GAME_LOGGING_ENABLED=true|false` to disable move-level logging entirely
|
||||||
|
- Useful for non-analysis environments (dev, load testing)
|
||||||
|
- Game outcomes and stats would still be recorded
|
||||||
|
|
||||||
|
### 5. Batch Inserts
|
||||||
|
|
||||||
|
- Buffer moves in memory and flush periodically instead of per-move INSERT
|
||||||
|
- Reduces database round-trips during active games
|
||||||
|
- Risk: data loss if server crashes mid-game (acceptable for non-critical move logs)
|
||||||
|
|
||||||
|
## Measurements Needed
|
||||||
|
|
||||||
|
Before optimizing, measure current impact:
|
||||||
|
|
||||||
|
- Average JSONB size per move (bytes)
|
||||||
|
- Average moves per game
|
||||||
|
- Total storage per game (moves + overhead)
|
||||||
|
- Query patterns: how often is per-move data actually read?
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- None (independent infrastructure improvement)
|
||||||
276
docs/v3/refactor-ai.md
Normal file
276
docs/v3/refactor-ai.md
Normal file
@ -0,0 +1,276 @@
|
|||||||
|
# Plan 2: ai.py Refactor
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
`ai.py` is 1,978 lines with a single function (`choose_swap_or_discard`) at **666 lines** and cyclomatic complexity 50+. The goal is to decompose it into testable, understandable pieces without changing any AI behavior.
|
||||||
|
|
||||||
|
Key constraint: **AI behavior must remain identical.** This is pure structural refactoring. We can validate with `python server/simulate.py 500` before and after - stats should match within normal variance.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Problem Functions
|
||||||
|
|
||||||
|
| Function | Lines | What It Does |
|
||||||
|
|----------|-------|-------------|
|
||||||
|
| `choose_swap_or_discard()` | ~666 | Decides which position (0-5) to swap drawn card into, or None to discard |
|
||||||
|
| `calculate_swap_score()` | ~240 | Scores a single position for swapping |
|
||||||
|
| `should_take_discard()` | ~160 | Decides whether to take from discard pile |
|
||||||
|
| `process_cpu_turn()` | ~240 | Orchestrates a full CPU turn with timing |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Refactoring Plan
|
||||||
|
|
||||||
|
### Step 1: Extract Named Constants
|
||||||
|
|
||||||
|
Create section at top of `ai.py` (or a separate `ai_constants.py` if preferred):
|
||||||
|
|
||||||
|
```python
|
||||||
|
# =============================================================================
|
||||||
|
# AI Decision Constants
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Expected value of an unknown (face-down) card, based on deck distribution
|
||||||
|
EXPECTED_HIDDEN_VALUE = 4.5
|
||||||
|
|
||||||
|
# Pessimistic estimate for hidden cards (used in go-out safety checks)
|
||||||
|
PESSIMISTIC_HIDDEN_VALUE = 6.0
|
||||||
|
|
||||||
|
# Conservative estimate (used by conservative personality)
|
||||||
|
CONSERVATIVE_HIDDEN_VALUE = 2.5
|
||||||
|
|
||||||
|
# Cards at or above this value should never be swapped into unknown positions
|
||||||
|
HIGH_CARD_THRESHOLD = 8
|
||||||
|
|
||||||
|
# Maximum card value for unpredictability swaps
|
||||||
|
UNPREDICTABLE_MAX_VALUE = 7
|
||||||
|
|
||||||
|
# Pair potential discount when adjacent card matches
|
||||||
|
PAIR_POTENTIAL_DISCOUNT = 0.25
|
||||||
|
|
||||||
|
# Blackjack target score
|
||||||
|
BLACKJACK_TARGET = 21
|
||||||
|
|
||||||
|
# Base acceptable score range for go-out decisions
|
||||||
|
GO_OUT_SCORE_BASE = 12
|
||||||
|
GO_OUT_SCORE_MAX = 20
|
||||||
|
```
|
||||||
|
|
||||||
|
**Locations to update:** ~30 magic number sites across the file. Each becomes a named reference.
|
||||||
|
|
||||||
|
### Step 2: Extract Column/Pair Utility Functions
|
||||||
|
|
||||||
|
The "iterate columns, check pairs" pattern appears 8+ times. Create shared utilities:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def iter_columns(player: Player):
|
||||||
|
"""Yield (col_index, top_idx, bot_idx, top_card, bot_card) for each column."""
|
||||||
|
for col in range(3):
|
||||||
|
top_idx = col
|
||||||
|
bot_idx = col + 3
|
||||||
|
yield col, top_idx, bot_idx, player.cards[top_idx], player.cards[bot_idx]
|
||||||
|
|
||||||
|
|
||||||
|
def project_score(player: Player, swap_pos: int, new_card: Card, options: GameOptions) -> int:
|
||||||
|
"""Calculate what the player's score would be if new_card were swapped into swap_pos.
|
||||||
|
|
||||||
|
Handles pair cancellation correctly. Used by multiple decision paths.
|
||||||
|
"""
|
||||||
|
total = 0
|
||||||
|
for col, top_idx, bot_idx, top_card, bot_card in iter_columns(player):
|
||||||
|
# Substitute the new card if it's in this column
|
||||||
|
effective_top = new_card if top_idx == swap_pos else top_card
|
||||||
|
effective_bot = new_card if bot_idx == swap_pos else bot_card
|
||||||
|
|
||||||
|
if effective_top.rank == effective_bot.rank:
|
||||||
|
# Pair cancels (with house rule exceptions)
|
||||||
|
continue
|
||||||
|
total += get_ai_card_value(effective_top, options)
|
||||||
|
total += get_ai_card_value(effective_bot, options)
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
def count_hidden(player: Player) -> int:
|
||||||
|
"""Count face-down cards."""
|
||||||
|
return sum(1 for c in player.cards if not c.face_up)
|
||||||
|
|
||||||
|
|
||||||
|
def hidden_positions(player: Player) -> list[int]:
|
||||||
|
"""Get indices of face-down cards."""
|
||||||
|
return [i for i, c in enumerate(player.cards) if not c.face_up]
|
||||||
|
|
||||||
|
|
||||||
|
def known_score(player: Player, options: GameOptions) -> int:
|
||||||
|
"""Calculate score from face-up cards only, using EXPECTED_HIDDEN_VALUE for unknowns."""
|
||||||
|
# Centralized version of the repeated estimation logic
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
This replaces duplicated loops at roughly lines: 679, 949, 1002, 1053, 1145, 1213, 1232.
|
||||||
|
|
||||||
|
### Step 3: Decompose `choose_swap_or_discard()`
|
||||||
|
|
||||||
|
Break into focused sub-functions. The current flow is roughly:
|
||||||
|
|
||||||
|
1. **Go-out safety check** (lines ~1087-1186) - "I'm about to go out, pick the best swap to minimize my score"
|
||||||
|
2. **Score all 6 positions** (lines ~1190-1270) - Calculate swap benefit for each position
|
||||||
|
3. **Filter and rank candidates** (lines ~1270-1330) - Safety filters, personality tie-breaking
|
||||||
|
4. **Blackjack special case** (lines ~1330-1380) - If blackjack rule enabled, check for 21
|
||||||
|
5. **Endgame safety** (lines ~1380-1410) - Don't swap 8+ into unknowns in endgame
|
||||||
|
6. **Denial logic** (lines ~1410-1480) - Block opponent by taking their useful cards
|
||||||
|
|
||||||
|
Proposed decomposition:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def choose_swap_or_discard(player, drawn_card, profile, game, ...) -> Optional[int]:
|
||||||
|
"""Main orchestrator - delegates to focused sub-functions."""
|
||||||
|
|
||||||
|
# Check if we should force a go-out swap
|
||||||
|
go_out_pos = _check_go_out_swap(player, drawn_card, profile, game, ...)
|
||||||
|
if go_out_pos is not None:
|
||||||
|
return go_out_pos
|
||||||
|
|
||||||
|
# Score all positions
|
||||||
|
candidates = _score_all_positions(player, drawn_card, profile, game, ...)
|
||||||
|
|
||||||
|
# Apply filters and select best
|
||||||
|
best = _select_best_candidate(candidates, player, drawn_card, profile, game, ...)
|
||||||
|
|
||||||
|
if best is not None:
|
||||||
|
return best
|
||||||
|
|
||||||
|
# Try denial as fallback
|
||||||
|
return _check_denial_swap(player, drawn_card, profile, game, ...)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_go_out_swap(player, drawn_card, profile, game, ...) -> Optional[int]:
|
||||||
|
"""If player is close to going out, find the best position to minimize final score.
|
||||||
|
|
||||||
|
Handles:
|
||||||
|
- All-but-one face-up: find the best slot for the drawn card
|
||||||
|
- Acceptable score threshold based on game state and personality
|
||||||
|
- Pair completion opportunities
|
||||||
|
"""
|
||||||
|
# Lines ~1087-1186 of current choose_swap_or_discard
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def _score_all_positions(player, drawn_card, profile, game, ...) -> list[tuple[int, float]]:
|
||||||
|
"""Calculate swap benefit score for each of the 6 positions.
|
||||||
|
|
||||||
|
Returns list of (position, score) tuples, sorted by score descending.
|
||||||
|
Each score represents how much the swap improves the player's hand.
|
||||||
|
"""
|
||||||
|
# Lines ~1190-1270 - calls calculate_swap_score() for each position
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def _select_best_candidate(candidates, player, drawn_card, profile, game, ...) -> Optional[int]:
|
||||||
|
"""From scored candidates, apply personality modifiers and safety filters.
|
||||||
|
|
||||||
|
Handles:
|
||||||
|
- Minimum improvement threshold
|
||||||
|
- Personality tie-breaking (pair_hunter prefers pair columns, etc.)
|
||||||
|
- Unpredictability (occasional random choice with value threshold)
|
||||||
|
- High-card safety filter (never swap 8+ into hidden positions)
|
||||||
|
- Blackjack special case (swap to reach exactly 21)
|
||||||
|
- Endgame safety (discard 8+ rather than force into unknown)
|
||||||
|
"""
|
||||||
|
# Lines ~1270-1410
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def _check_denial_swap(player, drawn_card, profile, game, ...) -> Optional[int]:
|
||||||
|
"""Check if we should swap to deny opponents a useful card.
|
||||||
|
|
||||||
|
Only triggers for profiles with denial_aggression > 0.
|
||||||
|
Skips hidden positions for high cards (8+).
|
||||||
|
"""
|
||||||
|
# Lines ~1410-1480
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Simplify `calculate_swap_score()`
|
||||||
|
|
||||||
|
Currently ~240 lines. Some of its complexity comes from inlined pair calculations and standings pressure. Extract:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _pair_improvement(player, position, new_card, options) -> float:
|
||||||
|
"""Calculate pair-related benefit of swapping into this position."""
|
||||||
|
# Would the swap create a new pair? Break an existing pair?
|
||||||
|
...
|
||||||
|
|
||||||
|
def _standings_pressure(player, game) -> float:
|
||||||
|
"""Calculate how much standings position should affect decisions."""
|
||||||
|
# Shared between calculate_swap_score and should_take_discard
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 5: Simplify `should_take_discard()`
|
||||||
|
|
||||||
|
Currently ~160 lines. Much of the complexity is from re-deriving information that `calculate_swap_score` also computes. After Step 2's utilities exist, this should shrink significantly since `project_score()` and `known_score()` handle the repeated estimation logic.
|
||||||
|
|
||||||
|
### Step 6: Clean up `process_cpu_turn()`
|
||||||
|
|
||||||
|
Currently ~240 lines. This function is the CPU turn orchestrator and is mostly fine structurally, but has some inline logic for:
|
||||||
|
- Flip-as-action decisions (~30 lines)
|
||||||
|
- Knock-early decisions (~30 lines)
|
||||||
|
- Game logging (~20 lines repeated twice)
|
||||||
|
|
||||||
|
Extract:
|
||||||
|
```python
|
||||||
|
def _should_flip_as_action(player, game, profile) -> Optional[int]:
|
||||||
|
"""Decide whether to use flip-as-action and which position."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def _should_knock_early(player, game, profile) -> bool:
|
||||||
|
"""Decide whether to knock early."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def _log_cpu_action(game_id, player, action, card=None, position=None, reason=""):
|
||||||
|
"""Log a CPU action if logger is available."""
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution Order
|
||||||
|
|
||||||
|
1. **Step 1** (constants) - Safe, mechanical, reduces cognitive load immediately
|
||||||
|
2. **Step 2** (utilities) - Foundation for everything else
|
||||||
|
3. **Step 3** (decompose choose_swap_or_discard) - The big win
|
||||||
|
4. **Step 4** (simplify calculate_swap_score) - Benefits from Step 2 utilities
|
||||||
|
5. **Step 5** (simplify should_take_discard) - Benefits from Step 2 utilities
|
||||||
|
6. **Step 6** (clean up process_cpu_turn) - Lower priority
|
||||||
|
|
||||||
|
**Run `python server/simulate.py 500` before Step 1 and after each step to verify identical behavior.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation Strategy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Before any changes - capture baseline
|
||||||
|
python server/simulate.py 500 > /tmp/ai_baseline.txt
|
||||||
|
|
||||||
|
# After each step
|
||||||
|
python server/simulate.py 500 > /tmp/ai_after_stepN.txt
|
||||||
|
|
||||||
|
# Compare key metrics:
|
||||||
|
# - Average scores per personality
|
||||||
|
# - "Swapped 8+ into unknown" rate (should stay < 0.1%)
|
||||||
|
# - Win rate distribution
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files Touched
|
||||||
|
|
||||||
|
- `server/ai.py` - major restructuring (same file, new internal organization)
|
||||||
|
- No new files needed (all changes within ai.py unless we decide to split constants out)
|
||||||
|
|
||||||
|
## Risk Assessment
|
||||||
|
|
||||||
|
- **Low risk** if done mechanically (cut-paste into functions, update call sites)
|
||||||
|
- **Medium risk** if we accidentally change conditional logic order or miss an early return
|
||||||
|
- Simulation tests are the safety net - run after every step
|
||||||
279
docs/v3/refactor-main-game.md
Normal file
279
docs/v3/refactor-main-game.md
Normal file
@ -0,0 +1,279 @@
|
|||||||
|
# Plan 1: main.py & game.py Refactor
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Break apart the 575-line WebSocket handler in `main.py` into discrete message handlers, eliminate repeated patterns (logging, locking, error responses), and clean up `game.py`'s scattered house rule display logic and options boilerplate.
|
||||||
|
|
||||||
|
No backwards-compatibility concerns - no existing userbase.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part A: main.py WebSocket Handler Decomposition
|
||||||
|
|
||||||
|
### A1. Create `server/handlers.py` - Message Handler Registry
|
||||||
|
|
||||||
|
Extract each `elif msg_type == "..."` block from `websocket_endpoint()` into standalone async handler functions. One function per message type:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# server/handlers.py
|
||||||
|
|
||||||
|
async def handle_create_room(ws, data, ctx) -> None: ...
|
||||||
|
async def handle_join_room(ws, data, ctx) -> None: ...
|
||||||
|
async def handle_get_cpu_profiles(ws, data, ctx) -> None: ...
|
||||||
|
async def handle_add_cpu(ws, data, ctx) -> None: ...
|
||||||
|
async def handle_remove_cpu(ws, data, ctx) -> None: ...
|
||||||
|
async def handle_start_game(ws, data, ctx) -> None: ...
|
||||||
|
async def handle_flip_initial(ws, data, ctx) -> None: ...
|
||||||
|
async def handle_draw(ws, data, ctx) -> None: ...
|
||||||
|
async def handle_swap(ws, data, ctx) -> None: ...
|
||||||
|
async def handle_discard(ws, data, ctx) -> None: ...
|
||||||
|
async def handle_cancel_draw(ws, data, ctx) -> None: ...
|
||||||
|
async def handle_flip_card(ws, data, ctx) -> None: ...
|
||||||
|
async def handle_skip_flip(ws, data, ctx) -> None: ...
|
||||||
|
async def handle_flip_as_action(ws, data, ctx) -> None: ...
|
||||||
|
async def handle_knock_early(ws, data, ctx) -> None: ...
|
||||||
|
async def handle_next_round(ws, data, ctx) -> None: ...
|
||||||
|
async def handle_leave_room(ws, data, ctx) -> None: ...
|
||||||
|
async def handle_leave_game(ws, data, ctx) -> None: ...
|
||||||
|
async def handle_end_game(ws, data, ctx) -> None: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Context object** passed to every handler:
|
||||||
|
```python
|
||||||
|
@dataclass
|
||||||
|
class ConnectionContext:
|
||||||
|
websocket: WebSocket
|
||||||
|
connection_id: str
|
||||||
|
player_id: str
|
||||||
|
auth_user_id: Optional[str]
|
||||||
|
authenticated_user: Optional[User]
|
||||||
|
current_room: Optional[Room] # mutable reference
|
||||||
|
```
|
||||||
|
|
||||||
|
**Handler dispatch** in `websocket_endpoint()` becomes:
|
||||||
|
```python
|
||||||
|
HANDLERS = {
|
||||||
|
"create_room": handle_create_room,
|
||||||
|
"join_room": handle_join_room,
|
||||||
|
# ... etc
|
||||||
|
}
|
||||||
|
|
||||||
|
while True:
|
||||||
|
data = await websocket.receive_json()
|
||||||
|
handler = HANDLERS.get(data.get("type"))
|
||||||
|
if handler:
|
||||||
|
await handler(data, ctx)
|
||||||
|
```
|
||||||
|
|
||||||
|
This takes `websocket_endpoint()` from ~575 lines to ~30 lines.
|
||||||
|
|
||||||
|
### A2. Extract Game Action Logger Helper
|
||||||
|
|
||||||
|
The pattern repeated 8 times across draw/swap/discard/flip/skip_flip/flip_as_action/knock_early:
|
||||||
|
|
||||||
|
```python
|
||||||
|
game_logger = get_logger()
|
||||||
|
if game_logger and current_room.game_log_id and player:
|
||||||
|
game_logger.log_move(
|
||||||
|
game_id=current_room.game_log_id,
|
||||||
|
player=player,
|
||||||
|
is_cpu=False,
|
||||||
|
action="...",
|
||||||
|
card=...,
|
||||||
|
position=...,
|
||||||
|
game=current_room.game,
|
||||||
|
decision_reason="...",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Extract to:
|
||||||
|
```python
|
||||||
|
# In handlers.py or a small helpers module
|
||||||
|
def log_human_action(room, player, action, card=None, position=None, reason=""):
|
||||||
|
game_logger = get_logger()
|
||||||
|
if game_logger and room.game_log_id and player:
|
||||||
|
game_logger.log_move(
|
||||||
|
game_id=room.game_log_id,
|
||||||
|
player=player,
|
||||||
|
is_cpu=False,
|
||||||
|
action=action,
|
||||||
|
card=card,
|
||||||
|
position=position,
|
||||||
|
game=room.game,
|
||||||
|
decision_reason=reason,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Each handler call site becomes a single line.
|
||||||
|
|
||||||
|
### A3. Replace Static File Routes with `StaticFiles` Mount
|
||||||
|
|
||||||
|
Currently 15+ hand-written `@app.get()` routes for static files (lines 1188-1255). Replace with:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
# Serve specific HTML routes first
|
||||||
|
@app.get("/")
|
||||||
|
async def serve_index():
|
||||||
|
return FileResponse(os.path.join(client_path, "index.html"))
|
||||||
|
|
||||||
|
@app.get("/admin")
|
||||||
|
async def serve_admin():
|
||||||
|
return FileResponse(os.path.join(client_path, "admin.html"))
|
||||||
|
|
||||||
|
@app.get("/replay/{share_code}")
|
||||||
|
async def serve_replay_page(share_code: str):
|
||||||
|
return FileResponse(os.path.join(client_path, "index.html"))
|
||||||
|
|
||||||
|
# Mount static files for everything else (JS, CSS, SVG, etc.)
|
||||||
|
app.mount("/", StaticFiles(directory=client_path), name="static")
|
||||||
|
```
|
||||||
|
|
||||||
|
Eliminates ~70 lines and auto-handles any new client files without code changes.
|
||||||
|
|
||||||
|
### A4. Clean Up Lifespan Service Init
|
||||||
|
|
||||||
|
The lifespan function (lines 83-242) has a deeply nested try/except block initializing ~8 services with lots of `set_*` calls. Simplify by extracting service init:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def _init_database_services():
|
||||||
|
"""Initialize all PostgreSQL-dependent services. Returns dict of services."""
|
||||||
|
# All the import/init/set logic currently in lifespan
|
||||||
|
...
|
||||||
|
|
||||||
|
async def _init_redis(redis_url):
|
||||||
|
"""Initialize Redis client and rate limiter."""
|
||||||
|
...
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
if config.REDIS_URL:
|
||||||
|
await _init_redis(config.REDIS_URL)
|
||||||
|
if config.POSTGRES_URL:
|
||||||
|
await _init_database_services()
|
||||||
|
|
||||||
|
# health check setup
|
||||||
|
...
|
||||||
|
yield
|
||||||
|
# shutdown...
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part B: game.py Cleanup
|
||||||
|
|
||||||
|
### B1. Data-Driven Active Rules Display
|
||||||
|
|
||||||
|
Replace the 38-line if-chain in `get_state()` (lines 1546-1584) with a declarative approach:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# On GameOptions class or as module-level constant
|
||||||
|
_RULE_DISPLAY = [
|
||||||
|
# (attribute, display_name, condition_fn_or_None)
|
||||||
|
("knock_penalty", "Knock Penalty", None),
|
||||||
|
("lucky_swing", "Lucky Swing", None),
|
||||||
|
("eagle_eye", "Eagle-Eye", None),
|
||||||
|
("super_kings", "Super Kings", None),
|
||||||
|
("ten_penny", "Ten Penny", None),
|
||||||
|
("knock_bonus", "Knock Bonus", None),
|
||||||
|
("underdog_bonus", "Underdog", None),
|
||||||
|
("tied_shame", "Tied Shame", None),
|
||||||
|
("blackjack", "Blackjack", None),
|
||||||
|
("wolfpack", "Wolfpack", None),
|
||||||
|
("flip_as_action", "Flip as Action", None),
|
||||||
|
("four_of_a_kind", "Four of a Kind", None),
|
||||||
|
("negative_pairs_keep_value", "Negative Pairs Keep Value", None),
|
||||||
|
("one_eyed_jacks", "One-Eyed Jacks", None),
|
||||||
|
("knock_early", "Early Knock", None),
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_active_rules(self) -> list[str]:
|
||||||
|
rules = []
|
||||||
|
# Special: flip mode
|
||||||
|
if self.options.flip_mode == FlipMode.ALWAYS.value:
|
||||||
|
rules.append("Speed Golf")
|
||||||
|
elif self.options.flip_mode == FlipMode.ENDGAME.value:
|
||||||
|
rules.append("Endgame Flip")
|
||||||
|
# Special: jokers (only if not overridden by lucky_swing/eagle_eye)
|
||||||
|
if self.options.use_jokers and not self.options.lucky_swing and not self.options.eagle_eye:
|
||||||
|
rules.append("Jokers")
|
||||||
|
# Boolean rules
|
||||||
|
for attr, display_name, _ in _RULE_DISPLAY:
|
||||||
|
if getattr(self.options, attr):
|
||||||
|
rules.append(display_name)
|
||||||
|
return rules
|
||||||
|
```
|
||||||
|
|
||||||
|
### B2. Simplify `_options_to_dict()`
|
||||||
|
|
||||||
|
Replace the 22-line manual dict construction (lines 791-813) with `dataclasses.asdict()` or a simple comprehension:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from dataclasses import asdict
|
||||||
|
|
||||||
|
def _options_to_dict(self) -> dict:
|
||||||
|
return asdict(self.options)
|
||||||
|
```
|
||||||
|
|
||||||
|
Or if we want to exclude `deck_colors` or similar:
|
||||||
|
```python
|
||||||
|
def _options_to_dict(self) -> dict:
|
||||||
|
return {k: v for k, v in asdict(self.options).items()}
|
||||||
|
```
|
||||||
|
|
||||||
|
### B3. Add `GameOptions.to_start_game_dict()` for main.py
|
||||||
|
|
||||||
|
The `start_game` handler in main.py (lines 663-689) manually maps 17 `data.get()` calls to `GameOptions()`. Add a classmethod:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@classmethod
|
||||||
|
def from_client_data(cls, data: dict) -> "GameOptions":
|
||||||
|
"""Build GameOptions from client WebSocket message data."""
|
||||||
|
return cls(
|
||||||
|
flip_mode=data.get("flip_mode", "never"),
|
||||||
|
initial_flips=max(0, min(2, data.get("initial_flips", 2))),
|
||||||
|
knock_penalty=data.get("knock_penalty", False),
|
||||||
|
use_jokers=data.get("use_jokers", False),
|
||||||
|
lucky_swing=data.get("lucky_swing", False),
|
||||||
|
super_kings=data.get("super_kings", False),
|
||||||
|
ten_penny=data.get("ten_penny", False),
|
||||||
|
knock_bonus=data.get("knock_bonus", False),
|
||||||
|
underdog_bonus=data.get("underdog_bonus", False),
|
||||||
|
tied_shame=data.get("tied_shame", False),
|
||||||
|
blackjack=data.get("blackjack", False),
|
||||||
|
eagle_eye=data.get("eagle_eye", False),
|
||||||
|
wolfpack=data.get("wolfpack", False),
|
||||||
|
flip_as_action=data.get("flip_as_action", False),
|
||||||
|
four_of_a_kind=data.get("four_of_a_kind", False),
|
||||||
|
negative_pairs_keep_value=data.get("negative_pairs_keep_value", False),
|
||||||
|
one_eyed_jacks=data.get("one_eyed_jacks", False),
|
||||||
|
knock_early=data.get("knock_early", False),
|
||||||
|
deck_colors=data.get("deck_colors", ["red", "blue", "gold"]),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
This keeps the construction logic on the class that owns it and out of the WebSocket handler.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution Order
|
||||||
|
|
||||||
|
1. **B2, B3** (game.py small wins) - low risk, immediate cleanup
|
||||||
|
2. **A2** (log helper) - extract before moving handlers, so handlers are clean from the start
|
||||||
|
3. **A1** (handler extraction) - the big refactor, each handler is a cut-paste + cleanup
|
||||||
|
4. **A3** (static file mount) - easy win, independent
|
||||||
|
5. **B1** (active rules) - can do anytime
|
||||||
|
6. **A4** (lifespan cleanup) - lower priority, nice-to-have
|
||||||
|
|
||||||
|
## Files Touched
|
||||||
|
|
||||||
|
- `server/main.py` - major changes (handler extraction, static files, lifespan)
|
||||||
|
- `server/handlers.py` - **new file** with all message handlers
|
||||||
|
- `server/game.py` - minor changes (active rules, options_to_dict, from_client_data)
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- All existing tests in `test_game.py` should continue passing (game.py changes are additive/cosmetic)
|
||||||
|
- The WebSocket handler refactor is structural only - same logic, just reorganized
|
||||||
|
- Manual smoke test: create room, add CPU, play a round, verify everything works
|
||||||
175
docs/v3/refactor-misc.md
Normal file
175
docs/v3/refactor-misc.md
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
# Plan 3: Miscellaneous Refactoring & Improvements
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Everything that doesn't fall under the main.py/game.py or ai.py refactors: shared utilities, dead code, test improvements, and structural cleanup.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## M1. Duplicate `get_card_value` Functions
|
||||||
|
|
||||||
|
There are currently **three** functions that compute card values:
|
||||||
|
|
||||||
|
1. `game.py:get_card_value(card: Card, options)` - Takes Card objects
|
||||||
|
2. `constants.py:get_card_value_for_rank(rank_str, options_dict)` - Takes rank strings
|
||||||
|
3. `ai.py:get_ai_card_value(card, options)` - AI-specific wrapper (also handles face-down estimation)
|
||||||
|
|
||||||
|
**Problem:** `game.py` and `constants.py` do the same thing with different interfaces, and neither handles all house rules identically. The AI version adds face-down logic but duplicates the base value lookup.
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
- Keep `game.py:get_card_value()` as the canonical Card-based function (it already is the most complete)
|
||||||
|
- Keep `constants.py:get_card_value_for_rank()` for string-based lookups from logs/JSON
|
||||||
|
- Have `ai.py:get_ai_card_value()` delegate to `game.py:get_card_value()` for the base value, only adding its face-down estimation on top
|
||||||
|
- Add a brief comment in each noting which is canonical and why each variant exists
|
||||||
|
|
||||||
|
This is a minor cleanup - the current code works, it's just slightly confusing to have three entry points.
|
||||||
|
|
||||||
|
## M2. `GameOptions` Boilerplate Reduction
|
||||||
|
|
||||||
|
`GameOptions` currently has 17+ boolean fields. Every time a new house rule is added, you have to update:
|
||||||
|
|
||||||
|
1. `GameOptions` dataclass definition
|
||||||
|
2. `_options_to_dict()` in game.py
|
||||||
|
3. `get_active_rules()` logic in `get_state()`
|
||||||
|
4. `from_client_data()` (proposed in Plan 1)
|
||||||
|
5. `start_game` handler in main.py (currently, will move to handlers.py)
|
||||||
|
|
||||||
|
**Fix:** Use `dataclasses.fields()` introspection to auto-generate the dict and client data parsing:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from dataclasses import fields, asdict
|
||||||
|
|
||||||
|
# _options_to_dict becomes:
|
||||||
|
def _options_to_dict(self) -> dict:
|
||||||
|
return asdict(self.options)
|
||||||
|
|
||||||
|
# from_client_data becomes:
|
||||||
|
@classmethod
|
||||||
|
def from_client_data(cls, data: dict) -> "GameOptions":
|
||||||
|
field_defaults = {f.name: f.default for f in fields(cls)}
|
||||||
|
kwargs = {}
|
||||||
|
for f in fields(cls):
|
||||||
|
if f.name in data:
|
||||||
|
kwargs[f.name] = data[f.name]
|
||||||
|
# Special validation
|
||||||
|
kwargs["initial_flips"] = max(0, min(2, kwargs.get("initial_flips", 2)))
|
||||||
|
return cls(**kwargs)
|
||||||
|
```
|
||||||
|
|
||||||
|
This means adding a new house rule only requires adding the field to `GameOptions` and its entry in the active_rules display table (from Plan 1's B1).
|
||||||
|
|
||||||
|
## M3. Consolidate Game Logger Pattern in AI
|
||||||
|
|
||||||
|
`ai.py:process_cpu_turn()` has the same logger boilerplate as main.py's human handlers. After Plan 1's A2 creates `log_human_action()`, create a parallel:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def log_cpu_action(game_id, player, action, card=None, position=None, game=None, reason=""):
|
||||||
|
game_logger = get_logger()
|
||||||
|
if game_logger and game_id:
|
||||||
|
game_logger.log_move(
|
||||||
|
game_id=game_id,
|
||||||
|
player=player,
|
||||||
|
is_cpu=True,
|
||||||
|
action=action,
|
||||||
|
card=card,
|
||||||
|
position=position,
|
||||||
|
game=game,
|
||||||
|
decision_reason=reason,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
This appears ~4 times in `process_cpu_turn()`.
|
||||||
|
|
||||||
|
## M4. `Player.get_player()` Linear Search
|
||||||
|
|
||||||
|
`Game.get_player()` does a linear scan of the players list:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def get_player(self, player_id: str) -> Optional[Player]:
|
||||||
|
for player in self.players:
|
||||||
|
if player.id == player_id:
|
||||||
|
return player
|
||||||
|
return None
|
||||||
|
```
|
||||||
|
|
||||||
|
With max 6 players this is fine performance-wise, but it's called frequently. Could add a `_player_lookup: dict[str, Player]` cache maintained by `add_player`/`remove_player`. Very minor optimization - only worth doing if we're already touching these methods.
|
||||||
|
|
||||||
|
## M5. Room Code Collision Potential
|
||||||
|
|
||||||
|
`RoomManager._generate_code()` generates random 4-letter codes and retries on collision. With 26^4 = 456,976 possibilities this is fine now, but if we ever scale, the while-True loop could theoretically spin. Low priority, but a simple improvement:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _generate_code(self, max_attempts=100) -> str:
|
||||||
|
for _ in range(max_attempts):
|
||||||
|
code = "".join(random.choices(string.ascii_uppercase, k=4))
|
||||||
|
if code not in self.rooms:
|
||||||
|
return code
|
||||||
|
raise RuntimeError("Could not generate unique room code")
|
||||||
|
```
|
||||||
|
|
||||||
|
## M6. Test Coverage Gaps
|
||||||
|
|
||||||
|
Current test files:
|
||||||
|
- `test_game.py` - Core game logic (good coverage)
|
||||||
|
- `test_house_rules.py` - House rule scoring
|
||||||
|
- `test_v3_features.py` - New v3 features
|
||||||
|
- `test_maya_bug.py` - Specific regression test
|
||||||
|
- `tests/test_event_replay.py`, `test_persistence.py`, `test_replay.py` - Event system
|
||||||
|
|
||||||
|
**Missing:**
|
||||||
|
- No tests for `room.py` (Room, RoomManager, RoomPlayer)
|
||||||
|
- No tests for WebSocket message handlers (will be much easier to test after Plan 1's handler extraction)
|
||||||
|
- No unit tests for individual AI decision functions (will be much easier after Plan 2's decomposition)
|
||||||
|
|
||||||
|
**Recommendation:** After Plans 1 and 2 are complete, add:
|
||||||
|
- `test_handlers.py` - Test each message handler with mock WebSocket/Room
|
||||||
|
- `test_ai_decisions.py` - Test individual AI sub-functions (go-out logic, denial, etc.)
|
||||||
|
- `test_room.py` - Test Room/RoomManager CRUD operations
|
||||||
|
|
||||||
|
## M7. Unused/Dead Code Audit
|
||||||
|
|
||||||
|
Things to verify and potentially remove:
|
||||||
|
- `score_analysis.py` - Is this used anywhere or was it a one-off analysis tool?
|
||||||
|
- `game_analyzer.py` - Same question
|
||||||
|
- `auth.py` (top-level, not in routers/) - Appears to be an old file superseded by `services/auth_service.py`?
|
||||||
|
- `models/game_state.py` - Check if used or leftover from earlier design
|
||||||
|
|
||||||
|
## M8. Type Hints Consistency
|
||||||
|
|
||||||
|
Some functions have full type hints, others don't. The AI functions especially are loosely typed. After the ai.py refactor (Plan 2), ensure all new sub-functions have proper type hints:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _check_go_out_swap(
|
||||||
|
player: Player,
|
||||||
|
drawn_card: Card,
|
||||||
|
profile: CPUProfile,
|
||||||
|
game: Game,
|
||||||
|
game_state: dict,
|
||||||
|
) -> Optional[int]:
|
||||||
|
```
|
||||||
|
|
||||||
|
This helps with IDE navigation and catching bugs during future changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution Order
|
||||||
|
|
||||||
|
1. **M3** (AI logger helper) - Do alongside Plan 1's A2
|
||||||
|
2. **M2** (GameOptions introspection) - Do alongside Plan 1's B2/B3
|
||||||
|
3. **M1** (card value consolidation) - Quick cleanup
|
||||||
|
4. **M7** (dead code audit) - Quick investigation
|
||||||
|
5. **M5** (room code safety) - 2 lines
|
||||||
|
6. **M6** (tests) - After Plans 1 and 2 are complete
|
||||||
|
7. **M4** (player lookup) - Only if touching add/remove_player for other reasons
|
||||||
|
8. **M8** (type hints) - Ongoing, do as part of Plan 2
|
||||||
|
|
||||||
|
## Files Touched
|
||||||
|
|
||||||
|
- `server/ai.py` - logger helper, card value delegation
|
||||||
|
- `server/game.py` - GameOptions introspection
|
||||||
|
- `server/constants.py` - comments clarifying role
|
||||||
|
- `server/room.py` - room code safety (minor)
|
||||||
|
- `server/test_room.py` - **new file** (eventually)
|
||||||
|
- `server/test_handlers.py` - **new file** (eventually)
|
||||||
|
- `server/test_ai_decisions.py` - **new file** (eventually)
|
||||||
|
- Various files checked in dead code audit
|
||||||
42
docs/v3/refactor-remaining.md
Normal file
42
docs/v3/refactor-remaining.md
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
# Remaining Refactor Tasks
|
||||||
|
|
||||||
|
Leftover items from the v3 refactor plans that are functional but could benefit from further cleanup.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## R1. Decompose `calculate_swap_score()` (from Plan 2, Step 4)
|
||||||
|
|
||||||
|
**File:** `server/ai.py` (~236 lines)
|
||||||
|
|
||||||
|
Scores a single position for swapping. Still long with inline pair calculations, point gain logic, reveal bonuses, and comeback bonuses. Could extract:
|
||||||
|
|
||||||
|
- `_pair_improvement(player, position, new_card, options)` — pair-related benefit of swapping into a position
|
||||||
|
- `_standings_pressure(player, game)` — how much standings position should affect decisions (shared with `should_take_discard`)
|
||||||
|
|
||||||
|
**Validation:** `python server/simulate.py 500` before and after — stats should match within normal variance.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## R2. Decompose `should_take_discard()` (from Plan 2, Step 5)
|
||||||
|
|
||||||
|
**File:** `server/ai.py` (~148 lines)
|
||||||
|
|
||||||
|
Decides whether to take from discard pile. Contains a nested `has_good_swap_option()` helper. After R1's extracted utilities exist, this should shrink since `project_score()` and `known_score()` handle the repeated estimation logic.
|
||||||
|
|
||||||
|
**Validation:** Same simulation approach as R1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## R3. New Test Files (from Plan 3, M6)
|
||||||
|
|
||||||
|
After Plans 1 and 2, the extracted handlers and AI sub-functions are much easier to unit test. Add:
|
||||||
|
|
||||||
|
- **`server/test_handlers.py`** — Test each message handler with mock WebSocket/Room
|
||||||
|
- **`server/test_ai_decisions.py`** — Test individual AI sub-functions (go-out logic, denial, etc.)
|
||||||
|
- **`server/test_room.py`** — Test Room/RoomManager CRUD operations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Priority
|
||||||
|
|
||||||
|
R1 and R2 are pure structural refactors — no behavior changes, low risk, but also low urgency since the code works fine. R3 adds safety nets for future changes.
|
||||||
@ -1,10 +1,10 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "golfgame"
|
name = "golfgame"
|
||||||
version = "0.1.0"
|
version = "3.1.6"
|
||||||
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"
|
||||||
license = {text = "MIT"}
|
license = {text = "GPL-3.0-or-later"}
|
||||||
authors = [
|
authors = [
|
||||||
{name = "alee"}
|
{name = "alee"}
|
||||||
]
|
]
|
||||||
@ -13,7 +13,7 @@ classifiers = [
|
|||||||
"Development Status :: 3 - Alpha",
|
"Development Status :: 3 - Alpha",
|
||||||
"Framework :: FastAPI",
|
"Framework :: FastAPI",
|
||||||
"Intended Audience :: End Users/Desktop",
|
"Intended Audience :: End Users/Desktop",
|
||||||
"License :: OSI Approved :: MIT License",
|
"License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
|
||||||
"Programming Language :: Python :: 3",
|
"Programming Language :: Python :: 3",
|
||||||
"Programming Language :: Python :: 3.11",
|
"Programming Language :: Python :: 3.11",
|
||||||
"Programming Language :: Python :: 3.12",
|
"Programming Language :: Python :: 3.12",
|
||||||
@ -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]
|
||||||
|
|||||||
@ -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
|
|
||||||
23
scripts/deploy-staging.sh
Executable file
23
scripts/deploy-staging.sh
Executable file
@ -0,0 +1,23 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
DROPLET="root@129.212.150.189"
|
||||||
|
REMOTE_DIR="/opt/golfgame"
|
||||||
|
|
||||||
|
echo "Syncing to staging ($DROPLET)..."
|
||||||
|
rsync -az --delete \
|
||||||
|
--exclude='.git' \
|
||||||
|
--exclude='__pycache__' \
|
||||||
|
--exclude='node_modules' \
|
||||||
|
--exclude='.env' \
|
||||||
|
--exclude='internal/' \
|
||||||
|
server/ "$DROPLET:$REMOTE_DIR/server/"
|
||||||
|
rsync -az --delete \
|
||||||
|
--exclude='.git' \
|
||||||
|
--exclude='__pycache__' \
|
||||||
|
--exclude='node_modules' \
|
||||||
|
client/ "$DROPLET:$REMOTE_DIR/client/"
|
||||||
|
|
||||||
|
echo "Rebuilding app container..."
|
||||||
|
ssh $DROPLET "cd $REMOTE_DIR && docker compose -f docker-compose.staging.yml up -d --build app"
|
||||||
|
echo "Staging deploy complete."
|
||||||
9
scripts/deploy.sh
Executable file
9
scripts/deploy.sh
Executable file
@ -0,0 +1,9 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
DROPLET="root@165.245.152.51"
|
||||||
|
REMOTE_DIR="/opt/golfgame"
|
||||||
|
|
||||||
|
echo "Deploying to $DROPLET..."
|
||||||
|
ssh $DROPLET "cd $REMOTE_DIR && git pull origin main && docker compose -f docker-compose.prod.yml up -d --build app"
|
||||||
|
echo "Deploy complete."
|
||||||
39
scripts/dev-server.sh
Executable file
39
scripts/dev-server.sh
Executable file
@ -0,0 +1,39 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Start the Golf Game development server
|
||||||
|
#
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
|
|
||||||
|
cd "$PROJECT_DIR"
|
||||||
|
|
||||||
|
# Check if venv exists
|
||||||
|
if [ ! -f "bin/python" ]; then
|
||||||
|
echo "Virtual environment not found. Run ./scripts/install.sh first."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if Docker services are running
|
||||||
|
if command -v docker &> /dev/null; then
|
||||||
|
if ! docker ps --filter "name=redis" --format "{{.Names}}" 2>/dev/null | grep -q redis; then
|
||||||
|
echo "Warning: Redis container not running. Start with:"
|
||||||
|
echo " docker-compose -f docker-compose.dev.yml up -d"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Load .env if exists
|
||||||
|
if [ -f ".env" ]; then
|
||||||
|
set -a
|
||||||
|
source .env
|
||||||
|
set +a
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Starting Golf Game development server..."
|
||||||
|
echo "Server will be available at http://localhost:${PORT:-8000}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
cd server
|
||||||
|
exec ../bin/uvicorn main:app --reload --host "${HOST:-0.0.0.0}" --port "${PORT:-8000}"
|
||||||
43
scripts/docker-build.sh
Executable file
43
scripts/docker-build.sh
Executable file
@ -0,0 +1,43 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Build Docker images for Golf Game
|
||||||
|
#
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
|
|
||||||
|
cd "$PROJECT_DIR"
|
||||||
|
|
||||||
|
# Colors
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
IMAGE_NAME="${IMAGE_NAME:-golfgame}"
|
||||||
|
TAG="${TAG:-latest}"
|
||||||
|
|
||||||
|
echo -e "${BLUE}Building Golf Game Docker image...${NC}"
|
||||||
|
echo "Image: $IMAGE_NAME:$TAG"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
docker build -t "$IMAGE_NAME:$TAG" .
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}Build complete!${NC}"
|
||||||
|
echo ""
|
||||||
|
echo "To run with docker-compose (production):"
|
||||||
|
echo ""
|
||||||
|
echo " export DB_PASSWORD=your-secure-password"
|
||||||
|
echo " export SECRET_KEY=\$(python3 -c 'import secrets; print(secrets.token_hex(32))')"
|
||||||
|
echo " export ACME_EMAIL=your-email@example.com"
|
||||||
|
echo " export DOMAIN=your-domain.com"
|
||||||
|
echo " docker-compose -f docker-compose.prod.yml up -d"
|
||||||
|
echo ""
|
||||||
|
echo "To run standalone:"
|
||||||
|
echo ""
|
||||||
|
echo " docker run -d -p 8000:8000 \\"
|
||||||
|
echo " -e POSTGRES_URL=postgresql://user:pass@host:5432/golf \\"
|
||||||
|
echo " -e REDIS_URL=redis://host:6379 \\"
|
||||||
|
echo " -e SECRET_KEY=your-secret-key \\"
|
||||||
|
echo " $IMAGE_NAME:$TAG"
|
||||||
529
scripts/install.sh
Executable file
529
scripts/install.sh
Executable file
@ -0,0 +1,529 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Golf Game Installer
|
||||||
|
#
|
||||||
|
# This script provides a menu-driven installation for the Golf card game.
|
||||||
|
# Run with: ./scripts/install.sh
|
||||||
|
#
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Get the directory where this script lives
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
|
|
||||||
|
echo -e "${BLUE}"
|
||||||
|
echo "╔══════════════════════════════════════════════════════════════╗"
|
||||||
|
echo "║ Golf Game Installer ║"
|
||||||
|
echo "╚══════════════════════════════════════════════════════════════╝"
|
||||||
|
echo -e "${NC}"
|
||||||
|
|
||||||
|
show_menu() {
|
||||||
|
echo ""
|
||||||
|
echo "Select an option:"
|
||||||
|
echo ""
|
||||||
|
echo " 1) Development Setup"
|
||||||
|
echo " - Start Docker services (PostgreSQL, Redis)"
|
||||||
|
echo " - Create Python virtual environment"
|
||||||
|
echo " - Install dependencies"
|
||||||
|
echo " - Create .env from template"
|
||||||
|
echo ""
|
||||||
|
echo " 2) Production Install to /opt/golfgame"
|
||||||
|
echo " - Install application to /opt/golfgame"
|
||||||
|
echo " - Create production .env"
|
||||||
|
echo " - Set up systemd service"
|
||||||
|
echo ""
|
||||||
|
echo " 3) Docker Services Only"
|
||||||
|
echo " - Start PostgreSQL and Redis containers"
|
||||||
|
echo ""
|
||||||
|
echo " 4) Create/Update Systemd Service"
|
||||||
|
echo " - Create or update the systemd service file"
|
||||||
|
echo ""
|
||||||
|
echo " 5) Uninstall Production"
|
||||||
|
echo " - Stop and remove systemd service"
|
||||||
|
echo " - Optionally remove /opt/golfgame"
|
||||||
|
echo ""
|
||||||
|
echo " 6) Show Status"
|
||||||
|
echo " - Check Docker containers"
|
||||||
|
echo " - Check systemd service"
|
||||||
|
echo " - Test endpoints"
|
||||||
|
echo ""
|
||||||
|
echo " q) Quit"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
check_requirements() {
|
||||||
|
local missing=()
|
||||||
|
|
||||||
|
if ! command -v python3 &> /dev/null; then
|
||||||
|
missing+=("python3")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v docker &> /dev/null; then
|
||||||
|
missing+=("docker")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v docker-compose &> /dev/null && ! docker compose version &> /dev/null; then
|
||||||
|
missing+=("docker-compose")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ${#missing[@]} -gt 0 ]; then
|
||||||
|
echo -e "${RED}Missing required tools: ${missing[*]}${NC}"
|
||||||
|
echo "Please install them before continuing."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
start_docker_services() {
|
||||||
|
echo -e "${BLUE}Starting Docker services...${NC}"
|
||||||
|
cd "$PROJECT_DIR"
|
||||||
|
|
||||||
|
if docker compose version &> /dev/null; then
|
||||||
|
docker compose -f docker-compose.dev.yml up -d
|
||||||
|
else
|
||||||
|
docker-compose -f docker-compose.dev.yml up -d
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}Docker services started.${NC}"
|
||||||
|
echo ""
|
||||||
|
echo "Services:"
|
||||||
|
echo " - PostgreSQL: localhost:5432 (user: golf, password: devpassword, db: golf)"
|
||||||
|
echo " - Redis: localhost:6379"
|
||||||
|
}
|
||||||
|
|
||||||
|
setup_dev_venv() {
|
||||||
|
echo -e "${BLUE}Setting up Python virtual environment...${NC}"
|
||||||
|
cd "$PROJECT_DIR"
|
||||||
|
|
||||||
|
# Check Python version
|
||||||
|
PYTHON_VERSION=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
|
||||||
|
echo "Using Python $PYTHON_VERSION"
|
||||||
|
|
||||||
|
# Remove old venv if it exists and is broken
|
||||||
|
if [ -f "pyvenv.cfg" ]; then
|
||||||
|
if [ -L "bin/python" ] && [ ! -e "bin/python" ]; then
|
||||||
|
echo -e "${YELLOW}Removing broken virtual environment...${NC}"
|
||||||
|
rm -rf bin lib lib64 pyvenv.cfg include share 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create venv if it doesn't exist
|
||||||
|
if [ ! -f "pyvenv.cfg" ]; then
|
||||||
|
echo "Creating virtual environment..."
|
||||||
|
python3 -m venv .
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
echo "Installing dependencies..."
|
||||||
|
./bin/pip install --upgrade pip
|
||||||
|
./bin/pip install -e ".[dev]"
|
||||||
|
|
||||||
|
echo -e "${GREEN}Virtual environment ready.${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
setup_dev_env() {
|
||||||
|
echo -e "${BLUE}Setting up .env file...${NC}"
|
||||||
|
cd "$PROJECT_DIR"
|
||||||
|
|
||||||
|
if [ -f ".env" ]; then
|
||||||
|
echo -e "${YELLOW}.env file already exists. Overwrite? (y/N)${NC}"
|
||||||
|
read -r response
|
||||||
|
if [[ ! "$response" =~ ^[Yy]$ ]]; then
|
||||||
|
echo "Keeping existing .env"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat > .env << 'EOF'
|
||||||
|
# Golf Game Development Configuration
|
||||||
|
# Generated by install.sh
|
||||||
|
|
||||||
|
HOST=0.0.0.0
|
||||||
|
PORT=8000
|
||||||
|
DEBUG=true
|
||||||
|
LOG_LEVEL=DEBUG
|
||||||
|
ENVIRONMENT=development
|
||||||
|
|
||||||
|
# PostgreSQL (from docker-compose.dev.yml)
|
||||||
|
DATABASE_URL=postgresql://golf:devpassword@localhost:5432/golf
|
||||||
|
POSTGRES_URL=postgresql://golf:devpassword@localhost:5432/golf
|
||||||
|
|
||||||
|
# Room Settings
|
||||||
|
MAX_PLAYERS_PER_ROOM=6
|
||||||
|
ROOM_TIMEOUT_MINUTES=60
|
||||||
|
ROOM_CODE_LENGTH=4
|
||||||
|
|
||||||
|
# Game Defaults
|
||||||
|
DEFAULT_ROUNDS=9
|
||||||
|
DEFAULT_INITIAL_FLIPS=2
|
||||||
|
DEFAULT_USE_JOKERS=false
|
||||||
|
DEFAULT_FLIP_ON_DISCARD=false
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo -e "${GREEN}.env file created.${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
dev_setup() {
|
||||||
|
echo -e "${BLUE}=== Development Setup ===${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if ! check_requirements; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
start_docker_services
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
setup_dev_venv
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
setup_dev_env
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo -e "${GREEN}=== Development Setup Complete ===${NC}"
|
||||||
|
echo ""
|
||||||
|
echo "To start the development server:"
|
||||||
|
echo ""
|
||||||
|
echo " cd $PROJECT_DIR/server"
|
||||||
|
echo " ../bin/uvicorn main:app --reload --host 0.0.0.0 --port 8000"
|
||||||
|
echo ""
|
||||||
|
echo "Or use the helper script:"
|
||||||
|
echo ""
|
||||||
|
echo " $PROJECT_DIR/scripts/dev-server.sh"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
prod_install() {
|
||||||
|
echo -e "${BLUE}=== Production Installation ===${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
INSTALL_DIR="/opt/golfgame"
|
||||||
|
|
||||||
|
# Check if running as root or with sudo available
|
||||||
|
if [ "$EUID" -ne 0 ]; then
|
||||||
|
if ! command -v sudo &> /dev/null; then
|
||||||
|
echo -e "${RED}This option requires root privileges. Run with sudo or as root.${NC}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
SUDO="sudo"
|
||||||
|
else
|
||||||
|
SUDO=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "This will install Golf Game to $INSTALL_DIR"
|
||||||
|
echo -e "${YELLOW}Continue? (y/N)${NC}"
|
||||||
|
read -r response
|
||||||
|
if [[ ! "$response" =~ ^[Yy]$ ]]; then
|
||||||
|
echo "Aborted."
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create directory
|
||||||
|
echo "Creating $INSTALL_DIR..."
|
||||||
|
$SUDO mkdir -p "$INSTALL_DIR"
|
||||||
|
|
||||||
|
# Copy files
|
||||||
|
echo "Copying application files..."
|
||||||
|
$SUDO cp -r "$PROJECT_DIR/server" "$INSTALL_DIR/"
|
||||||
|
$SUDO cp -r "$PROJECT_DIR/client" "$INSTALL_DIR/"
|
||||||
|
$SUDO cp "$PROJECT_DIR/pyproject.toml" "$INSTALL_DIR/"
|
||||||
|
$SUDO cp "$PROJECT_DIR/README.md" "$INSTALL_DIR/"
|
||||||
|
$SUDO cp "$PROJECT_DIR/INSTALL.md" "$INSTALL_DIR/"
|
||||||
|
$SUDO cp "$PROJECT_DIR/.env.example" "$INSTALL_DIR/"
|
||||||
|
$SUDO cp -r "$PROJECT_DIR/scripts" "$INSTALL_DIR/"
|
||||||
|
|
||||||
|
# Create venv
|
||||||
|
echo "Creating virtual environment..."
|
||||||
|
$SUDO python3 -m venv "$INSTALL_DIR"
|
||||||
|
$SUDO "$INSTALL_DIR/bin/pip" install --upgrade pip
|
||||||
|
$SUDO "$INSTALL_DIR/bin/pip" install "$INSTALL_DIR"
|
||||||
|
|
||||||
|
# Create production .env if it doesn't exist
|
||||||
|
if [ ! -f "$INSTALL_DIR/.env" ]; then
|
||||||
|
echo "Creating production .env..."
|
||||||
|
|
||||||
|
# Generate a secret key
|
||||||
|
SECRET_KEY=$(python3 -c "import secrets; print(secrets.token_hex(32))")
|
||||||
|
|
||||||
|
$SUDO tee "$INSTALL_DIR/.env" > /dev/null << EOF
|
||||||
|
# Golf Game Production Configuration
|
||||||
|
# Generated by install.sh
|
||||||
|
|
||||||
|
HOST=0.0.0.0
|
||||||
|
PORT=8000
|
||||||
|
DEBUG=false
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
ENVIRONMENT=production
|
||||||
|
|
||||||
|
# PostgreSQL - UPDATE THESE VALUES
|
||||||
|
DATABASE_URL=postgresql://golf:CHANGE_ME@localhost:5432/golf
|
||||||
|
POSTGRES_URL=postgresql://golf:CHANGE_ME@localhost:5432/golf
|
||||||
|
|
||||||
|
# Security
|
||||||
|
SECRET_KEY=$SECRET_KEY
|
||||||
|
|
||||||
|
# Room Settings
|
||||||
|
MAX_PLAYERS_PER_ROOM=6
|
||||||
|
ROOM_TIMEOUT_MINUTES=60
|
||||||
|
ROOM_CODE_LENGTH=4
|
||||||
|
|
||||||
|
# Game Defaults
|
||||||
|
DEFAULT_ROUNDS=9
|
||||||
|
DEFAULT_INITIAL_FLIPS=2
|
||||||
|
DEFAULT_USE_JOKERS=false
|
||||||
|
DEFAULT_FLIP_ON_DISCARD=false
|
||||||
|
|
||||||
|
# Optional: Sentry error tracking
|
||||||
|
# SENTRY_DSN=https://your-sentry-dsn
|
||||||
|
EOF
|
||||||
|
$SUDO chmod 600 "$INSTALL_DIR/.env"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Set ownership
|
||||||
|
echo "Setting permissions..."
|
||||||
|
$SUDO chown -R www-data:www-data "$INSTALL_DIR"
|
||||||
|
|
||||||
|
echo -e "${GREEN}Application installed to $INSTALL_DIR${NC}"
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}IMPORTANT: Edit $INSTALL_DIR/.env and update:${NC}"
|
||||||
|
echo " - DATABASE_URL / POSTGRES_URL with your PostgreSQL credentials"
|
||||||
|
echo " - Any other settings as needed"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Offer to set up systemd
|
||||||
|
echo "Set up systemd service now? (Y/n)"
|
||||||
|
read -r response
|
||||||
|
if [[ ! "$response" =~ ^[Nn]$ ]]; then
|
||||||
|
setup_systemd
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
setup_systemd() {
|
||||||
|
echo -e "${BLUE}=== Systemd Service Setup ===${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
INSTALL_DIR="/opt/golfgame"
|
||||||
|
SERVICE_FILE="/etc/systemd/system/golfgame.service"
|
||||||
|
|
||||||
|
if [ "$EUID" -ne 0 ]; then
|
||||||
|
if ! command -v sudo &> /dev/null; then
|
||||||
|
echo -e "${RED}This option requires root privileges.${NC}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
SUDO="sudo"
|
||||||
|
else
|
||||||
|
SUDO=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -d "$INSTALL_DIR" ]; then
|
||||||
|
echo -e "${RED}$INSTALL_DIR does not exist. Run production install first.${NC}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Creating systemd service..."
|
||||||
|
|
||||||
|
$SUDO tee "$SERVICE_FILE" > /dev/null << 'EOF'
|
||||||
|
[Unit]
|
||||||
|
Description=Golf Card Game Server
|
||||||
|
Documentation=https://github.com/alee/golfgame
|
||||||
|
After=network.target postgresql.service redis.service
|
||||||
|
Wants=postgresql.service redis.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=www-data
|
||||||
|
Group=www-data
|
||||||
|
WorkingDirectory=/opt/golfgame/server
|
||||||
|
Environment="PATH=/opt/golfgame/bin:/usr/local/bin:/usr/bin:/bin"
|
||||||
|
EnvironmentFile=/opt/golfgame/.env
|
||||||
|
ExecStart=/opt/golfgame/bin/uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4
|
||||||
|
ExecReload=/bin/kill -HUP $MAINPID
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
|
||||||
|
# Security hardening
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
ReadWritePaths=/opt/golfgame
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "Reloading systemd..."
|
||||||
|
$SUDO systemctl daemon-reload
|
||||||
|
|
||||||
|
echo "Enabling service..."
|
||||||
|
$SUDO systemctl enable golfgame
|
||||||
|
|
||||||
|
echo -e "${GREEN}Systemd service created.${NC}"
|
||||||
|
echo ""
|
||||||
|
echo "Commands:"
|
||||||
|
echo " sudo systemctl start golfgame # Start the service"
|
||||||
|
echo " sudo systemctl stop golfgame # Stop the service"
|
||||||
|
echo " sudo systemctl restart golfgame # Restart the service"
|
||||||
|
echo " sudo systemctl status golfgame # Check status"
|
||||||
|
echo " journalctl -u golfgame -f # View logs"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "Start the service now? (Y/n)"
|
||||||
|
read -r response
|
||||||
|
if [[ ! "$response" =~ ^[Nn]$ ]]; then
|
||||||
|
$SUDO systemctl start golfgame
|
||||||
|
sleep 2
|
||||||
|
$SUDO systemctl status golfgame --no-pager
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
uninstall_prod() {
|
||||||
|
echo -e "${BLUE}=== Production Uninstall ===${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [ "$EUID" -ne 0 ]; then
|
||||||
|
if ! command -v sudo &> /dev/null; then
|
||||||
|
echo -e "${RED}This option requires root privileges.${NC}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
SUDO="sudo"
|
||||||
|
else
|
||||||
|
SUDO=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${YELLOW}This will stop and remove the systemd service.${NC}"
|
||||||
|
echo "Continue? (y/N)"
|
||||||
|
read -r response
|
||||||
|
if [[ ! "$response" =~ ^[Yy]$ ]]; then
|
||||||
|
echo "Aborted."
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Stop and disable service
|
||||||
|
if [ -f "/etc/systemd/system/golfgame.service" ]; then
|
||||||
|
echo "Stopping service..."
|
||||||
|
$SUDO systemctl stop golfgame 2>/dev/null || true
|
||||||
|
$SUDO systemctl disable golfgame 2>/dev/null || true
|
||||||
|
$SUDO rm -f /etc/systemd/system/golfgame.service
|
||||||
|
$SUDO systemctl daemon-reload
|
||||||
|
echo "Service removed."
|
||||||
|
else
|
||||||
|
echo "No systemd service found."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Optionally remove installation directory
|
||||||
|
if [ -d "/opt/golfgame" ]; then
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}Remove /opt/golfgame directory? (y/N)${NC}"
|
||||||
|
read -r response
|
||||||
|
if [[ "$response" =~ ^[Yy]$ ]]; then
|
||||||
|
$SUDO rm -rf /opt/golfgame
|
||||||
|
echo "Directory removed."
|
||||||
|
else
|
||||||
|
echo "Directory kept."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}Uninstall complete.${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
show_status() {
|
||||||
|
echo -e "${BLUE}=== Status Check ===${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Docker containers
|
||||||
|
echo "Docker Containers:"
|
||||||
|
if command -v docker &> /dev/null; then
|
||||||
|
docker ps --filter "name=golfgame" --format " {{.Names}}: {{.Status}}" 2>/dev/null || echo " (none running)"
|
||||||
|
echo ""
|
||||||
|
else
|
||||||
|
echo " Docker not installed"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Systemd service
|
||||||
|
echo "Systemd Service:"
|
||||||
|
if [ -f "/etc/systemd/system/golfgame.service" ]; then
|
||||||
|
systemctl status golfgame --no-pager 2>/dev/null | head -5 || echo " Service not running"
|
||||||
|
else
|
||||||
|
echo " Not installed"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
echo "Health Check:"
|
||||||
|
for port in 8000; do
|
||||||
|
if curl -s "http://localhost:$port/health" > /dev/null 2>&1; then
|
||||||
|
response=$(curl -s "http://localhost:$port/health")
|
||||||
|
echo -e " Port $port: ${GREEN}OK${NC} - $response"
|
||||||
|
else
|
||||||
|
echo -e " Port $port: ${RED}Not responding${NC}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Database
|
||||||
|
echo "PostgreSQL:"
|
||||||
|
if command -v pg_isready &> /dev/null; then
|
||||||
|
if pg_isready -h localhost -p 5432 > /dev/null 2>&1; then
|
||||||
|
echo -e " ${GREEN}Running${NC} on localhost:5432"
|
||||||
|
else
|
||||||
|
echo -e " ${RED}Not responding${NC}"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
if docker ps --filter "name=postgres" --format "{{.Names}}" 2>/dev/null | grep -q postgres; then
|
||||||
|
echo -e " ${GREEN}Running${NC} (Docker)"
|
||||||
|
else
|
||||||
|
echo " Unable to check (pg_isready not installed)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
echo "Redis:"
|
||||||
|
if command -v redis-cli &> /dev/null; then
|
||||||
|
if redis-cli ping > /dev/null 2>&1; then
|
||||||
|
echo -e " ${GREEN}Running${NC} on localhost:6379"
|
||||||
|
else
|
||||||
|
echo -e " ${RED}Not responding${NC}"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
if docker ps --filter "name=redis" --format "{{.Names}}" 2>/dev/null | grep -q redis; then
|
||||||
|
echo -e " ${GREEN}Running${NC} (Docker)"
|
||||||
|
else
|
||||||
|
echo " Unable to check (redis-cli not installed)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main loop
|
||||||
|
while true; do
|
||||||
|
show_menu
|
||||||
|
echo -n "Enter choice: "
|
||||||
|
read -r choice
|
||||||
|
|
||||||
|
case $choice in
|
||||||
|
1) dev_setup ;;
|
||||||
|
2) prod_install ;;
|
||||||
|
3) start_docker_services ;;
|
||||||
|
4) setup_systemd ;;
|
||||||
|
5) uninstall_prod ;;
|
||||||
|
6) show_status ;;
|
||||||
|
q|Q) echo "Goodbye!"; exit 0 ;;
|
||||||
|
*) echo -e "${RED}Invalid option${NC}" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Press Enter to continue..."
|
||||||
|
read -r
|
||||||
|
done
|
||||||
@ -7,6 +7,24 @@ PORT=8000
|
|||||||
DEBUG=true
|
DEBUG=true
|
||||||
LOG_LEVEL=DEBUG
|
LOG_LEVEL=DEBUG
|
||||||
|
|
||||||
|
# Per-module log level overrides (optional)
|
||||||
|
# These override LOG_LEVEL for specific modules.
|
||||||
|
# LOG_LEVEL_GAME=DEBUG # Core game logic
|
||||||
|
# LOG_LEVEL_AI=DEBUG # AI decisions (very verbose at DEBUG)
|
||||||
|
# LOG_LEVEL_HANDLERS=DEBUG # WebSocket message handlers
|
||||||
|
# LOG_LEVEL_ROOM=DEBUG # Room/lobby management
|
||||||
|
# LOG_LEVEL_AUTH=DEBUG # Auth stack (auth, routers.auth, services.auth_service)
|
||||||
|
# LOG_LEVEL_STORES=DEBUG # Database/Redis operations
|
||||||
|
|
||||||
|
# --- Preset examples ---
|
||||||
|
# Staging (debug game logic, quiet everything else):
|
||||||
|
# LOG_LEVEL=INFO
|
||||||
|
# LOG_LEVEL_GAME=DEBUG
|
||||||
|
# LOG_LEVEL_AI=DEBUG
|
||||||
|
#
|
||||||
|
# Production (minimal logging):
|
||||||
|
# LOG_LEVEL=WARNING
|
||||||
|
|
||||||
# Environment (development, staging, production)
|
# Environment (development, staging, production)
|
||||||
# Affects logging format, security headers (HSTS), etc.
|
# Affects logging format, security headers (HSTS), etc.
|
||||||
ENVIRONMENT=development
|
ENVIRONMENT=development
|
||||||
|
|||||||
1439
server/ai.py
1439
server/ai.py
File diff suppressed because it is too large
Load Diff
@ -1,3 +1,4 @@
|
|||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
"""
|
"""
|
||||||
Authentication and user management for Golf game.
|
Authentication and user management for Golf game.
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
"""
|
"""
|
||||||
Centralized configuration for Golf game server.
|
Centralized configuration for Golf game server.
|
||||||
|
|
||||||
@ -142,12 +143,28 @@ class ServerConfig:
|
|||||||
MAX_PLAYERS_PER_ROOM: int = 6
|
MAX_PLAYERS_PER_ROOM: int = 6
|
||||||
ROOM_TIMEOUT_MINUTES: int = 60
|
ROOM_TIMEOUT_MINUTES: int = 60
|
||||||
ROOM_CODE_LENGTH: int = 4
|
ROOM_CODE_LENGTH: int = 4
|
||||||
|
ROOM_IDLE_TIMEOUT_SECONDS: int = 300 # 5 minutes of inactivity
|
||||||
|
|
||||||
# Security (for future auth system)
|
# Security (for future auth system)
|
||||||
SECRET_KEY: str = ""
|
SECRET_KEY: str = ""
|
||||||
INVITE_ONLY: bool = False
|
INVITE_ONLY: bool = True
|
||||||
|
|
||||||
|
# Metered open signups (public beta)
|
||||||
|
# 0 = disabled (invite-only), -1 = unlimited, N = max per day
|
||||||
|
DAILY_OPEN_SIGNUPS: int = 0
|
||||||
|
# Max signups per IP per day (0 = unlimited)
|
||||||
|
DAILY_SIGNUPS_PER_IP: int = 3
|
||||||
|
|
||||||
|
# Bootstrap admin (for first-time setup when INVITE_ONLY=true)
|
||||||
|
BOOTSTRAP_ADMIN_USERNAME: str = ""
|
||||||
|
BOOTSTRAP_ADMIN_PASSWORD: str = ""
|
||||||
ADMIN_EMAILS: list[str] = field(default_factory=list)
|
ADMIN_EMAILS: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
# Matchmaking
|
||||||
|
MATCHMAKING_ENABLED: bool = True
|
||||||
|
MATCHMAKING_MIN_PLAYERS: int = 2
|
||||||
|
MATCHMAKING_MAX_PLAYERS: int = 4
|
||||||
|
|
||||||
# Rate limiting
|
# Rate limiting
|
||||||
RATE_LIMIT_ENABLED: bool = True
|
RATE_LIMIT_ENABLED: bool = True
|
||||||
|
|
||||||
@ -183,8 +200,16 @@ class ServerConfig:
|
|||||||
MAX_PLAYERS_PER_ROOM=get_env_int("MAX_PLAYERS_PER_ROOM", 6),
|
MAX_PLAYERS_PER_ROOM=get_env_int("MAX_PLAYERS_PER_ROOM", 6),
|
||||||
ROOM_TIMEOUT_MINUTES=get_env_int("ROOM_TIMEOUT_MINUTES", 60),
|
ROOM_TIMEOUT_MINUTES=get_env_int("ROOM_TIMEOUT_MINUTES", 60),
|
||||||
ROOM_CODE_LENGTH=get_env_int("ROOM_CODE_LENGTH", 4),
|
ROOM_CODE_LENGTH=get_env_int("ROOM_CODE_LENGTH", 4),
|
||||||
|
ROOM_IDLE_TIMEOUT_SECONDS=get_env_int("ROOM_IDLE_TIMEOUT_SECONDS", 300),
|
||||||
SECRET_KEY=get_env("SECRET_KEY", ""),
|
SECRET_KEY=get_env("SECRET_KEY", ""),
|
||||||
INVITE_ONLY=get_env_bool("INVITE_ONLY", False),
|
INVITE_ONLY=get_env_bool("INVITE_ONLY", True),
|
||||||
|
DAILY_OPEN_SIGNUPS=get_env_int("DAILY_OPEN_SIGNUPS", 0),
|
||||||
|
DAILY_SIGNUPS_PER_IP=get_env_int("DAILY_SIGNUPS_PER_IP", 3),
|
||||||
|
BOOTSTRAP_ADMIN_USERNAME=get_env("BOOTSTRAP_ADMIN_USERNAME", ""),
|
||||||
|
BOOTSTRAP_ADMIN_PASSWORD=get_env("BOOTSTRAP_ADMIN_PASSWORD", ""),
|
||||||
|
MATCHMAKING_ENABLED=get_env_bool("MATCHMAKING_ENABLED", True),
|
||||||
|
MATCHMAKING_MIN_PLAYERS=get_env_int("MATCHMAKING_MIN_PLAYERS", 2),
|
||||||
|
MATCHMAKING_MAX_PLAYERS=get_env_int("MATCHMAKING_MAX_PLAYERS", 4),
|
||||||
ADMIN_EMAILS=admin_emails,
|
ADMIN_EMAILS=admin_emails,
|
||||||
RATE_LIMIT_ENABLED=get_env_bool("RATE_LIMIT_ENABLED", True),
|
RATE_LIMIT_ENABLED=get_env_bool("RATE_LIMIT_ENABLED", True),
|
||||||
SENTRY_DSN=get_env("SENTRY_DSN", ""),
|
SENTRY_DSN=get_env("SENTRY_DSN", ""),
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
"""
|
"""
|
||||||
Card value constants for 6-Card Golf.
|
Card value constants for 6-Card Golf.
|
||||||
|
|
||||||
|
|||||||
245
server/game.py
245
server/game.py
@ -1,3 +1,4 @@
|
|||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
"""
|
"""
|
||||||
Game logic for 6-Card Golf.
|
Game logic for 6-Card Golf.
|
||||||
|
|
||||||
@ -21,7 +22,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 +131,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 +157,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 +216,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 +262,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.
|
||||||
@ -347,6 +359,13 @@ class Player:
|
|||||||
jack_pairs = 0 # Track paired Jacks for Wolfpack bonus
|
jack_pairs = 0 # Track paired Jacks for Wolfpack bonus
|
||||||
paired_ranks: list[Rank] = [] # Track all paired ranks for four-of-a-kind
|
paired_ranks: list[Rank] = [] # Track all paired ranks for four-of-a-kind
|
||||||
|
|
||||||
|
# Evaluation order matters here. We check special-case pairs BEFORE the
|
||||||
|
# default "pairs cancel to 0" rule, because house rules can override that:
|
||||||
|
# 1. Eagle Eye joker pairs -> -4 (better than 0, exit early)
|
||||||
|
# 2. Negative pairs keep value -> sum of negatives (worse than 0, exit early)
|
||||||
|
# 3. Normal pairs -> 0 (skip both cards)
|
||||||
|
# 4. Non-matching -> sum both values
|
||||||
|
# Bonuses (wolfpack, four-of-a-kind) are applied after all columns are scored.
|
||||||
for col in range(3):
|
for col in range(3):
|
||||||
top_idx = col
|
top_idx = col
|
||||||
bottom_idx = col + 3
|
bottom_idx = col + 3
|
||||||
@ -498,6 +517,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 +614,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 +783,17 @@ 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)
|
||||||
|
if self.players:
|
||||||
|
# Adjust dealer_idx if needed after removal
|
||||||
|
if self.dealer_idx >= len(self.players):
|
||||||
|
self.dealer_idx = 0
|
||||||
|
# Adjust current_player_index after removal
|
||||||
|
if i < self.current_player_index:
|
||||||
|
# Removed player was before current: shift back
|
||||||
|
self.current_player_index -= 1
|
||||||
|
elif self.current_player_index >= len(self.players):
|
||||||
|
# Removed player was at/after current and index is now OOB
|
||||||
|
self.current_player_index = 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
|
||||||
@ -733,6 +816,8 @@ class Game:
|
|||||||
def current_player(self) -> Optional[Player]:
|
def current_player(self) -> Optional[Player]:
|
||||||
"""Get the player whose turn it currently is."""
|
"""Get the player whose turn it currently is."""
|
||||||
if self.players:
|
if self.players:
|
||||||
|
if self.current_player_index >= len(self.players):
|
||||||
|
self.current_player_index = self.current_player_index % len(self.players)
|
||||||
return self.players[self.current_player_index]
|
return self.players[self.current_player_index]
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@ -772,26 +857,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 +946,13 @@ 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)
|
||||||
|
|
||||||
|
# "Left of dealer goes first" — standard card game convention.
|
||||||
|
# In our circular list, "left" is the next index.
|
||||||
|
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 +961,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
|
||||||
@ -1319,6 +1434,9 @@ class Game:
|
|||||||
Args:
|
Args:
|
||||||
player: The player whose turn just ended.
|
player: The player whose turn just ended.
|
||||||
"""
|
"""
|
||||||
|
# This method and _next_turn() are tightly coupled. _check_end_turn populates
|
||||||
|
# players_with_final_turn BEFORE calling _next_turn(), which reads it to decide
|
||||||
|
# whether the round is over. Reordering these calls will break end-of-round logic.
|
||||||
if player.all_face_up() and self.finisher_id is None:
|
if player.all_face_up() and self.finisher_id is None:
|
||||||
self.finisher_id = player.id
|
self.finisher_id = player.id
|
||||||
self.phase = GamePhase.FINAL_TURN
|
self.phase = GamePhase.FINAL_TURN
|
||||||
@ -1335,7 +1453,8 @@ class Game:
|
|||||||
Advance to the next player's turn.
|
Advance to the next player's turn.
|
||||||
|
|
||||||
In FINAL_TURN phase, tracks which players have had their final turn
|
In FINAL_TURN phase, tracks which players have had their final turn
|
||||||
and ends the round when everyone has played.
|
and ends the round when everyone has played. Depends on _check_end_turn()
|
||||||
|
having already added the current player to players_with_final_turn.
|
||||||
"""
|
"""
|
||||||
if self.phase == GamePhase.FINAL_TURN:
|
if self.phase == GamePhase.FINAL_TURN:
|
||||||
next_index = (self.current_player_index + 1) % len(self.players)
|
next_index = (self.current_player_index + 1) % len(self.players)
|
||||||
@ -1378,6 +1497,10 @@ class Game:
|
|||||||
player.calculate_score(self.options)
|
player.calculate_score(self.options)
|
||||||
|
|
||||||
# --- Apply House Rule Bonuses/Penalties ---
|
# --- Apply House Rule Bonuses/Penalties ---
|
||||||
|
# Order matters. Blackjack converts 21->0 first, so knock penalty checks
|
||||||
|
# against the post-blackjack score. Knock penalty before knock bonus so they
|
||||||
|
# can stack (you get penalized AND rewarded, net +5). Underdog before tied shame
|
||||||
|
# so the -3 bonus can create new ties that then get punished. It's mean by design.
|
||||||
|
|
||||||
# Blackjack: exact score of 21 becomes 0
|
# Blackjack: exact score of 21 becomes 0
|
||||||
if self.options.blackjack:
|
if self.options.blackjack:
|
||||||
@ -1501,6 +1624,10 @@ class Game:
|
|||||||
"""
|
"""
|
||||||
current = self.current_player()
|
current = self.current_player()
|
||||||
|
|
||||||
|
# Card visibility has three cases:
|
||||||
|
# 1. Round/game over: all cards revealed to everyone (reveal=True)
|
||||||
|
# 2. Your own cards: always revealed to you (is_self=True)
|
||||||
|
# 3. Opponent cards mid-game: only face-up cards shown, hidden cards are redacted
|
||||||
players_data = []
|
players_data = []
|
||||||
for player in self.players:
|
for player in self.players:
|
||||||
reveal = self.phase in (GamePhase.ROUND_OVER, GamePhase.GAME_OVER)
|
reveal = self.phase in (GamePhase.ROUND_OVER, GamePhase.GAME_OVER)
|
||||||
@ -1518,56 +1645,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 +1672,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(),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,23 @@
|
|||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
"""
|
"""
|
||||||
Game Analyzer for 6-Card Golf AI decisions.
|
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 +350,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 +595,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())
|
||||||
|
|||||||
@ -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
|
|
||||||
BIN
server/games.db
BIN
server/games.db
Binary file not shown.
610
server/handlers.py
Normal file
610
server/handlers.py
Normal file
@ -0,0 +1,610 @@
|
|||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
"""WebSocket message handlers for the Golf card game.
|
||||||
|
|
||||||
|
Each handler corresponds to a single message type from the client.
|
||||||
|
Handlers are dispatched via the HANDLERS dict in main.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import WebSocket
|
||||||
|
|
||||||
|
from config import config
|
||||||
|
from game import GamePhase, GameOptions
|
||||||
|
from ai import GolfAI, get_all_profiles
|
||||||
|
from room import Room
|
||||||
|
from services.game_logger import get_logger
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ConnectionContext:
|
||||||
|
"""State tracked per WebSocket connection."""
|
||||||
|
|
||||||
|
websocket: WebSocket
|
||||||
|
connection_id: str
|
||||||
|
player_id: str
|
||||||
|
auth_user_id: Optional[str]
|
||||||
|
authenticated_user: object # Optional[User]
|
||||||
|
current_room: Optional[Room] = None
|
||||||
|
|
||||||
|
|
||||||
|
def log_human_action(room: Room, player, action: str, card=None, position=None, reason: str = ""):
|
||||||
|
"""Log a human player's game action (shared helper for all handlers)."""
|
||||||
|
game_logger = get_logger()
|
||||||
|
if game_logger and room.game_log_id and player:
|
||||||
|
game_logger.log_move(
|
||||||
|
game_id=room.game_log_id,
|
||||||
|
player=player,
|
||||||
|
is_cpu=False,
|
||||||
|
action=action,
|
||||||
|
card=card,
|
||||||
|
position=position,
|
||||||
|
game=room.game,
|
||||||
|
decision_reason=reason,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Lobby / Room handlers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def handle_create_room(data: dict, ctx: ConnectionContext, *, room_manager, count_user_games, max_concurrent, **kw) -> None:
|
||||||
|
if config.INVITE_ONLY and not ctx.authenticated_user:
|
||||||
|
await ctx.websocket.send_json({"type": "error", "message": "You must be logged in to play"})
|
||||||
|
return
|
||||||
|
|
||||||
|
if ctx.auth_user_id and count_user_games(ctx.auth_user_id) >= max_concurrent:
|
||||||
|
await ctx.websocket.send_json({
|
||||||
|
"type": "error",
|
||||||
|
"message": f"Maximum {max_concurrent} concurrent games allowed",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
|
||||||
|
# Use authenticated username as player name
|
||||||
|
player_name = ctx.authenticated_user.username if ctx.authenticated_user else data.get("player_name", "Player")
|
||||||
|
room = room_manager.create_room()
|
||||||
|
room.add_player(ctx.player_id, player_name, ctx.websocket, ctx.auth_user_id)
|
||||||
|
room.touch()
|
||||||
|
ctx.current_room = room
|
||||||
|
|
||||||
|
await ctx.websocket.send_json({
|
||||||
|
"type": "room_created",
|
||||||
|
"room_code": room.code,
|
||||||
|
"player_id": ctx.player_id,
|
||||||
|
"authenticated": ctx.authenticated_user is not None,
|
||||||
|
})
|
||||||
|
|
||||||
|
await room.broadcast({
|
||||||
|
"type": "player_joined",
|
||||||
|
"players": room.player_list(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_join_room(data: dict, ctx: ConnectionContext, *, room_manager, count_user_games, max_concurrent, **kw) -> None:
|
||||||
|
if config.INVITE_ONLY and not ctx.authenticated_user:
|
||||||
|
await ctx.websocket.send_json({"type": "error", "message": "You must be logged in to play"})
|
||||||
|
return
|
||||||
|
|
||||||
|
room_code = data.get("room_code", "").upper()
|
||||||
|
# Use authenticated username as player name
|
||||||
|
player_name = ctx.authenticated_user.username if ctx.authenticated_user else data.get("player_name", "Player")
|
||||||
|
|
||||||
|
if ctx.auth_user_id and count_user_games(ctx.auth_user_id) >= max_concurrent:
|
||||||
|
await ctx.websocket.send_json({
|
||||||
|
"type": "error",
|
||||||
|
"message": f"Maximum {max_concurrent} concurrent games allowed",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
|
||||||
|
room = room_manager.get_room(room_code)
|
||||||
|
if not room:
|
||||||
|
await ctx.websocket.send_json({"type": "error", "message": "Room not found"})
|
||||||
|
return
|
||||||
|
|
||||||
|
if len(room.players) >= 6:
|
||||||
|
await ctx.websocket.send_json({"type": "error", "message": "Room is full"})
|
||||||
|
return
|
||||||
|
|
||||||
|
if room.game.phase != GamePhase.WAITING:
|
||||||
|
await ctx.websocket.send_json({"type": "error", "message": "Game already in progress"})
|
||||||
|
return
|
||||||
|
|
||||||
|
room.add_player(ctx.player_id, player_name, ctx.websocket, ctx.auth_user_id)
|
||||||
|
room.touch()
|
||||||
|
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
|
||||||
|
ctx.current_room.touch()
|
||||||
|
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
ctx.current_room.touch()
|
||||||
|
|
||||||
|
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)
|
||||||
|
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
|
||||||
|
ctx.current_room.touch()
|
||||||
|
|
||||||
|
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
|
||||||
|
ctx.current_room.touch()
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# Capture old card info BEFORE the swap mutates the player's hand.
|
||||||
|
# game.swap_card() overwrites player.cards[position] in place, so if we
|
||||||
|
# read it after, we'd get the new card. The client needs the old card data
|
||||||
|
# to animate the outgoing card correctly.
|
||||||
|
old_was_face_down = old_card and not old_card.face_up if old_card else False
|
||||||
|
old_card_data = None
|
||||||
|
if old_card and old_was_face_down:
|
||||||
|
old_card_data = {
|
||||||
|
"rank": old_card.rank.value if old_card.rank else None,
|
||||||
|
"suit": old_card.suit.value if old_card.suit 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)
|
||||||
|
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
|
||||||
|
ctx.current_room.touch()
|
||||||
|
|
||||||
|
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)
|
||||||
|
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")
|
||||||
|
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
|
||||||
|
ctx.current_room.touch()
|
||||||
|
|
||||||
|
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)
|
||||||
|
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
|
||||||
|
ctx.current_room.touch()
|
||||||
|
|
||||||
|
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)
|
||||||
|
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
|
||||||
|
ctx.current_room.touch()
|
||||||
|
|
||||||
|
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)
|
||||||
|
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
|
||||||
|
ctx.current_room.touch()
|
||||||
|
|
||||||
|
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)
|
||||||
|
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
|
||||||
|
ctx.current_room.touch()
|
||||||
|
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
ctx.current_room.touch()
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# Cancel any running CPU turn task so the game ends immediately
|
||||||
|
if ctx.current_room.cpu_turn_task:
|
||||||
|
ctx.current_room.cpu_turn_task.cancel()
|
||||||
|
try:
|
||||||
|
await ctx.current_room.cpu_turn_task
|
||||||
|
except (asyncio.CancelledError, Exception):
|
||||||
|
pass
|
||||||
|
ctx.current_room.cpu_turn_task = None
|
||||||
|
|
||||||
|
await ctx.current_room.broadcast({
|
||||||
|
"type": "game_ended",
|
||||||
|
"reason": "Host ended the game",
|
||||||
|
})
|
||||||
|
|
||||||
|
room_code = ctx.current_room.code
|
||||||
|
for cpu in list(ctx.current_room.get_cpu_players()):
|
||||||
|
ctx.current_room.remove_player(cpu.id)
|
||||||
|
cleanup_room_profiles(room_code)
|
||||||
|
room_manager.remove_room(room_code)
|
||||||
|
ctx.current_room = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Handler dispatch table
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Matchmaking handlers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def handle_queue_join(data: dict, ctx: ConnectionContext, *, matchmaking_service=None, rating_service=None, **kw) -> None:
|
||||||
|
if not matchmaking_service:
|
||||||
|
await ctx.websocket.send_json({"type": "error", "message": "Matchmaking not available"})
|
||||||
|
return
|
||||||
|
|
||||||
|
if not ctx.authenticated_user:
|
||||||
|
await ctx.websocket.send_json({"type": "error", "message": "You must be logged in to find a game"})
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get player's rating
|
||||||
|
rating = 1500.0
|
||||||
|
if rating_service:
|
||||||
|
try:
|
||||||
|
player_rating = await rating_service.get_rating(ctx.auth_user_id)
|
||||||
|
rating = player_rating.rating
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
status = await matchmaking_service.join_queue(
|
||||||
|
user_id=ctx.auth_user_id,
|
||||||
|
username=ctx.authenticated_user.username,
|
||||||
|
rating=rating,
|
||||||
|
websocket=ctx.websocket,
|
||||||
|
connection_id=ctx.connection_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
await ctx.websocket.send_json({
|
||||||
|
"type": "queue_joined",
|
||||||
|
**status,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_queue_leave(data: dict, ctx: ConnectionContext, *, matchmaking_service=None, **kw) -> None:
|
||||||
|
if not matchmaking_service or not ctx.auth_user_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
removed = await matchmaking_service.leave_queue(ctx.auth_user_id)
|
||||||
|
await ctx.websocket.send_json({
|
||||||
|
"type": "queue_left",
|
||||||
|
"was_queued": removed,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_queue_status(data: dict, ctx: ConnectionContext, *, matchmaking_service=None, **kw) -> None:
|
||||||
|
if not matchmaking_service or not ctx.auth_user_id:
|
||||||
|
await ctx.websocket.send_json({"type": "queue_status", "in_queue": False})
|
||||||
|
return
|
||||||
|
|
||||||
|
status = await matchmaking_service.get_queue_status(ctx.auth_user_id)
|
||||||
|
await ctx.websocket.send_json({
|
||||||
|
"type": "queue_status",
|
||||||
|
**status,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
HANDLERS = {
|
||||||
|
"create_room": handle_create_room,
|
||||||
|
"join_room": handle_join_room,
|
||||||
|
"get_cpu_profiles": handle_get_cpu_profiles,
|
||||||
|
"add_cpu": handle_add_cpu,
|
||||||
|
"remove_cpu": handle_remove_cpu,
|
||||||
|
"start_game": handle_start_game,
|
||||||
|
"flip_initial": handle_flip_initial,
|
||||||
|
"draw": handle_draw,
|
||||||
|
"swap": handle_swap,
|
||||||
|
"discard": handle_discard,
|
||||||
|
"cancel_draw": handle_cancel_draw,
|
||||||
|
"flip_card": handle_flip_card,
|
||||||
|
"skip_flip": handle_skip_flip,
|
||||||
|
"flip_as_action": handle_flip_as_action,
|
||||||
|
"knock_early": handle_knock_early,
|
||||||
|
"next_round": handle_next_round,
|
||||||
|
"leave_room": handle_leave_room,
|
||||||
|
"leave_game": handle_leave_game,
|
||||||
|
"end_game": handle_end_game,
|
||||||
|
"queue_join": handle_queue_join,
|
||||||
|
"queue_leave": handle_queue_leave,
|
||||||
|
"queue_status": handle_queue_status,
|
||||||
|
}
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
"""
|
"""
|
||||||
Structured logging configuration for Golf game server.
|
Structured logging configuration for Golf game server.
|
||||||
|
|
||||||
@ -148,6 +149,39 @@ class DevelopmentFormatter(logging.Formatter):
|
|||||||
return output
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
# Per-module log level overrides via env vars.
|
||||||
|
# Key: env var suffix, Value: list of Python logger names to apply to.
|
||||||
|
MODULE_LOGGER_MAP = {
|
||||||
|
"GAME": ["game"],
|
||||||
|
"AI": ["ai"],
|
||||||
|
"HANDLERS": ["handlers"],
|
||||||
|
"ROOM": ["room"],
|
||||||
|
"AUTH": ["auth", "routers.auth", "services.auth_service"],
|
||||||
|
"STORES": ["stores"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_module_overrides() -> dict[str, str]:
|
||||||
|
"""
|
||||||
|
Apply per-module log level overrides from LOG_LEVEL_{MODULE} env vars.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict of module name -> level for any overrides that were applied.
|
||||||
|
"""
|
||||||
|
active = {}
|
||||||
|
for module, logger_names in MODULE_LOGGER_MAP.items():
|
||||||
|
env_val = os.environ.get(f"LOG_LEVEL_{module}", "").upper()
|
||||||
|
if not env_val:
|
||||||
|
continue
|
||||||
|
level = getattr(logging, env_val, None)
|
||||||
|
if level is None:
|
||||||
|
continue
|
||||||
|
active[module] = env_val
|
||||||
|
for name in logger_names:
|
||||||
|
logging.getLogger(name).setLevel(level)
|
||||||
|
return active
|
||||||
|
|
||||||
|
|
||||||
def setup_logging(
|
def setup_logging(
|
||||||
level: str = "INFO",
|
level: str = "INFO",
|
||||||
environment: str = "development",
|
environment: str = "development",
|
||||||
@ -182,12 +216,19 @@ def setup_logging(
|
|||||||
logging.getLogger("websockets").setLevel(logging.WARNING)
|
logging.getLogger("websockets").setLevel(logging.WARNING)
|
||||||
logging.getLogger("asyncio").setLevel(logging.WARNING)
|
logging.getLogger("asyncio").setLevel(logging.WARNING)
|
||||||
|
|
||||||
|
# Apply per-module overrides from env vars
|
||||||
|
overrides = _apply_module_overrides()
|
||||||
|
|
||||||
# Log startup
|
# Log startup
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Logging configured: level={level}, environment={environment}",
|
f"Logging configured: level={level}, environment={environment}",
|
||||||
extra={"level": level, "environment": environment},
|
extra={"level": level, "environment": environment},
|
||||||
)
|
)
|
||||||
|
if overrides:
|
||||||
|
logger.info(
|
||||||
|
f"Per-module log level overrides: {', '.join(f'{m}={l}' for m, l in overrides.items())}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ContextLogger(logging.LoggerAdapter):
|
class ContextLogger(logging.LoggerAdapter):
|
||||||
|
|||||||
1016
server/main.py
1016
server/main.py
File diff suppressed because it is too large
Load Diff
@ -1,3 +1,4 @@
|
|||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
"""
|
"""
|
||||||
Middleware components for Golf game server.
|
Middleware components for Golf game server.
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
"""
|
"""
|
||||||
Rate limiting middleware for FastAPI.
|
Rate limiting middleware for FastAPI.
|
||||||
|
|
||||||
@ -81,10 +82,14 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
|||||||
# Generate client key
|
# Generate client key
|
||||||
client_key = self.limiter.get_client_key(request, user_id)
|
client_key = self.limiter.get_client_key(request, user_id)
|
||||||
|
|
||||||
# Check rate limit
|
# Check rate limit (fail closed for auth endpoints)
|
||||||
endpoint_key = self._get_endpoint_key(path)
|
endpoint_key = self._get_endpoint_key(path)
|
||||||
full_key = f"{endpoint_key}:{client_key}"
|
full_key = f"{endpoint_key}:{client_key}"
|
||||||
|
|
||||||
|
is_auth_endpoint = path.startswith("/api/auth")
|
||||||
|
if is_auth_endpoint:
|
||||||
|
allowed, info = await self.limiter.is_allowed_strict(full_key, limit, window)
|
||||||
|
else:
|
||||||
allowed, info = await self.limiter.is_allowed(full_key, limit, window)
|
allowed, info = await self.limiter.is_allowed(full_key, limit, window)
|
||||||
|
|
||||||
# Build response
|
# Build response
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
"""
|
"""
|
||||||
Request ID middleware for request tracing.
|
Request ID middleware for request tracing.
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
"""
|
"""
|
||||||
Security headers middleware for FastAPI.
|
Security headers middleware for FastAPI.
|
||||||
|
|
||||||
@ -110,8 +111,10 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
|||||||
|
|
||||||
# Add WebSocket URLs
|
# Add WebSocket URLs
|
||||||
if self.environment == "production":
|
if self.environment == "production":
|
||||||
|
connect_sources.append(f"ws://{host}")
|
||||||
connect_sources.append(f"wss://{host}")
|
connect_sources.append(f"wss://{host}")
|
||||||
for allowed_host in self.allowed_hosts:
|
for allowed_host in self.allowed_hosts:
|
||||||
|
connect_sources.append(f"ws://{allowed_host}")
|
||||||
connect_sources.append(f"wss://{allowed_host}")
|
connect_sources.append(f"wss://{allowed_host}")
|
||||||
else:
|
else:
|
||||||
# Development - allow ws:// and wss://
|
# Development - allow ws:// and wss://
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
"""Models package for Golf game V2."""
|
"""Models package for Golf game V2."""
|
||||||
|
|
||||||
from .events import EventType, GameEvent
|
from .events import EventType, GameEvent
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
"""
|
"""
|
||||||
Event definitions for Golf game event sourcing.
|
Event definitions for Golf game event sourcing.
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
"""
|
"""
|
||||||
Game state rebuilder for event sourcing.
|
Game state rebuilder for event sourcing.
|
||||||
|
|
||||||
@ -237,7 +238,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)
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
"""
|
"""
|
||||||
User-related models for Golf game authentication.
|
User-related models for Golf game authentication.
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
"""
|
"""
|
||||||
Room management for multiplayer Golf games.
|
Room management for multiplayer Golf games.
|
||||||
|
|
||||||
@ -14,6 +15,7 @@ A Room contains:
|
|||||||
import asyncio
|
import asyncio
|
||||||
import random
|
import random
|
||||||
import string
|
import string
|
||||||
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@ -69,6 +71,12 @@ class Room:
|
|||||||
settings: dict = field(default_factory=lambda: {"decks": 1, "rounds": 1})
|
settings: dict = field(default_factory=lambda: {"decks": 1, "rounds": 1})
|
||||||
game_log_id: Optional[str] = None
|
game_log_id: Optional[str] = None
|
||||||
game_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
game_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||||
|
cpu_turn_task: Optional[asyncio.Task] = None
|
||||||
|
last_activity: float = field(default_factory=time.time)
|
||||||
|
|
||||||
|
def touch(self) -> None:
|
||||||
|
"""Update last_activity timestamp to mark room as active."""
|
||||||
|
self.last_activity = time.time()
|
||||||
|
|
||||||
def add_player(
|
def add_player(
|
||||||
self,
|
self,
|
||||||
@ -91,6 +99,9 @@ class Room:
|
|||||||
Returns:
|
Returns:
|
||||||
The created RoomPlayer object.
|
The created RoomPlayer object.
|
||||||
"""
|
"""
|
||||||
|
# First player in becomes host. On reconnection, the player gets a new
|
||||||
|
# connection_id, so they rejoin as a "new" player — host status may shift
|
||||||
|
# if the original host disconnected and someone else was promoted.
|
||||||
is_host = len(self.players) == 0
|
is_host = len(self.players) == 0
|
||||||
room_player = RoomPlayer(
|
room_player = RoomPlayer(
|
||||||
id=player_id,
|
id=player_id,
|
||||||
@ -166,7 +177,9 @@ class Room:
|
|||||||
if room_player.is_cpu:
|
if room_player.is_cpu:
|
||||||
release_profile(room_player.name, self.code)
|
release_profile(room_player.name, self.code)
|
||||||
|
|
||||||
# Assign new host if needed
|
# Assign new host if needed. next(iter(...)) gives us the first value in
|
||||||
|
# insertion order (Python 3.7+ dict guarantee). This means the longest-tenured
|
||||||
|
# player becomes host, which is the least surprising behavior.
|
||||||
if room_player.is_host and self.players:
|
if room_player.is_host and self.players:
|
||||||
next_host = next(iter(self.players.values()))
|
next_host = next(iter(self.players.values()))
|
||||||
next_host.is_host = True
|
next_host.is_host = True
|
||||||
@ -254,12 +267,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:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
"""Routers package for Golf game API."""
|
"""Routers package for Golf game API."""
|
||||||
|
|
||||||
from .auth import router as auth_router
|
from .auth import router as auth_router
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
"""
|
"""
|
||||||
Admin API router for Golf game V2.
|
Admin API router for Golf game V2.
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
"""
|
"""
|
||||||
Authentication API router for Golf game V2.
|
Authentication API router for Golf game V2.
|
||||||
|
|
||||||
@ -5,14 +6,18 @@ Provides endpoints for user registration, login, password management,
|
|||||||
and session handling.
|
and session handling.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Header, Request
|
from fastapi import APIRouter, Depends, HTTPException, Header, Request
|
||||||
from pydantic import BaseModel, EmailStr
|
from pydantic import BaseModel, EmailStr
|
||||||
|
|
||||||
|
from config import config
|
||||||
from models.user import User
|
from models.user import User
|
||||||
from services.auth_service import AuthService
|
from services.auth_service import AuthService
|
||||||
|
from services.admin_service import AdminService
|
||||||
|
from services.ratelimit import SignupLimiter
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -29,6 +34,7 @@ class RegisterRequest(BaseModel):
|
|||||||
username: str
|
username: str
|
||||||
password: str
|
password: str
|
||||||
email: Optional[str] = None
|
email: Optional[str] = None
|
||||||
|
invite_code: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class LoginRequest(BaseModel):
|
class LoginRequest(BaseModel):
|
||||||
@ -111,6 +117,8 @@ class SessionResponse(BaseModel):
|
|||||||
|
|
||||||
# These will be set by main.py during startup
|
# These will be set by main.py during startup
|
||||||
_auth_service: Optional[AuthService] = None
|
_auth_service: Optional[AuthService] = None
|
||||||
|
_admin_service: Optional[AdminService] = None
|
||||||
|
_signup_limiter: Optional[SignupLimiter] = None
|
||||||
|
|
||||||
|
|
||||||
def set_auth_service(service: AuthService) -> None:
|
def set_auth_service(service: AuthService) -> None:
|
||||||
@ -119,6 +127,18 @@ def set_auth_service(service: AuthService) -> None:
|
|||||||
_auth_service = service
|
_auth_service = service
|
||||||
|
|
||||||
|
|
||||||
|
def set_admin_service_for_auth(service: AdminService) -> None:
|
||||||
|
"""Set the admin service instance for invite code validation (called from main.py)."""
|
||||||
|
global _admin_service
|
||||||
|
_admin_service = service
|
||||||
|
|
||||||
|
|
||||||
|
def set_signup_limiter(limiter: SignupLimiter) -> None:
|
||||||
|
"""Set the signup limiter instance (called from main.py)."""
|
||||||
|
global _signup_limiter
|
||||||
|
_signup_limiter = limiter
|
||||||
|
|
||||||
|
|
||||||
def get_auth_service_dep() -> AuthService:
|
def get_auth_service_dep() -> AuthService:
|
||||||
"""Dependency to get auth service."""
|
"""Dependency to get auth service."""
|
||||||
if _auth_service is None:
|
if _auth_service is None:
|
||||||
@ -201,6 +221,51 @@ async def register(
|
|||||||
auth_service: AuthService = Depends(get_auth_service_dep),
|
auth_service: AuthService = Depends(get_auth_service_dep),
|
||||||
):
|
):
|
||||||
"""Register a new user account."""
|
"""Register a new user account."""
|
||||||
|
has_invite = bool(request_body.invite_code)
|
||||||
|
is_open_signup = not has_invite
|
||||||
|
client_ip = get_client_ip(request)
|
||||||
|
ip_hash = hashlib.sha256(client_ip.encode()).hexdigest()[:16] if client_ip else "unknown"
|
||||||
|
|
||||||
|
# --- Per-IP daily signup limit (applies to ALL signups) ---
|
||||||
|
if config.DAILY_SIGNUPS_PER_IP > 0 and _signup_limiter:
|
||||||
|
ip_allowed, ip_remaining = await _signup_limiter.check_ip_limit(
|
||||||
|
ip_hash, config.DAILY_SIGNUPS_PER_IP
|
||||||
|
)
|
||||||
|
if not ip_allowed:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=429,
|
||||||
|
detail="Too many signups from this address today. Please try again tomorrow.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Invite code validation ---
|
||||||
|
if has_invite:
|
||||||
|
if not _admin_service:
|
||||||
|
raise HTTPException(status_code=503, detail="Admin service not initialized")
|
||||||
|
if not await _admin_service.validate_invite_code(request_body.invite_code):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid or expired invite code")
|
||||||
|
else:
|
||||||
|
# No invite code — check if open signups are allowed
|
||||||
|
if config.INVITE_ONLY and config.DAILY_OPEN_SIGNUPS == 0:
|
||||||
|
raise HTTPException(status_code=400, detail="Invite code required")
|
||||||
|
|
||||||
|
# Check daily open signup limit
|
||||||
|
if config.DAILY_OPEN_SIGNUPS != 0 and _signup_limiter:
|
||||||
|
daily_allowed, daily_remaining = await _signup_limiter.check_daily_limit(
|
||||||
|
config.DAILY_OPEN_SIGNUPS
|
||||||
|
)
|
||||||
|
if not daily_allowed:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=429,
|
||||||
|
detail="Daily signup limit reached. Please try again tomorrow or use an invite code.",
|
||||||
|
)
|
||||||
|
elif config.DAILY_OPEN_SIGNUPS != 0 and not _signup_limiter:
|
||||||
|
# Signup limiter requires Redis — fail closed
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="Registration temporarily unavailable. Please try again later.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Create the account ---
|
||||||
result = await auth_service.register(
|
result = await auth_service.register(
|
||||||
username=request_body.username,
|
username=request_body.username,
|
||||||
password=request_body.password,
|
password=request_body.password,
|
||||||
@ -210,8 +275,19 @@ async def register(
|
|||||||
if not result.success:
|
if not result.success:
|
||||||
raise HTTPException(status_code=400, detail=result.error)
|
raise HTTPException(status_code=400, detail=result.error)
|
||||||
|
|
||||||
|
# --- Post-registration bookkeeping ---
|
||||||
|
# Consume invite code if used
|
||||||
|
if has_invite and _admin_service:
|
||||||
|
await _admin_service.use_invite_code(request_body.invite_code)
|
||||||
|
|
||||||
|
# Increment signup counters
|
||||||
|
if _signup_limiter:
|
||||||
|
if is_open_signup and config.DAILY_OPEN_SIGNUPS != 0:
|
||||||
|
await _signup_limiter.increment_daily()
|
||||||
|
if config.DAILY_SIGNUPS_PER_IP > 0:
|
||||||
|
await _signup_limiter.increment_ip(ip_hash)
|
||||||
|
|
||||||
if result.requires_verification:
|
if result.requires_verification:
|
||||||
# Return user info but note they need to verify
|
|
||||||
return {
|
return {
|
||||||
"user": _user_to_response(result.user),
|
"user": _user_to_response(result.user),
|
||||||
"token": "",
|
"token": "",
|
||||||
@ -224,7 +300,7 @@ async def register(
|
|||||||
username=request_body.username,
|
username=request_body.username,
|
||||||
password=request_body.password,
|
password=request_body.password,
|
||||||
device_info=get_device_info(request),
|
device_info=get_device_info(request),
|
||||||
ip_address=get_client_ip(request),
|
ip_address=client_ip,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not login_result.success:
|
if not login_result.success:
|
||||||
@ -237,6 +313,32 @@ async def register(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/signup-info")
|
||||||
|
async def signup_info():
|
||||||
|
"""
|
||||||
|
Public endpoint: returns signup availability info.
|
||||||
|
|
||||||
|
Tells the client whether invite codes are required,
|
||||||
|
and how many open signup slots remain today.
|
||||||
|
"""
|
||||||
|
open_signups_enabled = config.DAILY_OPEN_SIGNUPS != 0
|
||||||
|
invite_required = config.INVITE_ONLY and not open_signups_enabled
|
||||||
|
unlimited = config.DAILY_OPEN_SIGNUPS < 0
|
||||||
|
|
||||||
|
remaining = None
|
||||||
|
if open_signups_enabled and not unlimited and _signup_limiter:
|
||||||
|
daily_count = await _signup_limiter.get_daily_count()
|
||||||
|
remaining = max(0, config.DAILY_OPEN_SIGNUPS - daily_count)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"invite_required": invite_required,
|
||||||
|
"open_signups_enabled": open_signups_enabled,
|
||||||
|
"daily_limit": config.DAILY_OPEN_SIGNUPS if not unlimited else None,
|
||||||
|
"remaining_today": remaining,
|
||||||
|
"unlimited": unlimited,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/verify-email")
|
@router.post("/verify-email")
|
||||||
async def verify_email(
|
async def verify_email(
|
||||||
request_body: VerifyEmailRequest,
|
request_body: VerifyEmailRequest,
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
"""
|
"""
|
||||||
Health check endpoints for production deployment.
|
Health check endpoints for production deployment.
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
"""
|
"""
|
||||||
Replay API router for Golf game.
|
Replay API router for Golf game.
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
"""
|
"""
|
||||||
Stats and Leaderboards API router for Golf game.
|
Stats and Leaderboards API router for Golf game.
|
||||||
|
|
||||||
@ -155,7 +156,7 @@ async def require_user(
|
|||||||
|
|
||||||
@router.get("/leaderboard", response_model=LeaderboardResponse)
|
@router.get("/leaderboard", response_model=LeaderboardResponse)
|
||||||
async def get_leaderboard(
|
async def get_leaderboard(
|
||||||
metric: str = Query("wins", pattern="^(wins|win_rate|avg_score|knockouts|streak)$"),
|
metric: str = Query("wins", pattern="^(wins|win_rate|avg_score|knockouts|streak|rating)$"),
|
||||||
limit: int = Query(50, ge=1, le=100),
|
limit: int = Query(50, ge=1, le=100),
|
||||||
offset: int = Query(0, ge=0),
|
offset: int = Query(0, ge=0),
|
||||||
service: StatsService = Depends(get_stats_service_dep),
|
service: StatsService = Depends(get_stats_service_dep),
|
||||||
@ -226,7 +227,7 @@ async def get_player_stats(
|
|||||||
@router.get("/players/{user_id}/rank", response_model=PlayerRankResponse)
|
@router.get("/players/{user_id}/rank", response_model=PlayerRankResponse)
|
||||||
async def get_player_rank(
|
async def get_player_rank(
|
||||||
user_id: str,
|
user_id: str,
|
||||||
metric: str = Query("wins", pattern="^(wins|win_rate|avg_score|knockouts|streak)$"),
|
metric: str = Query("wins", pattern="^(wins|win_rate|avg_score|knockouts|streak|rating)$"),
|
||||||
service: StatsService = Depends(get_stats_service_dep),
|
service: StatsService = Depends(get_stats_service_dep),
|
||||||
):
|
):
|
||||||
"""Get player's rank on a leaderboard."""
|
"""Get player's rank on a leaderboard."""
|
||||||
@ -346,7 +347,7 @@ async def get_my_stats(
|
|||||||
|
|
||||||
@router.get("/me/rank", response_model=PlayerRankResponse)
|
@router.get("/me/rank", response_model=PlayerRankResponse)
|
||||||
async def get_my_rank(
|
async def get_my_rank(
|
||||||
metric: str = Query("wins", pattern="^(wins|win_rate|avg_score|knockouts|streak)$"),
|
metric: str = Query("wins", pattern="^(wins|win_rate|avg_score|knockouts|streak|rating)$"),
|
||||||
user: User = Depends(require_user),
|
user: User = Depends(require_user),
|
||||||
service: StatsService = Depends(get_stats_service_dep),
|
service: StatsService = Depends(get_stats_service_dep),
|
||||||
):
|
):
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
"""
|
"""
|
||||||
Score distribution analysis for Golf AI.
|
Score distribution analysis for Golf AI.
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
"""
|
"""
|
||||||
Create an admin user for the Golf game.
|
Create an admin user for the Golf game.
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
"""Services package for Golf game V2 business logic."""
|
"""Services package for Golf game V2 business logic."""
|
||||||
|
|
||||||
from .recovery_service import RecoveryService, RecoveryResult
|
from .recovery_service import RecoveryService, RecoveryResult
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user