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
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# Per-module log level overrides (optional)
|
||||
# These override LOG_LEVEL for specific modules.
|
||||
# LOG_LEVEL_GAME=DEBUG # Core game logic
|
||||
# LOG_LEVEL_AI=DEBUG # AI decisions (very verbose at DEBUG)
|
||||
# LOG_LEVEL_HANDLERS=DEBUG # WebSocket message handlers
|
||||
# LOG_LEVEL_ROOM=DEBUG # Room/lobby management
|
||||
# LOG_LEVEL_AUTH=DEBUG # Auth stack (auth, routers.auth, services.auth_service)
|
||||
# LOG_LEVEL_STORES=DEBUG # Database/Redis operations
|
||||
|
||||
# --- Preset examples ---
|
||||
# Staging (debug game logic, quiet everything else):
|
||||
# LOG_LEVEL=INFO
|
||||
# LOG_LEVEL_GAME=DEBUG
|
||||
# LOG_LEVEL_AI=DEBUG
|
||||
#
|
||||
# Production (minimal logging):
|
||||
# LOG_LEVEL=WARNING
|
||||
|
||||
# Environment name (development, staging, production)
|
||||
ENVIRONMENT=development
|
||||
|
||||
@ -73,21 +55,7 @@ ROOM_CODE_LENGTH=4
|
||||
SECRET_KEY=
|
||||
|
||||
# Enable invite-only mode (requires invitation to register)
|
||||
INVITE_ONLY=true
|
||||
|
||||
# Metered open signups (public beta)
|
||||
# 0 = disabled (invite-only enforced), -1 = unlimited, N = max open signups per day
|
||||
# When set > 0, users can register without an invite code up to the daily limit.
|
||||
# Invite codes always work regardless of this limit.
|
||||
DAILY_OPEN_SIGNUPS=0
|
||||
|
||||
# Max signups per IP address per day (0 = unlimited)
|
||||
DAILY_SIGNUPS_PER_IP=3
|
||||
|
||||
# Bootstrap admin account (for first-time setup with INVITE_ONLY=true)
|
||||
# Remove these after first login!
|
||||
# BOOTSTRAP_ADMIN_USERNAME=admin
|
||||
# BOOTSTRAP_ADMIN_PASSWORD=changeme12345
|
||||
INVITE_ONLY=false
|
||||
|
||||
# Comma-separated list of admin email addresses
|
||||
ADMIN_EMAILS=
|
||||
@ -136,13 +104,5 @@ CARD_LUCKY_SWING_JOKER=-5 # Joker value when lucky_swing enabled
|
||||
# Enable rate limiting (recommended for production)
|
||||
# RATE_LIMIT_ENABLED=true
|
||||
|
||||
# Redis URL (required for matchmaking and rate limiting)
|
||||
# REDIS_URL=redis://localhost:6379
|
||||
|
||||
# Base URL for email links
|
||||
# BASE_URL=https://your-domain.com
|
||||
|
||||
# Matchmaking (skill-based public games)
|
||||
MATCHMAKING_ENABLED=true
|
||||
MATCHMAKING_MIN_PLAYERS=2
|
||||
MATCHMAKING_MAX_PLAYERS=4
|
||||
|
||||
27
.gitignore
vendored
27
.gitignore
vendored
@ -136,31 +136,7 @@ celerybeat.pid
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.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
|
||||
env/
|
||||
venv/
|
||||
@ -225,9 +201,6 @@ pyvenv.cfg
|
||||
# Personal notes
|
||||
lookfah.md
|
||||
|
||||
# Internal docs (deployment info, credentials references, etc.)
|
||||
internal/
|
||||
|
||||
# Ruff stuff:
|
||||
.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
|
||||
|
||||
# Run with uvicorn from the server directory (server uses relative imports)
|
||||
WORKDIR /app/server
|
||||
CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
# Run with uvicorn
|
||||
CMD ["python", "-m", "uvicorn", "server.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
|
||||
|
||||
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
|
||||
* JavaScript for admin interface functionality
|
||||
@ -318,7 +317,7 @@ async function loadUsers() {
|
||||
<td>${user.games_played} (${user.games_won} wins)</td>
|
||||
<td>${formatDateShort(user.created_at)}</td>
|
||||
<td>
|
||||
<button class="btn btn-small" data-action="view-user" data-id="${user.id}">View</button>
|
||||
<button class="btn btn-small" onclick="viewUser('${user.id}')">View</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
@ -405,7 +404,7 @@ async function loadGames() {
|
||||
<td><span class="badge badge-${game.status === 'playing' ? 'success' : 'info'}">${game.status}</span></td>
|
||||
<td>${formatDate(game.created_at)}</td>
|
||||
<td>
|
||||
<button class="btn btn-small btn-danger" data-action="end-game" data-id="${game.game_id}">End</button>
|
||||
<button class="btn btn-small btn-danger" onclick="promptEndGame('${game.game_id}')">End</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
@ -455,8 +454,7 @@ async function loadInvites() {
|
||||
<td>${status}</td>
|
||||
<td>
|
||||
${invite.is_active && !isExpired && invite.remaining_uses > 0
|
||||
? `<button class="btn btn-small" data-action="copy-invite" data-code="${escapeHtml(invite.code)}">Copy Link</button>
|
||||
<button class="btn btn-small btn-danger" data-action="revoke-invite" data-code="${escapeHtml(invite.code)}">Revoke</button>`
|
||||
? `<button class="btn btn-small btn-danger" onclick="promptRevokeInvite('${invite.code}')">Revoke</button>`
|
||||
: '-'
|
||||
}
|
||||
</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) {
|
||||
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
|
||||
checkAuth();
|
||||
});
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// AnimationQueue - Sequences card animations properly
|
||||
// Ensures animations play in order without overlap
|
||||
|
||||
@ -32,17 +31,14 @@ class AnimationQueue {
|
||||
};
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Add movements to the queue and start processing
|
||||
async enqueue(movements, onComplete) {
|
||||
if (!movements || movements.length === 0) {
|
||||
if (onComplete) onComplete();
|
||||
return;
|
||||
}
|
||||
|
||||
// Attach callback to last movement only
|
||||
// Add completion callback to last movement
|
||||
const movementsWithCallback = movements.map((m, i) => ({
|
||||
...m,
|
||||
onComplete: i === movements.length - 1 ? onComplete : null
|
||||
@ -189,9 +185,7 @@ class AnimationQueue {
|
||||
await this.delay(this.timing.flipDuration);
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Step 2: Quick crossfade swap
|
||||
handCard.classList.add('fade-out');
|
||||
heldCard.classList.add('fade-out');
|
||||
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
|
||||
// Replaces draw-animations.js and handles ALL card animations
|
||||
|
||||
@ -44,15 +43,10 @@ class CardAnimations {
|
||||
const discardRect = this.getDiscardRect();
|
||||
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 cardWidth = deckRect.width;
|
||||
const cardHeight = deckRect.height;
|
||||
const isMobilePortrait = document.body.classList.contains('mobile-portrait');
|
||||
// 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);
|
||||
const overlapOffset = cardHeight * 0.35;
|
||||
|
||||
return {
|
||||
left: centerX - cardWidth / 2,
|
||||
@ -81,13 +75,6 @@ class CardAnimations {
|
||||
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
|
||||
createAnimCard(rect, showBack = false, deckColor = null) {
|
||||
const card = document.createElement('div');
|
||||
@ -105,9 +92,6 @@ class CardAnimations {
|
||||
card.style.top = rect.top + 'px';
|
||||
card.style.width = rect.width + '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
|
||||
@ -161,20 +145,12 @@ class CardAnimations {
|
||||
}
|
||||
this.activeAnimations.clear();
|
||||
|
||||
// Remove all animation overlay elements
|
||||
document.querySelectorAll('.draw-anim-card, .traveling-card, .deal-anim-container').forEach(el => {
|
||||
// Remove all animation card elements (including those marked as animating)
|
||||
document.querySelectorAll('.draw-anim-card').forEach(el => {
|
||||
delete el.dataset.animating;
|
||||
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
|
||||
const discardPile = document.getElementById('discard');
|
||||
if (discardPile && discardPile.style.opacity === '0') {
|
||||
@ -225,7 +201,6 @@ class CardAnimations {
|
||||
}
|
||||
|
||||
_animateDrawDeckCard(cardData, deckRect, holdingRect, onComplete) {
|
||||
console.log('[DEBUG] _animateDrawDeckCard called with cardData:', cardData ? `${cardData.rank} of ${cardData.suit}` : 'NULL');
|
||||
const deckColor = this.getDeckColor();
|
||||
const animCard = this.createAnimCard(deckRect, true, deckColor);
|
||||
animCard.dataset.animating = 'true'; // Mark as actively animating
|
||||
@ -234,9 +209,6 @@ class CardAnimations {
|
||||
|
||||
if (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');
|
||||
@ -425,7 +397,6 @@ class CardAnimations {
|
||||
}
|
||||
|
||||
// Animate initial flip at game start - smooth flip only, no lift
|
||||
// Uses overlay sized to match the source card exactly
|
||||
animateInitialFlip(cardElement, cardData, onComplete) {
|
||||
if (!cardElement) {
|
||||
if (onComplete) onComplete();
|
||||
@ -439,16 +410,8 @@ class CardAnimations {
|
||||
const animCard = this.createAnimCard(rect, true, deckColor);
|
||||
this.setCardContent(animCard, cardData);
|
||||
|
||||
// Match the front face styling to player hand cards (not deck/discard cards)
|
||||
const front = animCard.querySelector('.draw-anim-front');
|
||||
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';
|
||||
// Hide original card during animation
|
||||
cardElement.style.opacity = '0';
|
||||
|
||||
const inner = animCard.querySelector('.draw-anim-inner');
|
||||
const duration = window.TIMING?.card?.flip || 320;
|
||||
@ -463,19 +426,16 @@ class CardAnimations {
|
||||
begin: () => this.playSound('flip'),
|
||||
complete: () => {
|
||||
animCard.remove();
|
||||
cardElement.style.visibility = '';
|
||||
cardElement.style.opacity = '1';
|
||||
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: () => {} });
|
||||
} catch (e) {
|
||||
console.error('Initial flip animation error:', e);
|
||||
animCard.remove();
|
||||
cardElement.style.visibility = '';
|
||||
cardElement.style.opacity = '1';
|
||||
if (onComplete) onComplete();
|
||||
}
|
||||
}
|
||||
@ -488,6 +448,10 @@ class CardAnimations {
|
||||
const deckColor = this.getDeckColor();
|
||||
|
||||
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);
|
||||
|
||||
// Apply rotation to match arch layout
|
||||
@ -643,6 +607,10 @@ class CardAnimations {
|
||||
const deckColor = this.getDeckColor();
|
||||
|
||||
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);
|
||||
|
||||
if (rotation) {
|
||||
@ -780,40 +748,28 @@ class CardAnimations {
|
||||
const id = 'turnPulse';
|
||||
this.stopTurnPulse(element);
|
||||
|
||||
// Quick shake animation - target cards only, not labels
|
||||
const T = window.TIMING?.turnPulse || {};
|
||||
const cards = element.querySelectorAll(':scope > .pile-wrapper > .card, :scope > .pile-wrapper > .discard-stack > #discard');
|
||||
// Quick shake animation
|
||||
const doShake = () => {
|
||||
if (!this.activeAnimations.has(id)) return;
|
||||
|
||||
anime({
|
||||
targets: cards.length ? cards : element,
|
||||
translateX: [0, -6, 6, -4, 3, 0],
|
||||
duration: T.duration || 300,
|
||||
targets: element,
|
||||
translateX: [0, -8, 8, -6, 4, 0],
|
||||
duration: 400,
|
||||
easing: 'easeInOutQuad'
|
||||
});
|
||||
};
|
||||
|
||||
// Two-phase timing: wait initialDelay, then shake on an interval.
|
||||
// Edge case: if stopTurnPulse() is called between the timeout firing and
|
||||
// the interval being stored on the entry, the interval would leak. That's
|
||||
// why we re-check activeAnimations.has(id) after the timeout fires — if
|
||||
// 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 });
|
||||
// Do initial shake, then repeat every 3 seconds
|
||||
doShake();
|
||||
const interval = setInterval(doShake, 3000);
|
||||
this.activeAnimations.set(id, { interval });
|
||||
}
|
||||
|
||||
stopTurnPulse(element) {
|
||||
const id = 'turnPulse';
|
||||
const existing = this.activeAnimations.get(id);
|
||||
if (existing) {
|
||||
if (existing.timeout) clearTimeout(existing.timeout);
|
||||
if (existing.interval) clearInterval(existing.interval);
|
||||
if (existing.pause) existing.pause();
|
||||
this.activeAnimations.delete(id);
|
||||
@ -1106,7 +1062,7 @@ class CardAnimations {
|
||||
// heldRect: position of the held card (or null to use default holding position)
|
||||
// options: { rotation, wasHandFaceDown, onComplete }
|
||||
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 discardRect = this.getDiscardRect();
|
||||
|
||||
@ -1126,27 +1082,27 @@ class CardAnimations {
|
||||
return;
|
||||
}
|
||||
|
||||
// Collision detection: if a draw animation is still in flight (its overlay cards
|
||||
// are still in the DOM), we can't start the swap yet — both animations touch the
|
||||
// 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).
|
||||
// Wait for any in-progress draw animation to complete
|
||||
// Check if there's an active draw animation by looking for overlay cards
|
||||
const existingDrawCards = document.querySelectorAll('.draw-anim-card[data-animating="true"]');
|
||||
if (existingDrawCards.length > 0) {
|
||||
// Draw animation still in progress - wait a bit and retry
|
||||
setTimeout(() => {
|
||||
// Clean up the draw animation overlay
|
||||
existingDrawCards.forEach(el => {
|
||||
delete el.dataset.animating;
|
||||
el.remove();
|
||||
});
|
||||
this._runUnifiedSwap(handCardData, heldCardData, handRect, heldRect, discardRect, T, rotation, wasHandFaceDown, onComplete, onStart);
|
||||
}, 350);
|
||||
// Now run the swap animation
|
||||
this._runUnifiedSwap(handCardData, heldCardData, handRect, heldRect, discardRect, T, rotation, wasHandFaceDown, onComplete);
|
||||
}, 100);
|
||||
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
|
||||
const travelingHand = this.createCardFromData(handCardData, handRect, rotation);
|
||||
const travelingHeld = this.createCardFromData(heldCardData, heldRect, 0);
|
||||
@ -1155,9 +1111,6 @@ class CardAnimations {
|
||||
document.body.appendChild(travelingHand);
|
||||
document.body.appendChild(travelingHeld);
|
||||
|
||||
// Now that overlays cover the originals, hide them
|
||||
if (onStart) onStart();
|
||||
|
||||
this.playSound('card');
|
||||
|
||||
// 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)
|
||||
const handFront = travelingHand.querySelector('.draw-anim-front');
|
||||
const heldFront = travelingHeld.querySelector('.draw-anim-front');
|
||||
|
||||
timeline.add({
|
||||
targets: travelingHand,
|
||||
left: discardRect.left,
|
||||
@ -1223,24 +1173,11 @@ class CardAnimations {
|
||||
],
|
||||
width: discardRect.width,
|
||||
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],
|
||||
duration: T.arc,
|
||||
easing: this.getEasing('arc'),
|
||||
}, `-=${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)
|
||||
timeline.add({
|
||||
targets: travelingHeld,
|
||||
@ -1256,16 +1193,6 @@ class CardAnimations {
|
||||
easing: this.getEasing('arc'),
|
||||
}, `-=${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
|
||||
timeline.add({
|
||||
targets: [travelingHand, travelingHeld],
|
||||
@ -1477,9 +1404,6 @@ class CardAnimations {
|
||||
card.style.top = rect.top + 'px';
|
||||
card.style.width = rect.width + '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) {
|
||||
card.style.transform = `rotate(${rotation}deg)`;
|
||||
@ -1520,8 +1444,9 @@ class CardAnimations {
|
||||
try {
|
||||
anime({
|
||||
targets: element,
|
||||
opacity: [0, 1],
|
||||
duration: 200,
|
||||
scale: [0.5, 1.25, 1.15],
|
||||
opacity: [0, 1, 1],
|
||||
duration: 300,
|
||||
easing: 'easeOutQuad'
|
||||
});
|
||||
} catch (e) {
|
||||
@ -1563,7 +1488,6 @@ class CardAnimations {
|
||||
|
||||
// Create container for animation cards
|
||||
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;';
|
||||
document.body.appendChild(container);
|
||||
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// CardManager - Manages persistent card DOM elements
|
||||
// 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.
|
||||
// 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.
|
||||
// Get the deck color class for a card based on its deck_id
|
||||
getDeckColorClass(cardData) {
|
||||
if (!cardData || cardData.deck_id === undefined || cardData.deck_id === null) {
|
||||
return null;
|
||||
}
|
||||
// Get deck colors from game state (set by app.js)
|
||||
const deckColors = window.currentDeckColors || ['red', 'blue', 'gold'];
|
||||
const colorName = deckColors[cardData.deck_id] || deckColors[0] || 'red';
|
||||
return `deck-${colorName}`;
|
||||
@ -129,16 +126,6 @@ class CardManager {
|
||||
cardEl.style.width = `${rect.width}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) {
|
||||
const moveDuration = window.TIMING?.card?.moving || 350;
|
||||
setTimeout(() => cardEl.classList.remove('moving'), moveDuration);
|
||||
@ -241,9 +228,7 @@ class CardManager {
|
||||
await this.delay(flipDuration);
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Step 2: Move card to discard
|
||||
cardEl.classList.add('moving');
|
||||
this.positionCard(cardEl, discardRect);
|
||||
await this.delay(duration + 50);
|
||||
|
||||
@ -59,9 +59,9 @@
|
||||
<!-- Outer edge highlight -->
|
||||
<circle cx="50" cy="44" r="46" fill="none" stroke="#ffffff" stroke-width="0.5" opacity="0.5"/>
|
||||
|
||||
<!-- Card suits - 2x2 grid -->
|
||||
<text x="36" y="40" font-family="Arial, sans-serif" font-size="28" 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="36" y="64" font-family="Arial, sans-serif" font-size="28" font-weight="bold" fill="#cc0000" text-anchor="middle">♥</text>
|
||||
<text x="64" y="64" font-family="Arial, sans-serif" font-size="28" font-weight="bold" fill="#1a1a1a" text-anchor="middle">♠</text>
|
||||
<!-- Card suits - single row, larger -->
|
||||
<text x="22" y="51" font-family="Arial, sans-serif" font-size="32" font-weight="bold" fill="#1a1a1a" text-anchor="middle">♣</text>
|
||||
<text x="41" y="51" font-family="Arial, sans-serif" font-size="32" font-weight="bold" fill="#cc0000" text-anchor="middle">♦</text>
|
||||
<text x="59" y="51" font-family="Arial, sans-serif" font-size="32" font-weight="bold" fill="#1a1a1a" text-anchor="middle">♠</text>
|
||||
<text x="77" y="51" font-family="Arial, sans-serif" font-size="32" font-weight="bold" fill="#cc0000" text-anchor="middle">♥</text>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.8 KiB |
@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<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>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
@ -16,57 +16,36 @@
|
||||
|
||||
<!-- Lobby Screen -->
|
||||
<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>
|
||||
|
||||
<div class="alpha-banner">Beta Testing - Bear with us while, stuff.</div>
|
||||
|
||||
<!-- Auth prompt for unauthenticated users -->
|
||||
<div id="auth-prompt" class="auth-prompt">
|
||||
<p>Log in or sign up to play.</p>
|
||||
<div class="button-group">
|
||||
<button id="login-btn" class="btn btn-primary">Login</button>
|
||||
<button id="signup-btn" class="btn btn-secondary">Sign Up</button>
|
||||
</div>
|
||||
<!-- Auth buttons for guests (hidden until auth check confirms not logged in) -->
|
||||
<div id="auth-buttons" class="auth-buttons hidden">
|
||||
<button id="login-btn" class="btn btn-small">Login</button>
|
||||
<button id="signup-btn" class="btn btn-small btn-primary">Sign Up</button>
|
||||
</div>
|
||||
|
||||
<!-- Game controls (shown only when authenticated) -->
|
||||
<div id="lobby-game-controls" class="hidden">
|
||||
<div class="button-group">
|
||||
<button id="find-game-btn" class="btn btn-primary">Find Game</button>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="player-name">Your Name</label>
|
||||
<input type="text" id="player-name" placeholder="Enter your name" maxlength="12">
|
||||
</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">
|
||||
<button id="create-room-btn" class="btn btn-secondary">Create Private Room</button>
|
||||
</div>
|
||||
<div class="divider">or</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="room-code">Join Private Room</label>
|
||||
<input type="text" id="room-code" placeholder="ABCD" maxlength="4">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="room-code">Room Code</label>
|
||||
<input type="text" id="room-code" placeholder="ABCD" maxlength="4">
|
||||
</div>
|
||||
|
||||
<div class="button-group">
|
||||
<button id="join-room-btn" class="btn btn-secondary">Join Room</button>
|
||||
</div>
|
||||
<div class="button-group">
|
||||
<button id="join-room-btn" class="btn btn-secondary">Join Room</button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<!-- Waiting Room Screen -->
|
||||
@ -82,16 +61,16 @@
|
||||
<div class="waiting-layout">
|
||||
<div class="waiting-left-col">
|
||||
<div class="players-list">
|
||||
<div class="players-list-header">
|
||||
<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>
|
||||
<h3>Players</h3>
|
||||
<ul id="players-list"></ul>
|
||||
</div>
|
||||
<div id="cpu-controls-section" class="cpu-controls-section hidden">
|
||||
<h4>Add CPU Opponents</h4>
|
||||
<div class="cpu-controls">
|
||||
<button id="remove-cpu-btn" class="btn btn-small btn-danger" title="Remove last CPU">−</button>
|
||||
<button id="add-cpu-btn" class="btn btn-small btn-success" title="Add CPU player">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<button id="leave-room-btn" class="btn btn-danger">Leave Room</button>
|
||||
</div>
|
||||
|
||||
@ -286,8 +265,6 @@
|
||||
|
||||
<p id="waiting-message" class="info">Waiting for host to start the game...</p>
|
||||
</div>
|
||||
|
||||
<footer class="app-footer">v3.1.6 © Aaron D. Lee</footer>
|
||||
</div>
|
||||
|
||||
<!-- Game Screen -->
|
||||
@ -309,6 +286,7 @@
|
||||
<div id="final-turn-badge" class="final-turn-badge hidden">
|
||||
<span class="final-turn-icon">⚡</span>
|
||||
<span class="final-turn-text">FINAL TURN</span>
|
||||
<span class="final-turn-remaining"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-col header-col-right">
|
||||
@ -332,24 +310,18 @@
|
||||
</div>
|
||||
<span class="held-label">Holding</span>
|
||||
</div>
|
||||
<div class="pile-wrapper">
|
||||
<span class="pile-label">DRAW</span>
|
||||
<div id="deck" class="card card-back"></div>
|
||||
</div>
|
||||
<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 id="deck" class="card card-back"></div>
|
||||
<div class="discard-stack">
|
||||
<div id="discard" class="card">
|
||||
<span id="discard-content"></span>
|
||||
</div>
|
||||
<!-- Floating held card (appears larger over discard when holding) -->
|
||||
<div id="held-card-floating" class="card card-front held-card-floating hidden">
|
||||
<span id="held-card-floating-content"></span>
|
||||
</div>
|
||||
<button id="discard-btn" class="btn btn-small hidden">Discard</button>
|
||||
<button id="skip-flip-btn" class="btn btn-small btn-secondary hidden">Skip Flip</button>
|
||||
<button id="knock-early-btn" class="btn btn-small btn-danger hidden">Knock!</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -408,32 +380,15 @@
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</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>
|
||||
|
||||
<!-- Rules Screen -->
|
||||
<div id="rules-screen" class="screen">
|
||||
<div class="rules-container">
|
||||
<button id="rules-back-btn" class="btn rules-back-btn">« Back</button>
|
||||
|
||||
<div class="rules-header">
|
||||
<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>
|
||||
<p class="rules-subtitle">6-Card Golf Card Game - Complete Guide</p>
|
||||
</div>
|
||||
@ -749,8 +704,9 @@ TOTAL: 0 + 8 + 16 = 24 points</pre>
|
||||
<!-- Leaderboard Screen -->
|
||||
<div id="leaderboard-screen" class="screen">
|
||||
<div class="leaderboard-container">
|
||||
<button id="leaderboard-back-btn" class="btn leaderboard-back-btn">« Back</button>
|
||||
|
||||
<div class="leaderboard-header">
|
||||
<button id="leaderboard-back-btn" class="btn leaderboard-back-btn">« Back</button>
|
||||
<h1>Leaderboard</h1>
|
||||
<p class="leaderboard-subtitle">Top players ranked by performance</p>
|
||||
</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="knockouts">Knockouts</button>
|
||||
<button class="leaderboard-tab" data-metric="streak">Best Streak</button>
|
||||
<button class="leaderboard-tab" data-metric="rating">Rating</button>
|
||||
</div>
|
||||
|
||||
<div id="leaderboard-content">
|
||||
@ -855,48 +810,12 @@ TOTAL: 0 + 8 + 16 = 24 points</pre>
|
||||
<button type="submit" class="btn btn-primary btn-full">Login</button>
|
||||
</form>
|
||||
<p class="auth-switch">Don't have an account? <a href="#" id="show-signup">Sign up</a></p>
|
||||
<p class="auth-switch"><a href="#" id="show-forgot">Forgot password?</a></p>
|
||||
</div>
|
||||
|
||||
<!-- Forgot Password Form -->
|
||||
<div id="forgot-form-container" class="hidden">
|
||||
<h3>Reset Password</h3>
|
||||
<p class="auth-hint">Enter your email and we'll send you a reset link.</p>
|
||||
<form id="forgot-form">
|
||||
<div class="form-group">
|
||||
<input type="email" id="forgot-email" placeholder="Email" required>
|
||||
</div>
|
||||
<p id="forgot-error" class="error"></p>
|
||||
<p id="forgot-success" class="success"></p>
|
||||
<button type="submit" class="btn btn-primary btn-full">Send Reset Link</button>
|
||||
</form>
|
||||
<p class="auth-switch"><a href="#" id="forgot-back-login">Back to login</a></p>
|
||||
</div>
|
||||
|
||||
<!-- Reset Password Form (from email link) -->
|
||||
<div id="reset-form-container" class="hidden">
|
||||
<h3>Set New Password</h3>
|
||||
<form id="reset-form">
|
||||
<div class="form-group">
|
||||
<input type="password" id="reset-password" placeholder="New password" required minlength="8">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="password" id="reset-password-confirm" placeholder="Confirm password" required minlength="8">
|
||||
</div>
|
||||
<p id="reset-error" class="error"></p>
|
||||
<p id="reset-success" class="success"></p>
|
||||
<button type="submit" class="btn btn-primary btn-full">Reset Password</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Signup Form -->
|
||||
<div id="signup-form-container" class="hidden">
|
||||
<h3>Sign Up</h3>
|
||||
<form id="signup-form">
|
||||
<div class="form-group" 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">
|
||||
<input type="text" id="signup-username" placeholder="Username" required minlength="3" maxlength="20">
|
||||
</div>
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
/**
|
||||
* Leaderboard component for Golf game.
|
||||
* Handles leaderboard display, metric switching, and player stats modal.
|
||||
@ -27,7 +26,6 @@ class LeaderboardComponent {
|
||||
avg_score: 'Avg Score',
|
||||
knockouts: 'Knockouts',
|
||||
streak: 'Best Streak',
|
||||
rating: 'Rating',
|
||||
};
|
||||
|
||||
this.metricFormats = {
|
||||
@ -36,7 +34,6 @@ class LeaderboardComponent {
|
||||
avg_score: (v) => v.toFixed(1),
|
||||
knockouts: (v) => v.toLocaleString(),
|
||||
streak: (v) => v.toLocaleString(),
|
||||
rating: (v) => Math.round(v).toLocaleString(),
|
||||
};
|
||||
|
||||
this.init();
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Golf Card Game - Replay Viewer
|
||||
|
||||
class ReplayViewer {
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// StateDiffer - Detects what changed between game states
|
||||
// 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
|
||||
// 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
|
||||
reveal: {
|
||||
lastPlayPause: 2000, // Pause after last play animation before reveals
|
||||
voluntaryWindow: 2000, // Time for players to flip their own cards
|
||||
initialPause: 250, // Pause before auto-reveals start
|
||||
cardStagger: 50, // Between cards in same hand
|
||||
@ -130,25 +128,6 @@ const TIMING = {
|
||||
pulseDelay: 200, // Delay before card appears (pulse visible first)
|
||||
},
|
||||
|
||||
// Turn pulse (deck shake)
|
||||
turnPulse: {
|
||||
initialDelay: 5000, // Delay before first shake
|
||||
interval: 5400, // Time between shakes
|
||||
duration: 300, // Shake animation duration
|
||||
},
|
||||
|
||||
// V3_17: Knock notification
|
||||
knock: {
|
||||
statusDuration: 2500, // How long the knock status message persists
|
||||
},
|
||||
|
||||
// V3_17: Scoresheet modal
|
||||
scoresheet: {
|
||||
playerStagger: 150, // Delay between player row animations
|
||||
columnStagger: 80, // Delay between column animations within a row
|
||||
pairGlowDelay: 200, // Delay before paired columns glow
|
||||
},
|
||||
|
||||
// Player swap animation steps - smooth continuous motion
|
||||
playerSwap: {
|
||||
flipToReveal: 400, // Initial flip to show card
|
||||
|
||||
@ -17,71 +17,49 @@
|
||||
|
||||
services:
|
||||
app:
|
||||
restart: unless-stopped
|
||||
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:-production}
|
||||
- LOG_LEVEL=${LOG_LEVEL:-WARNING}
|
||||
- LOG_LEVEL_GAME=${LOG_LEVEL_GAME:-}
|
||||
- LOG_LEVEL_AI=${LOG_LEVEL_AI:-}
|
||||
- LOG_LEVEL_HANDLERS=${LOG_LEVEL_HANDLERS:-}
|
||||
- LOG_LEVEL_ROOM=${LOG_LEVEL_ROOM:-}
|
||||
- LOG_LEVEL_AUTH=${LOG_LEVEL_AUTH:-}
|
||||
- LOG_LEVEL_STORES=${LOG_LEVEL_STORES:-}
|
||||
- ENVIRONMENT=production
|
||||
- LOG_LEVEL=INFO
|
||||
- BASE_URL=${BASE_URL:-https://golf.example.com}
|
||||
- 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:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
deploy:
|
||||
replicas: 2
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
max_attempts: 3
|
||||
resources:
|
||||
limits:
|
||||
memory: 256M
|
||||
memory: 512M
|
||||
reservations:
|
||||
memory: 64M
|
||||
memory: 256M
|
||||
networks:
|
||||
- internal
|
||||
- web
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=traefik_web"
|
||||
- "traefik.http.routers.golf.rule=Host(`${DOMAIN:-golf.example.com}`)"
|
||||
- "traefik.http.routers.golf.entrypoints=websecure"
|
||||
- "traefik.http.routers.golf.tls=true"
|
||||
- "traefik.http.routers.golf.tls.certresolver=letsencrypt"
|
||||
# www -> bare domain redirect
|
||||
- "traefik.http.routers.golf-www.rule=Host(`www.${DOMAIN:-golf.example.com}`)"
|
||||
- "traefik.http.routers.golf-www.entrypoints=websecure"
|
||||
- "traefik.http.routers.golf-www.tls=true"
|
||||
- "traefik.http.routers.golf-www.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.routers.golf-www.middlewares=www-redirect"
|
||||
- "traefik.http.middlewares.www-redirect.redirectregex.regex=^https://www\\.(.+)"
|
||||
- "traefik.http.middlewares.www-redirect.redirectregex.replacement=https://$${1}"
|
||||
- "traefik.http.middlewares.www-redirect.redirectregex.permanent=true"
|
||||
- "traefik.http.services.golf.loadbalancer.server.port=8000"
|
||||
# WebSocket sticky sessions
|
||||
- "traefik.http.services.golf.loadbalancer.sticky.cookie=true"
|
||||
- "traefik.http.services.golf.loadbalancer.sticky.cookie.name=golf_server"
|
||||
|
||||
postgres:
|
||||
restart: unless-stopped
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_DB: golf
|
||||
@ -99,14 +77,13 @@ services:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 192M
|
||||
memory: 512M
|
||||
reservations:
|
||||
memory: 64M
|
||||
memory: 256M
|
||||
|
||||
redis:
|
||||
restart: unless-stopped
|
||||
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:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
@ -119,18 +96,44 @@ services:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 64M
|
||||
memory: 192M
|
||||
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:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
letsencrypt:
|
||||
|
||||
networks:
|
||||
internal:
|
||||
driver: bridge
|
||||
web:
|
||||
name: traefik_web
|
||||
external: true
|
||||
driver: bridge
|
||||
|
||||
@ -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_15_DISCARD_PILE_HISTORY.md` | Show recent discards fanned | Low | Medium | None |
|
||||
| `V3_16_REALISTIC_CARD_SOUNDS.md` | Improved audio feedback | Nice | Medium | None |
|
||||
| `V3_17_MOBILE_PORTRAIT_LAYOUT.md` | Full mobile portrait layout + animation fixes | High | High | 02, 11 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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]
|
||||
name = "golfgame"
|
||||
version = "3.1.6"
|
||||
version = "2.0.1"
|
||||
description = "6-Card Golf card game with AI opponents"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
license = {text = "GPL-3.0-or-later"}
|
||||
license = {text = "MIT"}
|
||||
authors = [
|
||||
{name = "alee"}
|
||||
]
|
||||
@ -13,7 +13,7 @@ classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Framework :: FastAPI",
|
||||
"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.11",
|
||||
"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
|
||||
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)
|
||||
# Affects logging format, security headers (HSTS), etc.
|
||||
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."""
|
||||
|
||||
import logging
|
||||
@ -44,9 +43,8 @@ CPU_TIMING = {
|
||||
# Delay before CPU "looks at" the discard pile
|
||||
"initial_look": (0.3, 0.5),
|
||||
# Brief pause after draw broadcast - let draw animation complete
|
||||
# Must be >= client draw animation duration (~1.09s for deck, ~0.4s for discard)
|
||||
# Extra margin prevents swap message from arriving before draw flip completes
|
||||
"post_draw_settle": 1.3,
|
||||
# Must be >= client draw animation duration (~1s for deck, ~0.4s for discard)
|
||||
"post_draw_settle": 1.1,
|
||||
# Consideration time after drawing (before swap/discard decision)
|
||||
"post_draw_consider": (0.2, 0.4),
|
||||
# Variance multiplier range for chaotic personality players
|
||||
@ -56,15 +54,17 @@ CPU_TIMING = {
|
||||
"post_action_pause": (0.5, 0.7),
|
||||
}
|
||||
|
||||
# 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 ranges by card difficulty (seconds)
|
||||
THINKING_TIME = {
|
||||
# Obviously good cards (Jokers, Kings, 2s, Aces) - easy take
|
||||
"easy_good": (0.15, 0.3),
|
||||
# Obviously bad cards (10s, Jacks, Queens) - easy pass
|
||||
"easy_bad": (0.15, 0.3),
|
||||
# Medium difficulty (3, 4, 8, 9)
|
||||
"medium": (0.15, 0.3),
|
||||
# Hardest decisions (5, 6, 7 - middle of range)
|
||||
"hard": (0.15, 0.3),
|
||||
# No discard available - quick decision
|
||||
"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)")
|
||||
return True
|
||||
|
||||
# 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.
|
||||
# Take card if it could make a column pair (but NOT for negative value cards)
|
||||
if discard_value > 0:
|
||||
for i, card in enumerate(player.cards):
|
||||
pair_pos = (i + 3) % 6 if i < 3 else i - 3
|
||||
@ -1032,11 +1030,7 @@ class GolfAI:
|
||||
if not creates_negative_pair:
|
||||
expected_hidden = EXPECTED_HIDDEN_VALUE
|
||||
point_gain = expected_hidden - drawn_value
|
||||
# Personality discount: swap_threshold ranges 0-8, so this maps to 0.5-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)
|
||||
discount = 0.5 + (profile.swap_threshold / 16) # Range: 0.5 to 1.0
|
||||
return point_gain * discount
|
||||
return 0.0
|
||||
|
||||
@ -1257,6 +1251,8 @@ class GolfAI:
|
||||
"""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.
|
||||
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
|
||||
face_down_positions = hidden_positions(player)
|
||||
@ -1305,28 +1301,12 @@ class GolfAI:
|
||||
|
||||
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}, "
|
||||
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"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)
|
||||
f"max_acceptable={max_acceptable_go_out}")
|
||||
|
||||
# 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:
|
||||
ai_log(f" >> SAFETY: both options bad, but swap ({score_if_swap}) "
|
||||
f"<= flip ({score_if_flip}), forcing swap")
|
||||
@ -1342,7 +1322,7 @@ class GolfAI:
|
||||
return None
|
||||
|
||||
# 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}")
|
||||
return last_pos
|
||||
|
||||
@ -1364,11 +1344,7 @@ class GolfAI:
|
||||
if not face_down or random.random() >= 0.5:
|
||||
return None
|
||||
|
||||
# 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.
|
||||
# SAFETY: Don't randomly go out with a bad score
|
||||
if len(face_down) == 1:
|
||||
last_pos = face_down[0]
|
||||
projected = drawn_value
|
||||
@ -1763,23 +1739,9 @@ class GolfAI:
|
||||
expected_hidden_total = len(face_down) * EXPECTED_HIDDEN_VALUE
|
||||
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
|
||||
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
|
||||
all_opponents_bad = all(
|
||||
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:
|
||||
# Scale knock chance by how good the projected score is
|
||||
if projected_score <= 4:
|
||||
knock_chance = profile.aggression * 0.35 # Max 35%
|
||||
elif projected_score <= 6:
|
||||
if projected_score <= 5:
|
||||
knock_chance = profile.aggression * 0.3 # Max 30%
|
||||
elif projected_score <= 7:
|
||||
knock_chance = profile.aggression * 0.15 # Max 15%
|
||||
elif projected_score <= 8:
|
||||
knock_chance = profile.aggression * 0.06 # Max 6%
|
||||
else: # 9-10
|
||||
knock_chance = profile.aggression * 0.02 # Max 2% (very rare)
|
||||
else:
|
||||
knock_chance = profile.aggression * 0.05 # Max 5% (very rare)
|
||||
|
||||
if random.random() < knock_chance:
|
||||
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(
|
||||
game: Game, cpu_player: Player, broadcast_callback, game_id: Optional[str] = None,
|
||||
reveal_callback=None,
|
||||
game: Game, cpu_player: Player, broadcast_callback, game_id: Optional[str] = None
|
||||
) -> None:
|
||||
"""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.
|
||||
"""
|
||||
"""Process a complete turn for a CPU player."""
|
||||
import asyncio
|
||||
from services.game_logger import get_logger
|
||||
|
||||
@ -2007,8 +1962,10 @@ async def process_cpu_turn(
|
||||
await asyncio.sleep(thinking_time)
|
||||
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)
|
||||
# (Opponent threat logic consolidated into should_knock_early)
|
||||
if GolfAI.should_knock_early(game, cpu_player, profile):
|
||||
if game.knock_early(cpu_player.id):
|
||||
_log_cpu_action(logger, game_id, cpu_player, game,
|
||||
@ -2091,13 +2048,6 @@ async def process_cpu_turn(
|
||||
|
||||
if swap_pos is not None:
|
||||
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)
|
||||
_log_cpu_action(logger, game_id, cpu_player, game,
|
||||
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.
|
||||
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""
|
||||
Centralized configuration for Golf game server.
|
||||
|
||||
@ -143,28 +142,12 @@ class ServerConfig:
|
||||
MAX_PLAYERS_PER_ROOM: int = 6
|
||||
ROOM_TIMEOUT_MINUTES: int = 60
|
||||
ROOM_CODE_LENGTH: int = 4
|
||||
ROOM_IDLE_TIMEOUT_SECONDS: int = 300 # 5 minutes of inactivity
|
||||
|
||||
# Security (for future auth system)
|
||||
SECRET_KEY: str = ""
|
||||
INVITE_ONLY: bool = True
|
||||
|
||||
# Metered open signups (public beta)
|
||||
# 0 = disabled (invite-only), -1 = unlimited, N = max per day
|
||||
DAILY_OPEN_SIGNUPS: int = 0
|
||||
# Max signups per IP per day (0 = unlimited)
|
||||
DAILY_SIGNUPS_PER_IP: int = 3
|
||||
|
||||
# Bootstrap admin (for first-time setup when INVITE_ONLY=true)
|
||||
BOOTSTRAP_ADMIN_USERNAME: str = ""
|
||||
BOOTSTRAP_ADMIN_PASSWORD: str = ""
|
||||
INVITE_ONLY: bool = False
|
||||
ADMIN_EMAILS: list[str] = field(default_factory=list)
|
||||
|
||||
# Matchmaking
|
||||
MATCHMAKING_ENABLED: bool = True
|
||||
MATCHMAKING_MIN_PLAYERS: int = 2
|
||||
MATCHMAKING_MAX_PLAYERS: int = 4
|
||||
|
||||
# Rate limiting
|
||||
RATE_LIMIT_ENABLED: bool = True
|
||||
|
||||
@ -200,16 +183,8 @@ class ServerConfig:
|
||||
MAX_PLAYERS_PER_ROOM=get_env_int("MAX_PLAYERS_PER_ROOM", 6),
|
||||
ROOM_TIMEOUT_MINUTES=get_env_int("ROOM_TIMEOUT_MINUTES", 60),
|
||||
ROOM_CODE_LENGTH=get_env_int("ROOM_CODE_LENGTH", 4),
|
||||
ROOM_IDLE_TIMEOUT_SECONDS=get_env_int("ROOM_IDLE_TIMEOUT_SECONDS", 300),
|
||||
SECRET_KEY=get_env("SECRET_KEY", ""),
|
||||
INVITE_ONLY=get_env_bool("INVITE_ONLY", True),
|
||||
DAILY_OPEN_SIGNUPS=get_env_int("DAILY_OPEN_SIGNUPS", 0),
|
||||
DAILY_SIGNUPS_PER_IP=get_env_int("DAILY_SIGNUPS_PER_IP", 3),
|
||||
BOOTSTRAP_ADMIN_USERNAME=get_env("BOOTSTRAP_ADMIN_USERNAME", ""),
|
||||
BOOTSTRAP_ADMIN_PASSWORD=get_env("BOOTSTRAP_ADMIN_PASSWORD", ""),
|
||||
MATCHMAKING_ENABLED=get_env_bool("MATCHMAKING_ENABLED", True),
|
||||
MATCHMAKING_MIN_PLAYERS=get_env_int("MATCHMAKING_MIN_PLAYERS", 2),
|
||||
MATCHMAKING_MAX_PLAYERS=get_env_int("MATCHMAKING_MAX_PLAYERS", 4),
|
||||
INVITE_ONLY=get_env_bool("INVITE_ONLY", False),
|
||||
ADMIN_EMAILS=admin_emails,
|
||||
RATE_LIMIT_ENABLED=get_env_bool("RATE_LIMIT_ENABLED", True),
|
||||
SENTRY_DSN=get_env("SENTRY_DSN", ""),
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""
|
||||
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.
|
||||
|
||||
@ -359,13 +358,6 @@ class Player:
|
||||
jack_pairs = 0 # Track paired Jacks for Wolfpack bonus
|
||||
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):
|
||||
top_idx = col
|
||||
bottom_idx = col + 3
|
||||
@ -783,17 +775,9 @@ class Game:
|
||||
for i, player in enumerate(self.players):
|
||||
if player.id == player_id:
|
||||
removed = self.players.pop(i)
|
||||
if self.players:
|
||||
# Adjust dealer_idx if needed after removal
|
||||
if self.dealer_idx >= len(self.players):
|
||||
self.dealer_idx = 0
|
||||
# Adjust current_player_index after removal
|
||||
if i < self.current_player_index:
|
||||
# Removed player was before current: shift back
|
||||
self.current_player_index -= 1
|
||||
elif self.current_player_index >= len(self.players):
|
||||
# Removed player was at/after current and index is now OOB
|
||||
self.current_player_index = 0
|
||||
# Adjust dealer_idx if needed after removal
|
||||
if self.players and self.dealer_idx >= len(self.players):
|
||||
self.dealer_idx = 0
|
||||
self._emit("player_left", player_id=player_id, reason=reason)
|
||||
return removed
|
||||
return None
|
||||
@ -816,8 +800,6 @@ class Game:
|
||||
def current_player(self) -> Optional[Player]:
|
||||
"""Get the player whose turn it currently is."""
|
||||
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 None
|
||||
|
||||
@ -950,8 +932,7 @@ class Game:
|
||||
if self.current_round > 1:
|
||||
self.dealer_idx = (self.dealer_idx + 1) % len(self.players)
|
||||
|
||||
# "Left of dealer goes first" — standard card game convention.
|
||||
# In our circular list, "left" is the next index.
|
||||
# First player is to the left of dealer (next in order)
|
||||
self.current_player_index = (self.dealer_idx + 1) % len(self.players)
|
||||
|
||||
# Emit round_started event with deck seed and all dealt cards
|
||||
@ -1434,9 +1415,6 @@ class Game:
|
||||
Args:
|
||||
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:
|
||||
self.finisher_id = player.id
|
||||
self.phase = GamePhase.FINAL_TURN
|
||||
@ -1453,8 +1431,7 @@ class Game:
|
||||
Advance to the next player's 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()
|
||||
having already added the current player to players_with_final_turn.
|
||||
and ends the round when everyone has played.
|
||||
"""
|
||||
if self.phase == GamePhase.FINAL_TURN:
|
||||
next_index = (self.current_player_index + 1) % len(self.players)
|
||||
@ -1497,10 +1474,6 @@ class Game:
|
||||
player.calculate_score(self.options)
|
||||
|
||||
# --- 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
|
||||
if self.options.blackjack:
|
||||
@ -1624,10 +1597,6 @@ class Game:
|
||||
"""
|
||||
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 = []
|
||||
for player in self.players:
|
||||
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.
|
||||
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""WebSocket message handlers for the Golf card game.
|
||||
|
||||
Each handler corresponds to a single message type from the client.
|
||||
@ -13,7 +12,6 @@ from typing import Optional
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
from config import config
|
||||
from game import GamePhase, GameOptions
|
||||
from ai import GolfAI, get_all_profiles
|
||||
from room import Room
|
||||
@ -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:
|
||||
if config.INVITE_ONLY and not ctx.authenticated_user:
|
||||
await ctx.websocket.send_json({"type": "error", "message": "You must be logged in to play"})
|
||||
return
|
||||
|
||||
if ctx.auth_user_id and count_user_games(ctx.auth_user_id) >= max_concurrent:
|
||||
await ctx.websocket.send_json({
|
||||
"type": "error",
|
||||
@ -66,11 +60,11 @@ async def handle_create_room(data: dict, ctx: ConnectionContext, *, room_manager
|
||||
})
|
||||
return
|
||||
|
||||
# Use authenticated username as player name
|
||||
player_name = ctx.authenticated_user.username if ctx.authenticated_user else data.get("player_name", "Player")
|
||||
player_name = data.get("player_name", "Player")
|
||||
if ctx.authenticated_user and ctx.authenticated_user.display_name:
|
||||
player_name = ctx.authenticated_user.display_name
|
||||
room = room_manager.create_room()
|
||||
room.add_player(ctx.player_id, player_name, ctx.websocket, ctx.auth_user_id)
|
||||
room.touch()
|
||||
ctx.current_room = room
|
||||
|
||||
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:
|
||||
if config.INVITE_ONLY and not ctx.authenticated_user:
|
||||
await ctx.websocket.send_json({"type": "error", "message": "You must be logged in to play"})
|
||||
return
|
||||
|
||||
room_code = data.get("room_code", "").upper()
|
||||
# Use authenticated username as player name
|
||||
player_name = ctx.authenticated_user.username if ctx.authenticated_user else data.get("player_name", "Player")
|
||||
player_name = data.get("player_name", "Player")
|
||||
|
||||
if ctx.auth_user_id and count_user_games(ctx.auth_user_id) >= max_concurrent:
|
||||
await ctx.websocket.send_json({
|
||||
@ -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"})
|
||||
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.touch()
|
||||
ctx.current_room = room
|
||||
|
||||
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:
|
||||
if not ctx.current_room:
|
||||
return
|
||||
ctx.current_room.touch()
|
||||
|
||||
room_player = ctx.current_room.get_player(ctx.player_id)
|
||||
if not room_player or not room_player.is_host:
|
||||
@ -233,19 +222,18 @@ async def handle_start_game(data: dict, ctx: ConnectionContext, *, broadcast_gam
|
||||
"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:
|
||||
if not ctx.current_room:
|
||||
return
|
||||
ctx.current_room.touch()
|
||||
|
||||
positions = data.get("positions", [])
|
||||
async with ctx.current_room.game_lock:
|
||||
if ctx.current_room.game.flip_initial_cards(ctx.player_id, positions):
|
||||
await broadcast_game_state(ctx.current_room)
|
||||
check_and_run_cpu_turn(ctx.current_room)
|
||||
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:
|
||||
if not ctx.current_room:
|
||||
return
|
||||
ctx.current_room.touch()
|
||||
|
||||
source = data.get("source", "deck")
|
||||
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:
|
||||
if not ctx.current_room:
|
||||
return
|
||||
ctx.current_room.touch()
|
||||
|
||||
position = data.get("position", 0)
|
||||
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)
|
||||
old_card = player.cards[position] if player and 0 <= position < len(player.cards) else None
|
||||
|
||||
# Capture old card info BEFORE the swap mutates the player's hand.
|
||||
# game.swap_card() overwrites player.cards[position] in place, so if we
|
||||
# read it after, we'd get the new card. The client needs the old card data
|
||||
# to animate the outgoing card correctly.
|
||||
old_was_face_down = old_card and not old_card.face_up if old_card else False
|
||||
old_card_data = None
|
||||
if old_card and old_was_face_down:
|
||||
old_card_data = {
|
||||
"rank": old_card.rank.value if old_card.rank else None,
|
||||
"suit": old_card.suit.value if old_card.suit else None,
|
||||
}
|
||||
|
||||
discarded = ctx.current_room.game.swap_card(ctx.player_id, position)
|
||||
|
||||
if discarded:
|
||||
@ -316,13 +290,12 @@ async def handle_swap(data: dict, ctx: ConnectionContext, *, broadcast_game_stat
|
||||
|
||||
await broadcast_game_state(ctx.current_room)
|
||||
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:
|
||||
if not ctx.current_room:
|
||||
return
|
||||
ctx.current_room.touch()
|
||||
|
||||
async with ctx.current_room.game_lock:
|
||||
drawn_card = ctx.current_room.game.drawn_card
|
||||
@ -349,12 +322,12 @@ async def handle_discard(data: dict, ctx: ConnectionContext, *, broadcast_game_s
|
||||
})
|
||||
else:
|
||||
await asyncio.sleep(0.5)
|
||||
check_and_run_cpu_turn(ctx.current_room)
|
||||
await check_and_run_cpu_turn(ctx.current_room)
|
||||
else:
|
||||
logger.debug("Player discarded, waiting 0.5s before CPU turn")
|
||||
await asyncio.sleep(0.5)
|
||||
logger.debug("Post-discard delay complete, checking for CPU turn")
|
||||
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:
|
||||
@ -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:
|
||||
if not ctx.current_room:
|
||||
return
|
||||
ctx.current_room.touch()
|
||||
|
||||
position = data.get("position", 0)
|
||||
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)
|
||||
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:
|
||||
if not ctx.current_room:
|
||||
return
|
||||
ctx.current_room.touch()
|
||||
|
||||
async with ctx.current_room.game_lock:
|
||||
player = ctx.current_room.game.get_player(ctx.player_id)
|
||||
@ -402,13 +373,12 @@ async def handle_skip_flip(data: dict, ctx: ConnectionContext, *, broadcast_game
|
||||
)
|
||||
|
||||
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:
|
||||
if not ctx.current_room:
|
||||
return
|
||||
ctx.current_room.touch()
|
||||
|
||||
position = data.get("position", 0)
|
||||
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)
|
||||
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:
|
||||
if not ctx.current_room:
|
||||
return
|
||||
ctx.current_room.touch()
|
||||
|
||||
async with ctx.current_room.game_lock:
|
||||
player = ctx.current_room.game.get_player(ctx.player_id)
|
||||
@ -442,13 +411,12 @@ async def handle_knock_early(data: dict, ctx: ConnectionContext, *, broadcast_ga
|
||||
)
|
||||
|
||||
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:
|
||||
if not ctx.current_room:
|
||||
return
|
||||
ctx.current_room.touch()
|
||||
|
||||
room_player = ctx.current_room.get_player(ctx.player_id)
|
||||
if not room_player or not room_player.is_host:
|
||||
@ -468,7 +436,7 @@ async def handle_next_round(data: dict, ctx: ConnectionContext, *, broadcast_gam
|
||||
"game_state": game_state,
|
||||
})
|
||||
|
||||
check_and_run_cpu_turn(ctx.current_room)
|
||||
await check_and_run_cpu_turn(ctx.current_room)
|
||||
else:
|
||||
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:
|
||||
if not ctx.current_room:
|
||||
return
|
||||
ctx.current_room.touch()
|
||||
|
||||
room_player = ctx.current_room.get_player(ctx.player_id)
|
||||
if not room_player or not room_player.is_host:
|
||||
await ctx.websocket.send_json({"type": "error", "message": "Only the host can end the game"})
|
||||
return
|
||||
|
||||
# Cancel any running CPU turn task so the game ends immediately
|
||||
if ctx.current_room.cpu_turn_task:
|
||||
ctx.current_room.cpu_turn_task.cancel()
|
||||
try:
|
||||
await ctx.current_room.cpu_turn_task
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
ctx.current_room.cpu_turn_task = None
|
||||
|
||||
await ctx.current_room.broadcast({
|
||||
"type": "game_ended",
|
||||
"reason": "Host ended the game",
|
||||
@ -525,65 +483,6 @@ async def handle_end_game(data: dict, ctx: ConnectionContext, *, room_manager, c
|
||||
# Handler dispatch table
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Matchmaking handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def handle_queue_join(data: dict, ctx: ConnectionContext, *, matchmaking_service=None, rating_service=None, **kw) -> None:
|
||||
if not matchmaking_service:
|
||||
await ctx.websocket.send_json({"type": "error", "message": "Matchmaking not available"})
|
||||
return
|
||||
|
||||
if not ctx.authenticated_user:
|
||||
await ctx.websocket.send_json({"type": "error", "message": "You must be logged in to find a game"})
|
||||
return
|
||||
|
||||
# Get player's rating
|
||||
rating = 1500.0
|
||||
if rating_service:
|
||||
try:
|
||||
player_rating = await rating_service.get_rating(ctx.auth_user_id)
|
||||
rating = player_rating.rating
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
status = await matchmaking_service.join_queue(
|
||||
user_id=ctx.auth_user_id,
|
||||
username=ctx.authenticated_user.username,
|
||||
rating=rating,
|
||||
websocket=ctx.websocket,
|
||||
connection_id=ctx.connection_id,
|
||||
)
|
||||
|
||||
await ctx.websocket.send_json({
|
||||
"type": "queue_joined",
|
||||
**status,
|
||||
})
|
||||
|
||||
|
||||
async def handle_queue_leave(data: dict, ctx: ConnectionContext, *, matchmaking_service=None, **kw) -> None:
|
||||
if not matchmaking_service or not ctx.auth_user_id:
|
||||
return
|
||||
|
||||
removed = await matchmaking_service.leave_queue(ctx.auth_user_id)
|
||||
await ctx.websocket.send_json({
|
||||
"type": "queue_left",
|
||||
"was_queued": removed,
|
||||
})
|
||||
|
||||
|
||||
async def handle_queue_status(data: dict, ctx: ConnectionContext, *, matchmaking_service=None, **kw) -> None:
|
||||
if not matchmaking_service or not ctx.auth_user_id:
|
||||
await ctx.websocket.send_json({"type": "queue_status", "in_queue": False})
|
||||
return
|
||||
|
||||
status = await matchmaking_service.get_queue_status(ctx.auth_user_id)
|
||||
await ctx.websocket.send_json({
|
||||
"type": "queue_status",
|
||||
**status,
|
||||
})
|
||||
|
||||
|
||||
HANDLERS = {
|
||||
"create_room": handle_create_room,
|
||||
"join_room": handle_join_room,
|
||||
@ -604,7 +503,4 @@ HANDLERS = {
|
||||
"leave_room": handle_leave_room,
|
||||
"leave_game": handle_leave_game,
|
||||
"end_game": handle_end_game,
|
||||
"queue_join": handle_queue_join,
|
||||
"queue_leave": handle_queue_leave,
|
||||
"queue_status": handle_queue_status,
|
||||
}
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""
|
||||
Structured logging configuration for Golf game server.
|
||||
|
||||
@ -149,39 +148,6 @@ class DevelopmentFormatter(logging.Formatter):
|
||||
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(
|
||||
level: str = "INFO",
|
||||
environment: str = "development",
|
||||
@ -216,19 +182,12 @@ def setup_logging(
|
||||
logging.getLogger("websockets").setLevel(logging.WARNING)
|
||||
logging.getLogger("asyncio").setLevel(logging.WARNING)
|
||||
|
||||
# Apply per-module overrides from env vars
|
||||
overrides = _apply_module_overrides()
|
||||
|
||||
# Log startup
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(
|
||||
f"Logging configured: 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):
|
||||
|
||||
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."""
|
||||
|
||||
import asyncio
|
||||
@ -60,12 +59,9 @@ _user_store = None
|
||||
_auth_service = None
|
||||
_admin_service = None
|
||||
_stats_service = None
|
||||
_rating_service = None
|
||||
_matchmaking_service = None
|
||||
_replay_service = None
|
||||
_spectator_manager = None
|
||||
_leaderboard_refresh_task = None
|
||||
_room_cleanup_task = None
|
||||
_redis_client = None
|
||||
_rate_limiter = None
|
||||
_shutdown_event = asyncio.Event()
|
||||
@ -85,74 +81,8 @@ async def _periodic_leaderboard_refresh():
|
||||
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():
|
||||
"""Initialize Redis client, rate limiter, and signup limiter."""
|
||||
"""Initialize Redis client and rate limiter."""
|
||||
global _redis_client, _rate_limiter
|
||||
try:
|
||||
_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
|
||||
_rate_limiter = await get_rate_limiter(_redis_client)
|
||||
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:
|
||||
logger.warning(f"Redis connection failed: {e} - rate limiting disabled")
|
||||
_redis_client = None
|
||||
@ -182,7 +101,7 @@ async def _init_redis():
|
||||
|
||||
async def _init_database_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
|
||||
|
||||
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.admin_service import get_admin_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.stats import set_stats_service as set_stats_router_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,
|
||||
)
|
||||
set_admin_service(_admin_service)
|
||||
set_admin_service_for_auth(_admin_service)
|
||||
logger.info("Admin services initialized")
|
||||
|
||||
# Stats + event store
|
||||
@ -219,23 +137,6 @@ async def _init_database_services():
|
||||
set_stats_auth_service(_auth_service)
|
||||
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 = GameLogger(_event_store)
|
||||
set_logger(_game_logger)
|
||||
@ -264,56 +165,12 @@ async def _init_database_services():
|
||||
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():
|
||||
"""Gracefully shut down all services."""
|
||||
_shutdown_event.set()
|
||||
|
||||
await _close_all_websockets()
|
||||
|
||||
# Stop matchmaking
|
||||
if _matchmaking_service:
|
||||
await _matchmaking_service.stop()
|
||||
await _matchmaking_service.cleanup()
|
||||
|
||||
# Clean up rooms and CPU profiles
|
||||
for room in list(room_manager.rooms.values()):
|
||||
for cpu in list(room.get_cpu_players()):
|
||||
@ -322,14 +179,6 @@ async def _shutdown_services():
|
||||
reset_all_profiles()
|
||||
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:
|
||||
_leaderboard_refresh_task.cancel()
|
||||
try:
|
||||
@ -376,10 +225,6 @@ async def lifespan(app: FastAPI):
|
||||
else:
|
||||
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
|
||||
from routers.health import set_health_dependencies
|
||||
set_health_dependencies(
|
||||
@ -388,26 +233,6 @@ async def lifespan(app: FastAPI):
|
||||
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})")
|
||||
|
||||
yield
|
||||
@ -432,7 +257,7 @@ async def _close_all_websockets():
|
||||
app = FastAPI(
|
||||
title="Golf Card Game",
|
||||
debug=config.DEBUG,
|
||||
version="3.2.0",
|
||||
version="2.0.1",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
@ -616,8 +441,6 @@ async def reset_cpu_profiles():
|
||||
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
|
||||
|
||||
|
||||
@ -635,7 +458,7 @@ def count_user_games(user_id: str) -> int:
|
||||
async def websocket_endpoint(websocket: WebSocket):
|
||||
await websocket.accept()
|
||||
|
||||
# Extract token from query param for authentication
|
||||
# Extract token from query param for optional authentication
|
||||
token = websocket.query_params.get("token")
|
||||
authenticated_user = None
|
||||
if token and _auth_service:
|
||||
@ -644,12 +467,6 @@ async def websocket_endpoint(websocket: WebSocket):
|
||||
except Exception as 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())
|
||||
auth_user_id = str(authenticated_user.id) if authenticated_user else None
|
||||
|
||||
@ -658,10 +475,6 @@ async def websocket_endpoint(websocket: WebSocket):
|
||||
else:
|
||||
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(
|
||||
websocket=websocket,
|
||||
connection_id=connection_id,
|
||||
@ -679,8 +492,6 @@ async def websocket_endpoint(websocket: WebSocket):
|
||||
check_and_run_cpu_turn=check_and_run_cpu_turn,
|
||||
handle_player_leave=handle_player_leave,
|
||||
cleanup_room_profiles=cleanup_room_profiles,
|
||||
matchmaking_service=_matchmaking_service,
|
||||
rating_service=_rating_service,
|
||||
)
|
||||
|
||||
try:
|
||||
@ -723,23 +534,6 @@ async def _process_stats_safe(room: Room):
|
||||
game_options=room.game.options,
|
||||
)
|
||||
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:
|
||||
logger.error(f"Failed to process game stats: {e}")
|
||||
|
||||
@ -765,7 +559,7 @@ async def broadcast_game_state(room: Room):
|
||||
# Check for round over
|
||||
if room.game.phase == GamePhase.ROUND_OVER:
|
||||
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
|
||||
]
|
||||
# Build rankings
|
||||
@ -774,7 +568,6 @@ async def broadcast_game_state(room: Room):
|
||||
await player.websocket.send_json({
|
||||
"type": "round_over",
|
||||
"scores": scores,
|
||||
"finisher_id": room.game.finisher_id,
|
||||
"round": room.game.current_round,
|
||||
"total_rounds": room.game.num_rounds,
|
||||
"rankings": {
|
||||
@ -819,13 +612,8 @@ async def broadcast_game_state(room: Room):
|
||||
})
|
||||
|
||||
|
||||
def check_and_run_cpu_turn(room: Room):
|
||||
"""Check if current player is CPU and start their turn as a background task.
|
||||
|
||||
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.
|
||||
"""
|
||||
async def check_and_run_cpu_turn(room: Room):
|
||||
"""Check if current player is CPU and run their turn."""
|
||||
if room.game.phase not in (GamePhase.PLAYING, GamePhase.FINAL_TURN):
|
||||
return
|
||||
|
||||
@ -837,77 +625,25 @@ def check_and_run_cpu_turn(room: Room):
|
||||
if not room_player or not room_player.is_cpu:
|
||||
return
|
||||
|
||||
task = asyncio.create_task(_run_cpu_chain(room))
|
||||
room.cpu_turn_task = task
|
||||
# Brief pause before CPU starts - animations are faster now
|
||||
await asyncio.sleep(0.25)
|
||||
|
||||
def _on_done(t: asyncio.Task):
|
||||
# Clear the reference when the task finishes (success, cancel, or error)
|
||||
if room.cpu_turn_task is t:
|
||||
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()}")
|
||||
# Run CPU turn
|
||||
async def broadcast_cb():
|
||||
await broadcast_game_state(room)
|
||||
|
||||
task.add_done_callback(_on_done)
|
||||
await process_cpu_turn(room.game, current, broadcast_cb, game_id=room.game_log_id)
|
||||
|
||||
|
||||
async def _run_cpu_chain(room: 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)
|
||||
# Check if next player is also CPU (chain CPU turns)
|
||||
await check_and_run_cpu_turn(room)
|
||||
|
||||
|
||||
async def handle_player_leave(room: Room, player_id: str):
|
||||
"""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_player = room.remove_player(player_id)
|
||||
|
||||
# Check both is_empty() AND human_player_count() — CPU players keep rooms
|
||||
# technically non-empty, but a room with only CPUs is an abandoned room.
|
||||
# If no human players left, clean up the room entirely
|
||||
if room.is_empty() or room.human_player_count() == 0:
|
||||
# Remove all remaining CPU players to release their profiles
|
||||
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):
|
||||
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.)
|
||||
# Wrap StaticFiles to reject WebSocket requests gracefully instead of
|
||||
# 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")
|
||||
app.mount("/", StaticFiles(directory=client_path), name="static")
|
||||
|
||||
|
||||
def run():
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""
|
||||
Middleware components for Golf game server.
|
||||
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""
|
||||
Rate limiting middleware for FastAPI.
|
||||
|
||||
@ -82,15 +81,11 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
# Generate client key
|
||||
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)
|
||||
full_key = f"{endpoint_key}:{client_key}"
|
||||
|
||||
is_auth_endpoint = path.startswith("/api/auth")
|
||||
if is_auth_endpoint:
|
||||
allowed, info = await self.limiter.is_allowed_strict(full_key, limit, window)
|
||||
else:
|
||||
allowed, info = await self.limiter.is_allowed(full_key, limit, window)
|
||||
allowed, info = await self.limiter.is_allowed(full_key, limit, window)
|
||||
|
||||
# Build response
|
||||
if allowed:
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""
|
||||
Request ID middleware for request tracing.
|
||||
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""
|
||||
Security headers middleware for FastAPI.
|
||||
|
||||
@ -111,10 +110,8 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||
|
||||
# Add WebSocket URLs
|
||||
if self.environment == "production":
|
||||
connect_sources.append(f"ws://{host}")
|
||||
connect_sources.append(f"wss://{host}")
|
||||
for allowed_host in self.allowed_hosts:
|
||||
connect_sources.append(f"ws://{allowed_host}")
|
||||
connect_sources.append(f"wss://{allowed_host}")
|
||||
else:
|
||||
# Development - allow ws:// and wss://
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""Models package for Golf game V2."""
|
||||
|
||||
from .events import EventType, GameEvent
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""
|
||||
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.
|
||||
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""
|
||||
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.
|
||||
|
||||
@ -15,7 +14,6 @@ A Room contains:
|
||||
import asyncio
|
||||
import random
|
||||
import string
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
@ -71,12 +69,6 @@ class Room:
|
||||
settings: dict = field(default_factory=lambda: {"decks": 1, "rounds": 1})
|
||||
game_log_id: Optional[str] = None
|
||||
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(
|
||||
self,
|
||||
@ -99,9 +91,6 @@ class Room:
|
||||
Returns:
|
||||
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
|
||||
room_player = RoomPlayer(
|
||||
id=player_id,
|
||||
@ -177,9 +166,7 @@ class Room:
|
||||
if room_player.is_cpu:
|
||||
release_profile(room_player.name, self.code)
|
||||
|
||||
# Assign new host if needed. next(iter(...)) gives us the first value in
|
||||
# insertion order (Python 3.7+ dict guarantee). This means the longest-tenured
|
||||
# player becomes host, which is the least surprising behavior.
|
||||
# Assign new host if needed
|
||||
if room_player.is_host and self.players:
|
||||
next_host = next(iter(self.players.values()))
|
||||
next_host.is_host = True
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""Routers package for Golf game API."""
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""
|
||||
Authentication API router for Golf game V2.
|
||||
|
||||
@ -6,18 +5,14 @@ Provides endpoints for user registration, login, password management,
|
||||
and session handling.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Header, Request
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
from config import config
|
||||
from models.user import User
|
||||
from services.auth_service import AuthService
|
||||
from services.admin_service import AdminService
|
||||
from services.ratelimit import SignupLimiter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -34,7 +29,6 @@ class RegisterRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
email: Optional[str] = None
|
||||
invite_code: Optional[str] = None
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
@ -117,8 +111,6 @@ class SessionResponse(BaseModel):
|
||||
|
||||
# These will be set by main.py during startup
|
||||
_auth_service: Optional[AuthService] = None
|
||||
_admin_service: Optional[AdminService] = None
|
||||
_signup_limiter: Optional[SignupLimiter] = None
|
||||
|
||||
|
||||
def set_auth_service(service: AuthService) -> None:
|
||||
@ -127,18 +119,6 @@ def set_auth_service(service: AuthService) -> None:
|
||||
_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:
|
||||
"""Dependency to get auth service."""
|
||||
if _auth_service is None:
|
||||
@ -221,51 +201,6 @@ async def register(
|
||||
auth_service: AuthService = Depends(get_auth_service_dep),
|
||||
):
|
||||
"""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(
|
||||
username=request_body.username,
|
||||
password=request_body.password,
|
||||
@ -275,19 +210,8 @@ async def register(
|
||||
if not result.success:
|
||||
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:
|
||||
# Return user info but note they need to verify
|
||||
return {
|
||||
"user": _user_to_response(result.user),
|
||||
"token": "",
|
||||
@ -300,7 +224,7 @@ async def register(
|
||||
username=request_body.username,
|
||||
password=request_body.password,
|
||||
device_info=get_device_info(request),
|
||||
ip_address=client_ip,
|
||||
ip_address=get_client_ip(request),
|
||||
)
|
||||
|
||||
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")
|
||||
async def verify_email(
|
||||
request_body: VerifyEmailRequest,
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""
|
||||
Health check endpoints for production deployment.
|
||||
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""
|
||||
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.
|
||||
|
||||
@ -156,7 +155,7 @@ async def require_user(
|
||||
|
||||
@router.get("/leaderboard", response_model=LeaderboardResponse)
|
||||
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),
|
||||
offset: int = Query(0, ge=0),
|
||||
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)
|
||||
async def get_player_rank(
|
||||
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),
|
||||
):
|
||||
"""Get player's rank on a leaderboard."""
|
||||
@ -347,7 +346,7 @@ async def get_my_stats(
|
||||
|
||||
@router.get("/me/rank", response_model=PlayerRankResponse)
|
||||
async def get_my_rank(
|
||||
metric: str = Query("wins", pattern="^(wins|win_rate|avg_score|knockouts|streak|rating)$"),
|
||||
metric: str = Query("wins", pattern="^(wins|win_rate|avg_score|knockouts|streak)$"),
|
||||
user: User = Depends(require_user),
|
||||
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.
|
||||
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""
|
||||
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."""
|
||||
|
||||
from .recovery_service import RecoveryService, RecoveryResult
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""
|
||||
Admin service for Golf game.
|
||||
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""
|
||||
Authentication service for Golf game.
|
||||
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""
|
||||
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.
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -92,42 +91,9 @@ class RateLimiter:
|
||||
|
||||
except redis.RedisError as e:
|
||||
# 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}")
|
||||
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(
|
||||
self,
|
||||
request: Request | WebSocket,
|
||||
@ -231,110 +197,8 @@ class ConnectionMessageLimiter:
|
||||
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
|
||||
_rate_limiter: Optional[RateLimiter] = None
|
||||
_signup_limiter: Optional[SignupLimiter] = None
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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():
|
||||
"""Close the global rate limiter."""
|
||||
global _rate_limiter, _signup_limiter
|
||||
global _rate_limiter
|
||||
_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.
|
||||
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""
|
||||
Replay service for Golf game.
|
||||
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""
|
||||
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.
|
||||
|
||||
@ -38,8 +37,6 @@ class PlayerStats:
|
||||
wolfpacks: int = 0
|
||||
current_win_streak: int = 0
|
||||
best_win_streak: int = 0
|
||||
rating: float = 1500.0
|
||||
rating_deviation: float = 350.0
|
||||
first_game_at: Optional[datetime] = None
|
||||
last_game_at: Optional[datetime] = None
|
||||
achievements: List[str] = field(default_factory=list)
|
||||
@ -159,8 +156,6 @@ class StatsService:
|
||||
wolfpacks=row["wolfpacks"] or 0,
|
||||
current_win_streak=row["current_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,
|
||||
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],
|
||||
@ -189,7 +184,6 @@ class StatsService:
|
||||
"avg_score": ("avg_score", "ASC"), # Lower is better
|
||||
"knockouts": ("knockouts", "DESC"),
|
||||
"streak": ("best_win_streak", "DESC"),
|
||||
"rating": ("rating", "DESC"),
|
||||
}
|
||||
|
||||
if metric not in order_map:
|
||||
@ -209,7 +203,6 @@ class StatsService:
|
||||
SELECT
|
||||
user_id, username, games_played, games_won,
|
||||
win_rate, avg_score, knockouts, best_win_streak,
|
||||
COALESCE(rating, 1500) as rating,
|
||||
ROW_NUMBER() OVER (ORDER BY {column} {direction}) as rank
|
||||
FROM leaderboard_overall
|
||||
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.total_points::numeric / NULLIF(s.total_rounds, 0), 1) as avg_score,
|
||||
s.knockouts, s.best_win_streak,
|
||||
COALESCE(s.rating, 1500) as rating,
|
||||
ROW_NUMBER() OVER (ORDER BY {column} {direction}) as rank
|
||||
FROM player_stats s
|
||||
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
|
||||
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""Stores package for Golf game V2 persistence."""
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""
|
||||
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.
|
||||
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""
|
||||
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
|
||||
ALTER TABLE player_stats ADD COLUMN games_won_vs_humans INT DEFAULT 0;
|
||||
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 $$;
|
||||
|
||||
-- Stats processing queue (for async stats processing)
|
||||
@ -282,19 +265,9 @@ CREATE TABLE IF NOT EXISTS system_metrics (
|
||||
);
|
||||
|
||||
-- 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 $$
|
||||
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
|
||||
EXECUTE '
|
||||
CREATE MATERIALIZED VIEW leaderboard_overall AS
|
||||
@ -309,7 +282,6 @@ BEGIN
|
||||
s.best_score as best_round_score,
|
||||
s.knockouts,
|
||||
s.best_win_streak,
|
||||
COALESCE(s.rating, 1500) as rating,
|
||||
s.last_game_at
|
||||
FROM player_stats s
|
||||
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
|
||||
CREATE INDEX idx_leaderboard_overall_score ON leaderboard_overall(avg_score ASC);
|
||||
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 $$;
|
||||
"""
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""
|
||||
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.
|
||||
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""
|
||||
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.
|
||||
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""
|
||||
Test suite for WebSocket message handlers.
|
||||
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""
|
||||
House Rules Testing Suite
|
||||
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""
|
||||
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.
|
||||
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""
|
||||
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."""
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""
|
||||
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.
|
||||
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""
|
||||
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