Compare commits
No commits in common. "main" and "3.0" have entirely different histories.
42
.env.example
42
.env.example
@ -20,24 +20,6 @@ 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 name (development, staging, production)
|
||||||
ENVIRONMENT=development
|
ENVIRONMENT=development
|
||||||
|
|
||||||
@ -73,21 +55,7 @@ ROOM_CODE_LENGTH=4
|
|||||||
SECRET_KEY=
|
SECRET_KEY=
|
||||||
|
|
||||||
# Enable invite-only mode (requires invitation to register)
|
# Enable invite-only mode (requires invitation to register)
|
||||||
INVITE_ONLY=true
|
INVITE_ONLY=false
|
||||||
|
|
||||||
# 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=
|
||||||
@ -136,13 +104,5 @@ CARD_LUCKY_SWING_JOKER=-5 # Joker value when lucky_swing enabled
|
|||||||
# Enable rate limiting (recommended for production)
|
# Enable rate limiting (recommended for production)
|
||||||
# RATE_LIMIT_ENABLED=true
|
# RATE_LIMIT_ENABLED=true
|
||||||
|
|
||||||
# Redis URL (required for matchmaking and rate limiting)
|
|
||||||
# REDIS_URL=redis://localhost:6379
|
|
||||||
|
|
||||||
# Base URL for email links
|
# Base URL for email links
|
||||||
# BASE_URL=https://your-domain.com
|
# BASE_URL=https://your-domain.com
|
||||||
|
|
||||||
# Matchmaking (skill-based public games)
|
|
||||||
MATCHMAKING_ENABLED=true
|
|
||||||
MATCHMAKING_MIN_PLAYERS=2
|
|
||||||
MATCHMAKING_MAX_PLAYERS=4
|
|
||||||
|
|||||||
27
.gitignore
vendored
27
.gitignore
vendored
@ -136,31 +136,7 @@ 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/
|
||||||
@ -225,9 +201,6 @@ pyvenv.cfg
|
|||||||
# Personal notes
|
# Personal notes
|
||||||
lookfah.md
|
lookfah.md
|
||||||
|
|
||||||
# Internal docs (deployment info, credentials references, etc.)
|
|
||||||
internal/
|
|
||||||
|
|
||||||
# Ruff stuff:
|
# Ruff stuff:
|
||||||
.ruff_cache/
|
.ruff_cache/
|
||||||
|
|
||||||
|
|||||||
@ -1,304 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
@ -33,6 +33,5 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
|||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
# Run with uvicorn from the server directory (server uses relative imports)
|
# Run with uvicorn
|
||||||
WORKDIR /app/server
|
CMD ["python", "-m", "uvicorn", "server.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
|
||||||
|
|||||||
674
LICENSE
674
LICENSE
@ -1,674 +0,0 @@
|
|||||||
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>.
|
|
||||||
@ -203,4 +203,4 @@ From testing (1000+ games):
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
GPL-3.0-or-later — see [LICENSE](LICENSE) for the full text.
|
MIT
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
/**
|
/**
|
||||||
* Golf Admin Dashboard
|
* Golf Admin Dashboard
|
||||||
* JavaScript for admin interface functionality
|
* JavaScript for admin interface functionality
|
||||||
@ -318,7 +317,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" data-action="view-user" data-id="${user.id}">View</button>
|
<button class="btn btn-small" onclick="viewUser('${user.id}')">View</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`;
|
||||||
@ -405,7 +404,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" data-action="end-game" data-id="${game.game_id}">End</button>
|
<button class="btn btn-small btn-danger" onclick="promptEndGame('${game.game_id}')">End</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`;
|
||||||
@ -455,8 +454,7 @@ 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" data-action="copy-invite" data-code="${escapeHtml(invite.code)}">Copy Link</button>
|
? `<button class="btn btn-small btn-danger" onclick="promptRevokeInvite('${invite.code}')">Revoke</button>`
|
||||||
<button class="btn btn-small btn-danger" data-action="revoke-invite" data-code="${escapeHtml(invite.code)}">Revoke</button>`
|
|
||||||
: '-'
|
: '-'
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
@ -621,16 +619,6 @@ 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;
|
||||||
|
|
||||||
@ -816,18 +804,6 @@ 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,4 +1,3 @@
|
|||||||
// 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
|
||||||
|
|
||||||
@ -32,17 +31,14 @@ class AnimationQueue {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attach callback to last movement only
|
// Add completion callback to last movement
|
||||||
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
|
||||||
@ -189,9 +185,7 @@ class AnimationQueue {
|
|||||||
await this.delay(this.timing.flipDuration);
|
await this.delay(this.timing.flipDuration);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Quick crossfade swap.
|
// 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');
|
handCard.classList.add('fade-out');
|
||||||
heldCard.classList.add('fade-out');
|
heldCard.classList.add('fade-out');
|
||||||
await this.delay(150);
|
await this.delay(150);
|
||||||
|
|||||||
1156
client/app.js
1156
client/app.js
File diff suppressed because it is too large
Load Diff
@ -1,4 +1,3 @@
|
|||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// CardAnimations - Unified anime.js-based animation system
|
// CardAnimations - Unified anime.js-based animation system
|
||||||
// Replaces draw-animations.js and handles ALL card animations
|
// Replaces draw-animations.js and handles ALL card animations
|
||||||
|
|
||||||
@ -44,15 +43,10 @@ class CardAnimations {
|
|||||||
const discardRect = this.getDiscardRect();
|
const discardRect = this.getDiscardRect();
|
||||||
if (!deckRect || !discardRect) return null;
|
if (!deckRect || !discardRect) return null;
|
||||||
|
|
||||||
// Center the held card between deck and discard pile
|
|
||||||
const centerX = (deckRect.left + deckRect.right + discardRect.left + discardRect.right) / 4;
|
const centerX = (deckRect.left + deckRect.right + discardRect.left + discardRect.right) / 4;
|
||||||
const cardWidth = deckRect.width;
|
const cardWidth = deckRect.width;
|
||||||
const cardHeight = deckRect.height;
|
const cardHeight = deckRect.height;
|
||||||
const isMobilePortrait = document.body.classList.contains('mobile-portrait');
|
const overlapOffset = cardHeight * 0.35;
|
||||||
// Overlap percentages: how much the held card peeks above the deck/discard row.
|
|
||||||
// 48% on mobile (tighter vertical space, needs more overlap to fit),
|
|
||||||
// 35% on desktop (more breathing room). Tuned by eye, not by math.
|
|
||||||
const overlapOffset = cardHeight * (isMobilePortrait ? 0.48 : 0.35);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
left: centerX - cardWidth / 2,
|
left: centerX - cardWidth / 2,
|
||||||
@ -81,13 +75,6 @@ class CardAnimations {
|
|||||||
return easings[type] || 'easeOutQuad';
|
return easings[type] || 'easeOutQuad';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Font size proportional to card width — consistent across all card types.
|
|
||||||
// Mobile uses a tighter ratio since cards are smaller and closer together.
|
|
||||||
cardFontSize(width) {
|
|
||||||
const ratio = document.body.classList.contains('mobile-portrait') ? 0.35 : 0.5;
|
|
||||||
return (width * ratio) + 'px';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create animated card element with 3D flip structure
|
// Create animated card element with 3D flip structure
|
||||||
createAnimCard(rect, showBack = false, deckColor = null) {
|
createAnimCard(rect, showBack = false, deckColor = null) {
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
@ -105,9 +92,6 @@ class CardAnimations {
|
|||||||
card.style.top = rect.top + 'px';
|
card.style.top = rect.top + 'px';
|
||||||
card.style.width = rect.width + 'px';
|
card.style.width = rect.width + 'px';
|
||||||
card.style.height = rect.height + 'px';
|
card.style.height = rect.height + 'px';
|
||||||
// Scale font-size proportionally to card width
|
|
||||||
const front = card.querySelector('.draw-anim-front');
|
|
||||||
if (front) front.style.fontSize = this.cardFontSize(rect.width);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply deck color to back
|
// Apply deck color to back
|
||||||
@ -161,20 +145,12 @@ class CardAnimations {
|
|||||||
}
|
}
|
||||||
this.activeAnimations.clear();
|
this.activeAnimations.clear();
|
||||||
|
|
||||||
// Remove all animation overlay elements
|
// Remove all animation card elements (including those marked as animating)
|
||||||
document.querySelectorAll('.draw-anim-card, .traveling-card, .deal-anim-container').forEach(el => {
|
document.querySelectorAll('.draw-anim-card').forEach(el => {
|
||||||
delete el.dataset.animating;
|
delete el.dataset.animating;
|
||||||
el.remove();
|
el.remove();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Restore visibility on any cards hidden during animations
|
|
||||||
document.querySelectorAll('.card[style*="opacity: 0"], .card[style*="opacity:0"]').forEach(el => {
|
|
||||||
el.style.opacity = '';
|
|
||||||
});
|
|
||||||
document.querySelectorAll('.card[style*="visibility: hidden"], .card[style*="visibility:hidden"]').forEach(el => {
|
|
||||||
el.style.visibility = '';
|
|
||||||
});
|
|
||||||
|
|
||||||
// Restore discard pile visibility if it was hidden during animation
|
// Restore discard pile visibility if it was hidden during animation
|
||||||
const discardPile = document.getElementById('discard');
|
const discardPile = document.getElementById('discard');
|
||||||
if (discardPile && discardPile.style.opacity === '0') {
|
if (discardPile && discardPile.style.opacity === '0') {
|
||||||
@ -225,7 +201,6 @@ class CardAnimations {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_animateDrawDeckCard(cardData, deckRect, holdingRect, onComplete) {
|
_animateDrawDeckCard(cardData, deckRect, holdingRect, onComplete) {
|
||||||
console.log('[DEBUG] _animateDrawDeckCard called with cardData:', cardData ? `${cardData.rank} of ${cardData.suit}` : 'NULL');
|
|
||||||
const deckColor = this.getDeckColor();
|
const deckColor = this.getDeckColor();
|
||||||
const animCard = this.createAnimCard(deckRect, true, deckColor);
|
const animCard = this.createAnimCard(deckRect, true, deckColor);
|
||||||
animCard.dataset.animating = 'true'; // Mark as actively animating
|
animCard.dataset.animating = 'true'; // Mark as actively animating
|
||||||
@ -234,9 +209,6 @@ class CardAnimations {
|
|||||||
|
|
||||||
if (cardData) {
|
if (cardData) {
|
||||||
this.setCardContent(animCard, cardData);
|
this.setCardContent(animCard, cardData);
|
||||||
// Debug: verify what was actually set on the front face
|
|
||||||
const front = animCard.querySelector('.draw-anim-front');
|
|
||||||
console.log('[DEBUG] Draw anim card front content:', front?.innerHTML);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.playSound('draw-deck');
|
this.playSound('draw-deck');
|
||||||
@ -425,7 +397,6 @@ class CardAnimations {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Animate initial flip at game start - smooth flip only, no lift
|
// Animate initial flip at game start - smooth flip only, no lift
|
||||||
// Uses overlay sized to match the source card exactly
|
|
||||||
animateInitialFlip(cardElement, cardData, onComplete) {
|
animateInitialFlip(cardElement, cardData, onComplete) {
|
||||||
if (!cardElement) {
|
if (!cardElement) {
|
||||||
if (onComplete) onComplete();
|
if (onComplete) onComplete();
|
||||||
@ -439,16 +410,8 @@ class CardAnimations {
|
|||||||
const animCard = this.createAnimCard(rect, true, deckColor);
|
const animCard = this.createAnimCard(rect, true, deckColor);
|
||||||
this.setCardContent(animCard, cardData);
|
this.setCardContent(animCard, cardData);
|
||||||
|
|
||||||
// Match the front face styling to player hand cards (not deck/discard cards)
|
// Hide original card during animation
|
||||||
const front = animCard.querySelector('.draw-anim-front');
|
cardElement.style.opacity = '0';
|
||||||
if (front) {
|
|
||||||
front.style.background = 'linear-gradient(145deg, #fff 0%, #f5f5f5 100%)';
|
|
||||||
front.style.border = '2px solid #ddd';
|
|
||||||
front.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.3)';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide original card during animation (overlay covers it)
|
|
||||||
cardElement.style.visibility = 'hidden';
|
|
||||||
|
|
||||||
const inner = animCard.querySelector('.draw-anim-inner');
|
const inner = animCard.querySelector('.draw-anim-inner');
|
||||||
const duration = window.TIMING?.card?.flip || 320;
|
const duration = window.TIMING?.card?.flip || 320;
|
||||||
@ -463,19 +426,16 @@ class CardAnimations {
|
|||||||
begin: () => this.playSound('flip'),
|
begin: () => this.playSound('flip'),
|
||||||
complete: () => {
|
complete: () => {
|
||||||
animCard.remove();
|
animCard.remove();
|
||||||
cardElement.style.visibility = '';
|
cardElement.style.opacity = '1';
|
||||||
if (onComplete) onComplete();
|
if (onComplete) onComplete();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Register a no-op entry so cancelAll() can find and stop this animation.
|
|
||||||
// The actual anime.js instance doesn't need to be tracked (fire-and-forget),
|
|
||||||
// but we need SOMETHING in the map or cleanup won't know we're animating.
|
|
||||||
this.activeAnimations.set(`initialFlip-${Date.now()}`, { pause: () => {} });
|
this.activeAnimations.set(`initialFlip-${Date.now()}`, { pause: () => {} });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Initial flip animation error:', e);
|
console.error('Initial flip animation error:', e);
|
||||||
animCard.remove();
|
animCard.remove();
|
||||||
cardElement.style.visibility = '';
|
cardElement.style.opacity = '1';
|
||||||
if (onComplete) onComplete();
|
if (onComplete) onComplete();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -488,6 +448,10 @@ class CardAnimations {
|
|||||||
const deckColor = this.getDeckColor();
|
const deckColor = this.getDeckColor();
|
||||||
|
|
||||||
const animCard = this.createAnimCard(rect, true, deckColor);
|
const animCard = this.createAnimCard(rect, true, deckColor);
|
||||||
|
// Match source card's font-size (opponent cards are smaller than default)
|
||||||
|
const srcFontSize = getComputedStyle(cardElement).fontSize;
|
||||||
|
const front = animCard.querySelector('.draw-anim-front');
|
||||||
|
if (front) front.style.fontSize = srcFontSize;
|
||||||
this.setCardContent(animCard, cardData);
|
this.setCardContent(animCard, cardData);
|
||||||
|
|
||||||
// Apply rotation to match arch layout
|
// Apply rotation to match arch layout
|
||||||
@ -643,6 +607,10 @@ class CardAnimations {
|
|||||||
const deckColor = this.getDeckColor();
|
const deckColor = this.getDeckColor();
|
||||||
|
|
||||||
const animCard = this.createAnimCard(rect, true, deckColor);
|
const animCard = this.createAnimCard(rect, true, deckColor);
|
||||||
|
// Match source card's font-size (opponent cards are smaller than default)
|
||||||
|
const srcFontSize = getComputedStyle(sourceCardElement).fontSize;
|
||||||
|
const front = animCard.querySelector('.draw-anim-front');
|
||||||
|
if (front) front.style.fontSize = srcFontSize;
|
||||||
this.setCardContent(animCard, discardCard);
|
this.setCardContent(animCard, discardCard);
|
||||||
|
|
||||||
if (rotation) {
|
if (rotation) {
|
||||||
@ -780,40 +748,28 @@ class CardAnimations {
|
|||||||
const id = 'turnPulse';
|
const id = 'turnPulse';
|
||||||
this.stopTurnPulse(element);
|
this.stopTurnPulse(element);
|
||||||
|
|
||||||
// Quick shake animation - target cards only, not labels
|
// Quick shake animation
|
||||||
const T = window.TIMING?.turnPulse || {};
|
|
||||||
const cards = element.querySelectorAll(':scope > .pile-wrapper > .card, :scope > .pile-wrapper > .discard-stack > #discard');
|
|
||||||
const doShake = () => {
|
const doShake = () => {
|
||||||
if (!this.activeAnimations.has(id)) return;
|
if (!this.activeAnimations.has(id)) return;
|
||||||
|
|
||||||
anime({
|
anime({
|
||||||
targets: cards.length ? cards : element,
|
targets: element,
|
||||||
translateX: [0, -6, 6, -4, 3, 0],
|
translateX: [0, -8, 8, -6, 4, 0],
|
||||||
duration: T.duration || 300,
|
duration: 400,
|
||||||
easing: 'easeInOutQuad'
|
easing: 'easeInOutQuad'
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Two-phase timing: wait initialDelay, then shake on an interval.
|
// Do initial shake, then repeat every 3 seconds
|
||||||
// Edge case: if stopTurnPulse() is called between the timeout firing and
|
doShake();
|
||||||
// the interval being stored on the entry, the interval would leak. That's
|
const interval = setInterval(doShake, 3000);
|
||||||
// why we re-check activeAnimations.has(id) after the timeout fires — if
|
this.activeAnimations.set(id, { interval });
|
||||||
// stop was called during the delay, we bail before creating the interval.
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
if (!this.activeAnimations.has(id)) return;
|
|
||||||
doShake();
|
|
||||||
const interval = setInterval(doShake, T.interval || 3000);
|
|
||||||
const entry = this.activeAnimations.get(id);
|
|
||||||
if (entry) entry.interval = interval;
|
|
||||||
}, T.initialDelay || 5000);
|
|
||||||
this.activeAnimations.set(id, { timeout });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
stopTurnPulse(element) {
|
stopTurnPulse(element) {
|
||||||
const id = 'turnPulse';
|
const id = 'turnPulse';
|
||||||
const existing = this.activeAnimations.get(id);
|
const existing = this.activeAnimations.get(id);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (existing.timeout) clearTimeout(existing.timeout);
|
|
||||||
if (existing.interval) clearInterval(existing.interval);
|
if (existing.interval) clearInterval(existing.interval);
|
||||||
if (existing.pause) existing.pause();
|
if (existing.pause) existing.pause();
|
||||||
this.activeAnimations.delete(id);
|
this.activeAnimations.delete(id);
|
||||||
@ -1106,7 +1062,7 @@ class CardAnimations {
|
|||||||
// heldRect: position of the held card (or null to use default holding position)
|
// heldRect: position of the held card (or null to use default holding position)
|
||||||
// options: { rotation, wasHandFaceDown, onComplete }
|
// options: { rotation, wasHandFaceDown, onComplete }
|
||||||
animateUnifiedSwap(handCardData, heldCardData, handRect, heldRect, options = {}) {
|
animateUnifiedSwap(handCardData, heldCardData, handRect, heldRect, options = {}) {
|
||||||
const { rotation = 0, wasHandFaceDown = false, onComplete, onStart } = options;
|
const { rotation = 0, wasHandFaceDown = false, onComplete } = options;
|
||||||
const T = window.TIMING?.swap || { lift: 100, arc: 320, settle: 100 };
|
const T = window.TIMING?.swap || { lift: 100, arc: 320, settle: 100 };
|
||||||
const discardRect = this.getDiscardRect();
|
const discardRect = this.getDiscardRect();
|
||||||
|
|
||||||
@ -1126,27 +1082,27 @@ class CardAnimations {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collision detection: if a draw animation is still in flight (its overlay cards
|
// Wait for any in-progress draw animation to complete
|
||||||
// are still in the DOM), we can't start the swap yet — both animations touch the
|
// Check if there's an active draw animation by looking for overlay cards
|
||||||
// same visual space. 350ms is enough for the draw to finish its arc and land.
|
|
||||||
// This happens when the server sends the swap state update before the draw
|
|
||||||
// animation's callback fires (network is faster than anime.js, sometimes).
|
|
||||||
const existingDrawCards = document.querySelectorAll('.draw-anim-card[data-animating="true"]');
|
const existingDrawCards = document.querySelectorAll('.draw-anim-card[data-animating="true"]');
|
||||||
if (existingDrawCards.length > 0) {
|
if (existingDrawCards.length > 0) {
|
||||||
|
// Draw animation still in progress - wait a bit and retry
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
// Clean up the draw animation overlay
|
||||||
existingDrawCards.forEach(el => {
|
existingDrawCards.forEach(el => {
|
||||||
delete el.dataset.animating;
|
delete el.dataset.animating;
|
||||||
el.remove();
|
el.remove();
|
||||||
});
|
});
|
||||||
this._runUnifiedSwap(handCardData, heldCardData, handRect, heldRect, discardRect, T, rotation, wasHandFaceDown, onComplete, onStart);
|
// Now run the swap animation
|
||||||
}, 350);
|
this._runUnifiedSwap(handCardData, heldCardData, handRect, heldRect, discardRect, T, rotation, wasHandFaceDown, onComplete);
|
||||||
|
}, 100);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this._runUnifiedSwap(handCardData, heldCardData, handRect, heldRect, discardRect, T, rotation, wasHandFaceDown, onComplete, onStart);
|
this._runUnifiedSwap(handCardData, heldCardData, handRect, heldRect, discardRect, T, rotation, wasHandFaceDown, onComplete);
|
||||||
}
|
}
|
||||||
|
|
||||||
_runUnifiedSwap(handCardData, heldCardData, handRect, heldRect, discardRect, T, rotation, wasHandFaceDown, onComplete, onStart) {
|
_runUnifiedSwap(handCardData, heldCardData, handRect, heldRect, discardRect, T, rotation, wasHandFaceDown, onComplete) {
|
||||||
// Create the two traveling cards
|
// Create the two traveling cards
|
||||||
const travelingHand = this.createCardFromData(handCardData, handRect, rotation);
|
const travelingHand = this.createCardFromData(handCardData, handRect, rotation);
|
||||||
const travelingHeld = this.createCardFromData(heldCardData, heldRect, 0);
|
const travelingHeld = this.createCardFromData(heldCardData, heldRect, 0);
|
||||||
@ -1155,9 +1111,6 @@ class CardAnimations {
|
|||||||
document.body.appendChild(travelingHand);
|
document.body.appendChild(travelingHand);
|
||||||
document.body.appendChild(travelingHeld);
|
document.body.appendChild(travelingHeld);
|
||||||
|
|
||||||
// Now that overlays cover the originals, hide them
|
|
||||||
if (onStart) onStart();
|
|
||||||
|
|
||||||
this.playSound('card');
|
this.playSound('card');
|
||||||
|
|
||||||
// If hand card was face-down, flip it first
|
// If hand card was face-down, flip it first
|
||||||
@ -1211,9 +1164,6 @@ class CardAnimations {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Hand card arcs to discard (apply counter-rotation to land flat)
|
// Hand card arcs to discard (apply counter-rotation to land flat)
|
||||||
const handFront = travelingHand.querySelector('.draw-anim-front');
|
|
||||||
const heldFront = travelingHeld.querySelector('.draw-anim-front');
|
|
||||||
|
|
||||||
timeline.add({
|
timeline.add({
|
||||||
targets: travelingHand,
|
targets: travelingHand,
|
||||||
left: discardRect.left,
|
left: discardRect.left,
|
||||||
@ -1223,24 +1173,11 @@ class CardAnimations {
|
|||||||
],
|
],
|
||||||
width: discardRect.width,
|
width: discardRect.width,
|
||||||
height: discardRect.height,
|
height: discardRect.height,
|
||||||
// Counter-rotate from the card's grid tilt back to 0. The -3 intermediate
|
|
||||||
// value adds a slight overshoot that makes the arc feel physical.
|
|
||||||
// Do not "simplify" this to [rotation, 0]. It will look robotic.
|
|
||||||
rotate: [rotation, rotation - 3, 0],
|
rotate: [rotation, rotation - 3, 0],
|
||||||
duration: T.arc,
|
duration: T.arc,
|
||||||
easing: this.getEasing('arc'),
|
easing: this.getEasing('arc'),
|
||||||
}, `-=${T.lift / 2}`);
|
}, `-=${T.lift / 2}`);
|
||||||
|
|
||||||
// Scale hand card font to match discard size
|
|
||||||
if (handFront) {
|
|
||||||
timeline.add({
|
|
||||||
targets: handFront,
|
|
||||||
fontSize: this.cardFontSize(discardRect.width),
|
|
||||||
duration: T.arc,
|
|
||||||
easing: this.getEasing('arc'),
|
|
||||||
}, `-=${T.arc}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Held card arcs to hand slot (apply rotation to match hand position)
|
// Held card arcs to hand slot (apply rotation to match hand position)
|
||||||
timeline.add({
|
timeline.add({
|
||||||
targets: travelingHeld,
|
targets: travelingHeld,
|
||||||
@ -1256,16 +1193,6 @@ class CardAnimations {
|
|||||||
easing: this.getEasing('arc'),
|
easing: this.getEasing('arc'),
|
||||||
}, `-=${T.arc + T.lift / 2}`);
|
}, `-=${T.arc + T.lift / 2}`);
|
||||||
|
|
||||||
// Scale held card font to match hand size
|
|
||||||
if (heldFront) {
|
|
||||||
timeline.add({
|
|
||||||
targets: heldFront,
|
|
||||||
fontSize: this.cardFontSize(handRect.width),
|
|
||||||
duration: T.arc,
|
|
||||||
easing: this.getEasing('arc'),
|
|
||||||
}, `-=${T.arc}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Settle with gentle overshoot
|
// Settle with gentle overshoot
|
||||||
timeline.add({
|
timeline.add({
|
||||||
targets: [travelingHand, travelingHeld],
|
targets: [travelingHand, travelingHeld],
|
||||||
@ -1477,9 +1404,6 @@ class CardAnimations {
|
|||||||
card.style.top = rect.top + 'px';
|
card.style.top = rect.top + 'px';
|
||||||
card.style.width = rect.width + 'px';
|
card.style.width = rect.width + 'px';
|
||||||
card.style.height = rect.height + 'px';
|
card.style.height = rect.height + 'px';
|
||||||
// Scale font-size proportionally to card width
|
|
||||||
const front = card.querySelector('.draw-anim-front');
|
|
||||||
if (front) front.style.fontSize = this.cardFontSize(rect.width);
|
|
||||||
|
|
||||||
if (rotation) {
|
if (rotation) {
|
||||||
card.style.transform = `rotate(${rotation}deg)`;
|
card.style.transform = `rotate(${rotation}deg)`;
|
||||||
@ -1520,8 +1444,9 @@ class CardAnimations {
|
|||||||
try {
|
try {
|
||||||
anime({
|
anime({
|
||||||
targets: element,
|
targets: element,
|
||||||
opacity: [0, 1],
|
scale: [0.5, 1.25, 1.15],
|
||||||
duration: 200,
|
opacity: [0, 1, 1],
|
||||||
|
duration: 300,
|
||||||
easing: 'easeOutQuad'
|
easing: 'easeOutQuad'
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -1563,7 +1488,6 @@ class CardAnimations {
|
|||||||
|
|
||||||
// Create container for animation cards
|
// Create container for animation cards
|
||||||
const container = document.createElement('div');
|
const container = document.createElement('div');
|
||||||
container.className = 'deal-anim-container';
|
|
||||||
container.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;pointer-events:none;z-index:1000;';
|
container.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;pointer-events:none;z-index:1000;';
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
// 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
|
||||||
|
|
||||||
@ -101,14 +100,12 @@ class CardManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the deck color class for a card based on its deck_id.
|
// 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) {
|
getDeckColorClass(cardData) {
|
||||||
if (!cardData || cardData.deck_id === undefined || cardData.deck_id === null) {
|
if (!cardData || cardData.deck_id === undefined || cardData.deck_id === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
// Get deck colors from game state (set by app.js)
|
||||||
const deckColors = window.currentDeckColors || ['red', 'blue', 'gold'];
|
const deckColors = window.currentDeckColors || ['red', 'blue', 'gold'];
|
||||||
const colorName = deckColors[cardData.deck_id] || deckColors[0] || 'red';
|
const colorName = deckColors[cardData.deck_id] || deckColors[0] || 'red';
|
||||||
return `deck-${colorName}`;
|
return `deck-${colorName}`;
|
||||||
@ -129,16 +126,6 @@ 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) {
|
||||||
const moveDuration = window.TIMING?.card?.moving || 350;
|
const moveDuration = window.TIMING?.card?.moving || 350;
|
||||||
setTimeout(() => cardEl.classList.remove('moving'), moveDuration);
|
setTimeout(() => cardEl.classList.remove('moving'), moveDuration);
|
||||||
@ -241,9 +228,7 @@ class CardManager {
|
|||||||
await this.delay(flipDuration);
|
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);
|
||||||
|
|||||||
@ -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 - 2x2 grid -->
|
<!-- Card suits - single row, larger -->
|
||||||
<text x="36" y="40" font-family="Arial, sans-serif" font-size="28" font-weight="bold" fill="#1a1a1a" text-anchor="middle">♣</text>
|
<text x="22" y="51" font-family="Arial, sans-serif" font-size="32" font-weight="bold" fill="#1a1a1a" 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="41" y="51" font-family="Arial, sans-serif" font-size="32" font-weight="bold" fill="#cc0000" 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="59" y="51" font-family="Arial, sans-serif" font-size="32" font-weight="bold" fill="#1a1a1a" 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>
|
<text x="77" y="51" font-family="Arial, sans-serif" font-size="32" font-weight="bold" fill="#cc0000" 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, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<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,57 +16,36 @@
|
|||||||
|
|
||||||
<!-- Lobby Screen -->
|
<!-- Lobby Screen -->
|
||||||
<div id="lobby-screen" class="screen active">
|
<div id="lobby-screen" class="screen active">
|
||||||
<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>
|
<h1><img src="golfball-logo.svg" alt="" class="golfball-logo"><span class="golfer-swing">🏌️</span><span class="kicked-ball">⚪</span> <span class="golf-title">Golf</span></h1>
|
||||||
<p class="subtitle">6-Card Golf Card Game <button id="rules-btn" class="btn btn-small btn-rules">Rules</button> <button id="leaderboard-btn" class="btn btn-small leaderboard-btn">Leaderboard</button></p>
|
<p class="subtitle">6-Card Golf Card Game <button id="rules-btn" class="btn btn-small btn-rules">Rules</button> <button id="leaderboard-btn" class="btn btn-small leaderboard-btn">Leaderboard</button></p>
|
||||||
|
|
||||||
<div class="alpha-banner">Beta Testing - Bear with us while, stuff.</div>
|
<!-- Auth buttons for guests (hidden until auth check confirms not logged in) -->
|
||||||
|
<div id="auth-buttons" class="auth-buttons hidden">
|
||||||
<!-- Auth prompt for unauthenticated users -->
|
<button id="login-btn" class="btn btn-small">Login</button>
|
||||||
<div id="auth-prompt" class="auth-prompt">
|
<button id="signup-btn" class="btn btn-small btn-primary">Sign Up</button>
|
||||||
<p>Log in or sign up to play.</p>
|
|
||||||
<div class="button-group">
|
|
||||||
<button id="login-btn" class="btn btn-primary">Login</button>
|
|
||||||
<button id="signup-btn" class="btn btn-secondary">Sign Up</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Game controls (shown only when authenticated) -->
|
<div class="form-group">
|
||||||
<div id="lobby-game-controls" class="hidden">
|
<label for="player-name">Your Name</label>
|
||||||
<div class="button-group">
|
<input type="text" id="player-name" placeholder="Enter your name" maxlength="12">
|
||||||
<button id="find-game-btn" class="btn btn-primary">Find Game</button>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="divider">or</div>
|
<div class="button-group">
|
||||||
|
<button id="create-room-btn" class="btn btn-primary">Create Room</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="button-group">
|
<div class="divider">or</div>
|
||||||
<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">Join Private Room</label>
|
<label for="room-code">Room Code</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 -->
|
||||||
@ -82,16 +61,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>
|
||||||
|
|
||||||
@ -286,8 +265,6 @@
|
|||||||
|
|
||||||
<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 -->
|
||||||
@ -309,6 +286,7 @@
|
|||||||
<div id="final-turn-badge" class="final-turn-badge hidden">
|
<div id="final-turn-badge" class="final-turn-badge hidden">
|
||||||
<span class="final-turn-icon">⚡</span>
|
<span class="final-turn-icon">⚡</span>
|
||||||
<span class="final-turn-text">FINAL TURN</span>
|
<span class="final-turn-text">FINAL TURN</span>
|
||||||
|
<span class="final-turn-remaining"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-col header-col-right">
|
<div class="header-col header-col-right">
|
||||||
@ -332,24 +310,18 @@
|
|||||||
</div>
|
</div>
|
||||||
<span class="held-label">Holding</span>
|
<span class="held-label">Holding</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="pile-wrapper">
|
<div id="deck" class="card card-back"></div>
|
||||||
<span class="pile-label">DRAW</span>
|
<div class="discard-stack">
|
||||||
<div id="deck" class="card card-back"></div>
|
<div id="discard" class="card">
|
||||||
</div>
|
<span id="discard-content"></span>
|
||||||
<div class="pile-wrapper">
|
|
||||||
<span class="pile-label">DISCARD</span>
|
|
||||||
<div class="discard-stack">
|
|
||||||
<div id="discard" class="card">
|
|
||||||
<span id="discard-content"></span>
|
|
||||||
</div>
|
|
||||||
<!-- Floating held card (appears larger over discard when holding) -->
|
|
||||||
<div id="held-card-floating" class="card card-front held-card-floating hidden">
|
|
||||||
<span id="held-card-floating-content"></span>
|
|
||||||
</div>
|
|
||||||
<button id="discard-btn" class="btn btn-small hidden">Discard</button>
|
|
||||||
<button id="skip-flip-btn" class="btn btn-small btn-secondary hidden">Skip Flip</button>
|
|
||||||
<button id="knock-early-btn" class="btn btn-small btn-danger hidden">Knock!</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Floating held card (appears larger over discard when holding) -->
|
||||||
|
<div id="held-card-floating" class="card card-front held-card-floating hidden">
|
||||||
|
<span id="held-card-floating-content"></span>
|
||||||
|
</div>
|
||||||
|
<button id="discard-btn" class="btn btn-small hidden">Discard</button>
|
||||||
|
<button id="skip-flip-btn" class="btn btn-small btn-secondary hidden">Skip Flip</button>
|
||||||
|
<button id="knock-early-btn" class="btn btn-small btn-danger hidden">Knock!</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -408,32 +380,15 @@
|
|||||||
<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>
|
||||||
@ -749,8 +704,9 @@ 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>
|
||||||
@ -761,7 +717,6 @@ 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">
|
||||||
@ -855,48 +810,12 @@ 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>
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
// 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.
|
||||||
@ -27,7 +26,6 @@ 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 = {
|
||||||
@ -36,7 +34,6 @@ 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,4 +1,3 @@
|
|||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// Golf Card Game - Replay Viewer
|
// Golf Card Game - Replay Viewer
|
||||||
|
|
||||||
class ReplayViewer {
|
class ReplayViewer {
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
// 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
|
||||||
|
|
||||||
|
|||||||
1446
client/style.css
1446
client/style.css
File diff suppressed because it is too large
Load Diff
@ -1,4 +1,3 @@
|
|||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// Centralized timing configuration for all animations and pauses
|
// Centralized timing configuration for all animations and pauses
|
||||||
// Edit these values to tune the feel of card animations and CPU gameplay
|
// Edit these values to tune the feel of card animations and CPU gameplay
|
||||||
|
|
||||||
@ -78,7 +77,6 @@ const TIMING = {
|
|||||||
|
|
||||||
// V3_03: Round end reveal timing
|
// V3_03: Round end reveal timing
|
||||||
reveal: {
|
reveal: {
|
||||||
lastPlayPause: 2000, // Pause after last play animation before reveals
|
|
||||||
voluntaryWindow: 2000, // Time for players to flip their own cards
|
voluntaryWindow: 2000, // Time for players to flip their own cards
|
||||||
initialPause: 250, // Pause before auto-reveals start
|
initialPause: 250, // Pause before auto-reveals start
|
||||||
cardStagger: 50, // Between cards in same hand
|
cardStagger: 50, // Between cards in same hand
|
||||||
@ -130,25 +128,6 @@ const TIMING = {
|
|||||||
pulseDelay: 200, // Delay before card appears (pulse visible first)
|
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
|
// Player swap animation steps - smooth continuous motion
|
||||||
playerSwap: {
|
playerSwap: {
|
||||||
flipToReveal: 400, // Initial flip to show card
|
flipToReveal: 400, // Initial flip to show card
|
||||||
|
|||||||
@ -17,71 +17,49 @@
|
|||||||
|
|
||||||
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=${ENVIRONMENT:-production}
|
- ENVIRONMENT=production
|
||||||
- LOG_LEVEL=${LOG_LEVEL:-WARNING}
|
- 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://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: 256M
|
memory: 512M
|
||||||
reservations:
|
reservations:
|
||||||
memory: 64M
|
memory: 256M
|
||||||
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
|
||||||
@ -99,14 +77,13 @@ services:
|
|||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
memory: 192M
|
memory: 512M
|
||||||
reservations:
|
reservations:
|
||||||
memory: 64M
|
memory: 256M
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
restart: unless-stopped
|
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
command: redis-server --appendonly yes --maxmemory 32mb --maxmemory-policy allkeys-lru
|
command: redis-server --appendonly yes --maxmemory 128mb --maxmemory-policy allkeys-lru
|
||||||
volumes:
|
volumes:
|
||||||
- redis_data:/data
|
- redis_data:/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@ -119,18 +96,44 @@ services:
|
|||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
memory: 64M
|
memory: 192M
|
||||||
reservations:
|
reservations:
|
||||||
memory: 16M
|
memory: 64M
|
||||||
|
|
||||||
|
traefik:
|
||||||
|
image: traefik:v2.10
|
||||||
|
command:
|
||||||
|
- "--api.dashboard=true"
|
||||||
|
- "--providers.docker=true"
|
||||||
|
- "--providers.docker.exposedbydefault=false"
|
||||||
|
- "--entrypoints.web.address=:80"
|
||||||
|
- "--entrypoints.web.http.redirections.entryPoint.to=websecure"
|
||||||
|
- "--entrypoints.web.http.redirections.entryPoint.scheme=https"
|
||||||
|
- "--entrypoints.websecure.address=:443"
|
||||||
|
- "--certificatesresolvers.letsencrypt.acme.httpchallenge=true"
|
||||||
|
- "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"
|
||||||
|
- "--certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL}"
|
||||||
|
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
- letsencrypt:/letsencrypt
|
||||||
|
networks:
|
||||||
|
- web
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 128M
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
redis_data:
|
redis_data:
|
||||||
|
letsencrypt:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
internal:
|
internal:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
web:
|
web:
|
||||||
name: traefik_web
|
driver: bridge
|
||||||
external: true
|
|
||||||
|
|||||||
@ -1,148 +0,0 @@
|
|||||||
# 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
|
|
||||||
@ -1,77 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@ -30,7 +30,6 @@ This plan is split into independent vertical slices ordered by priority and impa
|
|||||||
| `V3_14_ACTIVE_RULES_CONTEXT.md` | Contextual rule highlighting | Low | Low | 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_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_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 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -1,117 +0,0 @@
|
|||||||
# 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
|
|
||||||
@ -1,57 +0,0 @@
|
|||||||
# 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)
|
|
||||||
@ -1,10 +1,10 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "golfgame"
|
name = "golfgame"
|
||||||
version = "3.1.6"
|
version = "2.0.1"
|
||||||
description = "6-Card Golf card game with AI opponents"
|
description = "6-Card Golf card game with AI opponents"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
license = {text = "GPL-3.0-or-later"}
|
license = {text = "MIT"}
|
||||||
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 :: GNU General Public License v3 or later (GPLv3+)",
|
"License :: OSI Approved :: MIT License",
|
||||||
"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",
|
||||||
|
|||||||
@ -1,23 +0,0 @@
|
|||||||
#!/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."
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
#!/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."
|
|
||||||
@ -7,24 +7,6 @@ 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
|
||||||
|
|||||||
102
server/ai.py
102
server/ai.py
@ -1,4 +1,3 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
"""AI personalities for CPU players in Golf."""
|
"""AI personalities for CPU players in Golf."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@ -44,9 +43,8 @@ CPU_TIMING = {
|
|||||||
# Delay before CPU "looks at" the discard pile
|
# Delay before CPU "looks at" the discard pile
|
||||||
"initial_look": (0.3, 0.5),
|
"initial_look": (0.3, 0.5),
|
||||||
# Brief pause after draw broadcast - let draw animation complete
|
# Brief pause after draw broadcast - let draw animation complete
|
||||||
# Must be >= client draw animation duration (~1.09s for deck, ~0.4s for discard)
|
# Must be >= client draw animation duration (~1s for deck, ~0.4s for discard)
|
||||||
# Extra margin prevents swap message from arriving before draw flip completes
|
"post_draw_settle": 1.1,
|
||||||
"post_draw_settle": 1.3,
|
|
||||||
# Consideration time after drawing (before swap/discard decision)
|
# Consideration time after drawing (before swap/discard decision)
|
||||||
"post_draw_consider": (0.2, 0.4),
|
"post_draw_consider": (0.2, 0.4),
|
||||||
# Variance multiplier range for chaotic personality players
|
# Variance multiplier range for chaotic personality players
|
||||||
@ -56,15 +54,17 @@ CPU_TIMING = {
|
|||||||
"post_action_pause": (0.5, 0.7),
|
"post_action_pause": (0.5, 0.7),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Thinking time ranges by card difficulty (seconds).
|
# Thinking time ranges by card difficulty (seconds)
|
||||||
# Yes, these are all identical. That's intentional — the categories exist so we
|
|
||||||
# CAN tune them independently later, but right now a uniform 0.15-0.3s feels
|
|
||||||
# natural enough. The structure is the point, not the current values.
|
|
||||||
THINKING_TIME = {
|
THINKING_TIME = {
|
||||||
|
# Obviously good cards (Jokers, Kings, 2s, Aces) - easy take
|
||||||
"easy_good": (0.15, 0.3),
|
"easy_good": (0.15, 0.3),
|
||||||
|
# Obviously bad cards (10s, Jacks, Queens) - easy pass
|
||||||
"easy_bad": (0.15, 0.3),
|
"easy_bad": (0.15, 0.3),
|
||||||
|
# Medium difficulty (3, 4, 8, 9)
|
||||||
"medium": (0.15, 0.3),
|
"medium": (0.15, 0.3),
|
||||||
|
# Hardest decisions (5, 6, 7 - middle of range)
|
||||||
"hard": (0.15, 0.3),
|
"hard": (0.15, 0.3),
|
||||||
|
# No discard available - quick decision
|
||||||
"no_card": (0.15, 0.3),
|
"no_card": (0.15, 0.3),
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -799,9 +799,7 @@ class GolfAI:
|
|||||||
ai_log(f" >> TAKE: {discard_card.rank.value} for four-of-a-kind ({rank_count} visible)")
|
ai_log(f" >> TAKE: {discard_card.rank.value} for four-of-a-kind ({rank_count} visible)")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Take card if it could make a column pair (but NOT for negative value cards).
|
# Take card if it could make a column pair (but NOT for negative value cards)
|
||||||
# Why exclude negatives: a Joker (-2) paired in a column scores 0, which is
|
|
||||||
# worse than keeping it unpaired at -2. Same logic for 2s with default values.
|
|
||||||
if discard_value > 0:
|
if discard_value > 0:
|
||||||
for i, card in enumerate(player.cards):
|
for i, card in enumerate(player.cards):
|
||||||
pair_pos = (i + 3) % 6 if i < 3 else i - 3
|
pair_pos = (i + 3) % 6 if i < 3 else i - 3
|
||||||
@ -1032,11 +1030,7 @@ class GolfAI:
|
|||||||
if not creates_negative_pair:
|
if not creates_negative_pair:
|
||||||
expected_hidden = EXPECTED_HIDDEN_VALUE
|
expected_hidden = EXPECTED_HIDDEN_VALUE
|
||||||
point_gain = expected_hidden - drawn_value
|
point_gain = expected_hidden - drawn_value
|
||||||
# Personality discount: swap_threshold ranges 0-8, so this maps to 0.5-1.0.
|
discount = 0.5 + (profile.swap_threshold / 16) # Range: 0.5 to 1.0
|
||||||
# Conservative players (low threshold) discount heavily — they need a bigger
|
|
||||||
# point gain to justify swapping into the unknown. Aggressive players take
|
|
||||||
# the swap at closer to face value.
|
|
||||||
discount = 0.5 + (profile.swap_threshold / 16)
|
|
||||||
return point_gain * discount
|
return point_gain * discount
|
||||||
return 0.0
|
return 0.0
|
||||||
|
|
||||||
@ -1257,6 +1251,8 @@ class GolfAI:
|
|||||||
"""If player has exactly 1 face-down card, decide the best go-out swap.
|
"""If player has exactly 1 face-down card, decide the best go-out swap.
|
||||||
|
|
||||||
Returns position to swap into, or None to fall through to normal scoring.
|
Returns position to swap into, or None to fall through to normal scoring.
|
||||||
|
Uses a sentinel value of -1 (converted to None by caller) is not needed -
|
||||||
|
we return None to indicate "no early decision, continue normal flow".
|
||||||
"""
|
"""
|
||||||
options = game.options
|
options = game.options
|
||||||
face_down_positions = hidden_positions(player)
|
face_down_positions = hidden_positions(player)
|
||||||
@ -1305,28 +1301,12 @@ class GolfAI:
|
|||||||
|
|
||||||
max_acceptable_go_out = 14 + int(profile.aggression * 4)
|
max_acceptable_go_out = 14 + int(profile.aggression * 4)
|
||||||
|
|
||||||
# Check opponent scores - don't go out if we'd lose badly
|
|
||||||
opponent_min = estimate_opponent_min_score(player, game, optimistic=False)
|
|
||||||
# Aggressive players tolerate a bigger gap; conservative ones less
|
|
||||||
opponent_margin = 4 + int(profile.aggression * 4) # 4-8 points
|
|
||||||
opponent_cap = opponent_min + opponent_margin
|
|
||||||
|
|
||||||
# Use the more restrictive of the two thresholds
|
|
||||||
effective_max = min(max_acceptable_go_out, opponent_cap)
|
|
||||||
|
|
||||||
ai_log(f" Go-out safety check: visible_base={visible_score}, "
|
ai_log(f" Go-out safety check: visible_base={visible_score}, "
|
||||||
f"score_if_swap={score_if_swap}, score_if_flip={score_if_flip}, "
|
f"score_if_swap={score_if_swap}, score_if_flip={score_if_flip}, "
|
||||||
f"max_acceptable={max_acceptable_go_out}, opponent_min={opponent_min}, "
|
f"max_acceptable={max_acceptable_go_out}")
|
||||||
f"opponent_cap={opponent_cap}, effective_max={effective_max}")
|
|
||||||
|
|
||||||
# High-card safety: don't swap 8+ into hidden position unless it makes a pair
|
|
||||||
creates_pair = (last_partner.face_up and last_partner.rank == drawn_card.rank)
|
|
||||||
if drawn_value >= HIGH_CARD_THRESHOLD and not creates_pair:
|
|
||||||
ai_log(f" >> GO-OUT: high card ({drawn_value}) into hidden, preferring flip")
|
|
||||||
return None # Fall through to normal scoring (will flip)
|
|
||||||
|
|
||||||
# If BOTH options are bad, choose the better one
|
# If BOTH options are bad, choose the better one
|
||||||
if score_if_swap > effective_max and score_if_flip > effective_max:
|
if score_if_swap > max_acceptable_go_out and score_if_flip > max_acceptable_go_out:
|
||||||
if score_if_swap <= score_if_flip:
|
if score_if_swap <= score_if_flip:
|
||||||
ai_log(f" >> SAFETY: both options bad, but swap ({score_if_swap}) "
|
ai_log(f" >> SAFETY: both options bad, but swap ({score_if_swap}) "
|
||||||
f"<= flip ({score_if_flip}), forcing swap")
|
f"<= flip ({score_if_flip}), forcing swap")
|
||||||
@ -1342,7 +1322,7 @@ class GolfAI:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
# If swap is good, prefer it (known outcome vs unknown flip)
|
# If swap is good, prefer it (known outcome vs unknown flip)
|
||||||
elif score_if_swap <= effective_max and score_if_swap <= score_if_flip:
|
elif score_if_swap <= max_acceptable_go_out and score_if_swap <= score_if_flip:
|
||||||
ai_log(f" >> SAFETY: swap gives acceptable score {score_if_swap}")
|
ai_log(f" >> SAFETY: swap gives acceptable score {score_if_swap}")
|
||||||
return last_pos
|
return last_pos
|
||||||
|
|
||||||
@ -1364,11 +1344,7 @@ class GolfAI:
|
|||||||
if not face_down or random.random() >= 0.5:
|
if not face_down or random.random() >= 0.5:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# SAFETY: Don't randomly go out with a bad score.
|
# SAFETY: Don't randomly go out with a bad score
|
||||||
# This duplicates some logic from project_score() on purpose — project_score()
|
|
||||||
# is designed for strategic decisions with weighted estimates, but here we need
|
|
||||||
# a hard pass/fail check with exact pair math. Close enough isn't good enough
|
|
||||||
# when the downside is accidentally ending the round at 30 points.
|
|
||||||
if len(face_down) == 1:
|
if len(face_down) == 1:
|
||||||
last_pos = face_down[0]
|
last_pos = face_down[0]
|
||||||
projected = drawn_value
|
projected = drawn_value
|
||||||
@ -1763,23 +1739,9 @@ class GolfAI:
|
|||||||
expected_hidden_total = len(face_down) * EXPECTED_HIDDEN_VALUE
|
expected_hidden_total = len(face_down) * EXPECTED_HIDDEN_VALUE
|
||||||
projected_score = visible_score + expected_hidden_total
|
projected_score = visible_score + expected_hidden_total
|
||||||
|
|
||||||
# Hard cap: never knock with projected score > 10
|
|
||||||
if projected_score > 10:
|
|
||||||
ai_log(f" Knock rejected: projected score {projected_score:.1f} > 10 hard cap")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Tighter threshold: range 5 to 9 based on aggression
|
# Tighter threshold: range 5 to 9 based on aggression
|
||||||
max_acceptable = 5 + int(profile.aggression * 4)
|
max_acceptable = 5 + int(profile.aggression * 4)
|
||||||
|
|
||||||
# Check opponent threat - don't knock if an opponent likely beats us
|
|
||||||
opponent_min = estimate_opponent_min_score(player, game, optimistic=False)
|
|
||||||
if opponent_min < projected_score:
|
|
||||||
# Opponent is likely beating us - penalize threshold
|
|
||||||
threat_margin = projected_score - opponent_min
|
|
||||||
max_acceptable -= int(threat_margin * 0.75)
|
|
||||||
ai_log(f" Knock threat penalty: opponent est {opponent_min}, "
|
|
||||||
f"margin {threat_margin:.1f}, threshold now {max_acceptable}")
|
|
||||||
|
|
||||||
# Exception: if all opponents are showing terrible scores, relax threshold
|
# Exception: if all opponents are showing terrible scores, relax threshold
|
||||||
all_opponents_bad = all(
|
all_opponents_bad = all(
|
||||||
sum(get_ai_card_value(c, game.options) for c in p.cards if c.face_up) >= 25
|
sum(get_ai_card_value(c, game.options) for c in p.cards if c.face_up) >= 25
|
||||||
@ -1790,14 +1752,12 @@ class GolfAI:
|
|||||||
|
|
||||||
if projected_score <= max_acceptable:
|
if projected_score <= max_acceptable:
|
||||||
# Scale knock chance by how good the projected score is
|
# Scale knock chance by how good the projected score is
|
||||||
if projected_score <= 4:
|
if projected_score <= 5:
|
||||||
knock_chance = profile.aggression * 0.35 # Max 35%
|
knock_chance = profile.aggression * 0.3 # Max 30%
|
||||||
elif projected_score <= 6:
|
elif projected_score <= 7:
|
||||||
knock_chance = profile.aggression * 0.15 # Max 15%
|
knock_chance = profile.aggression * 0.15 # Max 15%
|
||||||
elif projected_score <= 8:
|
else:
|
||||||
knock_chance = profile.aggression * 0.06 # Max 6%
|
knock_chance = profile.aggression * 0.05 # Max 5% (very rare)
|
||||||
else: # 9-10
|
|
||||||
knock_chance = profile.aggression * 0.02 # Max 2% (very rare)
|
|
||||||
|
|
||||||
if random.random() < knock_chance:
|
if random.random() < knock_chance:
|
||||||
ai_log(f" Knock early: taking the gamble! (projected {projected_score:.1f})")
|
ai_log(f" Knock early: taking the gamble! (projected {projected_score:.1f})")
|
||||||
@ -1972,14 +1932,9 @@ def _log_cpu_action(logger, game_id: Optional[str], cpu_player: Player, game: Ga
|
|||||||
|
|
||||||
|
|
||||||
async def process_cpu_turn(
|
async def process_cpu_turn(
|
||||||
game: Game, cpu_player: Player, broadcast_callback, game_id: Optional[str] = None,
|
game: Game, cpu_player: Player, broadcast_callback, game_id: Optional[str] = None
|
||||||
reveal_callback=None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Process a complete turn for a CPU player.
|
"""Process a complete turn for a CPU player."""
|
||||||
|
|
||||||
May raise asyncio.CancelledError if the game is ended mid-turn.
|
|
||||||
The caller (check_and_run_cpu_turn) handles cancellation.
|
|
||||||
"""
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from services.game_logger import get_logger
|
from services.game_logger import get_logger
|
||||||
|
|
||||||
@ -2007,8 +1962,10 @@ async def process_cpu_turn(
|
|||||||
await asyncio.sleep(thinking_time)
|
await asyncio.sleep(thinking_time)
|
||||||
ai_log(f"{cpu_player.name} done thinking, making decision")
|
ai_log(f"{cpu_player.name} done thinking, making decision")
|
||||||
|
|
||||||
|
# Check if we should try to go out early
|
||||||
|
GolfAI.should_go_out_early(cpu_player, game, profile)
|
||||||
|
|
||||||
# Check if we should knock early (flip all remaining cards at once)
|
# Check if we should knock early (flip all remaining cards at once)
|
||||||
# (Opponent threat logic consolidated into should_knock_early)
|
|
||||||
if GolfAI.should_knock_early(game, cpu_player, profile):
|
if GolfAI.should_knock_early(game, cpu_player, profile):
|
||||||
if game.knock_early(cpu_player.id):
|
if game.knock_early(cpu_player.id):
|
||||||
_log_cpu_action(logger, game_id, cpu_player, game,
|
_log_cpu_action(logger, game_id, cpu_player, game,
|
||||||
@ -2091,13 +2048,6 @@ async def process_cpu_turn(
|
|||||||
|
|
||||||
if swap_pos is not None:
|
if swap_pos is not None:
|
||||||
old_card = cpu_player.cards[swap_pos]
|
old_card = cpu_player.cards[swap_pos]
|
||||||
# Reveal the face-down card before swapping
|
|
||||||
if not old_card.face_up and reveal_callback:
|
|
||||||
await reveal_callback(
|
|
||||||
cpu_player.id, swap_pos,
|
|
||||||
{"rank": old_card.rank.value, "suit": old_card.suit.value},
|
|
||||||
)
|
|
||||||
await asyncio.sleep(1.0)
|
|
||||||
game.swap_card(cpu_player.id, swap_pos)
|
game.swap_card(cpu_player.id, swap_pos)
|
||||||
_log_cpu_action(logger, game_id, cpu_player, game,
|
_log_cpu_action(logger, game_id, cpu_player, game,
|
||||||
action="swap", card=drawn, position=swap_pos,
|
action="swap", card=drawn, position=swap_pos,
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
"""
|
"""
|
||||||
Authentication and user management for Golf game.
|
Authentication and user management for Golf game.
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
"""
|
"""
|
||||||
Centralized configuration for Golf game server.
|
Centralized configuration for Golf game server.
|
||||||
|
|
||||||
@ -143,28 +142,12 @@ 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 = True
|
INVITE_ONLY: bool = False
|
||||||
|
|
||||||
# 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
|
||||||
|
|
||||||
@ -200,16 +183,8 @@ 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", True),
|
INVITE_ONLY=get_env_bool("INVITE_ONLY", False),
|
||||||
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,4 +1,3 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
"""
|
"""
|
||||||
Card value constants for 6-Card Golf.
|
Card value constants for 6-Card Golf.
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
"""
|
"""
|
||||||
Game logic for 6-Card Golf.
|
Game logic for 6-Card Golf.
|
||||||
|
|
||||||
@ -359,13 +358,6 @@ 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
|
||||||
@ -783,17 +775,9 @@ class Game:
|
|||||||
for i, player in enumerate(self.players):
|
for i, player in enumerate(self.players):
|
||||||
if player.id == player_id:
|
if player.id == player_id:
|
||||||
removed = self.players.pop(i)
|
removed = self.players.pop(i)
|
||||||
if self.players:
|
# Adjust dealer_idx if needed after removal
|
||||||
# Adjust dealer_idx if needed after removal
|
if self.players and self.dealer_idx >= len(self.players):
|
||||||
if self.dealer_idx >= len(self.players):
|
self.dealer_idx = 0
|
||||||
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
|
||||||
@ -816,8 +800,6 @@ 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
|
||||||
|
|
||||||
@ -950,8 +932,7 @@ class Game:
|
|||||||
if self.current_round > 1:
|
if self.current_round > 1:
|
||||||
self.dealer_idx = (self.dealer_idx + 1) % len(self.players)
|
self.dealer_idx = (self.dealer_idx + 1) % len(self.players)
|
||||||
|
|
||||||
# "Left of dealer goes first" — standard card game convention.
|
# First player is to the left of dealer (next in order)
|
||||||
# In our circular list, "left" is the next index.
|
|
||||||
self.current_player_index = (self.dealer_idx + 1) % len(self.players)
|
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
|
||||||
@ -1434,9 +1415,6 @@ 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
|
||||||
@ -1453,8 +1431,7 @@ 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. Depends on _check_end_turn()
|
and ends the round when everyone has played.
|
||||||
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)
|
||||||
@ -1497,10 +1474,6 @@ 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:
|
||||||
@ -1624,10 +1597,6 @@ 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)
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
"""
|
"""
|
||||||
Game Analyzer for 6-Card Golf AI decisions.
|
Game Analyzer for 6-Card Golf AI decisions.
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
"""WebSocket message handlers for the Golf card game.
|
"""WebSocket message handlers for the Golf card game.
|
||||||
|
|
||||||
Each handler corresponds to a single message type from the client.
|
Each handler corresponds to a single message type from the client.
|
||||||
@ -13,7 +12,6 @@ from typing import Optional
|
|||||||
|
|
||||||
from fastapi import WebSocket
|
from fastapi import WebSocket
|
||||||
|
|
||||||
from config import config
|
|
||||||
from game import GamePhase, GameOptions
|
from game import GamePhase, GameOptions
|
||||||
from ai import GolfAI, get_all_profiles
|
from ai import GolfAI, get_all_profiles
|
||||||
from room import Room
|
from room import Room
|
||||||
@ -55,10 +53,6 @@ def log_human_action(room: Room, player, action: str, card=None, position=None,
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async def handle_create_room(data: dict, ctx: ConnectionContext, *, room_manager, count_user_games, max_concurrent, **kw) -> None:
|
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:
|
if ctx.auth_user_id and count_user_games(ctx.auth_user_id) >= max_concurrent:
|
||||||
await ctx.websocket.send_json({
|
await ctx.websocket.send_json({
|
||||||
"type": "error",
|
"type": "error",
|
||||||
@ -66,11 +60,11 @@ async def handle_create_room(data: dict, ctx: ConnectionContext, *, room_manager
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
|
|
||||||
# Use authenticated username as player name
|
player_name = data.get("player_name", "Player")
|
||||||
player_name = ctx.authenticated_user.username if ctx.authenticated_user else data.get("player_name", "Player")
|
if ctx.authenticated_user and ctx.authenticated_user.display_name:
|
||||||
|
player_name = ctx.authenticated_user.display_name
|
||||||
room = room_manager.create_room()
|
room = room_manager.create_room()
|
||||||
room.add_player(ctx.player_id, player_name, ctx.websocket, ctx.auth_user_id)
|
room.add_player(ctx.player_id, player_name, ctx.websocket, ctx.auth_user_id)
|
||||||
room.touch()
|
|
||||||
ctx.current_room = room
|
ctx.current_room = room
|
||||||
|
|
||||||
await ctx.websocket.send_json({
|
await ctx.websocket.send_json({
|
||||||
@ -87,13 +81,8 @@ async def handle_create_room(data: dict, ctx: ConnectionContext, *, room_manager
|
|||||||
|
|
||||||
|
|
||||||
async def handle_join_room(data: dict, ctx: ConnectionContext, *, room_manager, count_user_games, max_concurrent, **kw) -> None:
|
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()
|
room_code = data.get("room_code", "").upper()
|
||||||
# Use authenticated username as player name
|
player_name = data.get("player_name", "Player")
|
||||||
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:
|
if ctx.auth_user_id and count_user_games(ctx.auth_user_id) >= max_concurrent:
|
||||||
await ctx.websocket.send_json({
|
await ctx.websocket.send_json({
|
||||||
@ -115,8 +104,9 @@ async def handle_join_room(data: dict, ctx: ConnectionContext, *, room_manager,
|
|||||||
await ctx.websocket.send_json({"type": "error", "message": "Game already in progress"})
|
await ctx.websocket.send_json({"type": "error", "message": "Game already in progress"})
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if ctx.authenticated_user and ctx.authenticated_user.display_name:
|
||||||
|
player_name = ctx.authenticated_user.display_name
|
||||||
room.add_player(ctx.player_id, player_name, ctx.websocket, ctx.auth_user_id)
|
room.add_player(ctx.player_id, player_name, ctx.websocket, ctx.auth_user_id)
|
||||||
room.touch()
|
|
||||||
ctx.current_room = room
|
ctx.current_room = room
|
||||||
|
|
||||||
await ctx.websocket.send_json({
|
await ctx.websocket.send_json({
|
||||||
@ -192,7 +182,6 @@ async def handle_remove_cpu(data: dict, ctx: ConnectionContext, **kw) -> None:
|
|||||||
async def handle_start_game(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
|
async def handle_start_game(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
|
||||||
if not ctx.current_room:
|
if not ctx.current_room:
|
||||||
return
|
return
|
||||||
ctx.current_room.touch()
|
|
||||||
|
|
||||||
room_player = ctx.current_room.get_player(ctx.player_id)
|
room_player = ctx.current_room.get_player(ctx.player_id)
|
||||||
if not room_player or not room_player.is_host:
|
if not room_player or not room_player.is_host:
|
||||||
@ -233,19 +222,18 @@ async def handle_start_game(data: dict, ctx: ConnectionContext, *, broadcast_gam
|
|||||||
"game_state": game_state,
|
"game_state": game_state,
|
||||||
})
|
})
|
||||||
|
|
||||||
check_and_run_cpu_turn(ctx.current_room)
|
await check_and_run_cpu_turn(ctx.current_room)
|
||||||
|
|
||||||
|
|
||||||
async def handle_flip_initial(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
|
async def handle_flip_initial(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
|
||||||
if not ctx.current_room:
|
if not ctx.current_room:
|
||||||
return
|
return
|
||||||
ctx.current_room.touch()
|
|
||||||
|
|
||||||
positions = data.get("positions", [])
|
positions = data.get("positions", [])
|
||||||
async with ctx.current_room.game_lock:
|
async with ctx.current_room.game_lock:
|
||||||
if ctx.current_room.game.flip_initial_cards(ctx.player_id, positions):
|
if ctx.current_room.game.flip_initial_cards(ctx.player_id, positions):
|
||||||
await broadcast_game_state(ctx.current_room)
|
await broadcast_game_state(ctx.current_room)
|
||||||
check_and_run_cpu_turn(ctx.current_room)
|
await check_and_run_cpu_turn(ctx.current_room)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@ -255,7 +243,6 @@ async def handle_flip_initial(data: dict, ctx: ConnectionContext, *, broadcast_g
|
|||||||
async def handle_draw(data: dict, ctx: ConnectionContext, *, broadcast_game_state, **kw) -> None:
|
async def handle_draw(data: dict, ctx: ConnectionContext, *, broadcast_game_state, **kw) -> None:
|
||||||
if not ctx.current_room:
|
if not ctx.current_room:
|
||||||
return
|
return
|
||||||
ctx.current_room.touch()
|
|
||||||
|
|
||||||
source = data.get("source", "deck")
|
source = data.get("source", "deck")
|
||||||
async with ctx.current_room.game_lock:
|
async with ctx.current_room.game_lock:
|
||||||
@ -283,7 +270,6 @@ async def handle_draw(data: dict, ctx: ConnectionContext, *, broadcast_game_stat
|
|||||||
async def handle_swap(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
|
async def handle_swap(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
|
||||||
if not ctx.current_room:
|
if not ctx.current_room:
|
||||||
return
|
return
|
||||||
ctx.current_room.touch()
|
|
||||||
|
|
||||||
position = data.get("position", 0)
|
position = data.get("position", 0)
|
||||||
async with ctx.current_room.game_lock:
|
async with ctx.current_room.game_lock:
|
||||||
@ -291,18 +277,6 @@ async def handle_swap(data: dict, ctx: ConnectionContext, *, broadcast_game_stat
|
|||||||
player = ctx.current_room.game.get_player(ctx.player_id)
|
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
|
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)
|
discarded = ctx.current_room.game.swap_card(ctx.player_id, position)
|
||||||
|
|
||||||
if discarded:
|
if discarded:
|
||||||
@ -316,13 +290,12 @@ async def handle_swap(data: dict, ctx: ConnectionContext, *, broadcast_game_stat
|
|||||||
|
|
||||||
await broadcast_game_state(ctx.current_room)
|
await broadcast_game_state(ctx.current_room)
|
||||||
await asyncio.sleep(1.0)
|
await asyncio.sleep(1.0)
|
||||||
check_and_run_cpu_turn(ctx.current_room)
|
await check_and_run_cpu_turn(ctx.current_room)
|
||||||
|
|
||||||
|
|
||||||
async def handle_discard(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
|
async def handle_discard(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
|
||||||
if not ctx.current_room:
|
if not ctx.current_room:
|
||||||
return
|
return
|
||||||
ctx.current_room.touch()
|
|
||||||
|
|
||||||
async with ctx.current_room.game_lock:
|
async with ctx.current_room.game_lock:
|
||||||
drawn_card = ctx.current_room.game.drawn_card
|
drawn_card = ctx.current_room.game.drawn_card
|
||||||
@ -349,12 +322,12 @@ async def handle_discard(data: dict, ctx: ConnectionContext, *, broadcast_game_s
|
|||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
await asyncio.sleep(0.5)
|
await asyncio.sleep(0.5)
|
||||||
check_and_run_cpu_turn(ctx.current_room)
|
await check_and_run_cpu_turn(ctx.current_room)
|
||||||
else:
|
else:
|
||||||
logger.debug("Player discarded, waiting 0.5s before CPU turn")
|
logger.debug("Player discarded, waiting 0.5s before CPU turn")
|
||||||
await asyncio.sleep(0.5)
|
await asyncio.sleep(0.5)
|
||||||
logger.debug("Post-discard delay complete, checking for CPU turn")
|
logger.debug("Post-discard delay complete, checking for CPU turn")
|
||||||
check_and_run_cpu_turn(ctx.current_room)
|
await check_and_run_cpu_turn(ctx.current_room)
|
||||||
|
|
||||||
|
|
||||||
async def handle_cancel_draw(data: dict, ctx: ConnectionContext, *, broadcast_game_state, **kw) -> None:
|
async def handle_cancel_draw(data: dict, ctx: ConnectionContext, *, broadcast_game_state, **kw) -> None:
|
||||||
@ -369,7 +342,6 @@ async def handle_cancel_draw(data: dict, ctx: ConnectionContext, *, broadcast_ga
|
|||||||
async def handle_flip_card(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
|
async def handle_flip_card(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
|
||||||
if not ctx.current_room:
|
if not ctx.current_room:
|
||||||
return
|
return
|
||||||
ctx.current_room.touch()
|
|
||||||
|
|
||||||
position = data.get("position", 0)
|
position = data.get("position", 0)
|
||||||
async with ctx.current_room.game_lock:
|
async with ctx.current_room.game_lock:
|
||||||
@ -385,13 +357,12 @@ async def handle_flip_card(data: dict, ctx: ConnectionContext, *, broadcast_game
|
|||||||
)
|
)
|
||||||
|
|
||||||
await broadcast_game_state(ctx.current_room)
|
await broadcast_game_state(ctx.current_room)
|
||||||
check_and_run_cpu_turn(ctx.current_room)
|
await check_and_run_cpu_turn(ctx.current_room)
|
||||||
|
|
||||||
|
|
||||||
async def handle_skip_flip(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
|
async def handle_skip_flip(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
|
||||||
if not ctx.current_room:
|
if not ctx.current_room:
|
||||||
return
|
return
|
||||||
ctx.current_room.touch()
|
|
||||||
|
|
||||||
async with ctx.current_room.game_lock:
|
async with ctx.current_room.game_lock:
|
||||||
player = ctx.current_room.game.get_player(ctx.player_id)
|
player = ctx.current_room.game.get_player(ctx.player_id)
|
||||||
@ -402,13 +373,12 @@ async def handle_skip_flip(data: dict, ctx: ConnectionContext, *, broadcast_game
|
|||||||
)
|
)
|
||||||
|
|
||||||
await broadcast_game_state(ctx.current_room)
|
await broadcast_game_state(ctx.current_room)
|
||||||
check_and_run_cpu_turn(ctx.current_room)
|
await check_and_run_cpu_turn(ctx.current_room)
|
||||||
|
|
||||||
|
|
||||||
async def handle_flip_as_action(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
|
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:
|
if not ctx.current_room:
|
||||||
return
|
return
|
||||||
ctx.current_room.touch()
|
|
||||||
|
|
||||||
position = data.get("position", 0)
|
position = data.get("position", 0)
|
||||||
async with ctx.current_room.game_lock:
|
async with ctx.current_room.game_lock:
|
||||||
@ -423,13 +393,12 @@ async def handle_flip_as_action(data: dict, ctx: ConnectionContext, *, broadcast
|
|||||||
)
|
)
|
||||||
|
|
||||||
await broadcast_game_state(ctx.current_room)
|
await broadcast_game_state(ctx.current_room)
|
||||||
check_and_run_cpu_turn(ctx.current_room)
|
await check_and_run_cpu_turn(ctx.current_room)
|
||||||
|
|
||||||
|
|
||||||
async def handle_knock_early(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
|
async def handle_knock_early(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
|
||||||
if not ctx.current_room:
|
if not ctx.current_room:
|
||||||
return
|
return
|
||||||
ctx.current_room.touch()
|
|
||||||
|
|
||||||
async with ctx.current_room.game_lock:
|
async with ctx.current_room.game_lock:
|
||||||
player = ctx.current_room.game.get_player(ctx.player_id)
|
player = ctx.current_room.game.get_player(ctx.player_id)
|
||||||
@ -442,13 +411,12 @@ async def handle_knock_early(data: dict, ctx: ConnectionContext, *, broadcast_ga
|
|||||||
)
|
)
|
||||||
|
|
||||||
await broadcast_game_state(ctx.current_room)
|
await broadcast_game_state(ctx.current_room)
|
||||||
check_and_run_cpu_turn(ctx.current_room)
|
await check_and_run_cpu_turn(ctx.current_room)
|
||||||
|
|
||||||
|
|
||||||
async def handle_next_round(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
|
async def handle_next_round(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
|
||||||
if not ctx.current_room:
|
if not ctx.current_room:
|
||||||
return
|
return
|
||||||
ctx.current_room.touch()
|
|
||||||
|
|
||||||
room_player = ctx.current_room.get_player(ctx.player_id)
|
room_player = ctx.current_room.get_player(ctx.player_id)
|
||||||
if not room_player or not room_player.is_host:
|
if not room_player or not room_player.is_host:
|
||||||
@ -468,7 +436,7 @@ async def handle_next_round(data: dict, ctx: ConnectionContext, *, broadcast_gam
|
|||||||
"game_state": game_state,
|
"game_state": game_state,
|
||||||
})
|
})
|
||||||
|
|
||||||
check_and_run_cpu_turn(ctx.current_room)
|
await check_and_run_cpu_turn(ctx.current_room)
|
||||||
else:
|
else:
|
||||||
await broadcast_game_state(ctx.current_room)
|
await broadcast_game_state(ctx.current_room)
|
||||||
|
|
||||||
@ -492,22 +460,12 @@ async def handle_leave_game(data: dict, ctx: ConnectionContext, *, handle_player
|
|||||||
async def handle_end_game(data: dict, ctx: ConnectionContext, *, room_manager, cleanup_room_profiles, **kw) -> None:
|
async def handle_end_game(data: dict, ctx: ConnectionContext, *, room_manager, cleanup_room_profiles, **kw) -> None:
|
||||||
if not ctx.current_room:
|
if not ctx.current_room:
|
||||||
return
|
return
|
||||||
ctx.current_room.touch()
|
|
||||||
|
|
||||||
room_player = ctx.current_room.get_player(ctx.player_id)
|
room_player = ctx.current_room.get_player(ctx.player_id)
|
||||||
if not room_player or not room_player.is_host:
|
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"})
|
await ctx.websocket.send_json({"type": "error", "message": "Only the host can end the game"})
|
||||||
return
|
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({
|
await ctx.current_room.broadcast({
|
||||||
"type": "game_ended",
|
"type": "game_ended",
|
||||||
"reason": "Host ended the game",
|
"reason": "Host ended the game",
|
||||||
@ -525,65 +483,6 @@ async def handle_end_game(data: dict, ctx: ConnectionContext, *, room_manager, c
|
|||||||
# Handler dispatch table
|
# 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 = {
|
HANDLERS = {
|
||||||
"create_room": handle_create_room,
|
"create_room": handle_create_room,
|
||||||
"join_room": handle_join_room,
|
"join_room": handle_join_room,
|
||||||
@ -604,7 +503,4 @@ HANDLERS = {
|
|||||||
"leave_room": handle_leave_room,
|
"leave_room": handle_leave_room,
|
||||||
"leave_game": handle_leave_game,
|
"leave_game": handle_leave_game,
|
||||||
"end_game": handle_end_game,
|
"end_game": handle_end_game,
|
||||||
"queue_join": handle_queue_join,
|
|
||||||
"queue_leave": handle_queue_leave,
|
|
||||||
"queue_status": handle_queue_status,
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
"""
|
"""
|
||||||
Structured logging configuration for Golf game server.
|
Structured logging configuration for Golf game server.
|
||||||
|
|
||||||
@ -149,39 +148,6 @@ 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",
|
||||||
@ -216,19 +182,12 @@ 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):
|
||||||
|
|||||||
315
server/main.py
315
server/main.py
@ -1,4 +1,3 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
"""FastAPI WebSocket server for Golf card game."""
|
"""FastAPI WebSocket server for Golf card game."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
@ -60,12 +59,9 @@ _user_store = None
|
|||||||
_auth_service = None
|
_auth_service = None
|
||||||
_admin_service = None
|
_admin_service = None
|
||||||
_stats_service = None
|
_stats_service = None
|
||||||
_rating_service = None
|
|
||||||
_matchmaking_service = None
|
|
||||||
_replay_service = None
|
_replay_service = None
|
||||||
_spectator_manager = None
|
_spectator_manager = None
|
||||||
_leaderboard_refresh_task = None
|
_leaderboard_refresh_task = None
|
||||||
_room_cleanup_task = None
|
|
||||||
_redis_client = None
|
_redis_client = None
|
||||||
_rate_limiter = None
|
_rate_limiter = None
|
||||||
_shutdown_event = asyncio.Event()
|
_shutdown_event = asyncio.Event()
|
||||||
@ -85,74 +81,8 @@ async def _periodic_leaderboard_refresh():
|
|||||||
logger.error(f"Leaderboard refresh failed: {e}")
|
logger.error(f"Leaderboard refresh failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
async def _periodic_room_cleanup():
|
|
||||||
"""Periodic task to clean up rooms idle for longer than ROOM_IDLE_TIMEOUT_SECONDS."""
|
|
||||||
import time
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
await asyncio.sleep(60)
|
|
||||||
now = time.time()
|
|
||||||
timeout = config.ROOM_IDLE_TIMEOUT_SECONDS
|
|
||||||
stale_rooms = [
|
|
||||||
room for room in room_manager.rooms.values()
|
|
||||||
if now - room.last_activity > timeout
|
|
||||||
]
|
|
||||||
for room in stale_rooms:
|
|
||||||
logger.info(
|
|
||||||
f"Cleaning up stale room {room.code} "
|
|
||||||
f"(idle {int(now - room.last_activity)}s, "
|
|
||||||
f"{len(room.players)} players)"
|
|
||||||
)
|
|
||||||
# Cancel CPU turn task
|
|
||||||
if room.cpu_turn_task:
|
|
||||||
room.cpu_turn_task.cancel()
|
|
||||||
try:
|
|
||||||
await room.cpu_turn_task
|
|
||||||
except (asyncio.CancelledError, Exception):
|
|
||||||
pass
|
|
||||||
room.cpu_turn_task = None
|
|
||||||
|
|
||||||
# Notify and close human WebSocket connections
|
|
||||||
for player in list(room.players.values()):
|
|
||||||
if player.websocket and not player.is_cpu:
|
|
||||||
try:
|
|
||||||
await player.websocket.send_json({
|
|
||||||
"type": "room_expired",
|
|
||||||
"message": "Room closed due to inactivity",
|
|
||||||
})
|
|
||||||
await player.websocket.close(code=4002, reason="Room expired")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Mark game as abandoned in DB
|
|
||||||
if room.game_log_id:
|
|
||||||
try:
|
|
||||||
async with _user_store.pool.acquire() as conn:
|
|
||||||
await conn.execute(
|
|
||||||
"UPDATE games_v2 SET status = 'abandoned', completed_at = NOW() WHERE id = $1 AND status = 'active'",
|
|
||||||
room.game_log_id,
|
|
||||||
)
|
|
||||||
logger.info(f"Marked game {room.game_log_id} as abandoned in DB")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to mark game {room.game_log_id} as abandoned: {e}")
|
|
||||||
|
|
||||||
# Clean up players and profiles
|
|
||||||
room_code = room.code
|
|
||||||
for cpu in list(room.get_cpu_players()):
|
|
||||||
room.remove_player(cpu.id)
|
|
||||||
cleanup_room_profiles(room_code)
|
|
||||||
room_manager.remove_room(room_code)
|
|
||||||
|
|
||||||
if stale_rooms:
|
|
||||||
logger.info(f"Cleaned up {len(stale_rooms)} stale room(s)")
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
break
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Room cleanup failed: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
async def _init_redis():
|
async def _init_redis():
|
||||||
"""Initialize Redis client, rate limiter, and signup limiter."""
|
"""Initialize Redis client and rate limiter."""
|
||||||
global _redis_client, _rate_limiter
|
global _redis_client, _rate_limiter
|
||||||
try:
|
try:
|
||||||
_redis_client = redis.from_url(config.REDIS_URL, decode_responses=False)
|
_redis_client = redis.from_url(config.REDIS_URL, decode_responses=False)
|
||||||
@ -163,17 +93,6 @@ async def _init_redis():
|
|||||||
from services.ratelimit import get_rate_limiter
|
from services.ratelimit import get_rate_limiter
|
||||||
_rate_limiter = await get_rate_limiter(_redis_client)
|
_rate_limiter = await get_rate_limiter(_redis_client)
|
||||||
logger.info("Rate limiter initialized")
|
logger.info("Rate limiter initialized")
|
||||||
|
|
||||||
# Initialize signup limiter for metered open signups
|
|
||||||
if config.DAILY_OPEN_SIGNUPS != 0 or config.DAILY_SIGNUPS_PER_IP > 0:
|
|
||||||
from services.ratelimit import get_signup_limiter
|
|
||||||
signup_limiter = await get_signup_limiter(_redis_client)
|
|
||||||
from routers.auth import set_signup_limiter
|
|
||||||
set_signup_limiter(signup_limiter)
|
|
||||||
logger.info(
|
|
||||||
f"Signup limiter initialized "
|
|
||||||
f"(daily={config.DAILY_OPEN_SIGNUPS}, per_ip={config.DAILY_SIGNUPS_PER_IP})"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Redis connection failed: {e} - rate limiting disabled")
|
logger.warning(f"Redis connection failed: {e} - rate limiting disabled")
|
||||||
_redis_client = None
|
_redis_client = None
|
||||||
@ -182,7 +101,7 @@ async def _init_redis():
|
|||||||
|
|
||||||
async def _init_database_services():
|
async def _init_database_services():
|
||||||
"""Initialize all PostgreSQL-dependent services."""
|
"""Initialize all PostgreSQL-dependent services."""
|
||||||
global _user_store, _auth_service, _admin_service, _stats_service, _rating_service, _matchmaking_service
|
global _user_store, _auth_service, _admin_service, _stats_service
|
||||||
global _replay_service, _spectator_manager, _leaderboard_refresh_task
|
global _replay_service, _spectator_manager, _leaderboard_refresh_task
|
||||||
|
|
||||||
from stores.user_store import get_user_store
|
from stores.user_store import get_user_store
|
||||||
@ -190,7 +109,7 @@ async def _init_database_services():
|
|||||||
from services.auth_service import get_auth_service
|
from services.auth_service import get_auth_service
|
||||||
from services.admin_service import get_admin_service
|
from services.admin_service import get_admin_service
|
||||||
from services.stats_service import StatsService, set_stats_service
|
from services.stats_service import StatsService, set_stats_service
|
||||||
from routers.auth import set_auth_service, set_admin_service_for_auth
|
from routers.auth import set_auth_service
|
||||||
from routers.admin import set_admin_service
|
from routers.admin import set_admin_service
|
||||||
from routers.stats import set_stats_service as set_stats_router_service
|
from routers.stats import set_stats_service as set_stats_router_service
|
||||||
from routers.stats import set_auth_service as set_stats_auth_service
|
from routers.stats import set_auth_service as set_stats_auth_service
|
||||||
@ -208,7 +127,6 @@ async def _init_database_services():
|
|||||||
state_cache=None,
|
state_cache=None,
|
||||||
)
|
)
|
||||||
set_admin_service(_admin_service)
|
set_admin_service(_admin_service)
|
||||||
set_admin_service_for_auth(_admin_service)
|
|
||||||
logger.info("Admin services initialized")
|
logger.info("Admin services initialized")
|
||||||
|
|
||||||
# Stats + event store
|
# Stats + event store
|
||||||
@ -219,23 +137,6 @@ async def _init_database_services():
|
|||||||
set_stats_auth_service(_auth_service)
|
set_stats_auth_service(_auth_service)
|
||||||
logger.info("Stats services initialized")
|
logger.info("Stats services initialized")
|
||||||
|
|
||||||
# Rating service (Glicko-2)
|
|
||||||
from services.rating_service import RatingService
|
|
||||||
_rating_service = RatingService(_user_store.pool)
|
|
||||||
logger.info("Rating service initialized")
|
|
||||||
|
|
||||||
# Matchmaking service
|
|
||||||
if config.MATCHMAKING_ENABLED:
|
|
||||||
from services.matchmaking import MatchmakingService, MatchmakingConfig
|
|
||||||
mm_config = MatchmakingConfig(
|
|
||||||
enabled=True,
|
|
||||||
min_players=config.MATCHMAKING_MIN_PLAYERS,
|
|
||||||
max_players=config.MATCHMAKING_MAX_PLAYERS,
|
|
||||||
)
|
|
||||||
_matchmaking_service = MatchmakingService(_redis_client, mm_config)
|
|
||||||
await _matchmaking_service.start(room_manager, broadcast_game_state)
|
|
||||||
logger.info("Matchmaking service initialized")
|
|
||||||
|
|
||||||
# Game logger
|
# Game logger
|
||||||
_game_logger = GameLogger(_event_store)
|
_game_logger = GameLogger(_event_store)
|
||||||
set_logger(_game_logger)
|
set_logger(_game_logger)
|
||||||
@ -264,56 +165,12 @@ async def _init_database_services():
|
|||||||
logger.info("Leaderboard refresh task started")
|
logger.info("Leaderboard refresh task started")
|
||||||
|
|
||||||
|
|
||||||
async def _bootstrap_admin():
|
|
||||||
"""Create bootstrap admin user if no admins exist yet."""
|
|
||||||
import bcrypt
|
|
||||||
from models.user import UserRole
|
|
||||||
|
|
||||||
# Check if any admin already exists
|
|
||||||
existing = await _user_store.get_user_by_username(config.BOOTSTRAP_ADMIN_USERNAME)
|
|
||||||
if existing:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Check if any admin exists at all
|
|
||||||
async with _user_store.pool.acquire() as conn:
|
|
||||||
admin_count = await conn.fetchval(
|
|
||||||
"SELECT COUNT(*) FROM users_v2 WHERE role = 'admin' AND deleted_at IS NULL"
|
|
||||||
)
|
|
||||||
if admin_count > 0:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Create the bootstrap admin
|
|
||||||
password_hash = bcrypt.hashpw(
|
|
||||||
config.BOOTSTRAP_ADMIN_PASSWORD.encode("utf-8"),
|
|
||||||
bcrypt.gensalt(),
|
|
||||||
).decode("utf-8")
|
|
||||||
|
|
||||||
user = await _user_store.create_user(
|
|
||||||
username=config.BOOTSTRAP_ADMIN_USERNAME,
|
|
||||||
password_hash=password_hash,
|
|
||||||
role=UserRole.ADMIN,
|
|
||||||
)
|
|
||||||
|
|
||||||
if user:
|
|
||||||
logger.warning(
|
|
||||||
f"Bootstrap admin '{config.BOOTSTRAP_ADMIN_USERNAME}' created. "
|
|
||||||
"Change the password and remove BOOTSTRAP_ADMIN_* env vars."
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.error("Failed to create bootstrap admin user")
|
|
||||||
|
|
||||||
|
|
||||||
async def _shutdown_services():
|
async def _shutdown_services():
|
||||||
"""Gracefully shut down all services."""
|
"""Gracefully shut down all services."""
|
||||||
_shutdown_event.set()
|
_shutdown_event.set()
|
||||||
|
|
||||||
await _close_all_websockets()
|
await _close_all_websockets()
|
||||||
|
|
||||||
# Stop matchmaking
|
|
||||||
if _matchmaking_service:
|
|
||||||
await _matchmaking_service.stop()
|
|
||||||
await _matchmaking_service.cleanup()
|
|
||||||
|
|
||||||
# Clean up rooms and CPU profiles
|
# Clean up rooms and CPU profiles
|
||||||
for room in list(room_manager.rooms.values()):
|
for room in list(room_manager.rooms.values()):
|
||||||
for cpu in list(room.get_cpu_players()):
|
for cpu in list(room.get_cpu_players()):
|
||||||
@ -322,14 +179,6 @@ async def _shutdown_services():
|
|||||||
reset_all_profiles()
|
reset_all_profiles()
|
||||||
logger.info("All rooms and CPU profiles cleaned up")
|
logger.info("All rooms and CPU profiles cleaned up")
|
||||||
|
|
||||||
if _room_cleanup_task:
|
|
||||||
_room_cleanup_task.cancel()
|
|
||||||
try:
|
|
||||||
await _room_cleanup_task
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
logger.info("Room cleanup task stopped")
|
|
||||||
|
|
||||||
if _leaderboard_refresh_task:
|
if _leaderboard_refresh_task:
|
||||||
_leaderboard_refresh_task.cancel()
|
_leaderboard_refresh_task.cancel()
|
||||||
try:
|
try:
|
||||||
@ -376,10 +225,6 @@ async def lifespan(app: FastAPI):
|
|||||||
else:
|
else:
|
||||||
logger.warning("POSTGRES_URL not configured - auth/admin/stats endpoints will not work")
|
logger.warning("POSTGRES_URL not configured - auth/admin/stats endpoints will not work")
|
||||||
|
|
||||||
# Bootstrap admin user if needed (for first-time setup with INVITE_ONLY)
|
|
||||||
if config.POSTGRES_URL and config.BOOTSTRAP_ADMIN_USERNAME and config.BOOTSTRAP_ADMIN_PASSWORD:
|
|
||||||
await _bootstrap_admin()
|
|
||||||
|
|
||||||
# Set up health check dependencies
|
# Set up health check dependencies
|
||||||
from routers.health import set_health_dependencies
|
from routers.health import set_health_dependencies
|
||||||
set_health_dependencies(
|
set_health_dependencies(
|
||||||
@ -388,26 +233,6 @@ async def lifespan(app: FastAPI):
|
|||||||
room_manager=room_manager,
|
room_manager=room_manager,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Mark any orphaned active games as abandoned (in-memory state lost on restart)
|
|
||||||
if _user_store:
|
|
||||||
try:
|
|
||||||
async with _user_store.pool.acquire() as conn:
|
|
||||||
result = await conn.execute(
|
|
||||||
"UPDATE games_v2 SET status = 'abandoned', completed_at = NOW() WHERE status = 'active'"
|
|
||||||
)
|
|
||||||
# PostgreSQL returns command tags like "UPDATE 3" — the last word is
|
|
||||||
# the affected row count. This is a documented protocol behavior.
|
|
||||||
count = int(result.split()[-1]) if result else 0
|
|
||||||
if count > 0:
|
|
||||||
logger.info(f"Marked {count} orphaned active game(s) as abandoned on startup")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to clean up orphaned games on startup: {e}")
|
|
||||||
|
|
||||||
# Start periodic room cleanup
|
|
||||||
global _room_cleanup_task
|
|
||||||
_room_cleanup_task = asyncio.create_task(_periodic_room_cleanup())
|
|
||||||
logger.info(f"Room cleanup task started (timeout={config.ROOM_IDLE_TIMEOUT_SECONDS}s)")
|
|
||||||
|
|
||||||
logger.info(f"Golf server started (environment={config.ENVIRONMENT})")
|
logger.info(f"Golf server started (environment={config.ENVIRONMENT})")
|
||||||
|
|
||||||
yield
|
yield
|
||||||
@ -432,7 +257,7 @@ async def _close_all_websockets():
|
|||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Golf Card Game",
|
title="Golf Card Game",
|
||||||
debug=config.DEBUG,
|
debug=config.DEBUG,
|
||||||
version="3.2.0",
|
version="2.0.1",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -616,8 +441,6 @@ async def reset_cpu_profiles():
|
|||||||
return {"status": "ok", "message": "All CPU profiles reset"}
|
return {"status": "ok", "message": "All CPU profiles reset"}
|
||||||
|
|
||||||
|
|
||||||
# Per-user game limit. Prevents a single account from creating dozens of rooms
|
|
||||||
# and exhausting server memory. 4 is generous — most people play 1 at a time.
|
|
||||||
MAX_CONCURRENT_GAMES = 4
|
MAX_CONCURRENT_GAMES = 4
|
||||||
|
|
||||||
|
|
||||||
@ -635,7 +458,7 @@ def count_user_games(user_id: str) -> int:
|
|||||||
async def websocket_endpoint(websocket: WebSocket):
|
async def websocket_endpoint(websocket: WebSocket):
|
||||||
await websocket.accept()
|
await websocket.accept()
|
||||||
|
|
||||||
# Extract token from query param for authentication
|
# Extract token from query param for optional authentication
|
||||||
token = websocket.query_params.get("token")
|
token = websocket.query_params.get("token")
|
||||||
authenticated_user = None
|
authenticated_user = None
|
||||||
if token and _auth_service:
|
if token and _auth_service:
|
||||||
@ -644,12 +467,6 @@ async def websocket_endpoint(websocket: WebSocket):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"WebSocket auth failed: {e}")
|
logger.debug(f"WebSocket auth failed: {e}")
|
||||||
|
|
||||||
# Reject unauthenticated connections when invite-only
|
|
||||||
if config.INVITE_ONLY and not authenticated_user:
|
|
||||||
await websocket.send_json({"type": "error", "message": "Authentication required. Please log in."})
|
|
||||||
await websocket.close(code=4001, reason="Authentication required")
|
|
||||||
return
|
|
||||||
|
|
||||||
connection_id = str(uuid.uuid4())
|
connection_id = str(uuid.uuid4())
|
||||||
auth_user_id = str(authenticated_user.id) if authenticated_user else None
|
auth_user_id = str(authenticated_user.id) if authenticated_user else None
|
||||||
|
|
||||||
@ -658,10 +475,6 @@ async def websocket_endpoint(websocket: WebSocket):
|
|||||||
else:
|
else:
|
||||||
logger.debug(f"WebSocket connected anonymously as {connection_id}")
|
logger.debug(f"WebSocket connected anonymously as {connection_id}")
|
||||||
|
|
||||||
# player_id = connection_id by design. Originally these were separate concepts
|
|
||||||
# (connection vs game identity), but in practice a player IS their connection.
|
|
||||||
# Reconnection creates a new connection_id, and the room layer handles the
|
|
||||||
# identity mapping. Keeping both fields lets handlers be explicit about intent.
|
|
||||||
ctx = ConnectionContext(
|
ctx = ConnectionContext(
|
||||||
websocket=websocket,
|
websocket=websocket,
|
||||||
connection_id=connection_id,
|
connection_id=connection_id,
|
||||||
@ -679,8 +492,6 @@ async def websocket_endpoint(websocket: WebSocket):
|
|||||||
check_and_run_cpu_turn=check_and_run_cpu_turn,
|
check_and_run_cpu_turn=check_and_run_cpu_turn,
|
||||||
handle_player_leave=handle_player_leave,
|
handle_player_leave=handle_player_leave,
|
||||||
cleanup_room_profiles=cleanup_room_profiles,
|
cleanup_room_profiles=cleanup_room_profiles,
|
||||||
matchmaking_service=_matchmaking_service,
|
|
||||||
rating_service=_rating_service,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -723,23 +534,6 @@ async def _process_stats_safe(room: Room):
|
|||||||
game_options=room.game.options,
|
game_options=room.game.options,
|
||||||
)
|
)
|
||||||
logger.debug(f"Stats processed for room {room.code}")
|
logger.debug(f"Stats processed for room {room.code}")
|
||||||
|
|
||||||
# Update Glicko-2 ratings for human players
|
|
||||||
if _rating_service:
|
|
||||||
player_results = []
|
|
||||||
for game_player in room.game.players:
|
|
||||||
if game_player.id in player_user_ids:
|
|
||||||
player_results.append((
|
|
||||||
player_user_ids[game_player.id],
|
|
||||||
game_player.total_score,
|
|
||||||
))
|
|
||||||
|
|
||||||
if len(player_results) >= 2:
|
|
||||||
await _rating_service.update_ratings(
|
|
||||||
player_results=player_results,
|
|
||||||
is_standard_rules=room.game.options.is_standard_rules(),
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to process game stats: {e}")
|
logger.error(f"Failed to process game stats: {e}")
|
||||||
|
|
||||||
@ -765,7 +559,7 @@ async def broadcast_game_state(room: Room):
|
|||||||
# Check for round over
|
# Check for round over
|
||||||
if room.game.phase == GamePhase.ROUND_OVER:
|
if room.game.phase == GamePhase.ROUND_OVER:
|
||||||
scores = [
|
scores = [
|
||||||
{"id": p.id, "name": p.name, "score": p.score, "total": p.total_score, "rounds_won": p.rounds_won}
|
{"name": p.name, "score": p.score, "total": p.total_score, "rounds_won": p.rounds_won}
|
||||||
for p in room.game.players
|
for p in room.game.players
|
||||||
]
|
]
|
||||||
# Build rankings
|
# Build rankings
|
||||||
@ -774,7 +568,6 @@ async def broadcast_game_state(room: Room):
|
|||||||
await player.websocket.send_json({
|
await player.websocket.send_json({
|
||||||
"type": "round_over",
|
"type": "round_over",
|
||||||
"scores": scores,
|
"scores": scores,
|
||||||
"finisher_id": room.game.finisher_id,
|
|
||||||
"round": room.game.current_round,
|
"round": room.game.current_round,
|
||||||
"total_rounds": room.game.num_rounds,
|
"total_rounds": room.game.num_rounds,
|
||||||
"rankings": {
|
"rankings": {
|
||||||
@ -819,13 +612,8 @@ async def broadcast_game_state(room: Room):
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
def check_and_run_cpu_turn(room: Room):
|
async def check_and_run_cpu_turn(room: Room):
|
||||||
"""Check if current player is CPU and start their turn as a background task.
|
"""Check if current player is CPU and run their turn."""
|
||||||
|
|
||||||
The CPU turn chain runs as a fire-and-forget asyncio.Task stored on
|
|
||||||
room.cpu_turn_task. This allows the WebSocket message loop to remain
|
|
||||||
responsive so that end_game/leave messages can cancel the task immediately.
|
|
||||||
"""
|
|
||||||
if room.game.phase not in (GamePhase.PLAYING, GamePhase.FINAL_TURN):
|
if room.game.phase not in (GamePhase.PLAYING, GamePhase.FINAL_TURN):
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -837,77 +625,25 @@ def check_and_run_cpu_turn(room: Room):
|
|||||||
if not room_player or not room_player.is_cpu:
|
if not room_player or not room_player.is_cpu:
|
||||||
return
|
return
|
||||||
|
|
||||||
task = asyncio.create_task(_run_cpu_chain(room))
|
# Brief pause before CPU starts - animations are faster now
|
||||||
room.cpu_turn_task = task
|
await asyncio.sleep(0.25)
|
||||||
|
|
||||||
def _on_done(t: asyncio.Task):
|
# Run CPU turn
|
||||||
# Clear the reference when the task finishes (success, cancel, or error)
|
async def broadcast_cb():
|
||||||
if room.cpu_turn_task is t:
|
await broadcast_game_state(room)
|
||||||
room.cpu_turn_task = None
|
|
||||||
if not t.cancelled() and t.exception():
|
|
||||||
logger.error(f"CPU turn task error in room {room.code}: {t.exception()}")
|
|
||||||
|
|
||||||
task.add_done_callback(_on_done)
|
await process_cpu_turn(room.game, current, broadcast_cb, game_id=room.game_log_id)
|
||||||
|
|
||||||
|
# Check if next player is also CPU (chain CPU turns)
|
||||||
async def _run_cpu_chain(room: Room):
|
await check_and_run_cpu_turn(room)
|
||||||
"""Run consecutive CPU turns until a human player's turn or game ends."""
|
|
||||||
while True:
|
|
||||||
if room.game.phase not in (GamePhase.PLAYING, GamePhase.FINAL_TURN):
|
|
||||||
return
|
|
||||||
|
|
||||||
current = room.game.current_player()
|
|
||||||
if not current:
|
|
||||||
return
|
|
||||||
|
|
||||||
room_player = room.get_player(current.id)
|
|
||||||
if not room_player or not room_player.is_cpu:
|
|
||||||
return
|
|
||||||
|
|
||||||
room.touch()
|
|
||||||
|
|
||||||
# Brief pause before CPU starts. Without this, the CPU's draw message arrives
|
|
||||||
# before the client has finished processing the previous turn's state update,
|
|
||||||
# and animations overlap. 0.25s is enough for the client to settle.
|
|
||||||
await asyncio.sleep(0.25)
|
|
||||||
|
|
||||||
# Run CPU turn
|
|
||||||
async def broadcast_cb():
|
|
||||||
await broadcast_game_state(room)
|
|
||||||
|
|
||||||
async def reveal_cb(player_id, position, card_data):
|
|
||||||
reveal_msg = {
|
|
||||||
"type": "card_revealed",
|
|
||||||
"player_id": player_id,
|
|
||||||
"position": position,
|
|
||||||
"card": card_data,
|
|
||||||
}
|
|
||||||
for pid, p in room.players.items():
|
|
||||||
if not p.is_cpu and p.websocket:
|
|
||||||
try:
|
|
||||||
await p.websocket.send_json(reveal_msg)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
await process_cpu_turn(room.game, current, broadcast_cb, game_id=room.game_log_id, reveal_callback=reveal_cb)
|
|
||||||
|
|
||||||
|
|
||||||
async def handle_player_leave(room: Room, player_id: str):
|
async def handle_player_leave(room: Room, player_id: str):
|
||||||
"""Handle a player leaving a room."""
|
"""Handle a player leaving a room."""
|
||||||
# Cancel any running CPU turn task before cleanup
|
|
||||||
if room.cpu_turn_task:
|
|
||||||
room.cpu_turn_task.cancel()
|
|
||||||
try:
|
|
||||||
await room.cpu_turn_task
|
|
||||||
except (asyncio.CancelledError, Exception):
|
|
||||||
pass
|
|
||||||
room.cpu_turn_task = None
|
|
||||||
|
|
||||||
room_code = room.code
|
room_code = room.code
|
||||||
room_player = room.remove_player(player_id)
|
room_player = room.remove_player(player_id)
|
||||||
|
|
||||||
# Check both is_empty() AND human_player_count() — CPU players keep rooms
|
# If no human players left, clean up the room entirely
|
||||||
# technically non-empty, but a room with only CPUs is an abandoned room.
|
|
||||||
if room.is_empty() or room.human_player_count() == 0:
|
if room.is_empty() or room.human_player_count() == 0:
|
||||||
# Remove all remaining CPU players to release their profiles
|
# Remove all remaining CPU players to release their profiles
|
||||||
for cpu in list(room.get_cpu_players()):
|
for cpu in list(room.get_cpu_players()):
|
||||||
@ -939,23 +675,8 @@ if os.path.exists(client_path):
|
|||||||
async def serve_replay_page(share_code: str):
|
async def serve_replay_page(share_code: str):
|
||||||
return FileResponse(os.path.join(client_path, "index.html"))
|
return FileResponse(os.path.join(client_path, "index.html"))
|
||||||
|
|
||||||
@app.get("/reset-password")
|
|
||||||
async def serve_reset_password_page():
|
|
||||||
return FileResponse(os.path.join(client_path, "index.html"))
|
|
||||||
|
|
||||||
# Mount static files for everything else (JS, CSS, SVG, etc.)
|
# Mount static files for everything else (JS, CSS, SVG, etc.)
|
||||||
# Wrap StaticFiles to reject WebSocket requests gracefully instead of
|
app.mount("/", StaticFiles(directory=client_path), name="static")
|
||||||
# crashing with AssertionError (starlette asserts scope["type"] == "http").
|
|
||||||
static_files = StaticFiles(directory=client_path)
|
|
||||||
|
|
||||||
async def safe_static_files(scope, receive, send):
|
|
||||||
if scope["type"] != "http":
|
|
||||||
if scope["type"] == "websocket":
|
|
||||||
await send({"type": "websocket.close", "code": 1000})
|
|
||||||
return
|
|
||||||
await static_files(scope, receive, send)
|
|
||||||
|
|
||||||
app.mount("/", safe_static_files, name="static")
|
|
||||||
|
|
||||||
|
|
||||||
def run():
|
def run():
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
"""
|
"""
|
||||||
Middleware components for Golf game server.
|
Middleware components for Golf game server.
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
"""
|
"""
|
||||||
Rate limiting middleware for FastAPI.
|
Rate limiting middleware for FastAPI.
|
||||||
|
|
||||||
@ -82,15 +81,11 @@ 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 (fail closed for auth endpoints)
|
# Check rate limit
|
||||||
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")
|
allowed, info = await self.limiter.is_allowed(full_key, limit, window)
|
||||||
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)
|
|
||||||
|
|
||||||
# Build response
|
# Build response
|
||||||
if allowed:
|
if allowed:
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
"""
|
"""
|
||||||
Request ID middleware for request tracing.
|
Request ID middleware for request tracing.
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
"""
|
"""
|
||||||
Security headers middleware for FastAPI.
|
Security headers middleware for FastAPI.
|
||||||
|
|
||||||
@ -111,10 +110,8 @@ 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,4 +1,3 @@
|
|||||||
# 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,4 +1,3 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
"""
|
"""
|
||||||
Event definitions for Golf game event sourcing.
|
Event definitions for Golf game event sourcing.
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
"""
|
"""
|
||||||
Game state rebuilder for event sourcing.
|
Game state rebuilder for event sourcing.
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
"""
|
"""
|
||||||
User-related models for Golf game authentication.
|
User-related models for Golf game authentication.
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
"""
|
"""
|
||||||
Room management for multiplayer Golf games.
|
Room management for multiplayer Golf games.
|
||||||
|
|
||||||
@ -15,7 +14,6 @@ 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
|
||||||
|
|
||||||
@ -71,12 +69,6 @@ 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,
|
||||||
@ -99,9 +91,6 @@ 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,
|
||||||
@ -177,9 +166,7 @@ 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. next(iter(...)) gives us the first value in
|
# Assign new host if needed
|
||||||
# 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
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
# 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,4 +1,3 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
"""
|
"""
|
||||||
Admin API router for Golf game V2.
|
Admin API router for Golf game V2.
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
"""
|
"""
|
||||||
Authentication API router for Golf game V2.
|
Authentication API router for Golf game V2.
|
||||||
|
|
||||||
@ -6,18 +5,14 @@ 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__)
|
||||||
|
|
||||||
@ -34,7 +29,6 @@ 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):
|
||||||
@ -117,8 +111,6 @@ 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:
|
||||||
@ -127,18 +119,6 @@ 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:
|
||||||
@ -221,51 +201,6 @@ 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,
|
||||||
@ -275,19 +210,8 @@ 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": "",
|
||||||
@ -300,7 +224,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=client_ip,
|
ip_address=get_client_ip(request),
|
||||||
)
|
)
|
||||||
|
|
||||||
if not login_result.success:
|
if not login_result.success:
|
||||||
@ -313,32 +237,6 @@ 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,4 +1,3 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
"""
|
"""
|
||||||
Health check endpoints for production deployment.
|
Health check endpoints for production deployment.
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
"""
|
"""
|
||||||
Replay API router for Golf game.
|
Replay API router for Golf game.
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
"""
|
"""
|
||||||
Stats and Leaderboards API router for Golf game.
|
Stats and Leaderboards API router for Golf game.
|
||||||
|
|
||||||
@ -156,7 +155,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|rating)$"),
|
metric: str = Query("wins", pattern="^(wins|win_rate|avg_score|knockouts|streak)$"),
|
||||||
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),
|
||||||
@ -227,7 +226,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|rating)$"),
|
metric: str = Query("wins", pattern="^(wins|win_rate|avg_score|knockouts|streak)$"),
|
||||||
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."""
|
||||||
@ -347,7 +346,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|rating)$"),
|
metric: str = Query("wins", pattern="^(wins|win_rate|avg_score|knockouts|streak)$"),
|
||||||
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,4 +1,3 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
"""
|
"""
|
||||||
Score distribution analysis for Golf AI.
|
Score distribution analysis for Golf AI.
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
#!/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,4 +1,3 @@
|
|||||||
# 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
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
"""
|
"""
|
||||||
Admin service for Golf game.
|
Admin service for Golf game.
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
"""
|
"""
|
||||||
Authentication service for Golf game.
|
Authentication service for Golf game.
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
"""
|
"""
|
||||||
Email service for Golf game authentication.
|
Email service for Golf game authentication.
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
"""
|
"""
|
||||||
PostgreSQL-backed game logging for AI decision analysis.
|
PostgreSQL-backed game logging for AI decision analysis.
|
||||||
|
|
||||||
|
|||||||
@ -1,394 +0,0 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
"""
|
|
||||||
Matchmaking service for public skill-based games.
|
|
||||||
|
|
||||||
Uses Redis sorted sets to maintain a queue of players looking for games,
|
|
||||||
grouped by rating. A background task periodically scans the queue and
|
|
||||||
creates matches when enough similar-skill players are available.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from fastapi import WebSocket
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class QueuedPlayer:
|
|
||||||
"""A player waiting in the matchmaking queue."""
|
|
||||||
user_id: str
|
|
||||||
username: str
|
|
||||||
rating: float
|
|
||||||
queued_at: float # time.time()
|
|
||||||
connection_id: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class MatchmakingConfig:
|
|
||||||
"""Configuration for the matchmaking system."""
|
|
||||||
enabled: bool = True
|
|
||||||
min_players: int = 2
|
|
||||||
max_players: int = 4
|
|
||||||
initial_rating_window: int = 100 # +/- rating range to start
|
|
||||||
expand_interval: int = 15 # seconds between range expansions
|
|
||||||
expand_amount: int = 50 # rating points to expand by
|
|
||||||
max_rating_window: int = 500 # maximum +/- range
|
|
||||||
match_check_interval: float = 3.0 # seconds between match attempts
|
|
||||||
countdown_seconds: int = 5 # countdown before matched game starts
|
|
||||||
|
|
||||||
|
|
||||||
class MatchmakingService:
|
|
||||||
"""
|
|
||||||
Manages the matchmaking queue and creates matches.
|
|
||||||
|
|
||||||
Players join the queue with their rating. A background task
|
|
||||||
periodically scans for groups of similarly-rated players and
|
|
||||||
creates games when matches are found.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, redis_client, config: Optional[MatchmakingConfig] = None):
|
|
||||||
self.redis = redis_client
|
|
||||||
self.config = config or MatchmakingConfig()
|
|
||||||
self._queue: dict[str, QueuedPlayer] = {} # user_id -> QueuedPlayer
|
|
||||||
self._websockets: dict[str, WebSocket] = {} # user_id -> WebSocket
|
|
||||||
self._connection_ids: dict[str, str] = {} # user_id -> connection_id
|
|
||||||
self._running = False
|
|
||||||
self._task: Optional[asyncio.Task] = None
|
|
||||||
|
|
||||||
async def join_queue(
|
|
||||||
self,
|
|
||||||
user_id: str,
|
|
||||||
username: str,
|
|
||||||
rating: float,
|
|
||||||
websocket: WebSocket,
|
|
||||||
connection_id: str,
|
|
||||||
) -> dict:
|
|
||||||
"""
|
|
||||||
Add a player to the matchmaking queue.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Queue status dict.
|
|
||||||
"""
|
|
||||||
if user_id in self._queue:
|
|
||||||
return {"position": self._get_position(user_id), "queue_size": len(self._queue)}
|
|
||||||
|
|
||||||
player = QueuedPlayer(
|
|
||||||
user_id=user_id,
|
|
||||||
username=username,
|
|
||||||
rating=rating,
|
|
||||||
queued_at=time.time(),
|
|
||||||
connection_id=connection_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
self._queue[user_id] = player
|
|
||||||
self._websockets[user_id] = websocket
|
|
||||||
self._connection_ids[user_id] = connection_id
|
|
||||||
|
|
||||||
# Also add to Redis for persistence across restarts
|
|
||||||
if self.redis:
|
|
||||||
try:
|
|
||||||
await self.redis.zadd("matchmaking:queue", {user_id: rating})
|
|
||||||
await self.redis.hset(
|
|
||||||
"matchmaking:players",
|
|
||||||
user_id,
|
|
||||||
json.dumps({
|
|
||||||
"username": username,
|
|
||||||
"rating": rating,
|
|
||||||
"queued_at": player.queued_at,
|
|
||||||
"connection_id": connection_id,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Redis matchmaking write failed: {e}")
|
|
||||||
|
|
||||||
position = self._get_position(user_id)
|
|
||||||
logger.info(f"Player {username} ({user_id[:8]}) joined queue (rating={rating:.0f}, pos={position})")
|
|
||||||
|
|
||||||
return {"position": position, "queue_size": len(self._queue)}
|
|
||||||
|
|
||||||
async def leave_queue(self, user_id: str) -> bool:
|
|
||||||
"""Remove a player from the matchmaking queue."""
|
|
||||||
if user_id not in self._queue:
|
|
||||||
return False
|
|
||||||
|
|
||||||
player = self._queue.pop(user_id, None)
|
|
||||||
self._websockets.pop(user_id, None)
|
|
||||||
self._connection_ids.pop(user_id, None)
|
|
||||||
|
|
||||||
if self.redis:
|
|
||||||
try:
|
|
||||||
await self.redis.zrem("matchmaking:queue", user_id)
|
|
||||||
await self.redis.hdel("matchmaking:players", user_id)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Redis matchmaking remove failed: {e}")
|
|
||||||
|
|
||||||
if player:
|
|
||||||
logger.info(f"Player {player.username} ({user_id[:8]}) left queue")
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
async def get_queue_status(self, user_id: str) -> dict:
|
|
||||||
"""Get current queue status for a player."""
|
|
||||||
if user_id not in self._queue:
|
|
||||||
return {"in_queue": False}
|
|
||||||
|
|
||||||
player = self._queue[user_id]
|
|
||||||
wait_time = time.time() - player.queued_at
|
|
||||||
current_window = self._get_rating_window(wait_time)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"in_queue": True,
|
|
||||||
"position": self._get_position(user_id),
|
|
||||||
"queue_size": len(self._queue),
|
|
||||||
"wait_time": int(wait_time),
|
|
||||||
"rating_window": current_window,
|
|
||||||
}
|
|
||||||
|
|
||||||
async def find_matches(self, room_manager, broadcast_game_state_fn) -> list[dict]:
|
|
||||||
"""
|
|
||||||
Scan the queue and create matches.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of match info dicts for matches created.
|
|
||||||
"""
|
|
||||||
if len(self._queue) < self.config.min_players:
|
|
||||||
return []
|
|
||||||
|
|
||||||
matches_created = []
|
|
||||||
matched_user_ids = set()
|
|
||||||
|
|
||||||
# Sort players by rating
|
|
||||||
sorted_players = sorted(self._queue.values(), key=lambda p: p.rating)
|
|
||||||
|
|
||||||
for player in sorted_players:
|
|
||||||
if player.user_id in matched_user_ids:
|
|
||||||
continue
|
|
||||||
|
|
||||||
wait_time = time.time() - player.queued_at
|
|
||||||
window = self._get_rating_window(wait_time)
|
|
||||||
|
|
||||||
# Find compatible players
|
|
||||||
candidates = []
|
|
||||||
for other in sorted_players:
|
|
||||||
if other.user_id == player.user_id or other.user_id in matched_user_ids:
|
|
||||||
continue
|
|
||||||
if abs(other.rating - player.rating) <= window:
|
|
||||||
candidates.append(other)
|
|
||||||
|
|
||||||
# Include the player themselves
|
|
||||||
group = [player] + candidates
|
|
||||||
|
|
||||||
if len(group) >= self.config.min_players:
|
|
||||||
# Take up to max_players
|
|
||||||
match_group = group[:self.config.max_players]
|
|
||||||
matched_user_ids.update(p.user_id for p in match_group)
|
|
||||||
|
|
||||||
# Create the match
|
|
||||||
match_info = await self._create_match(match_group, room_manager)
|
|
||||||
if match_info:
|
|
||||||
matches_created.append(match_info)
|
|
||||||
|
|
||||||
return matches_created
|
|
||||||
|
|
||||||
async def _create_match(self, players: list[QueuedPlayer], room_manager) -> Optional[dict]:
|
|
||||||
"""
|
|
||||||
Create a room for matched players and notify them.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Match info dict, or None if creation failed.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Create room
|
|
||||||
room = room_manager.create_room()
|
|
||||||
|
|
||||||
# Add all matched players to the room
|
|
||||||
for player in players:
|
|
||||||
ws = self._websockets.get(player.user_id)
|
|
||||||
if not ws:
|
|
||||||
continue
|
|
||||||
|
|
||||||
room.add_player(
|
|
||||||
player.connection_id,
|
|
||||||
player.username,
|
|
||||||
ws,
|
|
||||||
player.user_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Remove matched players from queue
|
|
||||||
for player in players:
|
|
||||||
await self.leave_queue(player.user_id)
|
|
||||||
|
|
||||||
# Notify all matched players
|
|
||||||
match_info = {
|
|
||||||
"room_code": room.code,
|
|
||||||
"players": [
|
|
||||||
{"username": p.username, "rating": round(p.rating)}
|
|
||||||
for p in players
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
for player in players:
|
|
||||||
ws = self._websockets.get(player.user_id)
|
|
||||||
if ws:
|
|
||||||
try:
|
|
||||||
await ws.send_json({
|
|
||||||
"type": "queue_matched",
|
|
||||||
"room_code": room.code,
|
|
||||||
"players": match_info["players"],
|
|
||||||
"countdown": self.config.countdown_seconds,
|
|
||||||
})
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to notify matched player {player.user_id[:8]}: {e}")
|
|
||||||
|
|
||||||
# Also send room_joined to each player so the client switches screens
|
|
||||||
for player in players:
|
|
||||||
ws = self._websockets.get(player.user_id)
|
|
||||||
if ws:
|
|
||||||
try:
|
|
||||||
await ws.send_json({
|
|
||||||
"type": "room_joined",
|
|
||||||
"room_code": room.code,
|
|
||||||
"player_id": player.connection_id,
|
|
||||||
"authenticated": True,
|
|
||||||
})
|
|
||||||
# Send player list
|
|
||||||
await ws.send_json({
|
|
||||||
"type": "player_joined",
|
|
||||||
"players": room.player_list(),
|
|
||||||
})
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
avg_rating = sum(p.rating for p in players) / len(players)
|
|
||||||
logger.info(
|
|
||||||
f"Match created: room={room.code}, "
|
|
||||||
f"players={[p.username for p in players]}, "
|
|
||||||
f"avg_rating={avg_rating:.0f}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Schedule auto-start after countdown
|
|
||||||
asyncio.create_task(self._auto_start_game(room, self.config.countdown_seconds))
|
|
||||||
|
|
||||||
return match_info
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to create match: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def _auto_start_game(self, room, countdown: int):
|
|
||||||
"""Auto-start a matched game after countdown."""
|
|
||||||
from game import GamePhase, GameOptions
|
|
||||||
|
|
||||||
await asyncio.sleep(countdown)
|
|
||||||
|
|
||||||
if room.game.phase != GamePhase.WAITING:
|
|
||||||
return # Game already started or room closed
|
|
||||||
|
|
||||||
if len(room.players) < 2:
|
|
||||||
return # Not enough players
|
|
||||||
|
|
||||||
# Standard rules for ranked games
|
|
||||||
options = GameOptions()
|
|
||||||
options.flip_mode = "never"
|
|
||||||
options.initial_flips = 2
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with room.game_lock:
|
|
||||||
room.game.start_game(1, 9, options) # 1 deck, 9 rounds, standard rules
|
|
||||||
|
|
||||||
# Send game started to all players
|
|
||||||
for pid, rp in room.players.items():
|
|
||||||
if rp.websocket and not rp.is_cpu:
|
|
||||||
try:
|
|
||||||
state = room.game.get_state(pid)
|
|
||||||
await rp.websocket.send_json({
|
|
||||||
"type": "game_started",
|
|
||||||
"game_state": state,
|
|
||||||
})
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
logger.info(f"Auto-started matched game in room {room.code}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to auto-start matched game: {e}")
|
|
||||||
|
|
||||||
def _get_rating_window(self, wait_time: float) -> int:
|
|
||||||
"""Calculate the current rating window based on wait time."""
|
|
||||||
expansions = int(wait_time / self.config.expand_interval)
|
|
||||||
window = self.config.initial_rating_window + (expansions * self.config.expand_amount)
|
|
||||||
return min(window, self.config.max_rating_window)
|
|
||||||
|
|
||||||
def _get_position(self, user_id: str) -> int:
|
|
||||||
"""Get a player's position in the queue (1-indexed)."""
|
|
||||||
sorted_ids = sorted(
|
|
||||||
self._queue.keys(),
|
|
||||||
key=lambda uid: self._queue[uid].queued_at,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
return sorted_ids.index(user_id) + 1
|
|
||||||
except ValueError:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
async def start(self, room_manager, broadcast_fn):
|
|
||||||
"""Start the matchmaking background task."""
|
|
||||||
if self._running:
|
|
||||||
return
|
|
||||||
|
|
||||||
self._running = True
|
|
||||||
self._task = asyncio.create_task(
|
|
||||||
self._matchmaking_loop(room_manager, broadcast_fn)
|
|
||||||
)
|
|
||||||
logger.info("Matchmaking service started")
|
|
||||||
|
|
||||||
async def stop(self):
|
|
||||||
"""Stop the matchmaking background task."""
|
|
||||||
self._running = False
|
|
||||||
if self._task:
|
|
||||||
self._task.cancel()
|
|
||||||
try:
|
|
||||||
await self._task
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
logger.info("Matchmaking service stopped")
|
|
||||||
|
|
||||||
async def _matchmaking_loop(self, room_manager, broadcast_fn):
|
|
||||||
"""Background task that periodically checks for matches."""
|
|
||||||
while self._running:
|
|
||||||
try:
|
|
||||||
matches = await self.find_matches(room_manager, broadcast_fn)
|
|
||||||
if matches:
|
|
||||||
logger.info(f"Created {len(matches)} match(es)")
|
|
||||||
|
|
||||||
# Send queue status updates to all queued players
|
|
||||||
for user_id in list(self._queue.keys()):
|
|
||||||
ws = self._websockets.get(user_id)
|
|
||||||
if ws:
|
|
||||||
try:
|
|
||||||
status = await self.get_queue_status(user_id)
|
|
||||||
await ws.send_json({
|
|
||||||
"type": "queue_status",
|
|
||||||
**status,
|
|
||||||
})
|
|
||||||
except Exception:
|
|
||||||
# Player disconnected, remove from queue
|
|
||||||
await self.leave_queue(user_id)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Matchmaking error: {e}")
|
|
||||||
|
|
||||||
await asyncio.sleep(self.config.match_check_interval)
|
|
||||||
|
|
||||||
async def cleanup(self):
|
|
||||||
"""Clean up Redis queue data on shutdown."""
|
|
||||||
if self.redis:
|
|
||||||
try:
|
|
||||||
await self.redis.delete("matchmaking:queue")
|
|
||||||
await self.redis.delete("matchmaking:players")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
@ -1,4 +1,3 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
"""
|
"""
|
||||||
Redis-based rate limiter service.
|
Redis-based rate limiter service.
|
||||||
|
|
||||||
@ -92,42 +91,9 @@ class RateLimiter:
|
|||||||
|
|
||||||
except redis.RedisError as e:
|
except redis.RedisError as e:
|
||||||
# If Redis is unavailable, fail open (allow request)
|
# If Redis is unavailable, fail open (allow request)
|
||||||
# For auth-critical paths, callers should use fail_closed=True
|
|
||||||
logger.error(f"Rate limiter Redis error: {e}")
|
logger.error(f"Rate limiter Redis error: {e}")
|
||||||
return True, {"remaining": limit, "reset": window_seconds, "limit": limit}
|
return True, {"remaining": limit, "reset": window_seconds, "limit": limit}
|
||||||
|
|
||||||
async def is_allowed_strict(
|
|
||||||
self,
|
|
||||||
key: str,
|
|
||||||
limit: int,
|
|
||||||
window_seconds: int,
|
|
||||||
) -> tuple[bool, dict]:
|
|
||||||
"""
|
|
||||||
Like is_allowed but fails closed (denies) when Redis is unavailable.
|
|
||||||
Use for security-critical paths like auth endpoints.
|
|
||||||
"""
|
|
||||||
now = int(time.time())
|
|
||||||
window_key = f"ratelimit:{key}:{now // window_seconds}"
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with self.redis.pipeline(transaction=True) as pipe:
|
|
||||||
pipe.incr(window_key)
|
|
||||||
pipe.expire(window_key, window_seconds + 1)
|
|
||||||
results = await pipe.execute()
|
|
||||||
|
|
||||||
current_count = results[0]
|
|
||||||
remaining = max(0, limit - current_count)
|
|
||||||
reset = window_seconds - (now % window_seconds)
|
|
||||||
|
|
||||||
return current_count <= limit, {
|
|
||||||
"remaining": remaining,
|
|
||||||
"reset": reset,
|
|
||||||
"limit": limit,
|
|
||||||
}
|
|
||||||
except redis.RedisError as e:
|
|
||||||
logger.error(f"Rate limiter Redis error (fail-closed): {e}")
|
|
||||||
return False, {"remaining": 0, "reset": window_seconds, "limit": limit}
|
|
||||||
|
|
||||||
def get_client_key(
|
def get_client_key(
|
||||||
self,
|
self,
|
||||||
request: Request | WebSocket,
|
request: Request | WebSocket,
|
||||||
@ -231,110 +197,8 @@ class ConnectionMessageLimiter:
|
|||||||
self.timestamps = []
|
self.timestamps = []
|
||||||
|
|
||||||
|
|
||||||
class SignupLimiter:
|
|
||||||
"""
|
|
||||||
Daily signup metering for public beta.
|
|
||||||
|
|
||||||
Tracks two counters in Redis:
|
|
||||||
- Global daily open signups (no invite code)
|
|
||||||
- Per-IP daily signups (with or without invite code)
|
|
||||||
|
|
||||||
Keys auto-expire after 24 hours.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, redis_client: redis.Redis):
|
|
||||||
self.redis = redis_client
|
|
||||||
|
|
||||||
def _today_key(self, prefix: str) -> str:
|
|
||||||
"""Generate a Redis key scoped to today's date (UTC)."""
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
||||||
return f"signup:{prefix}:{today}"
|
|
||||||
|
|
||||||
async def check_daily_limit(self, daily_limit: int) -> tuple[bool, int]:
|
|
||||||
"""
|
|
||||||
Check if global daily open signup limit allows another registration.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
daily_limit: Max open signups per day. -1 = unlimited, 0 = disabled.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (allowed, remaining). remaining is -1 when unlimited.
|
|
||||||
"""
|
|
||||||
if daily_limit == 0:
|
|
||||||
return False, 0
|
|
||||||
if daily_limit < 0:
|
|
||||||
return True, -1
|
|
||||||
|
|
||||||
key = self._today_key("daily_open")
|
|
||||||
try:
|
|
||||||
count = await self.redis.get(key)
|
|
||||||
current = int(count) if count else 0
|
|
||||||
remaining = max(0, daily_limit - current)
|
|
||||||
return current < daily_limit, remaining
|
|
||||||
except redis.RedisError as e:
|
|
||||||
logger.error(f"Signup limiter Redis error (daily check): {e}")
|
|
||||||
return False, 0 # Fail closed
|
|
||||||
|
|
||||||
async def check_ip_limit(self, ip_hash: str, ip_limit: int) -> tuple[bool, int]:
|
|
||||||
"""
|
|
||||||
Check if per-IP daily signup limit allows another registration.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
ip_hash: Hashed client IP.
|
|
||||||
ip_limit: Max signups per IP per day. 0 = unlimited.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (allowed, remaining).
|
|
||||||
"""
|
|
||||||
if ip_limit <= 0:
|
|
||||||
return True, -1
|
|
||||||
|
|
||||||
key = self._today_key(f"ip:{ip_hash}")
|
|
||||||
try:
|
|
||||||
count = await self.redis.get(key)
|
|
||||||
current = int(count) if count else 0
|
|
||||||
remaining = max(0, ip_limit - current)
|
|
||||||
return current < ip_limit, remaining
|
|
||||||
except redis.RedisError as e:
|
|
||||||
logger.error(f"Signup limiter Redis error (IP check): {e}")
|
|
||||||
return False, 0 # Fail closed
|
|
||||||
|
|
||||||
async def increment_daily(self) -> None:
|
|
||||||
"""Increment the global daily open signup counter."""
|
|
||||||
key = self._today_key("daily_open")
|
|
||||||
try:
|
|
||||||
async with self.redis.pipeline(transaction=True) as pipe:
|
|
||||||
pipe.incr(key)
|
|
||||||
pipe.expire(key, 86400 + 60) # 24h + 1min buffer
|
|
||||||
await pipe.execute()
|
|
||||||
except redis.RedisError as e:
|
|
||||||
logger.error(f"Signup limiter Redis error (daily incr): {e}")
|
|
||||||
|
|
||||||
async def increment_ip(self, ip_hash: str) -> None:
|
|
||||||
"""Increment the per-IP daily signup counter."""
|
|
||||||
key = self._today_key(f"ip:{ip_hash}")
|
|
||||||
try:
|
|
||||||
async with self.redis.pipeline(transaction=True) as pipe:
|
|
||||||
pipe.incr(key)
|
|
||||||
pipe.expire(key, 86400 + 60)
|
|
||||||
await pipe.execute()
|
|
||||||
except redis.RedisError as e:
|
|
||||||
logger.error(f"Signup limiter Redis error (IP incr): {e}")
|
|
||||||
|
|
||||||
async def get_daily_count(self) -> int:
|
|
||||||
"""Get current daily open signup count."""
|
|
||||||
key = self._today_key("daily_open")
|
|
||||||
try:
|
|
||||||
count = await self.redis.get(key)
|
|
||||||
return int(count) if count else 0
|
|
||||||
except redis.RedisError:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
# Global rate limiter instance
|
# Global rate limiter instance
|
||||||
_rate_limiter: Optional[RateLimiter] = None
|
_rate_limiter: Optional[RateLimiter] = None
|
||||||
_signup_limiter: Optional[SignupLimiter] = None
|
|
||||||
|
|
||||||
|
|
||||||
async def get_rate_limiter(redis_client: redis.Redis) -> RateLimiter:
|
async def get_rate_limiter(redis_client: redis.Redis) -> RateLimiter:
|
||||||
@ -353,16 +217,7 @@ async def get_rate_limiter(redis_client: redis.Redis) -> RateLimiter:
|
|||||||
return _rate_limiter
|
return _rate_limiter
|
||||||
|
|
||||||
|
|
||||||
async def get_signup_limiter(redis_client: redis.Redis) -> SignupLimiter:
|
|
||||||
"""Get or create the global signup limiter instance."""
|
|
||||||
global _signup_limiter
|
|
||||||
if _signup_limiter is None:
|
|
||||||
_signup_limiter = SignupLimiter(redis_client)
|
|
||||||
return _signup_limiter
|
|
||||||
|
|
||||||
|
|
||||||
def close_rate_limiter():
|
def close_rate_limiter():
|
||||||
"""Close the global rate limiter."""
|
"""Close the global rate limiter."""
|
||||||
global _rate_limiter, _signup_limiter
|
global _rate_limiter
|
||||||
_rate_limiter = None
|
_rate_limiter = None
|
||||||
_signup_limiter = None
|
|
||||||
|
|||||||
@ -1,323 +0,0 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
"""
|
|
||||||
Glicko-2 rating service for Golf game matchmaking.
|
|
||||||
|
|
||||||
Implements the Glicko-2 rating system adapted for multiplayer games.
|
|
||||||
Each game is treated as a set of pairwise comparisons between all players.
|
|
||||||
|
|
||||||
Reference: http://www.glicko.net/glicko/glicko2.pdf
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import math
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
import asyncpg
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Glicko-2 constants
|
|
||||||
INITIAL_RATING = 1500.0
|
|
||||||
INITIAL_RD = 350.0
|
|
||||||
INITIAL_VOLATILITY = 0.06
|
|
||||||
TAU = 0.5 # System constant (constrains volatility change)
|
|
||||||
CONVERGENCE_TOLERANCE = 0.000001
|
|
||||||
GLICKO2_SCALE = 173.7178 # Factor to convert between Glicko and Glicko-2 scales
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class PlayerRating:
|
|
||||||
"""A player's Glicko-2 rating."""
|
|
||||||
user_id: str
|
|
||||||
rating: float = INITIAL_RATING
|
|
||||||
rd: float = INITIAL_RD
|
|
||||||
volatility: float = INITIAL_VOLATILITY
|
|
||||||
updated_at: Optional[datetime] = None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def mu(self) -> float:
|
|
||||||
"""Convert rating to Glicko-2 scale."""
|
|
||||||
return (self.rating - 1500) / GLICKO2_SCALE
|
|
||||||
|
|
||||||
@property
|
|
||||||
def phi(self) -> float:
|
|
||||||
"""Convert RD to Glicko-2 scale."""
|
|
||||||
return self.rd / GLICKO2_SCALE
|
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
|
||||||
return {
|
|
||||||
"rating": round(self.rating, 1),
|
|
||||||
"rd": round(self.rd, 1),
|
|
||||||
"volatility": round(self.volatility, 6),
|
|
||||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _g(phi: float) -> float:
|
|
||||||
"""Glicko-2 g function."""
|
|
||||||
return 1.0 / math.sqrt(1.0 + 3.0 * phi * phi / (math.pi * math.pi))
|
|
||||||
|
|
||||||
|
|
||||||
def _E(mu: float, mu_j: float, phi_j: float) -> float:
|
|
||||||
"""Glicko-2 expected score."""
|
|
||||||
return 1.0 / (1.0 + math.exp(-_g(phi_j) * (mu - mu_j)))
|
|
||||||
|
|
||||||
|
|
||||||
def _compute_variance(mu: float, opponents: list[tuple[float, float]]) -> float:
|
|
||||||
"""
|
|
||||||
Compute the estimated variance of the player's rating
|
|
||||||
based on game outcomes.
|
|
||||||
|
|
||||||
opponents: list of (mu_j, phi_j) tuples
|
|
||||||
"""
|
|
||||||
v_inv = 0.0
|
|
||||||
for mu_j, phi_j in opponents:
|
|
||||||
g_phi = _g(phi_j)
|
|
||||||
e = _E(mu, mu_j, phi_j)
|
|
||||||
v_inv += g_phi * g_phi * e * (1.0 - e)
|
|
||||||
if v_inv == 0:
|
|
||||||
return float('inf')
|
|
||||||
return 1.0 / v_inv
|
|
||||||
|
|
||||||
|
|
||||||
def _compute_delta(mu: float, opponents: list[tuple[float, float, float]], v: float) -> float:
|
|
||||||
"""
|
|
||||||
Compute the estimated improvement in rating.
|
|
||||||
|
|
||||||
opponents: list of (mu_j, phi_j, score) tuples
|
|
||||||
"""
|
|
||||||
total = 0.0
|
|
||||||
for mu_j, phi_j, score in opponents:
|
|
||||||
total += _g(phi_j) * (score - _E(mu, mu_j, phi_j))
|
|
||||||
return v * total
|
|
||||||
|
|
||||||
|
|
||||||
def _new_volatility(sigma: float, phi: float, v: float, delta: float) -> float:
|
|
||||||
"""Compute new volatility using the Illinois algorithm (Glicko-2 Step 5)."""
|
|
||||||
a = math.log(sigma * sigma)
|
|
||||||
delta_sq = delta * delta
|
|
||||||
phi_sq = phi * phi
|
|
||||||
|
|
||||||
def f(x):
|
|
||||||
ex = math.exp(x)
|
|
||||||
num1 = ex * (delta_sq - phi_sq - v - ex)
|
|
||||||
denom1 = 2.0 * (phi_sq + v + ex) ** 2
|
|
||||||
return num1 / denom1 - (x - a) / (TAU * TAU)
|
|
||||||
|
|
||||||
# Set initial bounds
|
|
||||||
A = a
|
|
||||||
if delta_sq > phi_sq + v:
|
|
||||||
B = math.log(delta_sq - phi_sq - v)
|
|
||||||
else:
|
|
||||||
k = 1
|
|
||||||
while f(a - k * TAU) < 0:
|
|
||||||
k += 1
|
|
||||||
B = a - k * TAU
|
|
||||||
|
|
||||||
# Illinois algorithm
|
|
||||||
f_A = f(A)
|
|
||||||
f_B = f(B)
|
|
||||||
|
|
||||||
for _ in range(100): # Safety limit
|
|
||||||
if abs(B - A) < CONVERGENCE_TOLERANCE:
|
|
||||||
break
|
|
||||||
C = A + (A - B) * f_A / (f_B - f_A)
|
|
||||||
f_C = f(C)
|
|
||||||
|
|
||||||
if f_C * f_B <= 0:
|
|
||||||
A = B
|
|
||||||
f_A = f_B
|
|
||||||
else:
|
|
||||||
f_A /= 2.0
|
|
||||||
|
|
||||||
B = C
|
|
||||||
f_B = f_C
|
|
||||||
|
|
||||||
return math.exp(A / 2.0)
|
|
||||||
|
|
||||||
|
|
||||||
def update_rating(player: PlayerRating, opponents: list[tuple[float, float, float]]) -> PlayerRating:
|
|
||||||
"""
|
|
||||||
Update a single player's rating based on game results.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
player: Current player rating.
|
|
||||||
opponents: List of (mu_j, phi_j, score) where score is 1.0 (win), 0.5 (draw), 0.0 (loss).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Updated PlayerRating.
|
|
||||||
"""
|
|
||||||
if not opponents:
|
|
||||||
# No opponents - just increase RD for inactivity
|
|
||||||
new_phi = math.sqrt(player.phi ** 2 + player.volatility ** 2)
|
|
||||||
return PlayerRating(
|
|
||||||
user_id=player.user_id,
|
|
||||||
rating=player.rating,
|
|
||||||
rd=min(new_phi * GLICKO2_SCALE, INITIAL_RD),
|
|
||||||
volatility=player.volatility,
|
|
||||||
updated_at=datetime.now(timezone.utc),
|
|
||||||
)
|
|
||||||
|
|
||||||
mu = player.mu
|
|
||||||
phi = player.phi
|
|
||||||
sigma = player.volatility
|
|
||||||
|
|
||||||
opp_pairs = [(mu_j, phi_j) for mu_j, phi_j, _ in opponents]
|
|
||||||
|
|
||||||
v = _compute_variance(mu, opp_pairs)
|
|
||||||
delta = _compute_delta(mu, opponents, v)
|
|
||||||
|
|
||||||
# New volatility
|
|
||||||
new_sigma = _new_volatility(sigma, phi, v, delta)
|
|
||||||
|
|
||||||
# Update phi (pre-rating)
|
|
||||||
phi_star = math.sqrt(phi ** 2 + new_sigma ** 2)
|
|
||||||
|
|
||||||
# New phi
|
|
||||||
new_phi = 1.0 / math.sqrt(1.0 / (phi_star ** 2) + 1.0 / v)
|
|
||||||
|
|
||||||
# New mu
|
|
||||||
improvement = 0.0
|
|
||||||
for mu_j, phi_j, score in opponents:
|
|
||||||
improvement += _g(phi_j) * (score - _E(mu, mu_j, phi_j))
|
|
||||||
new_mu = mu + new_phi ** 2 * improvement
|
|
||||||
|
|
||||||
# Convert back to Glicko scale
|
|
||||||
new_rating = new_mu * GLICKO2_SCALE + 1500
|
|
||||||
new_rd = new_phi * GLICKO2_SCALE
|
|
||||||
|
|
||||||
# Clamp RD to reasonable range
|
|
||||||
new_rd = max(30.0, min(new_rd, INITIAL_RD))
|
|
||||||
|
|
||||||
return PlayerRating(
|
|
||||||
user_id=player.user_id,
|
|
||||||
rating=max(100.0, new_rating), # Floor at 100
|
|
||||||
rd=new_rd,
|
|
||||||
volatility=new_sigma,
|
|
||||||
updated_at=datetime.now(timezone.utc),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class RatingService:
|
|
||||||
"""
|
|
||||||
Manages Glicko-2 ratings for players.
|
|
||||||
|
|
||||||
Ratings are only updated for standard-rules games.
|
|
||||||
Multiplayer games are decomposed into pairwise comparisons.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, pool: asyncpg.Pool):
|
|
||||||
self.pool = pool
|
|
||||||
|
|
||||||
async def get_rating(self, user_id: str) -> PlayerRating:
|
|
||||||
"""Get a player's current rating."""
|
|
||||||
async with self.pool.acquire() as conn:
|
|
||||||
row = await conn.fetchrow(
|
|
||||||
"""
|
|
||||||
SELECT rating, rating_deviation, rating_volatility, rating_updated_at
|
|
||||||
FROM player_stats
|
|
||||||
WHERE user_id = $1
|
|
||||||
""",
|
|
||||||
user_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not row or row["rating"] is None:
|
|
||||||
return PlayerRating(user_id=user_id)
|
|
||||||
|
|
||||||
return PlayerRating(
|
|
||||||
user_id=user_id,
|
|
||||||
rating=float(row["rating"]),
|
|
||||||
rd=float(row["rating_deviation"]),
|
|
||||||
volatility=float(row["rating_volatility"]),
|
|
||||||
updated_at=row["rating_updated_at"],
|
|
||||||
)
|
|
||||||
|
|
||||||
async def get_ratings_batch(self, user_ids: list[str]) -> dict[str, PlayerRating]:
|
|
||||||
"""Get ratings for multiple players."""
|
|
||||||
ratings = {}
|
|
||||||
for uid in user_ids:
|
|
||||||
ratings[uid] = await self.get_rating(uid)
|
|
||||||
return ratings
|
|
||||||
|
|
||||||
async def update_ratings(
|
|
||||||
self,
|
|
||||||
player_results: list[tuple[str, int]],
|
|
||||||
is_standard_rules: bool,
|
|
||||||
) -> dict[str, PlayerRating]:
|
|
||||||
"""
|
|
||||||
Update ratings after a game.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
player_results: List of (user_id, total_score) for each human player.
|
|
||||||
is_standard_rules: Whether the game used standard rules.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict of user_id -> updated PlayerRating.
|
|
||||||
"""
|
|
||||||
if not is_standard_rules:
|
|
||||||
logger.debug("Skipping rating update for non-standard rules game")
|
|
||||||
return {}
|
|
||||||
|
|
||||||
if len(player_results) < 2:
|
|
||||||
logger.debug("Skipping rating update: fewer than 2 human players")
|
|
||||||
return {}
|
|
||||||
|
|
||||||
# Get current ratings
|
|
||||||
user_ids = [uid for uid, _ in player_results]
|
|
||||||
current_ratings = await self.get_ratings_batch(user_ids)
|
|
||||||
|
|
||||||
# Sort by score (lower is better in Golf)
|
|
||||||
sorted_results = sorted(player_results, key=lambda x: x[1])
|
|
||||||
|
|
||||||
# Build pairwise comparisons for each player
|
|
||||||
updated_ratings = {}
|
|
||||||
for uid, score in player_results:
|
|
||||||
player = current_ratings[uid]
|
|
||||||
opponents = []
|
|
||||||
|
|
||||||
for opp_uid, opp_score in player_results:
|
|
||||||
if opp_uid == uid:
|
|
||||||
continue
|
|
||||||
|
|
||||||
opp = current_ratings[opp_uid]
|
|
||||||
|
|
||||||
# Determine outcome (lower score wins in Golf)
|
|
||||||
if score < opp_score:
|
|
||||||
outcome = 1.0 # Win
|
|
||||||
elif score == opp_score:
|
|
||||||
outcome = 0.5 # Draw
|
|
||||||
else:
|
|
||||||
outcome = 0.0 # Loss
|
|
||||||
|
|
||||||
opponents.append((opp.mu, opp.phi, outcome))
|
|
||||||
|
|
||||||
updated = update_rating(player, opponents)
|
|
||||||
updated_ratings[uid] = updated
|
|
||||||
|
|
||||||
# Persist updated ratings
|
|
||||||
async with self.pool.acquire() as conn:
|
|
||||||
for uid, rating in updated_ratings.items():
|
|
||||||
await conn.execute(
|
|
||||||
"""
|
|
||||||
UPDATE player_stats
|
|
||||||
SET rating = $2,
|
|
||||||
rating_deviation = $3,
|
|
||||||
rating_volatility = $4,
|
|
||||||
rating_updated_at = $5
|
|
||||||
WHERE user_id = $1
|
|
||||||
""",
|
|
||||||
uid,
|
|
||||||
rating.rating,
|
|
||||||
rating.rd,
|
|
||||||
rating.volatility,
|
|
||||||
rating.updated_at,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"Ratings updated for {len(updated_ratings)} players: "
|
|
||||||
+ ", ".join(f"{uid[:8]}={r.rating:.0f}" for uid, r in updated_ratings.items())
|
|
||||||
)
|
|
||||||
|
|
||||||
return updated_ratings
|
|
||||||
@ -1,4 +1,3 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
"""
|
"""
|
||||||
Game recovery service for rebuilding active games from event store.
|
Game recovery service for rebuilding active games from event store.
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
"""
|
"""
|
||||||
Replay service for Golf game.
|
Replay service for Golf game.
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
"""
|
"""
|
||||||
Spectator manager for Golf game.
|
Spectator manager for Golf game.
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
"""
|
"""
|
||||||
Stats service for Golf game leaderboards and achievements.
|
Stats service for Golf game leaderboards and achievements.
|
||||||
|
|
||||||
@ -38,8 +37,6 @@ class PlayerStats:
|
|||||||
wolfpacks: int = 0
|
wolfpacks: int = 0
|
||||||
current_win_streak: int = 0
|
current_win_streak: int = 0
|
||||||
best_win_streak: int = 0
|
best_win_streak: int = 0
|
||||||
rating: float = 1500.0
|
|
||||||
rating_deviation: float = 350.0
|
|
||||||
first_game_at: Optional[datetime] = None
|
first_game_at: Optional[datetime] = None
|
||||||
last_game_at: Optional[datetime] = None
|
last_game_at: Optional[datetime] = None
|
||||||
achievements: List[str] = field(default_factory=list)
|
achievements: List[str] = field(default_factory=list)
|
||||||
@ -159,8 +156,6 @@ class StatsService:
|
|||||||
wolfpacks=row["wolfpacks"] or 0,
|
wolfpacks=row["wolfpacks"] or 0,
|
||||||
current_win_streak=row["current_win_streak"] or 0,
|
current_win_streak=row["current_win_streak"] or 0,
|
||||||
best_win_streak=row["best_win_streak"] or 0,
|
best_win_streak=row["best_win_streak"] or 0,
|
||||||
rating=float(row["rating"]) if row.get("rating") else 1500.0,
|
|
||||||
rating_deviation=float(row["rating_deviation"]) if row.get("rating_deviation") else 350.0,
|
|
||||||
first_game_at=row["first_game_at"].replace(tzinfo=timezone.utc) if row["first_game_at"] else None,
|
first_game_at=row["first_game_at"].replace(tzinfo=timezone.utc) if row["first_game_at"] else None,
|
||||||
last_game_at=row["last_game_at"].replace(tzinfo=timezone.utc) if row["last_game_at"] else None,
|
last_game_at=row["last_game_at"].replace(tzinfo=timezone.utc) if row["last_game_at"] else None,
|
||||||
achievements=[a["achievement_id"] for a in achievements],
|
achievements=[a["achievement_id"] for a in achievements],
|
||||||
@ -189,7 +184,6 @@ class StatsService:
|
|||||||
"avg_score": ("avg_score", "ASC"), # Lower is better
|
"avg_score": ("avg_score", "ASC"), # Lower is better
|
||||||
"knockouts": ("knockouts", "DESC"),
|
"knockouts": ("knockouts", "DESC"),
|
||||||
"streak": ("best_win_streak", "DESC"),
|
"streak": ("best_win_streak", "DESC"),
|
||||||
"rating": ("rating", "DESC"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if metric not in order_map:
|
if metric not in order_map:
|
||||||
@ -209,7 +203,6 @@ class StatsService:
|
|||||||
SELECT
|
SELECT
|
||||||
user_id, username, games_played, games_won,
|
user_id, username, games_played, games_won,
|
||||||
win_rate, avg_score, knockouts, best_win_streak,
|
win_rate, avg_score, knockouts, best_win_streak,
|
||||||
COALESCE(rating, 1500) as rating,
|
|
||||||
ROW_NUMBER() OVER (ORDER BY {column} {direction}) as rank
|
ROW_NUMBER() OVER (ORDER BY {column} {direction}) as rank
|
||||||
FROM leaderboard_overall
|
FROM leaderboard_overall
|
||||||
ORDER BY {column} {direction}
|
ORDER BY {column} {direction}
|
||||||
@ -223,7 +216,6 @@ class StatsService:
|
|||||||
ROUND(s.games_won::numeric / NULLIF(s.games_played, 0) * 100, 1) as win_rate,
|
ROUND(s.games_won::numeric / NULLIF(s.games_played, 0) * 100, 1) as win_rate,
|
||||||
ROUND(s.total_points::numeric / NULLIF(s.total_rounds, 0), 1) as avg_score,
|
ROUND(s.total_points::numeric / NULLIF(s.total_rounds, 0), 1) as avg_score,
|
||||||
s.knockouts, s.best_win_streak,
|
s.knockouts, s.best_win_streak,
|
||||||
COALESCE(s.rating, 1500) as rating,
|
|
||||||
ROW_NUMBER() OVER (ORDER BY {column} {direction}) as rank
|
ROW_NUMBER() OVER (ORDER BY {column} {direction}) as rank
|
||||||
FROM player_stats s
|
FROM player_stats s
|
||||||
JOIN users_v2 u ON s.user_id = u.id
|
JOIN users_v2 u ON s.user_id = u.id
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
"""
|
"""
|
||||||
Golf AI Simulation Runner
|
Golf AI Simulation Runner
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
"""Stores package for Golf game V2 persistence."""
|
"""Stores package for Golf game V2 persistence."""
|
||||||
|
|
||||||
from .event_store import EventStore, ConcurrencyError
|
from .event_store import EventStore, ConcurrencyError
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
"""
|
"""
|
||||||
PostgreSQL-backed event store for Golf game.
|
PostgreSQL-backed event store for Golf game.
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
"""
|
"""
|
||||||
Redis pub/sub for cross-server game events.
|
Redis pub/sub for cross-server game events.
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
"""
|
"""
|
||||||
Redis-backed live game state cache.
|
Redis-backed live game state cache.
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
"""
|
"""
|
||||||
PostgreSQL-backed user store for Golf game authentication.
|
PostgreSQL-backed user store for Golf game authentication.
|
||||||
|
|
||||||
@ -205,22 +204,6 @@ BEGIN
|
|||||||
WHERE table_name = 'player_stats' AND column_name = 'games_won_vs_humans') THEN
|
WHERE table_name = 'player_stats' AND column_name = 'games_won_vs_humans') THEN
|
||||||
ALTER TABLE player_stats ADD COLUMN games_won_vs_humans INT DEFAULT 0;
|
ALTER TABLE player_stats ADD COLUMN games_won_vs_humans INT DEFAULT 0;
|
||||||
END IF;
|
END IF;
|
||||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_name = 'player_stats' AND column_name = 'rating') THEN
|
|
||||||
ALTER TABLE player_stats ADD COLUMN rating DECIMAL(7,2) DEFAULT 1500.0;
|
|
||||||
END IF;
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_name = 'player_stats' AND column_name = 'rating_deviation') THEN
|
|
||||||
ALTER TABLE player_stats ADD COLUMN rating_deviation DECIMAL(7,2) DEFAULT 350.0;
|
|
||||||
END IF;
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_name = 'player_stats' AND column_name = 'rating_volatility') THEN
|
|
||||||
ALTER TABLE player_stats ADD COLUMN rating_volatility DECIMAL(8,6) DEFAULT 0.06;
|
|
||||||
END IF;
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_name = 'player_stats' AND column_name = 'rating_updated_at') THEN
|
|
||||||
ALTER TABLE player_stats ADD COLUMN rating_updated_at TIMESTAMPTZ;
|
|
||||||
END IF;
|
|
||||||
END $$;
|
END $$;
|
||||||
|
|
||||||
-- Stats processing queue (for async stats processing)
|
-- Stats processing queue (for async stats processing)
|
||||||
@ -282,19 +265,9 @@ CREATE TABLE IF NOT EXISTS system_metrics (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- Leaderboard materialized view (refreshed periodically)
|
-- Leaderboard materialized view (refreshed periodically)
|
||||||
-- Drop and recreate if missing rating column (v3.1.0 migration)
|
-- Note: Using DO block to handle case where view already exists
|
||||||
DO $$
|
DO $$
|
||||||
BEGIN
|
BEGIN
|
||||||
IF EXISTS (SELECT 1 FROM pg_matviews WHERE matviewname = 'leaderboard_overall') THEN
|
|
||||||
-- Check if rating column exists in the view
|
|
||||||
IF NOT EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_name = 'leaderboard_overall' AND column_name = 'rating'
|
|
||||||
) THEN
|
|
||||||
DROP MATERIALIZED VIEW leaderboard_overall;
|
|
||||||
END IF;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_matviews WHERE matviewname = 'leaderboard_overall') THEN
|
IF NOT EXISTS (SELECT 1 FROM pg_matviews WHERE matviewname = 'leaderboard_overall') THEN
|
||||||
EXECUTE '
|
EXECUTE '
|
||||||
CREATE MATERIALIZED VIEW leaderboard_overall AS
|
CREATE MATERIALIZED VIEW leaderboard_overall AS
|
||||||
@ -309,7 +282,6 @@ BEGIN
|
|||||||
s.best_score as best_round_score,
|
s.best_score as best_round_score,
|
||||||
s.knockouts,
|
s.knockouts,
|
||||||
s.best_win_streak,
|
s.best_win_streak,
|
||||||
COALESCE(s.rating, 1500) as rating,
|
|
||||||
s.last_game_at
|
s.last_game_at
|
||||||
FROM player_stats s
|
FROM player_stats s
|
||||||
JOIN users_v2 u ON s.user_id = u.id
|
JOIN users_v2 u ON s.user_id = u.id
|
||||||
@ -377,9 +349,6 @@ BEGIN
|
|||||||
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_leaderboard_overall_score') THEN
|
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_leaderboard_overall_score') THEN
|
||||||
CREATE INDEX idx_leaderboard_overall_score ON leaderboard_overall(avg_score ASC);
|
CREATE INDEX idx_leaderboard_overall_score ON leaderboard_overall(avg_score ASC);
|
||||||
END IF;
|
END IF;
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_leaderboard_overall_rating') THEN
|
|
||||||
CREATE INDEX idx_leaderboard_overall_rating ON leaderboard_overall(rating DESC);
|
|
||||||
END IF;
|
|
||||||
END IF;
|
END IF;
|
||||||
END $$;
|
END $$;
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
"""
|
"""
|
||||||
Test suite for AI decision sub-functions extracted from ai.py.
|
Test suite for AI decision sub-functions extracted from ai.py.
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
"""
|
"""
|
||||||
Tests for the GameAnalyzer decision evaluation logic.
|
Tests for the GameAnalyzer decision evaluation logic.
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
"""
|
"""
|
||||||
Tests for the authentication system.
|
Tests for the authentication system.
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
"""
|
"""
|
||||||
Test suite for 6-Card Golf game rules.
|
Test suite for 6-Card Golf game rules.
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
"""
|
"""
|
||||||
Test suite for WebSocket message handlers.
|
Test suite for WebSocket message handlers.
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
"""
|
"""
|
||||||
House Rules Testing Suite
|
House Rules Testing Suite
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
"""
|
"""
|
||||||
Test for the original Maya bug:
|
Test for the original Maya bug:
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
"""
|
"""
|
||||||
Test suite for Room and RoomManager CRUD operations.
|
Test suite for Room and RoomManager CRUD operations.
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
"""
|
"""
|
||||||
Test suite for V3 features in 6-Card Golf.
|
Test suite for V3 features in 6-Card Golf.
|
||||||
|
|
||||||
|
|||||||
@ -1,2 +1 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
"""Tests package for Golf game."""
|
"""Tests package for Golf game."""
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
"""
|
"""
|
||||||
Tests for event sourcing and state replay.
|
Tests for event sourcing and state replay.
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
"""
|
"""
|
||||||
Tests for V2 Persistence & Recovery components.
|
Tests for V2 Persistence & Recovery components.
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
"""
|
"""
|
||||||
Tests for the replay service.
|
Tests for the replay service.
|
||||||
|
|
||||||
|
|||||||
@ -1,20 +0,0 @@
|
|||||||
[project]
|
|
||||||
name = "golf-tui"
|
|
||||||
version = "0.1.0"
|
|
||||||
description = "Terminal client for the Golf card game"
|
|
||||||
requires-python = ">=3.11"
|
|
||||||
dependencies = [
|
|
||||||
"textual>=0.47.0",
|
|
||||||
"websockets>=12.0",
|
|
||||||
"httpx>=0.25.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[project.scripts]
|
|
||||||
golf-tui = "tui_client.__main__:main"
|
|
||||||
|
|
||||||
[build-system]
|
|
||||||
requires = ["setuptools>=68.0"]
|
|
||||||
build-backend = "setuptools.build_meta"
|
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
|
||||||
where = ["src"]
|
|
||||||
@ -1 +0,0 @@
|
|||||||
"""TUI client for the Golf card game."""
|
|
||||||
@ -1,59 +0,0 @@
|
|||||||
"""Entry point: python -m tui_client [--server HOST] [--no-tls]
|
|
||||||
|
|
||||||
Reads defaults from ~/.config/golf-tui.conf (create with --save-config).
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import sys
|
|
||||||
|
|
||||||
from tui_client.config import load_config, save_config, CONFIG_PATH
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
cfg = load_config()
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description="Golf Card Game TUI Client")
|
|
||||||
parser.add_argument(
|
|
||||||
"--server",
|
|
||||||
default=cfg.get("server", "golfcards.club"),
|
|
||||||
help=f"Server host[:port] (default: {cfg.get('server', 'golfcards.club')})",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--no-tls",
|
|
||||||
action="store_true",
|
|
||||||
default=cfg.get("tls", "true").lower() != "true",
|
|
||||||
help="Use ws:// and http:// instead of wss:// and https://",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--debug",
|
|
||||||
action="store_true",
|
|
||||||
help="Enable debug logging to tui_debug.log",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--save-config",
|
|
||||||
action="store_true",
|
|
||||||
help=f"Save current options as defaults to {CONFIG_PATH}",
|
|
||||||
)
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
if args.save_config:
|
|
||||||
save_config({
|
|
||||||
"server": args.server,
|
|
||||||
"tls": str(not args.no_tls).lower(),
|
|
||||||
})
|
|
||||||
print(f"Config saved to {CONFIG_PATH}")
|
|
||||||
print(f" server = {args.server}")
|
|
||||||
print(f" tls = {str(not args.no_tls).lower()}")
|
|
||||||
return
|
|
||||||
|
|
||||||
if args.debug:
|
|
||||||
import logging
|
|
||||||
logging.basicConfig(level=logging.DEBUG, filename="tui_debug.log")
|
|
||||||
|
|
||||||
from tui_client.app import GolfApp
|
|
||||||
app = GolfApp(server=args.server, use_tls=not args.no_tls)
|
|
||||||
app.run()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@ -1,130 +0,0 @@
|
|||||||
"""Main Textual App for the Golf TUI client."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from textual.app import App, ComposeResult
|
|
||||||
from textual.message import Message
|
|
||||||
from textual.widgets import Static
|
|
||||||
|
|
||||||
from tui_client.client import GameClient
|
|
||||||
|
|
||||||
|
|
||||||
class ServerMessage(Message):
|
|
||||||
"""A message received from the game server."""
|
|
||||||
|
|
||||||
def __init__(self, data: dict) -> None:
|
|
||||||
super().__init__()
|
|
||||||
self.msg_type: str = data.get("type", "")
|
|
||||||
self.data: dict = data
|
|
||||||
|
|
||||||
|
|
||||||
class KeymapBar(Static):
|
|
||||||
"""Bottom bar showing available keys for the current context."""
|
|
||||||
|
|
||||||
DEFAULT_CSS = """
|
|
||||||
KeymapBar {
|
|
||||||
dock: bottom;
|
|
||||||
height: 1;
|
|
||||||
background: #1a1a2e;
|
|
||||||
color: #888888;
|
|
||||||
padding: 0 1;
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class GolfApp(App):
|
|
||||||
"""Golf Card Game TUI Application."""
|
|
||||||
|
|
||||||
TITLE = "GolfCards.club"
|
|
||||||
CSS_PATH = "styles.tcss"
|
|
||||||
|
|
||||||
BINDINGS = [
|
|
||||||
("escape", "esc_pressed", ""),
|
|
||||||
("q", "quit_app", ""),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __init__(self, server: str, use_tls: bool = True):
|
|
||||||
super().__init__()
|
|
||||||
self.client = GameClient(server, use_tls)
|
|
||||||
self.client._app = self
|
|
||||||
self.player_id: str | None = None
|
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
|
||||||
yield KeymapBar(id="keymap-bar")
|
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
|
||||||
from tui_client.screens.splash import SplashScreen
|
|
||||||
self.push_screen(SplashScreen())
|
|
||||||
self._update_keymap()
|
|
||||||
|
|
||||||
def on_screen_resume(self) -> None:
|
|
||||||
self._update_keymap()
|
|
||||||
|
|
||||||
def post_server_message(self, data: dict) -> None:
|
|
||||||
"""Called from GameClient listener to inject server messages."""
|
|
||||||
msg = ServerMessage(data)
|
|
||||||
self.call_later(self._route_server_message, msg)
|
|
||||||
|
|
||||||
def _route_server_message(self, msg: ServerMessage) -> None:
|
|
||||||
"""Forward a server message to the active screen."""
|
|
||||||
screen = self.screen
|
|
||||||
handler = getattr(screen, "on_server_message", None)
|
|
||||||
if handler:
|
|
||||||
handler(msg)
|
|
||||||
|
|
||||||
def action_esc_pressed(self) -> None:
|
|
||||||
"""Escape goes back — delegated to the active screen."""
|
|
||||||
handler = getattr(self.screen, "handle_escape", None)
|
|
||||||
if handler:
|
|
||||||
handler()
|
|
||||||
|
|
||||||
def action_quit_app(self) -> None:
|
|
||||||
"""[q] quits the app. Immediate on login, confirmation elsewhere."""
|
|
||||||
# Don't capture q when typing in input fields
|
|
||||||
focused = self.focused
|
|
||||||
if focused and hasattr(focused, "value"):
|
|
||||||
return
|
|
||||||
# Don't handle here on game screen (game has its own q binding)
|
|
||||||
if self.screen.__class__.__name__ == "GameScreen":
|
|
||||||
return
|
|
||||||
|
|
||||||
screen_name = self.screen.__class__.__name__
|
|
||||||
if screen_name == "ConnectScreen":
|
|
||||||
self.exit()
|
|
||||||
else:
|
|
||||||
from tui_client.screens.confirm import ConfirmScreen
|
|
||||||
self.push_screen(
|
|
||||||
ConfirmScreen("Quit GolfCards?"),
|
|
||||||
callback=self._on_quit_confirm,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _on_quit_confirm(self, confirmed: bool) -> None:
|
|
||||||
if confirmed:
|
|
||||||
self.exit()
|
|
||||||
|
|
||||||
def _update_keymap(self) -> None:
|
|
||||||
"""Update the keymap bar based on current screen."""
|
|
||||||
screen_name = self.screen.__class__.__name__
|
|
||||||
keymap = getattr(self.screen, "KEYMAP_HINT", None)
|
|
||||||
if keymap:
|
|
||||||
text = keymap
|
|
||||||
elif screen_name == "ConnectScreen":
|
|
||||||
text = "[Tab] Navigate [Enter] Submit [q] Quit"
|
|
||||||
elif screen_name == "LobbyScreen":
|
|
||||||
text = "[Esc] Back [Tab] Navigate [Enter] Create/Join [q] Quit"
|
|
||||||
else:
|
|
||||||
text = "[q] Quit"
|
|
||||||
try:
|
|
||||||
self.query_one("#keymap-bar", KeymapBar).update(text)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def set_keymap(self, text: str) -> None:
|
|
||||||
"""Allow screens to update the keymap bar dynamically."""
|
|
||||||
try:
|
|
||||||
self.query_one("#keymap-bar", KeymapBar).update(text)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def on_unmount(self) -> None:
|
|
||||||
await self.client.disconnect()
|
|
||||||
@ -1,196 +0,0 @@
|
|||||||
"""WebSocket + HTTP networking for the TUI client."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
import websockets
|
|
||||||
from websockets.asyncio.client import ClientConnection
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
_SESSION_DIR = Path.home() / ".config" / "golfcards"
|
|
||||||
_SESSION_FILE = _SESSION_DIR / "session.json"
|
|
||||||
|
|
||||||
|
|
||||||
class GameClient:
|
|
||||||
"""Handles HTTP auth and WebSocket game communication."""
|
|
||||||
|
|
||||||
def __init__(self, host: str, use_tls: bool = True):
|
|
||||||
self.host = host
|
|
||||||
self.use_tls = use_tls
|
|
||||||
self._token: Optional[str] = None
|
|
||||||
self._ws: Optional[ClientConnection] = None
|
|
||||||
self._listener_task: Optional[asyncio.Task] = None
|
|
||||||
self._app = None # Set by GolfApp
|
|
||||||
self._username: Optional[str] = None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def http_base(self) -> str:
|
|
||||||
scheme = "https" if self.use_tls else "http"
|
|
||||||
return f"{scheme}://{self.host}"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def ws_url(self) -> str:
|
|
||||||
scheme = "wss" if self.use_tls else "ws"
|
|
||||||
url = f"{scheme}://{self.host}/ws"
|
|
||||||
if self._token:
|
|
||||||
url += f"?token={self._token}"
|
|
||||||
return url
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_authenticated(self) -> bool:
|
|
||||||
return self._token is not None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def username(self) -> Optional[str]:
|
|
||||||
return self._username
|
|
||||||
|
|
||||||
def save_session(self) -> None:
|
|
||||||
"""Persist token and server info to disk."""
|
|
||||||
if not self._token:
|
|
||||||
return
|
|
||||||
_SESSION_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
data = {
|
|
||||||
"host": self.host,
|
|
||||||
"use_tls": self.use_tls,
|
|
||||||
"token": self._token,
|
|
||||||
"username": self._username,
|
|
||||||
}
|
|
||||||
_SESSION_FILE.write_text(json.dumps(data))
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def load_session() -> dict | None:
|
|
||||||
"""Load saved session from disk, or None if not found."""
|
|
||||||
if not _SESSION_FILE.exists():
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return json.loads(_SESSION_FILE.read_text())
|
|
||||||
except (json.JSONDecodeError, OSError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def clear_session() -> None:
|
|
||||||
"""Delete saved session file."""
|
|
||||||
try:
|
|
||||||
_SESSION_FILE.unlink(missing_ok=True)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def verify_token(self) -> bool:
|
|
||||||
"""Check if the current token is still valid via /api/auth/me."""
|
|
||||||
if not self._token:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(verify=self.use_tls) as http:
|
|
||||||
resp = await http.get(
|
|
||||||
f"{self.http_base}/api/auth/me",
|
|
||||||
headers={"Authorization": f"Bearer {self._token}"},
|
|
||||||
)
|
|
||||||
if resp.status_code == 200:
|
|
||||||
data = resp.json()
|
|
||||||
self._username = data.get("username", self._username)
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
def restore_session(self, session: dict) -> None:
|
|
||||||
"""Restore client state from a saved session dict."""
|
|
||||||
self.host = session["host"]
|
|
||||||
self.use_tls = session["use_tls"]
|
|
||||||
self._token = session["token"]
|
|
||||||
self._username = session.get("username")
|
|
||||||
|
|
||||||
async def login(self, username: str, password: str) -> dict:
|
|
||||||
"""Login via HTTP and store JWT token.
|
|
||||||
|
|
||||||
Returns the response dict on success, raises on failure.
|
|
||||||
"""
|
|
||||||
async with httpx.AsyncClient(verify=self.use_tls) as http:
|
|
||||||
resp = await http.post(
|
|
||||||
f"{self.http_base}/api/auth/login",
|
|
||||||
json={"username": username, "password": password},
|
|
||||||
)
|
|
||||||
if resp.status_code != 200:
|
|
||||||
detail = resp.json().get("detail", "Login failed")
|
|
||||||
raise ConnectionError(detail)
|
|
||||||
data = resp.json()
|
|
||||||
self._token = data["token"]
|
|
||||||
self._username = data["user"]["username"]
|
|
||||||
return data
|
|
||||||
|
|
||||||
async def register(
|
|
||||||
self, username: str, password: str, invite_code: str = "", email: str = ""
|
|
||||||
) -> dict:
|
|
||||||
"""Register a new account via HTTP and store JWT token."""
|
|
||||||
payload: dict = {"username": username, "password": password}
|
|
||||||
if invite_code:
|
|
||||||
payload["invite_code"] = invite_code
|
|
||||||
if email:
|
|
||||||
payload["email"] = email
|
|
||||||
async with httpx.AsyncClient(verify=self.use_tls) as http:
|
|
||||||
resp = await http.post(
|
|
||||||
f"{self.http_base}/api/auth/register",
|
|
||||||
json=payload,
|
|
||||||
)
|
|
||||||
if resp.status_code != 200:
|
|
||||||
detail = resp.json().get("detail", "Registration failed")
|
|
||||||
raise ConnectionError(detail)
|
|
||||||
data = resp.json()
|
|
||||||
self._token = data["token"]
|
|
||||||
self._username = data["user"]["username"]
|
|
||||||
return data
|
|
||||||
|
|
||||||
async def connect(self) -> None:
|
|
||||||
"""Open WebSocket connection to the server."""
|
|
||||||
self._ws = await websockets.connect(self.ws_url)
|
|
||||||
self._listener_task = asyncio.create_task(self._listen())
|
|
||||||
|
|
||||||
async def disconnect(self) -> None:
|
|
||||||
"""Close WebSocket connection."""
|
|
||||||
if self._listener_task:
|
|
||||||
self._listener_task.cancel()
|
|
||||||
try:
|
|
||||||
await self._listener_task
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
self._listener_task = None
|
|
||||||
if self._ws:
|
|
||||||
await self._ws.close()
|
|
||||||
self._ws = None
|
|
||||||
|
|
||||||
async def send(self, msg_type: str, **kwargs) -> None:
|
|
||||||
"""Send a JSON message over WebSocket."""
|
|
||||||
if not self._ws:
|
|
||||||
raise ConnectionError("Not connected")
|
|
||||||
msg = {"type": msg_type, **kwargs}
|
|
||||||
logger.debug(f"TX: {msg}")
|
|
||||||
await self._ws.send(json.dumps(msg))
|
|
||||||
|
|
||||||
async def _listen(self) -> None:
|
|
||||||
"""Background task: read messages from WebSocket and post to app."""
|
|
||||||
try:
|
|
||||||
async for raw in self._ws:
|
|
||||||
try:
|
|
||||||
data = json.loads(raw)
|
|
||||||
logger.debug(f"RX: {data.get('type', '?')}")
|
|
||||||
if self._app:
|
|
||||||
self._app.post_server_message(data)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
logger.warning(f"Non-JSON message: {raw[:100]}")
|
|
||||||
except websockets.ConnectionClosed as e:
|
|
||||||
logger.info(f"WebSocket closed: {e}")
|
|
||||||
if self._app:
|
|
||||||
self._app.post_server_message({"type": "connection_closed", "reason": str(e)})
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"WebSocket listener error: {e}")
|
|
||||||
if self._app:
|
|
||||||
self._app.post_server_message({"type": "connection_error", "reason": str(e)})
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
"""User configuration for the TUI client.
|
|
||||||
|
|
||||||
Config file: ~/.config/golf-tui.conf
|
|
||||||
|
|
||||||
Example contents:
|
|
||||||
server = golfcards.club
|
|
||||||
tls = true
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
CONFIG_PATH = Path(os.environ.get("GOLF_TUI_CONFIG", "~/.config/golf-tui.conf")).expanduser()
|
|
||||||
|
|
||||||
DEFAULTS = {
|
|
||||||
"server": "golfcards.club",
|
|
||||||
"tls": "true",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def load_config() -> dict[str, str]:
|
|
||||||
"""Load config from file, falling back to defaults."""
|
|
||||||
cfg = dict(DEFAULTS)
|
|
||||||
if CONFIG_PATH.exists():
|
|
||||||
for line in CONFIG_PATH.read_text().splitlines():
|
|
||||||
line = line.strip()
|
|
||||||
if not line or line.startswith("#"):
|
|
||||||
continue
|
|
||||||
if "=" in line:
|
|
||||||
key, _, value = line.partition("=")
|
|
||||||
cfg[key.strip().lower()] = value.strip()
|
|
||||||
return cfg
|
|
||||||
|
|
||||||
|
|
||||||
def save_config(cfg: dict[str, str]) -> None:
|
|
||||||
"""Write config to file."""
|
|
||||||
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
lines = [f"{k} = {v}" for k, v in sorted(cfg.items())]
|
|
||||||
CONFIG_PATH.write_text("\n".join(lines) + "\n")
|
|
||||||
@ -1,156 +0,0 @@
|
|||||||
"""Data models for the TUI client."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class CardData:
|
|
||||||
"""A single card as received from the server."""
|
|
||||||
|
|
||||||
suit: Optional[str] = None # "hearts", "diamonds", "clubs", "spades"
|
|
||||||
rank: Optional[str] = None # "A", "2".."10", "J", "Q", "K", "★"
|
|
||||||
face_up: bool = False
|
|
||||||
deck_id: Optional[int] = None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_dict(cls, d: dict) -> CardData:
|
|
||||||
return cls(
|
|
||||||
suit=d.get("suit"),
|
|
||||||
rank=d.get("rank"),
|
|
||||||
face_up=d.get("face_up", False),
|
|
||||||
deck_id=d.get("deck_id"),
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def display_suit(self) -> str:
|
|
||||||
"""Unicode suit symbol."""
|
|
||||||
return {
|
|
||||||
"hearts": "\u2665",
|
|
||||||
"diamonds": "\u2666",
|
|
||||||
"clubs": "\u2663",
|
|
||||||
"spades": "\u2660",
|
|
||||||
}.get(self.suit or "", "")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def display_rank(self) -> str:
|
|
||||||
if self.rank == "10":
|
|
||||||
return "10"
|
|
||||||
return self.rank or ""
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_red(self) -> bool:
|
|
||||||
return self.suit in ("hearts", "diamonds")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_joker(self) -> bool:
|
|
||||||
return self.rank == "\u2605"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class PlayerData:
|
|
||||||
"""A player as received in game state."""
|
|
||||||
|
|
||||||
id: str = ""
|
|
||||||
name: str = ""
|
|
||||||
cards: list[CardData] = field(default_factory=list)
|
|
||||||
score: Optional[int] = None
|
|
||||||
total_score: int = 0
|
|
||||||
rounds_won: int = 0
|
|
||||||
all_face_up: bool = False
|
|
||||||
|
|
||||||
# Standard card values for visible score calculation
|
|
||||||
_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,
|
|
||||||
}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def visible_score(self) -> int:
|
|
||||||
"""Compute score from face-up cards, zeroing matched columns."""
|
|
||||||
if len(self.cards) < 6:
|
|
||||||
return 0
|
|
||||||
values = [0] * 6
|
|
||||||
for i, c in enumerate(self.cards):
|
|
||||||
if c.face_up and c.rank:
|
|
||||||
values[i] = self._CARD_VALUES.get(c.rank, 0)
|
|
||||||
# Zero out matched columns (same rank, both face-up)
|
|
||||||
for col in range(3):
|
|
||||||
top, bot = self.cards[col], self.cards[col + 3]
|
|
||||||
if top.face_up and bot.face_up and top.rank and top.rank == bot.rank:
|
|
||||||
values[col] = 0
|
|
||||||
values[col + 3] = 0
|
|
||||||
return sum(values)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_dict(cls, d: dict) -> PlayerData:
|
|
||||||
return cls(
|
|
||||||
id=d.get("id", ""),
|
|
||||||
name=d.get("name", ""),
|
|
||||||
cards=[CardData.from_dict(c) for c in d.get("cards", [])],
|
|
||||||
score=d.get("score"),
|
|
||||||
total_score=d.get("total_score", 0),
|
|
||||||
rounds_won=d.get("rounds_won", 0),
|
|
||||||
all_face_up=d.get("all_face_up", False),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class GameState:
|
|
||||||
"""Full game state from the server."""
|
|
||||||
|
|
||||||
phase: str = "waiting"
|
|
||||||
players: list[PlayerData] = field(default_factory=list)
|
|
||||||
current_player_id: Optional[str] = None
|
|
||||||
dealer_id: Optional[str] = None
|
|
||||||
discard_top: Optional[CardData] = None
|
|
||||||
deck_remaining: int = 0
|
|
||||||
current_round: int = 1
|
|
||||||
total_rounds: int = 1
|
|
||||||
has_drawn_card: bool = False
|
|
||||||
drawn_card: Optional[CardData] = None
|
|
||||||
drawn_player_id: Optional[str] = None
|
|
||||||
can_discard: bool = True
|
|
||||||
waiting_for_initial_flip: bool = False
|
|
||||||
initial_flips: int = 2
|
|
||||||
flip_on_discard: bool = False
|
|
||||||
flip_mode: str = "never"
|
|
||||||
flip_is_optional: bool = False
|
|
||||||
flip_as_action: bool = False
|
|
||||||
knock_early: bool = False
|
|
||||||
finisher_id: Optional[str] = None
|
|
||||||
card_values: dict = field(default_factory=dict)
|
|
||||||
active_rules: list = field(default_factory=list)
|
|
||||||
deck_colors: list[str] = field(default_factory=lambda: ["red", "blue", "gold"])
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_dict(cls, d: dict) -> GameState:
|
|
||||||
discard = d.get("discard_top")
|
|
||||||
drawn = d.get("drawn_card")
|
|
||||||
return cls(
|
|
||||||
phase=d.get("phase", "waiting"),
|
|
||||||
players=[PlayerData.from_dict(p) for p in d.get("players", [])],
|
|
||||||
current_player_id=d.get("current_player_id"),
|
|
||||||
dealer_id=d.get("dealer_id"),
|
|
||||||
discard_top=CardData.from_dict(discard) if discard else None,
|
|
||||||
deck_remaining=d.get("deck_remaining", 0),
|
|
||||||
current_round=d.get("current_round", 1),
|
|
||||||
total_rounds=d.get("total_rounds", 1),
|
|
||||||
has_drawn_card=d.get("has_drawn_card", False),
|
|
||||||
drawn_card=CardData.from_dict(drawn) if drawn else None,
|
|
||||||
drawn_player_id=d.get("drawn_player_id"),
|
|
||||||
can_discard=d.get("can_discard", True),
|
|
||||||
waiting_for_initial_flip=d.get("waiting_for_initial_flip", False),
|
|
||||||
initial_flips=d.get("initial_flips", 2),
|
|
||||||
flip_on_discard=d.get("flip_on_discard", False),
|
|
||||||
flip_mode=d.get("flip_mode", "never"),
|
|
||||||
flip_is_optional=d.get("flip_is_optional", False),
|
|
||||||
flip_as_action=d.get("flip_as_action", False),
|
|
||||||
knock_early=d.get("knock_early", False),
|
|
||||||
finisher_id=d.get("finisher_id"),
|
|
||||||
card_values=d.get("card_values", {}),
|
|
||||||
active_rules=d.get("active_rules", []),
|
|
||||||
deck_colors=d.get("deck_colors", ["red", "blue", "gold"]),
|
|
||||||
)
|
|
||||||
@ -1 +0,0 @@
|
|||||||
"""Screen modules for the TUI client."""
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
"""Reusable confirmation dialog."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from textual.app import ComposeResult
|
|
||||||
from textual.containers import Container, Horizontal
|
|
||||||
from textual.screen import ModalScreen
|
|
||||||
from textual.widgets import Button, Static
|
|
||||||
|
|
||||||
|
|
||||||
class ConfirmScreen(ModalScreen[bool]):
|
|
||||||
"""Modal confirmation prompt. Dismisses with True/False."""
|
|
||||||
|
|
||||||
BINDINGS = [
|
|
||||||
("y", "confirm", "Yes"),
|
|
||||||
("n", "cancel", "No"),
|
|
||||||
("escape", "cancel", "Cancel"),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __init__(self, message: str) -> None:
|
|
||||||
super().__init__()
|
|
||||||
self._message = message
|
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
|
||||||
with Container(id="confirm-dialog"):
|
|
||||||
yield Static(self._message, id="confirm-message")
|
|
||||||
with Horizontal(id="confirm-buttons"):
|
|
||||||
yield Button("Yes [Y]", id="btn-yes", variant="error")
|
|
||||||
yield Button("No [N]", id="btn-no", variant="primary")
|
|
||||||
|
|
||||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
||||||
if event.button.id == "btn-yes":
|
|
||||||
self.dismiss(True)
|
|
||||||
else:
|
|
||||||
self.dismiss(False)
|
|
||||||
|
|
||||||
def action_confirm(self) -> None:
|
|
||||||
self.dismiss(True)
|
|
||||||
|
|
||||||
def action_cancel(self) -> None:
|
|
||||||
self.dismiss(False)
|
|
||||||
@ -1,184 +0,0 @@
|
|||||||
"""Connection screen: login or sign up form."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from textual.app import ComposeResult
|
|
||||||
from textual.containers import Container, Horizontal, Vertical
|
|
||||||
from textual.screen import Screen
|
|
||||||
from textual.widgets import Button, Input, Static
|
|
||||||
|
|
||||||
|
|
||||||
_TITLE = (
|
|
||||||
"⛳🏌️ [bold]GolfCards.club[/bold] "
|
|
||||||
"[bold #aaaaaa]♠[/bold #aaaaaa]"
|
|
||||||
"[bold #cc0000]♥[/bold #cc0000]"
|
|
||||||
"[bold #aaaaaa]♣[/bold #aaaaaa]"
|
|
||||||
"[bold #cc0000]♦[/bold #cc0000]"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ConnectScreen(Screen):
|
|
||||||
"""Initial screen for logging in or signing up."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__()
|
|
||||||
self._mode: str = "login" # "login" or "signup"
|
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
|
||||||
with Container(id="connect-container"):
|
|
||||||
yield Static(_TITLE, id="connect-title")
|
|
||||||
|
|
||||||
# Login form
|
|
||||||
with Vertical(id="login-form"):
|
|
||||||
yield Static("Log in to play")
|
|
||||||
yield Input(placeholder="Username", id="input-username")
|
|
||||||
yield Input(placeholder="Password", password=True, id="input-password")
|
|
||||||
with Horizontal(id="connect-buttons"):
|
|
||||||
yield Button("Login", id="btn-login", variant="primary")
|
|
||||||
yield Button(
|
|
||||||
"No account? [bold cyan]Sign Up[/bold cyan]",
|
|
||||||
id="btn-toggle-signup",
|
|
||||||
variant="default",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Signup form
|
|
||||||
with Vertical(id="signup-form"):
|
|
||||||
yield Static("Create an account")
|
|
||||||
yield Input(placeholder="Invite Code", id="input-invite-code")
|
|
||||||
yield Input(placeholder="Username", id="input-signup-username")
|
|
||||||
yield Input(placeholder="Email (optional)", id="input-signup-email")
|
|
||||||
yield Input(
|
|
||||||
placeholder="Password (min 8 chars)",
|
|
||||||
password=True,
|
|
||||||
id="input-signup-password",
|
|
||||||
)
|
|
||||||
with Horizontal(id="signup-buttons"):
|
|
||||||
yield Button("Sign Up", id="btn-signup", variant="primary")
|
|
||||||
yield Button(
|
|
||||||
"Have an account? [bold cyan]Log In[/bold cyan]",
|
|
||||||
id="btn-toggle-login",
|
|
||||||
variant="default",
|
|
||||||
)
|
|
||||||
|
|
||||||
yield Static("", id="connect-status")
|
|
||||||
|
|
||||||
with Horizontal(classes="screen-footer"):
|
|
||||||
yield Static("", id="connect-footer-left", classes="screen-footer-left")
|
|
||||||
yield Static("\\[q] quit", id="connect-footer-right", classes="screen-footer-right")
|
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
|
||||||
self._update_form_visibility()
|
|
||||||
self._update_footer()
|
|
||||||
|
|
||||||
def _update_form_visibility(self) -> None:
|
|
||||||
try:
|
|
||||||
self.query_one("#login-form").display = self._mode == "login"
|
|
||||||
self.query_one("#signup-form").display = self._mode == "signup"
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
self._update_footer()
|
|
||||||
|
|
||||||
def _update_footer(self) -> None:
|
|
||||||
try:
|
|
||||||
left = self.query_one("#connect-footer-left", Static)
|
|
||||||
if self._mode == "signup":
|
|
||||||
left.update("\\[esc] back")
|
|
||||||
else:
|
|
||||||
left.update("")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
||||||
if event.button.id == "btn-login":
|
|
||||||
self._do_login()
|
|
||||||
elif event.button.id == "btn-signup":
|
|
||||||
self._do_signup()
|
|
||||||
elif event.button.id == "btn-toggle-signup":
|
|
||||||
self._mode = "signup"
|
|
||||||
self._set_status("")
|
|
||||||
self._update_form_visibility()
|
|
||||||
elif event.button.id == "btn-toggle-login":
|
|
||||||
self._mode = "login"
|
|
||||||
self._set_status("")
|
|
||||||
self._update_form_visibility()
|
|
||||||
|
|
||||||
def handle_escape(self) -> None:
|
|
||||||
"""Escape goes back to login if on signup form."""
|
|
||||||
if self._mode == "signup":
|
|
||||||
self._mode = "login"
|
|
||||||
self._set_status("")
|
|
||||||
self._update_form_visibility()
|
|
||||||
|
|
||||||
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
||||||
if event.input.id == "input-password":
|
|
||||||
self._do_login()
|
|
||||||
elif event.input.id == "input-signup-password":
|
|
||||||
self._do_signup()
|
|
||||||
|
|
||||||
def _do_login(self) -> None:
|
|
||||||
self._set_status("Logging in...")
|
|
||||||
self._disable_buttons()
|
|
||||||
self.run_worker(self._login_flow(), exclusive=True)
|
|
||||||
|
|
||||||
def _do_signup(self) -> None:
|
|
||||||
self._set_status("Signing up...")
|
|
||||||
self._disable_buttons()
|
|
||||||
self.run_worker(self._signup_flow(), exclusive=True)
|
|
||||||
|
|
||||||
async def _login_flow(self) -> None:
|
|
||||||
client = self.app.client
|
|
||||||
try:
|
|
||||||
username = self.query_one("#input-username", Input).value.strip()
|
|
||||||
password = self.query_one("#input-password", Input).value
|
|
||||||
if not username or not password:
|
|
||||||
self._set_status("Username and password required")
|
|
||||||
self._enable_buttons()
|
|
||||||
return
|
|
||||||
await client.login(username, password)
|
|
||||||
self._set_status(f"Logged in as {client.username}")
|
|
||||||
await self._connect_ws()
|
|
||||||
except Exception as e:
|
|
||||||
self._set_status(f"[red]{e}[/red]")
|
|
||||||
self._enable_buttons()
|
|
||||||
|
|
||||||
async def _signup_flow(self) -> None:
|
|
||||||
client = self.app.client
|
|
||||||
try:
|
|
||||||
invite = self.query_one("#input-invite-code", Input).value.strip()
|
|
||||||
username = self.query_one("#input-signup-username", Input).value.strip()
|
|
||||||
email = self.query_one("#input-signup-email", Input).value.strip()
|
|
||||||
password = self.query_one("#input-signup-password", Input).value
|
|
||||||
if not username or not password:
|
|
||||||
self._set_status("Username and password required")
|
|
||||||
self._enable_buttons()
|
|
||||||
return
|
|
||||||
if len(password) < 8:
|
|
||||||
self._set_status("Password must be at least 8 characters")
|
|
||||||
self._enable_buttons()
|
|
||||||
return
|
|
||||||
await client.register(username, password, invite_code=invite, email=email)
|
|
||||||
self._set_status(f"Account created! Welcome, {client.username}")
|
|
||||||
await self._connect_ws()
|
|
||||||
except Exception as e:
|
|
||||||
self._set_status(f"[red]{e}[/red]")
|
|
||||||
self._enable_buttons()
|
|
||||||
|
|
||||||
async def _connect_ws(self) -> None:
|
|
||||||
client = self.app.client
|
|
||||||
self._set_status("Connecting...")
|
|
||||||
await client.connect()
|
|
||||||
client.save_session()
|
|
||||||
self._set_status("Connected!")
|
|
||||||
from tui_client.screens.lobby import LobbyScreen
|
|
||||||
self.app.switch_screen(LobbyScreen())
|
|
||||||
|
|
||||||
def _set_status(self, text: str) -> None:
|
|
||||||
self.query_one("#connect-status", Static).update(text)
|
|
||||||
|
|
||||||
def _disable_buttons(self) -> None:
|
|
||||||
for btn in self.query("Button"):
|
|
||||||
btn.disabled = True
|
|
||||||
|
|
||||||
def _enable_buttons(self) -> None:
|
|
||||||
for btn in self.query("Button"):
|
|
||||||
btn.disabled = False
|
|
||||||
@ -1,810 +0,0 @@
|
|||||||
"""Main game board screen with keyboard actions and message dispatch."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from textual.app import ComposeResult
|
|
||||||
from textual.containers import Container, Horizontal
|
|
||||||
from textual.events import Resize
|
|
||||||
from textual.screen import ModalScreen, Screen
|
|
||||||
from textual.widgets import Button, Static
|
|
||||||
|
|
||||||
from tui_client.models import GameState, PlayerData
|
|
||||||
from tui_client.screens.confirm import ConfirmScreen
|
|
||||||
from tui_client.widgets.hand import HandWidget
|
|
||||||
from tui_client.widgets.play_area import PlayAreaWidget
|
|
||||||
from tui_client.widgets.scoreboard import ScoreboardScreen
|
|
||||||
from tui_client.widgets.status_bar import StatusBarWidget
|
|
||||||
|
|
||||||
|
|
||||||
_HELP_TEXT = """\
|
|
||||||
[bold]Keyboard Commands[/bold]
|
|
||||||
|
|
||||||
[bold]Drawing[/bold]
|
|
||||||
\\[d] Draw from deck
|
|
||||||
\\[s] Pick from discard pile
|
|
||||||
|
|
||||||
[bold]Card Actions[/bold]
|
|
||||||
\\[1]-\\[6] Select card position
|
|
||||||
(flip, swap, or initial flip)
|
|
||||||
\\[x] Discard held card
|
|
||||||
\\[c] Cancel draw (from discard)
|
|
||||||
|
|
||||||
[bold]Special Actions[/bold]
|
|
||||||
\\[f] Flip a card (when enabled)
|
|
||||||
\\[p] Skip optional flip
|
|
||||||
\\[k] Knock early (when enabled)
|
|
||||||
|
|
||||||
[bold]Game Flow[/bold]
|
|
||||||
\\[n] Next hole
|
|
||||||
\\[tab] Standings
|
|
||||||
\\[q] Quit / leave game
|
|
||||||
\\[h] This help screen
|
|
||||||
|
|
||||||
[dim]\\[esc] to close[/dim]\
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class StandingsScreen(ModalScreen):
|
|
||||||
"""Modal overlay showing current game standings."""
|
|
||||||
|
|
||||||
BINDINGS = [
|
|
||||||
("escape", "close", "Close"),
|
|
||||||
("tab", "close", "Close"),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __init__(self, players: list, current_round: int, total_rounds: int) -> None:
|
|
||||||
super().__init__()
|
|
||||||
self._players = players
|
|
||||||
self._current_round = current_round
|
|
||||||
self._total_rounds = total_rounds
|
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
|
||||||
with Container(id="standings-dialog"):
|
|
||||||
yield Static(
|
|
||||||
f"[bold]Standings — Hole {self._current_round}/{self._total_rounds}[/bold]",
|
|
||||||
id="standings-title",
|
|
||||||
)
|
|
||||||
yield Static(self._build_table(), id="standings-body")
|
|
||||||
yield Static("[dim]\\[esc] to close[/dim]", id="standings-hint")
|
|
||||||
|
|
||||||
def _build_table(self) -> str:
|
|
||||||
sorted_players = sorted(self._players, key=lambda p: p.total_score)
|
|
||||||
lines = []
|
|
||||||
for i, p in enumerate(sorted_players, 1):
|
|
||||||
score_str = f"{p.total_score:>4}"
|
|
||||||
lines.append(f" {i}. {p.name:<16} {score_str}")
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
def action_close(self) -> None:
|
|
||||||
self.dismiss()
|
|
||||||
|
|
||||||
|
|
||||||
class HelpScreen(ModalScreen):
|
|
||||||
"""Modal help overlay showing all keyboard commands."""
|
|
||||||
|
|
||||||
BINDINGS = [
|
|
||||||
("escape", "close", "Close"),
|
|
||||||
("h", "close", "Close"),
|
|
||||||
]
|
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
|
||||||
with Container(id="help-dialog"):
|
|
||||||
yield Static(_HELP_TEXT, id="help-text")
|
|
||||||
|
|
||||||
def action_close(self) -> None:
|
|
||||||
self.dismiss()
|
|
||||||
|
|
||||||
|
|
||||||
class GameScreen(Screen):
|
|
||||||
"""Main game board with card display and keyboard controls."""
|
|
||||||
|
|
||||||
BINDINGS = [
|
|
||||||
("d", "draw_deck", "Draw from deck"),
|
|
||||||
("s", "pick_discard", "Pick from discard"),
|
|
||||||
("1", "select_1", "Position 1"),
|
|
||||||
("2", "select_2", "Position 2"),
|
|
||||||
("3", "select_3", "Position 3"),
|
|
||||||
("4", "select_4", "Position 4"),
|
|
||||||
("5", "select_5", "Position 5"),
|
|
||||||
("6", "select_6", "Position 6"),
|
|
||||||
("x", "discard_held", "Discard held card"),
|
|
||||||
("c", "cancel_draw", "Cancel draw"),
|
|
||||||
("f", "flip_mode", "Flip card"),
|
|
||||||
("p", "skip_flip", "Skip flip"),
|
|
||||||
("k", "knock_early", "Knock early"),
|
|
||||||
("n", "next_round", "Next round"),
|
|
||||||
("q", "quit_game", "Quit game"),
|
|
||||||
("h", "show_help", "Help"),
|
|
||||||
("tab", "show_standings", "Standings"),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __init__(self, initial_state: dict, is_host: bool = False):
|
|
||||||
super().__init__()
|
|
||||||
self._state = GameState.from_dict(initial_state)
|
|
||||||
self._is_host = is_host
|
|
||||||
self._player_id: str = ""
|
|
||||||
self._awaiting_flip = False
|
|
||||||
self._awaiting_initial_flip = False
|
|
||||||
self._initial_flip_positions: list[int] = []
|
|
||||||
self._can_flip_optional = False
|
|
||||||
self._term_width: int = 80
|
|
||||||
self._swap_flash: dict[str, int] = {} # player_id -> position of last swap
|
|
||||||
self._discard_flash: bool = False # discard pile just changed
|
|
||||||
self._pending_reveal: dict | None = None # server-sent reveal for opponents
|
|
||||||
self._reveal_active: bool = False # reveal animation in progress
|
|
||||||
self._deferred_state: GameState | None = None # queued state during reveal
|
|
||||||
self._term_height: int = 24
|
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
|
||||||
yield StatusBarWidget(id="status-bar")
|
|
||||||
with Container(id="game-content"):
|
|
||||||
yield Static("", id="opponents-area")
|
|
||||||
with Horizontal(id="play-area-row"):
|
|
||||||
yield PlayAreaWidget(id="play-area")
|
|
||||||
yield Static("", id="local-hand-label")
|
|
||||||
yield HandWidget(id="local-hand")
|
|
||||||
with Horizontal(id="game-footer"):
|
|
||||||
yield Static("s\\[⇥]andings \\[h]elp", id="footer-left")
|
|
||||||
yield Static("", id="footer-center")
|
|
||||||
yield Static("\\[q]uit", id="footer-right")
|
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
|
||||||
self._player_id = self.app.player_id or ""
|
|
||||||
self._term_width = self.app.size.width
|
|
||||||
self._term_height = self.app.size.height
|
|
||||||
self._full_refresh()
|
|
||||||
|
|
||||||
def on_resize(self, event: Resize) -> None:
|
|
||||||
self._term_width = event.size.width
|
|
||||||
self._term_height = event.size.height
|
|
||||||
self._full_refresh()
|
|
||||||
|
|
||||||
def on_server_message(self, event) -> None:
|
|
||||||
"""Dispatch server messages to handlers."""
|
|
||||||
handler = getattr(self, f"_handle_{event.msg_type}", None)
|
|
||||||
if handler:
|
|
||||||
handler(event.data)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Server message handlers
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _handle_game_state(self, data: dict) -> None:
|
|
||||||
state_data = data.get("game_state", data)
|
|
||||||
old_state = self._state
|
|
||||||
new_state = GameState.from_dict(state_data)
|
|
||||||
reveal = self._detect_swaps(old_state, new_state)
|
|
||||||
if reveal:
|
|
||||||
# Briefly show the old face-down card before applying new state
|
|
||||||
self._show_reveal_then_update(reveal, new_state)
|
|
||||||
elif self._reveal_active:
|
|
||||||
# A reveal is showing — queue this state for after it finishes
|
|
||||||
self._deferred_state = new_state
|
|
||||||
else:
|
|
||||||
self._state = new_state
|
|
||||||
self._full_refresh()
|
|
||||||
|
|
||||||
def _show_reveal_then_update(
|
|
||||||
self,
|
|
||||||
reveal: dict,
|
|
||||||
new_state: GameState,
|
|
||||||
) -> None:
|
|
||||||
"""Show the old card face-up for 1s, then apply the new state."""
|
|
||||||
from tui_client.models import CardData
|
|
||||||
|
|
||||||
player_id = reveal["player_id"]
|
|
||||||
position = reveal["position"]
|
|
||||||
old_card_data = reveal["card"]
|
|
||||||
|
|
||||||
# Modify current state to show old card face-up
|
|
||||||
for p in self._state.players:
|
|
||||||
if p.id == player_id and position < len(p.cards):
|
|
||||||
p.cards[position] = CardData(
|
|
||||||
suit=old_card_data.get("suit"),
|
|
||||||
rank=old_card_data.get("rank"),
|
|
||||||
face_up=True,
|
|
||||||
deck_id=old_card_data.get("deck_id"),
|
|
||||||
)
|
|
||||||
break
|
|
||||||
|
|
||||||
self._reveal_active = True
|
|
||||||
self._deferred_state = new_state
|
|
||||||
self._full_refresh()
|
|
||||||
|
|
||||||
# After 1 second, apply the real new state
|
|
||||||
def apply_new():
|
|
||||||
self._reveal_active = False
|
|
||||||
state = self._deferred_state
|
|
||||||
self._deferred_state = None
|
|
||||||
if state:
|
|
||||||
self._state = state
|
|
||||||
self._full_refresh()
|
|
||||||
|
|
||||||
self.set_timer(1.0, apply_new)
|
|
||||||
|
|
||||||
def _handle_card_revealed(self, data: dict) -> None:
|
|
||||||
"""Server sent old card data for an opponent's face-down swap."""
|
|
||||||
# Store the reveal data so next game_state can use it
|
|
||||||
self._pending_reveal = {
|
|
||||||
"player_id": data.get("player_id"),
|
|
||||||
"position": data.get("position", 0),
|
|
||||||
"card": data.get("card", {}),
|
|
||||||
}
|
|
||||||
|
|
||||||
def _handle_your_turn(self, data: dict) -> None:
|
|
||||||
self._awaiting_flip = False
|
|
||||||
self._refresh_action_bar()
|
|
||||||
|
|
||||||
def _handle_card_drawn(self, data: dict) -> None:
|
|
||||||
from tui_client.models import CardData
|
|
||||||
|
|
||||||
card = CardData.from_dict(data.get("card", {}))
|
|
||||||
source = data.get("source", "deck")
|
|
||||||
rank = card.display_rank
|
|
||||||
suit = card.display_suit
|
|
||||||
|
|
||||||
if source == "discard":
|
|
||||||
self._set_action(
|
|
||||||
f"Holding {rank}{suit} — Choose spot \\[1] thru \\[6] or \\[c]ancel", active=True
|
|
||||||
)
|
|
||||||
self._set_keymap("[1-6] Swap [C] Cancel")
|
|
||||||
else:
|
|
||||||
self._set_action(
|
|
||||||
f"Holding {rank}{suit} — Choose spot \\[1] thru \\[6] or \\[x] to discard", active=True
|
|
||||||
)
|
|
||||||
self._set_keymap("[1-6] Swap [X] Discard")
|
|
||||||
|
|
||||||
def _handle_can_flip(self, data: dict) -> None:
|
|
||||||
self._awaiting_flip = True
|
|
||||||
optional = data.get("optional", False)
|
|
||||||
self._can_flip_optional = optional
|
|
||||||
if optional:
|
|
||||||
self._set_action("Flip a card \\[1] thru \\[6] or \\[p] to skip", active=True)
|
|
||||||
self._set_keymap("[1-6] Flip card [P] Skip")
|
|
||||||
else:
|
|
||||||
self._set_action("Flip a face-down card \\[1] thru \\[6]", active=True)
|
|
||||||
self._set_keymap("[1-6] Flip card")
|
|
||||||
|
|
||||||
def _handle_round_over(self, data: dict) -> None:
|
|
||||||
scores = data.get("scores", [])
|
|
||||||
round_num = data.get("round", 1)
|
|
||||||
total_rounds = data.get("total_rounds", 1)
|
|
||||||
finisher_id = data.get("finisher_id")
|
|
||||||
|
|
||||||
# Delay so players can see the final card layout before the overlay
|
|
||||||
self.set_timer(
|
|
||||||
3.0,
|
|
||||||
lambda: self.app.push_screen(
|
|
||||||
ScoreboardScreen(
|
|
||||||
scores=scores,
|
|
||||||
title=f"Hole {round_num} Complete",
|
|
||||||
is_game_over=False,
|
|
||||||
is_host=self._is_host,
|
|
||||||
round_num=round_num,
|
|
||||||
total_rounds=total_rounds,
|
|
||||||
finisher_id=finisher_id,
|
|
||||||
),
|
|
||||||
callback=self._on_scoreboard_dismiss,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
def _handle_game_over(self, data: dict) -> None:
|
|
||||||
scores = data.get("final_scores", [])
|
|
||||||
|
|
||||||
self.app.push_screen(
|
|
||||||
ScoreboardScreen(
|
|
||||||
scores=scores,
|
|
||||||
title="Game Over!",
|
|
||||||
is_game_over=True,
|
|
||||||
is_host=self._is_host,
|
|
||||||
),
|
|
||||||
callback=self._on_scoreboard_dismiss,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _handle_round_started(self, data: dict) -> None:
|
|
||||||
state_data = data.get("game_state", data)
|
|
||||||
self._state = GameState.from_dict(state_data)
|
|
||||||
self._awaiting_flip = False
|
|
||||||
self._awaiting_initial_flip = False
|
|
||||||
self._initial_flip_positions = []
|
|
||||||
self._full_refresh()
|
|
||||||
|
|
||||||
def _handle_game_ended(self, data: dict) -> None:
|
|
||||||
reason = data.get("reason", "Game ended")
|
|
||||||
self._set_action(f"{reason}. Press Escape to return to lobby.")
|
|
||||||
|
|
||||||
def _handle_error(self, data: dict) -> None:
|
|
||||||
msg = data.get("message", "Unknown error")
|
|
||||||
self._set_action(f"[red]Error: {msg}[/red]")
|
|
||||||
|
|
||||||
def _handle_connection_closed(self, data: dict) -> None:
|
|
||||||
self._set_action("[red]Connection lost.[/red]")
|
|
||||||
|
|
||||||
def _on_scoreboard_dismiss(self, result: str | None) -> None:
|
|
||||||
if result == "next_round":
|
|
||||||
self.run_worker(self._send("next_round"))
|
|
||||||
elif result == "lobby":
|
|
||||||
self.run_worker(self._send("leave_game"))
|
|
||||||
self.app.pop_screen()
|
|
||||||
# Reset lobby back to create/join state
|
|
||||||
lobby = self.app.screen
|
|
||||||
if hasattr(lobby, "reset_to_pre_room"):
|
|
||||||
lobby.reset_to_pre_room()
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Click handlers (from widget messages)
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def on_hand_widget_card_clicked(self, event: HandWidget.CardClicked) -> None:
|
|
||||||
"""Handle click on a card in the local hand."""
|
|
||||||
self._select_position(event.position)
|
|
||||||
|
|
||||||
def on_play_area_widget_deck_clicked(self, event: PlayAreaWidget.DeckClicked) -> None:
|
|
||||||
"""Handle click on the deck."""
|
|
||||||
self.action_draw_deck()
|
|
||||||
|
|
||||||
def on_play_area_widget_discard_clicked(self, event: PlayAreaWidget.DiscardClicked) -> None:
|
|
||||||
"""Handle click on the discard pile.
|
|
||||||
|
|
||||||
If holding a card, discard it. Otherwise, draw from discard.
|
|
||||||
"""
|
|
||||||
if self._state and self._state.has_drawn_card:
|
|
||||||
self.action_discard_held()
|
|
||||||
else:
|
|
||||||
self.action_pick_discard()
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Keyboard actions
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def action_draw_deck(self) -> None:
|
|
||||||
if not self._is_my_turn() or self._state.has_drawn_card:
|
|
||||||
return
|
|
||||||
self.run_worker(self._send("draw", source="deck"))
|
|
||||||
|
|
||||||
def action_pick_discard(self) -> None:
|
|
||||||
if not self._is_my_turn() or self._state.has_drawn_card:
|
|
||||||
return
|
|
||||||
if not self._state.discard_top:
|
|
||||||
return
|
|
||||||
self.run_worker(self._send("draw", source="discard"))
|
|
||||||
|
|
||||||
def action_select_1(self) -> None:
|
|
||||||
self._select_position(0)
|
|
||||||
|
|
||||||
def action_select_2(self) -> None:
|
|
||||||
self._select_position(1)
|
|
||||||
|
|
||||||
def action_select_3(self) -> None:
|
|
||||||
self._select_position(2)
|
|
||||||
|
|
||||||
def action_select_4(self) -> None:
|
|
||||||
self._select_position(3)
|
|
||||||
|
|
||||||
def action_select_5(self) -> None:
|
|
||||||
self._select_position(4)
|
|
||||||
|
|
||||||
def action_select_6(self) -> None:
|
|
||||||
self._select_position(5)
|
|
||||||
|
|
||||||
def _select_position(self, pos: int) -> None:
|
|
||||||
# Initial flip phase
|
|
||||||
if self._state.waiting_for_initial_flip:
|
|
||||||
self._handle_initial_flip_select(pos)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Flip after discard
|
|
||||||
if self._awaiting_flip:
|
|
||||||
self._do_flip(pos)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Swap with held card
|
|
||||||
if self._state.has_drawn_card and self._is_my_turn():
|
|
||||||
self.run_worker(self._send("swap", position=pos))
|
|
||||||
return
|
|
||||||
|
|
||||||
def _handle_initial_flip_select(self, pos: int) -> None:
|
|
||||||
if pos in self._initial_flip_positions:
|
|
||||||
return # already selected
|
|
||||||
# Reject already face-up cards
|
|
||||||
me = self._get_local_player()
|
|
||||||
if me and pos < len(me.cards) and me.cards[pos].face_up:
|
|
||||||
return
|
|
||||||
|
|
||||||
self._initial_flip_positions.append(pos)
|
|
||||||
|
|
||||||
# Immediately show the card as face-up locally for visual feedback
|
|
||||||
if me and pos < len(me.cards):
|
|
||||||
me.cards[pos].face_up = True
|
|
||||||
hand = self.query_one("#local-hand", HandWidget)
|
|
||||||
hand.update_player(
|
|
||||||
me,
|
|
||||||
deck_colors=self._state.deck_colors,
|
|
||||||
is_current_turn=False,
|
|
||||||
is_knocker=False,
|
|
||||||
is_dealer=(me.id == self._state.dealer_id),
|
|
||||||
highlight=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
needed = self._state.initial_flips
|
|
||||||
selected = len(self._initial_flip_positions)
|
|
||||||
|
|
||||||
if selected >= needed:
|
|
||||||
self.run_worker(
|
|
||||||
self._send("flip_initial", positions=self._initial_flip_positions)
|
|
||||||
)
|
|
||||||
self._awaiting_initial_flip = False
|
|
||||||
self._initial_flip_positions = []
|
|
||||||
else:
|
|
||||||
self._set_action(
|
|
||||||
f"Choose {needed - selected} more card(s) to flip ({selected}/{needed})", active=True
|
|
||||||
)
|
|
||||||
|
|
||||||
def _do_flip(self, pos: int) -> None:
|
|
||||||
me = self._get_local_player()
|
|
||||||
if me and pos < len(me.cards) and me.cards[pos].face_up:
|
|
||||||
self._set_action("That card is already face-up! Pick a face-down card.")
|
|
||||||
return
|
|
||||||
self.run_worker(self._send("flip_card", position=pos))
|
|
||||||
self._awaiting_flip = False
|
|
||||||
|
|
||||||
def action_discard_held(self) -> None:
|
|
||||||
if not self._is_my_turn() or not self._state.has_drawn_card:
|
|
||||||
return
|
|
||||||
if not self._state.can_discard:
|
|
||||||
self._set_action("Can't discard a card drawn from discard. Swap or cancel.")
|
|
||||||
return
|
|
||||||
self.run_worker(self._send("discard"))
|
|
||||||
|
|
||||||
def action_cancel_draw(self) -> None:
|
|
||||||
if not self._is_my_turn() or not self._state.has_drawn_card:
|
|
||||||
return
|
|
||||||
self.run_worker(self._send("cancel_draw"))
|
|
||||||
|
|
||||||
def action_flip_mode(self) -> None:
|
|
||||||
if self._state.flip_as_action and self._is_my_turn() and not self._state.has_drawn_card:
|
|
||||||
self._awaiting_flip = True
|
|
||||||
self._set_action("Flip mode: select a face-down card [1-6]", active=True)
|
|
||||||
|
|
||||||
def action_skip_flip(self) -> None:
|
|
||||||
if self._awaiting_flip and self._can_flip_optional:
|
|
||||||
self.run_worker(self._send("skip_flip"))
|
|
||||||
self._awaiting_flip = False
|
|
||||||
|
|
||||||
def action_knock_early(self) -> None:
|
|
||||||
if not self._is_my_turn() or self._state.has_drawn_card:
|
|
||||||
return
|
|
||||||
if not self._state.knock_early:
|
|
||||||
return
|
|
||||||
self.run_worker(self._send("knock_early"))
|
|
||||||
|
|
||||||
def action_next_round(self) -> None:
|
|
||||||
if self._is_host and self._state.phase == "round_over":
|
|
||||||
self.run_worker(self._send("next_round"))
|
|
||||||
|
|
||||||
def action_show_help(self) -> None:
|
|
||||||
self.app.push_screen(HelpScreen())
|
|
||||||
|
|
||||||
def action_show_standings(self) -> None:
|
|
||||||
self.app.push_screen(StandingsScreen(
|
|
||||||
self._state.players,
|
|
||||||
self._state.current_round,
|
|
||||||
self._state.total_rounds,
|
|
||||||
))
|
|
||||||
|
|
||||||
def action_quit_game(self) -> None:
|
|
||||||
if self._is_host:
|
|
||||||
msg = "End the game for everyone?"
|
|
||||||
else:
|
|
||||||
msg = "Leave this game?"
|
|
||||||
self.app.push_screen(ConfirmScreen(msg), callback=self._on_quit_confirm)
|
|
||||||
|
|
||||||
def _on_quit_confirm(self, confirmed: bool) -> None:
|
|
||||||
if confirmed:
|
|
||||||
self.action_leave_game()
|
|
||||||
|
|
||||||
def action_leave_game(self) -> None:
|
|
||||||
self.run_worker(self._send("leave_game"))
|
|
||||||
self.app.pop_screen()
|
|
||||||
# Reset lobby back to create/join state
|
|
||||||
lobby = self.app.screen
|
|
||||||
if hasattr(lobby, "reset_to_pre_room"):
|
|
||||||
lobby.reset_to_pre_room()
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Swap/discard detection
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _detect_swaps(self, old: GameState, new: GameState) -> dict | None:
|
|
||||||
"""Compare old and new state to find which card positions changed.
|
|
||||||
|
|
||||||
Returns reveal info dict if a face-down card was swapped, else None.
|
|
||||||
"""
|
|
||||||
reveal = None
|
|
||||||
if not old or not new or not old.players or not new.players:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Only reveal during active play, not initial flip or round end
|
|
||||||
reveal_eligible = old.phase in ("playing", "final_turn")
|
|
||||||
|
|
||||||
old_map = {p.id: p for p in old.players}
|
|
||||||
for np in new.players:
|
|
||||||
op = old_map.get(np.id)
|
|
||||||
if not op:
|
|
||||||
continue
|
|
||||||
for i, (oc, nc) in enumerate(zip(op.cards, np.cards)):
|
|
||||||
# Card changed: rank/suit differ and new card is face-up
|
|
||||||
if (oc.rank != nc.rank or oc.suit != nc.suit) and nc.face_up:
|
|
||||||
self._swap_flash[np.id] = i
|
|
||||||
|
|
||||||
# Was old card face-down? If we have its data, reveal it
|
|
||||||
if reveal_eligible and not oc.face_up and oc.rank and oc.suit:
|
|
||||||
# Local player — we know face-down card values
|
|
||||||
reveal = {
|
|
||||||
"player_id": np.id,
|
|
||||||
"position": i,
|
|
||||||
"card": {"rank": oc.rank, "suit": oc.suit, "deck_id": oc.deck_id},
|
|
||||||
}
|
|
||||||
elif reveal_eligible and not oc.face_up and self._pending_reveal:
|
|
||||||
# Opponent — use server-sent reveal data
|
|
||||||
pr = self._pending_reveal
|
|
||||||
if pr.get("player_id") == np.id and pr.get("position") == i:
|
|
||||||
reveal = {
|
|
||||||
"player_id": np.id,
|
|
||||||
"position": i,
|
|
||||||
"card": pr["card"],
|
|
||||||
}
|
|
||||||
break
|
|
||||||
|
|
||||||
self._pending_reveal = None
|
|
||||||
|
|
||||||
# Detect discard change (new discard top differs from old)
|
|
||||||
if old.discard_top and new.discard_top:
|
|
||||||
if (old.discard_top.rank != new.discard_top.rank or
|
|
||||||
old.discard_top.suit != new.discard_top.suit):
|
|
||||||
self._discard_flash = True
|
|
||||||
elif not old.discard_top and new.discard_top:
|
|
||||||
self._discard_flash = True
|
|
||||||
|
|
||||||
# Schedule flash clear after 2 seconds
|
|
||||||
if self._swap_flash or self._discard_flash:
|
|
||||||
self.set_timer(1.0, self._clear_flash)
|
|
||||||
|
|
||||||
return reveal
|
|
||||||
|
|
||||||
def _clear_flash(self) -> None:
|
|
||||||
"""Clear swap/discard flash highlights and re-render."""
|
|
||||||
self._swap_flash.clear()
|
|
||||||
self._discard_flash = False
|
|
||||||
self._full_refresh()
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Helpers
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _is_my_turn(self) -> bool:
|
|
||||||
return self._state.current_player_id == self._player_id
|
|
||||||
|
|
||||||
def _get_local_player(self) -> PlayerData | None:
|
|
||||||
for p in self._state.players:
|
|
||||||
if p.id == self._player_id:
|
|
||||||
return p
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def _send(self, msg_type: str, **kwargs) -> None:
|
|
||||||
try:
|
|
||||||
await self.app.client.send(msg_type, **kwargs)
|
|
||||||
except Exception as e:
|
|
||||||
self._set_action(f"[red]Send error: {e}[/red]")
|
|
||||||
|
|
||||||
def _set_action(self, text: str, active: bool = False) -> None:
|
|
||||||
import re
|
|
||||||
try:
|
|
||||||
if active:
|
|
||||||
# Highlight bracketed keys and parenthesized counts in amber,
|
|
||||||
# rest in bold white
|
|
||||||
ac = "#ffaa00"
|
|
||||||
# Color \\[...] key hints and (...) counts
|
|
||||||
text = re.sub(
|
|
||||||
r"(\\?\[.*?\]|\([\d/]+\))",
|
|
||||||
rf"[bold {ac}]\1[/]",
|
|
||||||
text,
|
|
||||||
)
|
|
||||||
text = f"[bold white]{text}[/]"
|
|
||||||
self.query_one("#footer-center", Static).update(text)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Rendering
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _full_refresh(self) -> None:
|
|
||||||
"""Refresh all widgets from current game state."""
|
|
||||||
state = self._state
|
|
||||||
|
|
||||||
# Status bar
|
|
||||||
status = self.query_one("#status-bar", StatusBarWidget)
|
|
||||||
status.update_state(state, self._player_id)
|
|
||||||
|
|
||||||
# Play area
|
|
||||||
play_area = self.query_one("#play-area", PlayAreaWidget)
|
|
||||||
play_area.update_state(state, local_player_id=self._player_id, discard_flash=self._discard_flash)
|
|
||||||
is_active = self._is_my_turn() and not state.waiting_for_initial_flip
|
|
||||||
play_area.set_class(is_active, "my-turn")
|
|
||||||
|
|
||||||
# Local player hand (in bordered box with turn/knocker indicators)
|
|
||||||
me = self._get_local_player()
|
|
||||||
if me:
|
|
||||||
self.query_one("#local-hand-label", Static).update("")
|
|
||||||
hand = self.query_one("#local-hand", HandWidget)
|
|
||||||
hand._is_local = True
|
|
||||||
# During initial flip, don't show current_turn borders (no one is "taking a turn")
|
|
||||||
show_turn = not state.waiting_for_initial_flip and me.id == state.current_player_id
|
|
||||||
hand.update_player(
|
|
||||||
me,
|
|
||||||
deck_colors=state.deck_colors,
|
|
||||||
is_current_turn=show_turn,
|
|
||||||
is_knocker=(me.id == state.finisher_id and state.phase == "final_turn"),
|
|
||||||
is_dealer=(me.id == state.dealer_id),
|
|
||||||
highlight=state.waiting_for_initial_flip,
|
|
||||||
flash_position=self._swap_flash.get(me.id),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.query_one("#local-hand-label", Static).update("")
|
|
||||||
|
|
||||||
# Opponents - bordered boxes in a single Static
|
|
||||||
opponents = [p for p in state.players if p.id != self._player_id]
|
|
||||||
self._render_opponents(opponents)
|
|
||||||
|
|
||||||
# Action bar
|
|
||||||
self._refresh_action_bar()
|
|
||||||
|
|
||||||
def _render_opponents(self, opponents: list[PlayerData]) -> None:
|
|
||||||
"""Render all opponent hands as bordered boxes into the opponents area.
|
|
||||||
|
|
||||||
Adapts layout based on terminal width:
|
|
||||||
- Narrow (<80): stack opponents vertically
|
|
||||||
- Medium (80-119): 2-3 side-by-side with moderate spacing
|
|
||||||
- Wide (120+): all side-by-side with generous spacing
|
|
||||||
"""
|
|
||||||
if not opponents:
|
|
||||||
self.query_one("#opponents-area", Static).update("")
|
|
||||||
return
|
|
||||||
|
|
||||||
from tui_client.widgets.hand import _check_column_match, _render_card_lines
|
|
||||||
from tui_client.widgets.player_box import _visible_len, render_player_box
|
|
||||||
|
|
||||||
state = self._state
|
|
||||||
deck_colors = state.deck_colors
|
|
||||||
width = self._term_width
|
|
||||||
|
|
||||||
# Build each opponent's boxed display
|
|
||||||
opp_blocks: list[list[str]] = []
|
|
||||||
for opp in opponents:
|
|
||||||
cards = opp.cards
|
|
||||||
matched = _check_column_match(cards)
|
|
||||||
card_lines = _render_card_lines(
|
|
||||||
cards, deck_colors=deck_colors, matched=matched,
|
|
||||||
flash_position=self._swap_flash.get(opp.id),
|
|
||||||
)
|
|
||||||
|
|
||||||
opp_turn = not state.waiting_for_initial_flip and opp.id == state.current_player_id
|
|
||||||
display_score = opp.score if opp.score is not None else opp.visible_score
|
|
||||||
box = render_player_box(
|
|
||||||
opp.name,
|
|
||||||
score=display_score,
|
|
||||||
total_score=opp.total_score,
|
|
||||||
content_lines=card_lines,
|
|
||||||
is_current_turn=opp_turn,
|
|
||||||
is_knocker=(opp.id == state.finisher_id and state.phase == "final_turn"),
|
|
||||||
is_dealer=(opp.id == state.dealer_id),
|
|
||||||
)
|
|
||||||
opp_blocks.append(box)
|
|
||||||
|
|
||||||
# Determine how many opponents fit per row
|
|
||||||
# Account for padding on the opponents-area widget (2 chars each side)
|
|
||||||
try:
|
|
||||||
opp_widget = self.query_one("#opponents-area", Static)
|
|
||||||
avail_width = opp_widget.content_size.width or (width - 4)
|
|
||||||
except Exception:
|
|
||||||
avail_width = width - 4
|
|
||||||
|
|
||||||
box_widths = [_visible_len(b[0]) if b else 22 for b in opp_blocks]
|
|
||||||
gap = " " if avail_width < 120 else " "
|
|
||||||
gap_len = len(gap)
|
|
||||||
|
|
||||||
# Greedily fit as many as possible in one row
|
|
||||||
per_row = 0
|
|
||||||
row_width = 0
|
|
||||||
for bw in box_widths:
|
|
||||||
needed_width = bw if per_row == 0 else gap_len + bw
|
|
||||||
if row_width + needed_width <= avail_width:
|
|
||||||
row_width += needed_width
|
|
||||||
per_row += 1
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
per_row = max(1, per_row)
|
|
||||||
|
|
||||||
# Render in rows of per_row opponents
|
|
||||||
all_row_lines: list[str] = []
|
|
||||||
for chunk_start in range(0, len(opp_blocks), per_row):
|
|
||||||
chunk = opp_blocks[chunk_start : chunk_start + per_row]
|
|
||||||
|
|
||||||
if len(chunk) == 1:
|
|
||||||
all_row_lines.extend(chunk[0])
|
|
||||||
else:
|
|
||||||
max_height = max(len(b) for b in chunk)
|
|
||||||
# Pad shorter blocks with spaces matching each block's visible width
|
|
||||||
for b in chunk:
|
|
||||||
if b:
|
|
||||||
pad_width = _visible_len(b[0])
|
|
||||||
else:
|
|
||||||
pad_width = 0
|
|
||||||
while len(b) < max_height:
|
|
||||||
b.append(" " * pad_width)
|
|
||||||
for row_idx in range(max_height):
|
|
||||||
parts = [b[row_idx] for b in chunk]
|
|
||||||
all_row_lines.append(gap.join(parts))
|
|
||||||
|
|
||||||
if chunk_start + per_row < len(opp_blocks):
|
|
||||||
all_row_lines.append("")
|
|
||||||
|
|
||||||
self.query_one("#opponents-area", Static).update("\n".join(all_row_lines))
|
|
||||||
|
|
||||||
def _refresh_action_bar(self) -> None:
|
|
||||||
"""Update action bar and keymap based on current game state."""
|
|
||||||
state = self._state
|
|
||||||
|
|
||||||
if state.phase in ("round_over", "game_over"):
|
|
||||||
self._set_action("\\[n]ext hole", active=True)
|
|
||||||
self._set_keymap("[N] Next hole")
|
|
||||||
return
|
|
||||||
|
|
||||||
if state.waiting_for_initial_flip:
|
|
||||||
needed = state.initial_flips
|
|
||||||
selected = len(self._initial_flip_positions)
|
|
||||||
self._set_action(
|
|
||||||
f"Choose {needed} cards \\[1] thru \\[6] to flip ({selected}/{needed})", active=True
|
|
||||||
)
|
|
||||||
self._set_keymap("[1-6] Select card")
|
|
||||||
return
|
|
||||||
|
|
||||||
if not self._is_my_turn():
|
|
||||||
if state.current_player_id:
|
|
||||||
for p in state.players:
|
|
||||||
if p.id == state.current_player_id:
|
|
||||||
self._set_action(f"Waiting for {p.name}...")
|
|
||||||
self._set_keymap("Waiting...")
|
|
||||||
return
|
|
||||||
self._set_action("Waiting...")
|
|
||||||
self._set_keymap("Waiting...")
|
|
||||||
return
|
|
||||||
|
|
||||||
if state.has_drawn_card:
|
|
||||||
keys = ["[1-6] Swap"]
|
|
||||||
if state.can_discard:
|
|
||||||
self._set_action("Choose spot \\[1] thru \\[6] or \\[x] to discard", active=True)
|
|
||||||
keys.append("[X] Discard")
|
|
||||||
else:
|
|
||||||
self._set_action("Choose spot \\[1] thru \\[6] or \\[c]ancel", active=True)
|
|
||||||
keys.append("[C] Cancel")
|
|
||||||
self._set_keymap(" ".join(keys))
|
|
||||||
return
|
|
||||||
|
|
||||||
parts = ["Choose \\[d]eck or di\\[s]card pile"]
|
|
||||||
keys = ["[D] Draw", "[S] Pick discard"]
|
|
||||||
if state.flip_as_action:
|
|
||||||
parts.append("\\[f]lip a card")
|
|
||||||
keys.append("[F] Flip")
|
|
||||||
if state.knock_early:
|
|
||||||
parts.append("\\[k]nock early")
|
|
||||||
keys.append("[K] Knock")
|
|
||||||
self._set_action(" or ".join(parts), active=True)
|
|
||||||
self._set_keymap(" ".join(keys))
|
|
||||||
|
|
||||||
def _set_keymap(self, text: str) -> None:
|
|
||||||
try:
|
|
||||||
self.app.set_keymap(text)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
@ -1,561 +0,0 @@
|
|||||||
"""Lobby screen: create/join room, add CPUs, configure, start game."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from textual.app import ComposeResult
|
|
||||||
from textual.containers import Container, Horizontal, Vertical
|
|
||||||
from textual.screen import Screen
|
|
||||||
from textual.widgets import (
|
|
||||||
Button,
|
|
||||||
Collapsible,
|
|
||||||
Input,
|
|
||||||
Label,
|
|
||||||
OptionList,
|
|
||||||
Select,
|
|
||||||
Static,
|
|
||||||
Switch,
|
|
||||||
)
|
|
||||||
from textual.widgets.option_list import Option
|
|
||||||
|
|
||||||
|
|
||||||
DECK_PRESETS = {
|
|
||||||
"classic": ["red", "blue", "gold"],
|
|
||||||
"ninja": ["green", "purple", "orange"],
|
|
||||||
"ocean": ["blue", "teal", "cyan"],
|
|
||||||
"forest": ["green", "gold", "brown"],
|
|
||||||
"sunset": ["orange", "red", "purple"],
|
|
||||||
"berry": ["purple", "pink", "red"],
|
|
||||||
"neon": ["pink", "cyan", "green"],
|
|
||||||
"royal": ["purple", "gold", "red"],
|
|
||||||
"earth": ["brown", "green", "gold"],
|
|
||||||
"all-red": ["red", "red", "red"],
|
|
||||||
"all-blue": ["blue", "blue", "blue"],
|
|
||||||
"all-green": ["green", "green", "green"],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class LobbyScreen(Screen):
|
|
||||||
"""Room creation, joining, and pre-game configuration."""
|
|
||||||
|
|
||||||
BINDINGS = [
|
|
||||||
("plus_sign", "add_cpu", "Add CPU"),
|
|
||||||
("equals_sign", "add_cpu", "Add CPU"),
|
|
||||||
("hyphen_minus", "remove_cpu", "Remove CPU"),
|
|
||||||
("enter", "start_or_create", "Start/Create"),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__()
|
|
||||||
self._room_code: str | None = None
|
|
||||||
self._player_id: str | None = None
|
|
||||||
self._is_host: bool = False
|
|
||||||
self._players: list[dict] = []
|
|
||||||
self._in_room: bool = False
|
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
|
||||||
with Container(id="lobby-container"):
|
|
||||||
yield Static(
|
|
||||||
"⛳🏌️ [bold]GolfCards.club[/bold] "
|
|
||||||
"[bold #aaaaaa]♠[/bold #aaaaaa]"
|
|
||||||
"[bold #cc0000]♥[/bold #cc0000]"
|
|
||||||
"[bold #aaaaaa]♣[/bold #aaaaaa]"
|
|
||||||
"[bold #cc0000]♦[/bold #cc0000]",
|
|
||||||
id="lobby-title",
|
|
||||||
)
|
|
||||||
# Pre-room: join/create
|
|
||||||
with Vertical(id="pre-room"):
|
|
||||||
yield Input(placeholder="Room code (leave blank to create new)", id="input-room-code")
|
|
||||||
with Horizontal(id="pre-room-buttons"):
|
|
||||||
yield Button("Create Room", id="btn-create", variant="primary")
|
|
||||||
yield Button("Join Room", id="btn-join", variant="default")
|
|
||||||
|
|
||||||
# In-room: player list + controls + settings
|
|
||||||
with Vertical(id="in-room"):
|
|
||||||
yield Static("", id="room-info")
|
|
||||||
yield Static("[bold]Players[/bold]", id="player-list-label")
|
|
||||||
yield Static("", id="player-list")
|
|
||||||
|
|
||||||
# CPU controls: compact [+] [-]
|
|
||||||
with Horizontal(id="cpu-controls"):
|
|
||||||
yield Label("CPU:", id="cpu-label")
|
|
||||||
yield Button("+", id="btn-cpu-add", variant="default")
|
|
||||||
yield Button("−", id="btn-cpu-remove", variant="warning")
|
|
||||||
yield Button("?", id="btn-cpu-random", variant="default")
|
|
||||||
|
|
||||||
# CPU profile picker (hidden by default)
|
|
||||||
yield OptionList(id="cpu-profile-list")
|
|
||||||
|
|
||||||
# Host settings (collapsible sections)
|
|
||||||
with Vertical(id="host-settings"):
|
|
||||||
with Collapsible(title="Game Settings", collapsed=True, id="coll-game"):
|
|
||||||
with Horizontal(classes="setting-row"):
|
|
||||||
yield Label("Holes")
|
|
||||||
yield Select(
|
|
||||||
[(str(v), v) for v in (1, 3, 9, 18)],
|
|
||||||
value=9,
|
|
||||||
id="sel-rounds",
|
|
||||||
allow_blank=False,
|
|
||||||
)
|
|
||||||
with Horizontal(classes="setting-row"):
|
|
||||||
yield Label("Decks")
|
|
||||||
yield Select(
|
|
||||||
[(str(v), v) for v in (1, 2, 3)],
|
|
||||||
value=1,
|
|
||||||
id="sel-decks",
|
|
||||||
allow_blank=False,
|
|
||||||
)
|
|
||||||
with Horizontal(classes="setting-row"):
|
|
||||||
yield Label("Initial Flips")
|
|
||||||
yield Select(
|
|
||||||
[(str(v), v) for v in (0, 1, 2)],
|
|
||||||
value=2,
|
|
||||||
id="sel-initial-flips",
|
|
||||||
allow_blank=False,
|
|
||||||
)
|
|
||||||
with Horizontal(classes="setting-row"):
|
|
||||||
yield Label("Flip Mode")
|
|
||||||
yield Select(
|
|
||||||
[("Never", "never"), ("Always", "always"), ("Endgame", "endgame")],
|
|
||||||
value="never",
|
|
||||||
id="sel-flip-mode",
|
|
||||||
allow_blank=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
with Collapsible(title="House Rules", collapsed=True, id="coll-rules"):
|
|
||||||
# Joker variant
|
|
||||||
with Horizontal(classes="setting-row"):
|
|
||||||
yield Label("Jokers")
|
|
||||||
yield Select(
|
|
||||||
[
|
|
||||||
("None", "none"),
|
|
||||||
("Standard (−2)", "standard"),
|
|
||||||
("Lucky Swing (−5)", "lucky_swing"),
|
|
||||||
("Eagle Eye (+2/−4)", "eagle_eye"),
|
|
||||||
],
|
|
||||||
value="none",
|
|
||||||
id="sel-jokers",
|
|
||||||
allow_blank=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Scoring rules
|
|
||||||
yield Static("[bold]Scoring[/bold]", classes="rules-header")
|
|
||||||
with Horizontal(classes="rule-row"):
|
|
||||||
yield Label("Super Kings (K = −2)")
|
|
||||||
yield Switch(id="sw-super_kings")
|
|
||||||
with Horizontal(classes="rule-row"):
|
|
||||||
yield Label("Ten Penny (10 = 1)")
|
|
||||||
yield Switch(id="sw-ten_penny")
|
|
||||||
with Horizontal(classes="rule-row"):
|
|
||||||
yield Label("One-Eyed Jacks (J♥/J♠ = 0)")
|
|
||||||
yield Switch(id="sw-one_eyed_jacks")
|
|
||||||
with Horizontal(classes="rule-row"):
|
|
||||||
yield Label("Negative Pairs Keep Value")
|
|
||||||
yield Switch(id="sw-negative_pairs_keep_value")
|
|
||||||
with Horizontal(classes="rule-row"):
|
|
||||||
yield Label("Four of a Kind (−20)")
|
|
||||||
yield Switch(id="sw-four_of_a_kind")
|
|
||||||
|
|
||||||
# Knock & Endgame
|
|
||||||
yield Static("[bold]Knock & Endgame[/bold]", classes="rules-header")
|
|
||||||
with Horizontal(classes="rule-row"):
|
|
||||||
yield Label("Knock Penalty (+10)")
|
|
||||||
yield Switch(id="sw-knock_penalty")
|
|
||||||
with Horizontal(classes="rule-row"):
|
|
||||||
yield Label("Knock Bonus (−5)")
|
|
||||||
yield Switch(id="sw-knock_bonus")
|
|
||||||
with Horizontal(classes="rule-row"):
|
|
||||||
yield Label("Knock Early")
|
|
||||||
yield Switch(id="sw-knock_early")
|
|
||||||
with Horizontal(classes="rule-row"):
|
|
||||||
yield Label("Flip as Action")
|
|
||||||
yield Switch(id="sw-flip_as_action")
|
|
||||||
|
|
||||||
# Bonuses & Penalties
|
|
||||||
yield Static("[bold]Bonuses & Penalties[/bold]", classes="rules-header")
|
|
||||||
with Horizontal(classes="rule-row"):
|
|
||||||
yield Label("Underdog Bonus (−3)")
|
|
||||||
yield Switch(id="sw-underdog_bonus")
|
|
||||||
with Horizontal(classes="rule-row"):
|
|
||||||
yield Label("Tied Shame (+5)")
|
|
||||||
yield Switch(id="sw-tied_shame")
|
|
||||||
with Horizontal(classes="rule-row"):
|
|
||||||
yield Label("Blackjack (21→0)")
|
|
||||||
yield Switch(id="sw-blackjack")
|
|
||||||
with Horizontal(classes="rule-row"):
|
|
||||||
yield Label("Wolfpack")
|
|
||||||
yield Switch(id="sw-wolfpack")
|
|
||||||
|
|
||||||
with Collapsible(title="Deck Style", collapsed=True, id="coll-deck"):
|
|
||||||
with Horizontal(classes="setting-row"):
|
|
||||||
yield Select(
|
|
||||||
[(name.replace("-", " ").title(), name) for name in DECK_PRESETS],
|
|
||||||
value="classic",
|
|
||||||
id="sel-deck-style",
|
|
||||||
allow_blank=False,
|
|
||||||
)
|
|
||||||
yield Static(
|
|
||||||
self._render_deck_preview("classic"),
|
|
||||||
id="deck-preview",
|
|
||||||
)
|
|
||||||
|
|
||||||
yield Button("Start Game", id="btn-start", variant="success")
|
|
||||||
|
|
||||||
yield Static("", id="lobby-status")
|
|
||||||
|
|
||||||
with Horizontal(classes="screen-footer"): # Outside lobby-container
|
|
||||||
yield Static("\\[esc] back", id="lobby-footer-left", classes="screen-footer-left")
|
|
||||||
yield Static("\\[q] quit", id="lobby-footer-right", classes="screen-footer-right")
|
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
|
||||||
self._update_visibility()
|
|
||||||
self._update_keymap()
|
|
||||||
self._update_footer()
|
|
||||||
|
|
||||||
def reset_to_pre_room(self) -> None:
|
|
||||||
"""Reset lobby back to create/join state after leaving a game."""
|
|
||||||
self._room_code = None
|
|
||||||
self._player_id = None
|
|
||||||
self._is_host = False
|
|
||||||
self._players = []
|
|
||||||
self._in_room = False
|
|
||||||
self._set_room_info("")
|
|
||||||
self._set_status("")
|
|
||||||
try:
|
|
||||||
self.query_one("#input-room-code", Input).value = ""
|
|
||||||
self.query_one("#player-list", Static).update("")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
self._update_visibility()
|
|
||||||
self._update_keymap()
|
|
||||||
|
|
||||||
def _update_visibility(self) -> None:
|
|
||||||
try:
|
|
||||||
self.query_one("#pre-room").display = not self._in_room
|
|
||||||
self.query_one("#in-room").display = self._in_room
|
|
||||||
# Host-only controls
|
|
||||||
self.query_one("#cpu-controls").display = self._in_room and self._is_host
|
|
||||||
self.query_one("#host-settings").display = self._in_room and self._is_host
|
|
||||||
self.query_one("#btn-start").display = self._in_room and self._is_host
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _update_footer(self) -> None:
|
|
||||||
try:
|
|
||||||
left = self.query_one("#lobby-footer-left", Static)
|
|
||||||
if self._in_room:
|
|
||||||
left.update("\\[esc] leave room")
|
|
||||||
else:
|
|
||||||
left.update("\\[esc] log out")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _update_keymap(self) -> None:
|
|
||||||
self._update_footer()
|
|
||||||
try:
|
|
||||||
if self._in_room and self._is_host:
|
|
||||||
self.app.set_keymap("[Esc] Leave [+] Add CPU [−] Remove [Enter] Start [q] Quit")
|
|
||||||
elif self._in_room:
|
|
||||||
self.app.set_keymap("[Esc] Leave Waiting for host... [q] Quit")
|
|
||||||
else:
|
|
||||||
self.app.set_keymap("[Esc] Log out [Tab] Navigate [Enter] Create/Join [q] Quit")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def handle_escape(self) -> None:
|
|
||||||
"""Single escape: leave room (with confirm if host), or log out."""
|
|
||||||
if self._in_room:
|
|
||||||
if self._is_host:
|
|
||||||
from tui_client.screens.confirm import ConfirmScreen
|
|
||||||
self.app.push_screen(
|
|
||||||
ConfirmScreen("End the game for everyone?"),
|
|
||||||
callback=self._on_leave_confirm,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.run_worker(self._send("leave_game"))
|
|
||||||
self.reset_to_pre_room()
|
|
||||||
else:
|
|
||||||
from tui_client.screens.confirm import ConfirmScreen
|
|
||||||
self.app.push_screen(
|
|
||||||
ConfirmScreen("Log out and return to login?"),
|
|
||||||
callback=self._on_logout_confirm,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _on_leave_confirm(self, confirmed: bool) -> None:
|
|
||||||
if confirmed:
|
|
||||||
self.run_worker(self._send("leave_game"))
|
|
||||||
self.reset_to_pre_room()
|
|
||||||
|
|
||||||
def _on_logout_confirm(self, confirmed: bool) -> None:
|
|
||||||
if confirmed:
|
|
||||||
from tui_client.client import GameClient
|
|
||||||
from tui_client.screens.connect import ConnectScreen
|
|
||||||
|
|
||||||
GameClient.clear_session()
|
|
||||||
self.app.client._token = None
|
|
||||||
self.app.client._username = None
|
|
||||||
self.app.switch_screen(ConnectScreen())
|
|
||||||
|
|
||||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
||||||
if event.button.id == "btn-create":
|
|
||||||
self._create_room()
|
|
||||||
elif event.button.id == "btn-join":
|
|
||||||
self._join_room()
|
|
||||||
elif event.button.id == "btn-cpu-add":
|
|
||||||
self._show_cpu_picker()
|
|
||||||
elif event.button.id == "btn-cpu-remove":
|
|
||||||
self._remove_cpu()
|
|
||||||
elif event.button.id == "btn-cpu-random":
|
|
||||||
self._add_random_cpu()
|
|
||||||
elif event.button.id == "btn-start":
|
|
||||||
self._start_game()
|
|
||||||
|
|
||||||
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
||||||
if event.input.id == "input-room-code":
|
|
||||||
code = event.value.strip()
|
|
||||||
if code:
|
|
||||||
self._join_room()
|
|
||||||
else:
|
|
||||||
self._create_room()
|
|
||||||
|
|
||||||
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
|
||||||
if event.option_list.id == "cpu-profile-list":
|
|
||||||
profile_name = str(event.option.id) if event.option.id else ""
|
|
||||||
self.run_worker(self._send("add_cpu", profile_name=profile_name))
|
|
||||||
event.option_list.display = False
|
|
||||||
|
|
||||||
def on_select_changed(self, event: Select.Changed) -> None:
|
|
||||||
if event.select.id == "sel-deck-style" and event.value is not None:
|
|
||||||
try:
|
|
||||||
preview = self.query_one("#deck-preview", Static)
|
|
||||||
preview.update(self._render_deck_preview(str(event.value)))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def action_add_cpu(self) -> None:
|
|
||||||
if self._in_room and self._is_host:
|
|
||||||
self._show_cpu_picker()
|
|
||||||
|
|
||||||
def action_remove_cpu(self) -> None:
|
|
||||||
if self._in_room and self._is_host:
|
|
||||||
self._remove_cpu()
|
|
||||||
|
|
||||||
def action_start_or_create(self) -> None:
|
|
||||||
if self._in_room and self._is_host:
|
|
||||||
self._start_game()
|
|
||||||
elif not self._in_room:
|
|
||||||
code = self.query_one("#input-room-code", Input).value.strip()
|
|
||||||
if code:
|
|
||||||
self._join_room()
|
|
||||||
else:
|
|
||||||
self._create_room()
|
|
||||||
|
|
||||||
def _create_room(self) -> None:
|
|
||||||
player_name = self.app.client.username or "Player"
|
|
||||||
self.run_worker(self._send("create_room", player_name=player_name))
|
|
||||||
|
|
||||||
def _join_room(self) -> None:
|
|
||||||
code = self.query_one("#input-room-code", Input).value.strip().upper()
|
|
||||||
if not code:
|
|
||||||
self._set_status("Enter a room code to join")
|
|
||||||
return
|
|
||||||
player_name = self.app.client.username or "Player"
|
|
||||||
self.run_worker(self._send("join_room", room_code=code, player_name=player_name))
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _render_deck_preview(preset_name: str) -> str:
|
|
||||||
"""Render mini card-back swatches for a deck color preset."""
|
|
||||||
from tui_client.widgets.card import BACK_COLORS, BORDER_COLOR
|
|
||||||
|
|
||||||
colors = DECK_PRESETS.get(preset_name, ["red", "blue", "gold"])
|
|
||||||
# Show unique colors only (e.g. all-red shows one wider swatch)
|
|
||||||
seen: list[str] = []
|
|
||||||
for c in colors:
|
|
||||||
if c not in seen:
|
|
||||||
seen.append(c)
|
|
||||||
|
|
||||||
bc = BORDER_COLOR
|
|
||||||
parts: list[str] = []
|
|
||||||
for color_name in seen:
|
|
||||||
hc = BACK_COLORS.get(color_name, BACK_COLORS["red"])
|
|
||||||
parts.append(
|
|
||||||
f"[{bc}]┌───┐[/{bc}] "
|
|
||||||
)
|
|
||||||
line1 = "".join(parts)
|
|
||||||
|
|
||||||
parts2: list[str] = []
|
|
||||||
for color_name in seen:
|
|
||||||
hc = BACK_COLORS.get(color_name, BACK_COLORS["red"])
|
|
||||||
parts2.append(
|
|
||||||
f"[{bc}]│[/{bc}][{hc}]▓▒▓[/{hc}][{bc}]│[/{bc}] "
|
|
||||||
)
|
|
||||||
line2 = "".join(parts2)
|
|
||||||
|
|
||||||
parts3: list[str] = []
|
|
||||||
for color_name in seen:
|
|
||||||
hc = BACK_COLORS.get(color_name, BACK_COLORS["red"])
|
|
||||||
parts3.append(
|
|
||||||
f"[{bc}]│[/{bc}][{hc}]▒▓▒[/{hc}][{bc}]│[/{bc}] "
|
|
||||||
)
|
|
||||||
line3 = "".join(parts3)
|
|
||||||
|
|
||||||
parts4: list[str] = []
|
|
||||||
for color_name in seen:
|
|
||||||
hc = BACK_COLORS.get(color_name, BACK_COLORS["red"])
|
|
||||||
parts4.append(
|
|
||||||
f"[{bc}]└───┘[/{bc}] "
|
|
||||||
)
|
|
||||||
line4 = "".join(parts4)
|
|
||||||
|
|
||||||
return f"{line1}\n{line2}\n{line3}\n{line4}"
|
|
||||||
|
|
||||||
def _add_random_cpu(self) -> None:
|
|
||||||
"""Add a random CPU (server picks the profile)."""
|
|
||||||
self.run_worker(self._send("add_cpu"))
|
|
||||||
|
|
||||||
def _show_cpu_picker(self) -> None:
|
|
||||||
"""Request CPU profiles from server and show picker."""
|
|
||||||
self.run_worker(self._send("get_cpu_profiles"))
|
|
||||||
|
|
||||||
def _handle_cpu_profiles(self, data: dict) -> None:
|
|
||||||
"""Populate and show the CPU profile option list."""
|
|
||||||
profiles = data.get("profiles", [])
|
|
||||||
option_list = self.query_one("#cpu-profile-list", OptionList)
|
|
||||||
option_list.clear_options()
|
|
||||||
for p in profiles:
|
|
||||||
name = p.get("name", "?")
|
|
||||||
style = p.get("style", "")
|
|
||||||
option_list.add_option(Option(f"{name} — {style}", id=name))
|
|
||||||
option_list.display = True
|
|
||||||
option_list.focus()
|
|
||||||
|
|
||||||
def _remove_cpu(self) -> None:
|
|
||||||
self.run_worker(self._send("remove_cpu"))
|
|
||||||
|
|
||||||
def _collect_settings(self) -> dict:
|
|
||||||
"""Read all Select/Switch values and return kwargs for start_game."""
|
|
||||||
settings: dict = {}
|
|
||||||
|
|
||||||
try:
|
|
||||||
settings["rounds"] = self.query_one("#sel-rounds", Select).value
|
|
||||||
settings["decks"] = self.query_one("#sel-decks", Select).value
|
|
||||||
settings["initial_flips"] = self.query_one("#sel-initial-flips", Select).value
|
|
||||||
settings["flip_mode"] = self.query_one("#sel-flip-mode", Select).value
|
|
||||||
except Exception:
|
|
||||||
settings.setdefault("rounds", 9)
|
|
||||||
settings.setdefault("decks", 1)
|
|
||||||
settings.setdefault("initial_flips", 2)
|
|
||||||
settings.setdefault("flip_mode", "never")
|
|
||||||
|
|
||||||
# Joker variant → booleans
|
|
||||||
try:
|
|
||||||
joker_mode = self.query_one("#sel-jokers", Select).value
|
|
||||||
except Exception:
|
|
||||||
joker_mode = "none"
|
|
||||||
|
|
||||||
settings["use_jokers"] = joker_mode != "none"
|
|
||||||
settings["lucky_swing"] = joker_mode == "lucky_swing"
|
|
||||||
settings["eagle_eye"] = joker_mode == "eagle_eye"
|
|
||||||
|
|
||||||
# Boolean house rules from switches
|
|
||||||
rule_ids = [
|
|
||||||
"super_kings", "ten_penny", "one_eyed_jacks",
|
|
||||||
"negative_pairs_keep_value", "four_of_a_kind",
|
|
||||||
"knock_penalty", "knock_bonus", "knock_early", "flip_as_action",
|
|
||||||
"underdog_bonus", "tied_shame", "blackjack", "wolfpack",
|
|
||||||
]
|
|
||||||
for rule_id in rule_ids:
|
|
||||||
try:
|
|
||||||
settings[rule_id] = self.query_one(f"#sw-{rule_id}", Switch).value
|
|
||||||
except Exception:
|
|
||||||
settings[rule_id] = False
|
|
||||||
|
|
||||||
# Deck colors from preset
|
|
||||||
try:
|
|
||||||
preset = self.query_one("#sel-deck-style", Select).value
|
|
||||||
settings["deck_colors"] = DECK_PRESETS.get(preset, ["red", "blue", "gold"])
|
|
||||||
except Exception:
|
|
||||||
settings["deck_colors"] = ["red", "blue", "gold"]
|
|
||||||
|
|
||||||
return settings
|
|
||||||
|
|
||||||
def _start_game(self) -> None:
|
|
||||||
self._set_status("Starting game...")
|
|
||||||
settings = self._collect_settings()
|
|
||||||
self.run_worker(self._send("start_game", **settings))
|
|
||||||
|
|
||||||
async def _send(self, msg_type: str, **kwargs) -> None:
|
|
||||||
try:
|
|
||||||
await self.app.client.send(msg_type, **kwargs)
|
|
||||||
except Exception as e:
|
|
||||||
self._set_status(f"Error: {e}")
|
|
||||||
|
|
||||||
def on_server_message(self, event) -> None:
|
|
||||||
handler = getattr(self, f"_handle_{event.msg_type}", None)
|
|
||||||
if handler:
|
|
||||||
handler(event.data)
|
|
||||||
|
|
||||||
def _handle_room_created(self, data: dict) -> None:
|
|
||||||
self._room_code = data.get("room_code", "")
|
|
||||||
self._player_id = data.get("player_id", "")
|
|
||||||
self.app.player_id = self._player_id
|
|
||||||
self._is_host = True
|
|
||||||
self._in_room = True
|
|
||||||
self._set_room_info(f"Room Code: [bold]{self._room_code}[/bold] (You are host)")
|
|
||||||
self._set_status("Add CPU opponents, then start when ready.")
|
|
||||||
self._update_visibility()
|
|
||||||
self._update_keymap()
|
|
||||||
|
|
||||||
def _handle_room_joined(self, data: dict) -> None:
|
|
||||||
self._room_code = data.get("room_code", "")
|
|
||||||
self._player_id = data.get("player_id", "")
|
|
||||||
self.app.player_id = self._player_id
|
|
||||||
self._in_room = True
|
|
||||||
self._set_room_info(f"Room Code: [bold]{self._room_code}[/bold]")
|
|
||||||
self._set_status("Waiting for host to start the game.")
|
|
||||||
self._update_visibility()
|
|
||||||
self._update_keymap()
|
|
||||||
|
|
||||||
def _handle_player_joined(self, data: dict) -> None:
|
|
||||||
self._players = data.get("players", [])
|
|
||||||
self._refresh_player_list()
|
|
||||||
self._auto_adjust_decks()
|
|
||||||
|
|
||||||
def _handle_game_started(self, data: dict) -> None:
|
|
||||||
from tui_client.screens.game import GameScreen
|
|
||||||
game_state = data.get("game_state", {})
|
|
||||||
self.app.push_screen(GameScreen(game_state, self._is_host))
|
|
||||||
|
|
||||||
def _handle_error(self, data: dict) -> None:
|
|
||||||
self._set_status(f"[red]Error: {data.get('message', 'Unknown error')}[/red]")
|
|
||||||
|
|
||||||
def _refresh_player_list(self) -> None:
|
|
||||||
lines = []
|
|
||||||
for i, p in enumerate(self._players, 1):
|
|
||||||
name = p.get("name", "?")
|
|
||||||
tags = []
|
|
||||||
if p.get("is_host"):
|
|
||||||
tags.append("[bold cyan]Host[/bold cyan]")
|
|
||||||
if p.get("is_cpu"):
|
|
||||||
tags.append("[yellow]CPU[/yellow]")
|
|
||||||
suffix = f" {' '.join(tags)}" if tags else ""
|
|
||||||
lines.append(f" {i}. {name}{suffix}")
|
|
||||||
self.query_one("#player-list", Static).update("\n".join(lines) if lines else " (empty)")
|
|
||||||
|
|
||||||
def _auto_adjust_decks(self) -> None:
|
|
||||||
"""Auto-set decks to 2 when more than 3 players."""
|
|
||||||
if not self._is_host:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
sel = self.query_one("#sel-decks", Select)
|
|
||||||
if len(self._players) > 3 and sel.value == 1:
|
|
||||||
sel.value = 2
|
|
||||||
elif len(self._players) <= 3 and sel.value == 2:
|
|
||||||
sel.value = 1
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _set_room_info(self, text: str) -> None:
|
|
||||||
self.query_one("#room-info", Static).update(text)
|
|
||||||
|
|
||||||
def _set_status(self, text: str) -> None:
|
|
||||||
self.query_one("#lobby-status", Static).update(text)
|
|
||||||
@ -1,74 +0,0 @@
|
|||||||
"""Splash screen: check for saved session token before showing login."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
from textual.app import ComposeResult
|
|
||||||
from textual.containers import Container, Horizontal
|
|
||||||
from textual.screen import Screen
|
|
||||||
from textual.widgets import Static
|
|
||||||
|
|
||||||
|
|
||||||
_TITLE = (
|
|
||||||
"⛳🏌️ [bold]GolfCards.club[/bold] "
|
|
||||||
"[bold #aaaaaa]♠[/bold #aaaaaa]"
|
|
||||||
"[bold #cc0000]♥[/bold #cc0000]"
|
|
||||||
"[bold #aaaaaa]♣[/bold #aaaaaa]"
|
|
||||||
"[bold #cc0000]♦[/bold #cc0000]"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SplashScreen(Screen):
|
|
||||||
"""Shows session check status, then routes to lobby or login."""
|
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
|
||||||
with Container(id="connect-container"):
|
|
||||||
yield Static(_TITLE, id="connect-title")
|
|
||||||
yield Static("", id="splash-status")
|
|
||||||
|
|
||||||
with Horizontal(classes="screen-footer"):
|
|
||||||
yield Static("", classes="screen-footer-left")
|
|
||||||
yield Static("\\[q] quit", classes="screen-footer-right")
|
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
|
||||||
self.run_worker(self._check_session(), exclusive=True)
|
|
||||||
|
|
||||||
async def _check_session(self) -> None:
|
|
||||||
from tui_client.client import GameClient
|
|
||||||
|
|
||||||
status = self.query_one("#splash-status", Static)
|
|
||||||
status.update("Checking for session token...")
|
|
||||||
await asyncio.sleep(0.5)
|
|
||||||
|
|
||||||
session = GameClient.load_session()
|
|
||||||
if not session:
|
|
||||||
status.update("Checking for session token... [bold yellow]NONE FOUND[/bold yellow]")
|
|
||||||
await asyncio.sleep(0.8)
|
|
||||||
self._go_to_login()
|
|
||||||
return
|
|
||||||
|
|
||||||
client = self.app.client
|
|
||||||
client.restore_session(session)
|
|
||||||
if await client.verify_token():
|
|
||||||
status.update(f"Checking for session token... [bold green]SUCCESS[/bold green]")
|
|
||||||
await asyncio.sleep(0.8)
|
|
||||||
await self._go_to_lobby()
|
|
||||||
else:
|
|
||||||
GameClient.clear_session()
|
|
||||||
status.update("Checking for session token... [bold red]EXPIRED[/bold red]")
|
|
||||||
await asyncio.sleep(0.8)
|
|
||||||
self._go_to_login()
|
|
||||||
|
|
||||||
def _go_to_login(self) -> None:
|
|
||||||
from tui_client.screens.connect import ConnectScreen
|
|
||||||
|
|
||||||
self.app.switch_screen(ConnectScreen())
|
|
||||||
|
|
||||||
async def _go_to_lobby(self) -> None:
|
|
||||||
client = self.app.client
|
|
||||||
await client.connect()
|
|
||||||
client.save_session()
|
|
||||||
from tui_client.screens.lobby import LobbyScreen
|
|
||||||
|
|
||||||
self.app.switch_screen(LobbyScreen())
|
|
||||||
@ -1,452 +0,0 @@
|
|||||||
/* Base app styles */
|
|
||||||
Screen {
|
|
||||||
background: $surface;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Splash screen */
|
|
||||||
SplashScreen {
|
|
||||||
align: center middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
#splash-status {
|
|
||||||
text-align: center;
|
|
||||||
width: 100%;
|
|
||||||
margin-top: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Connect screen */
|
|
||||||
ConnectScreen {
|
|
||||||
align: center middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
#connect-container {
|
|
||||||
width: 80%;
|
|
||||||
max-width: 64;
|
|
||||||
min-width: 40;
|
|
||||||
height: auto;
|
|
||||||
border: thick #f4a460;
|
|
||||||
background: #0a2a1a;
|
|
||||||
padding: 1 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
#connect-container Static {
|
|
||||||
text-align: center;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
#connect-title {
|
|
||||||
text-style: bold;
|
|
||||||
color: $text;
|
|
||||||
margin-bottom: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#connect-container Input {
|
|
||||||
margin-bottom: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#login-form, #signup-form {
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
#signup-form {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
#connect-buttons, #signup-buttons {
|
|
||||||
height: 3;
|
|
||||||
align: center middle;
|
|
||||||
margin-top: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#connect-buttons Button, #signup-buttons Button {
|
|
||||||
margin: 0 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#btn-toggle-signup, #btn-toggle-login {
|
|
||||||
width: 100%;
|
|
||||||
margin-top: 1;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: $text-muted;
|
|
||||||
}
|
|
||||||
|
|
||||||
#connect-status {
|
|
||||||
text-align: center;
|
|
||||||
color: $warning;
|
|
||||||
margin-top: 1;
|
|
||||||
height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Screen footer bar (shared by connect + lobby) */
|
|
||||||
.screen-footer {
|
|
||||||
dock: bottom;
|
|
||||||
width: 100%;
|
|
||||||
height: 1;
|
|
||||||
background: #1a1a2e;
|
|
||||||
color: #888888;
|
|
||||||
padding: 0 1;
|
|
||||||
}
|
|
||||||
.screen-footer-left {
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
.screen-footer-right {
|
|
||||||
width: 1fr;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Lobby screen */
|
|
||||||
LobbyScreen {
|
|
||||||
align: center middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
#lobby-container {
|
|
||||||
width: 80%;
|
|
||||||
max-width: 72;
|
|
||||||
min-width: 40;
|
|
||||||
height: auto;
|
|
||||||
border: thick #f4a460;
|
|
||||||
background: #0a2a1a;
|
|
||||||
padding: 1 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
#lobby-title {
|
|
||||||
text-style: bold;
|
|
||||||
text-align: center;
|
|
||||||
width: 100%;
|
|
||||||
margin-bottom: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#room-info {
|
|
||||||
text-align: center;
|
|
||||||
height: auto;
|
|
||||||
margin-bottom: 1;
|
|
||||||
border: tall #f4a460;
|
|
||||||
padding: 0 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Pre-room: join/create controls */
|
|
||||||
#pre-room {
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
#input-room-code {
|
|
||||||
margin-bottom: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#pre-room-buttons {
|
|
||||||
height: 3;
|
|
||||||
align: center middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
#pre-room-buttons Button {
|
|
||||||
margin: 0 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* In-room: player list + controls */
|
|
||||||
#in-room {
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
#player-list-label {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#player-list {
|
|
||||||
height: auto;
|
|
||||||
min-height: 3;
|
|
||||||
max-height: 12;
|
|
||||||
border: tall $primary;
|
|
||||||
padding: 0 1;
|
|
||||||
margin-bottom: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* CPU controls: compact [+] [-] */
|
|
||||||
#cpu-controls {
|
|
||||||
height: 3;
|
|
||||||
align: center middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
#cpu-controls Button {
|
|
||||||
min-width: 5;
|
|
||||||
margin: 0 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#cpu-label {
|
|
||||||
padding: 1 1 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#cpu-profile-list {
|
|
||||||
height: auto;
|
|
||||||
max-height: 12;
|
|
||||||
border: tall $accent;
|
|
||||||
margin-bottom: 1;
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Host settings */
|
|
||||||
#host-settings {
|
|
||||||
height: auto;
|
|
||||||
margin-top: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.setting-row {
|
|
||||||
height: 3;
|
|
||||||
align: left middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.setting-row Label {
|
|
||||||
width: 1fr;
|
|
||||||
padding: 1 1 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.setting-row Select {
|
|
||||||
width: 24;
|
|
||||||
}
|
|
||||||
|
|
||||||
#deck-preview {
|
|
||||||
width: auto;
|
|
||||||
height: auto;
|
|
||||||
padding: 1 1 0 1;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rule-row {
|
|
||||||
height: 3;
|
|
||||||
align: left middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rule-row Label {
|
|
||||||
width: 1fr;
|
|
||||||
padding: 1 1 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rule-row Switch {
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rules-header {
|
|
||||||
margin-top: 1;
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#btn-start {
|
|
||||||
width: 100%;
|
|
||||||
margin-top: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#lobby-status {
|
|
||||||
text-align: center;
|
|
||||||
color: $warning;
|
|
||||||
height: auto;
|
|
||||||
margin-top: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Game screen */
|
|
||||||
GameScreen {
|
|
||||||
align: center top;
|
|
||||||
layout: vertical;
|
|
||||||
background: #0a2a1a;
|
|
||||||
}
|
|
||||||
|
|
||||||
#game-content {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 120;
|
|
||||||
height: 100%;
|
|
||||||
layout: vertical;
|
|
||||||
}
|
|
||||||
|
|
||||||
#status-bar {
|
|
||||||
height: 1;
|
|
||||||
dock: top;
|
|
||||||
background: #2a1a0a;
|
|
||||||
color: #f4a460;
|
|
||||||
padding: 0 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
#opponents-area {
|
|
||||||
height: auto;
|
|
||||||
max-height: 50%;
|
|
||||||
padding: 1 2 1 2;
|
|
||||||
text-align: center;
|
|
||||||
content-align: center middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
#play-area-row {
|
|
||||||
height: auto;
|
|
||||||
align: center middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
#play-area {
|
|
||||||
height: auto;
|
|
||||||
width: auto;
|
|
||||||
padding: 0 2;
|
|
||||||
border: round $primary-lighten-2;
|
|
||||||
text-align: center;
|
|
||||||
content-align: center middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
#play-area.my-turn {
|
|
||||||
border: round #ffd700;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* Local hand label */
|
|
||||||
#local-hand-label {
|
|
||||||
text-align: center;
|
|
||||||
height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Local hand widget */
|
|
||||||
#local-hand {
|
|
||||||
height: auto;
|
|
||||||
margin-top: 1;
|
|
||||||
text-align: center;
|
|
||||||
content-align: center middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Scoreboard overlay */
|
|
||||||
ScoreboardScreen {
|
|
||||||
align: center middle;
|
|
||||||
background: $surface 80%;
|
|
||||||
}
|
|
||||||
|
|
||||||
#scoreboard-container {
|
|
||||||
width: 80%;
|
|
||||||
max-width: 64;
|
|
||||||
min-width: 40;
|
|
||||||
height: auto;
|
|
||||||
max-height: 80%;
|
|
||||||
border: thick $primary;
|
|
||||||
padding: 1 2;
|
|
||||||
background: $surface;
|
|
||||||
align: center middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
#scoreboard-title {
|
|
||||||
text-style: bold;
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#scoreboard-table {
|
|
||||||
width: auto;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
#scoreboard-buttons {
|
|
||||||
height: 3;
|
|
||||||
align: center middle;
|
|
||||||
margin-top: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Confirm quit dialog */
|
|
||||||
ConfirmScreen, ConfirmQuitScreen {
|
|
||||||
align: center middle;
|
|
||||||
background: $surface 80%;
|
|
||||||
}
|
|
||||||
|
|
||||||
#confirm-dialog {
|
|
||||||
width: auto;
|
|
||||||
max-width: 48;
|
|
||||||
height: auto;
|
|
||||||
border: thick $error;
|
|
||||||
padding: 1 2;
|
|
||||||
background: $surface;
|
|
||||||
}
|
|
||||||
|
|
||||||
#confirm-message {
|
|
||||||
text-align: center;
|
|
||||||
width: 100%;
|
|
||||||
margin-bottom: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#confirm-buttons {
|
|
||||||
height: 3;
|
|
||||||
align: center middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
#confirm-buttons Button {
|
|
||||||
margin: 0 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Game footer: [h]elp <action> [tab] standings [q]uit */
|
|
||||||
#game-footer {
|
|
||||||
height: 1;
|
|
||||||
dock: bottom;
|
|
||||||
background: $surface-darken-1;
|
|
||||||
padding: 0 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
#footer-left {
|
|
||||||
width: auto;
|
|
||||||
color: $text-muted;
|
|
||||||
}
|
|
||||||
|
|
||||||
#footer-center {
|
|
||||||
width: 1fr;
|
|
||||||
text-align: center;
|
|
||||||
content-align: center middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
#footer-right {
|
|
||||||
width: auto;
|
|
||||||
color: $text-muted;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Help dialog */
|
|
||||||
HelpScreen {
|
|
||||||
align: center middle;
|
|
||||||
background: $surface 80%;
|
|
||||||
}
|
|
||||||
|
|
||||||
#help-dialog {
|
|
||||||
width: 48;
|
|
||||||
height: auto;
|
|
||||||
max-height: 80%;
|
|
||||||
border: thick $primary;
|
|
||||||
padding: 1 2;
|
|
||||||
background: $surface;
|
|
||||||
}
|
|
||||||
|
|
||||||
#help-text {
|
|
||||||
width: 100%;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Standings dialog */
|
|
||||||
StandingsScreen {
|
|
||||||
align: center middle;
|
|
||||||
background: $surface 80%;
|
|
||||||
}
|
|
||||||
|
|
||||||
#standings-dialog {
|
|
||||||
width: 48;
|
|
||||||
height: auto;
|
|
||||||
max-height: 80%;
|
|
||||||
border: thick $primary;
|
|
||||||
padding: 1 2;
|
|
||||||
background: $surface;
|
|
||||||
}
|
|
||||||
|
|
||||||
#standings-title {
|
|
||||||
text-align: center;
|
|
||||||
width: 100%;
|
|
||||||
margin-bottom: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#standings-body {
|
|
||||||
width: 100%;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
#standings-hint {
|
|
||||||
width: 100%;
|
|
||||||
height: 1;
|
|
||||||
margin-top: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#standings-hint {
|
|
||||||
text-align: center;
|
|
||||||
width: 100%;
|
|
||||||
margin-top: 1;
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user