Compare commits

..

No commits in common. "main" and "v3.14" have entirely different histories.
main ... v3.14

103 changed files with 262 additions and 6193 deletions

View File

@ -20,24 +20,6 @@ DEBUG=false
# Logging level: DEBUG, INFO, WARNING, ERROR, CRITICAL # Logging level: DEBUG, INFO, WARNING, ERROR, CRITICAL
LOG_LEVEL=INFO LOG_LEVEL=INFO
# Per-module log level overrides (optional)
# These override LOG_LEVEL for specific modules.
# LOG_LEVEL_GAME=DEBUG # Core game logic
# LOG_LEVEL_AI=DEBUG # AI decisions (very verbose at DEBUG)
# LOG_LEVEL_HANDLERS=DEBUG # WebSocket message handlers
# LOG_LEVEL_ROOM=DEBUG # Room/lobby management
# LOG_LEVEL_AUTH=DEBUG # Auth stack (auth, routers.auth, services.auth_service)
# LOG_LEVEL_STORES=DEBUG # Database/Redis operations
# --- Preset examples ---
# Staging (debug game logic, quiet everything else):
# LOG_LEVEL=INFO
# LOG_LEVEL_GAME=DEBUG
# LOG_LEVEL_AI=DEBUG
#
# Production (minimal logging):
# LOG_LEVEL=WARNING
# Environment name (development, staging, production) # Environment name (development, staging, production)
ENVIRONMENT=development ENVIRONMENT=development
@ -75,15 +57,6 @@ SECRET_KEY=
# Enable invite-only mode (requires invitation to register) # Enable invite-only mode (requires invitation to register)
INVITE_ONLY=true 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) # Bootstrap admin account (for first-time setup with INVITE_ONLY=true)
# Remove these after first login! # Remove these after first login!
# BOOTSTRAP_ADMIN_USERNAME=admin # BOOTSTRAP_ADMIN_USERNAME=admin

27
.gitignore vendored
View File

@ -136,31 +136,7 @@ celerybeat.pid
# Environments # Environments
.env .env
.env.*
!.env.example
.envrc .envrc
# Private keys and certificates
*.pem
*.key
*.p12
*.pfx
*.jks
*.keystore
# Service credentials
credentials.json
service-account.json
*-credentials.json
# SSH keys
id_rsa
id_ecdsa
id_ed25519
# Other sensitive files
*.secrets
.htpasswd
.venv .venv
env/ env/
venv/ venv/
@ -225,9 +201,6 @@ pyvenv.cfg
# Personal notes # Personal notes
lookfah.md lookfah.md
# Internal docs (deployment info, credentials references, etc.)
internal/
# Ruff stuff: # Ruff stuff:
.ruff_cache/ .ruff_cache/

View File

@ -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"
}

674
LICENSE
View File

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

View File

@ -203,4 +203,4 @@ From testing (1000+ games):
## License ## License
GPL-3.0-or-later — see [LICENSE](LICENSE) for the full text. MIT

View File

@ -1,4 +1,3 @@
// SPDX-License-Identifier: GPL-3.0-or-later
/** /**
* Golf Admin Dashboard * Golf Admin Dashboard
* JavaScript for admin interface functionality * JavaScript for admin interface functionality

View File

@ -1,4 +1,3 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// AnimationQueue - Sequences card animations properly // AnimationQueue - Sequences card animations properly
// Ensures animations play in order without overlap // Ensures animations play in order without overlap
@ -32,17 +31,14 @@ class AnimationQueue {
}; };
} }
// Add movements to the queue and start processing. // Add movements to the queue and start processing
// The onComplete callback only fires after the LAST movement in this batch —
// intermediate movements don't trigger it. This is intentional: callers want
// to know when the whole sequence is done, not each individual step.
async enqueue(movements, onComplete) { async enqueue(movements, onComplete) {
if (!movements || movements.length === 0) { if (!movements || movements.length === 0) {
if (onComplete) onComplete(); if (onComplete) onComplete();
return; return;
} }
// Attach callback to last movement only // Add completion callback to last movement
const movementsWithCallback = movements.map((m, i) => ({ const movementsWithCallback = movements.map((m, i) => ({
...m, ...m,
onComplete: i === movements.length - 1 ? onComplete : null onComplete: i === movements.length - 1 ? onComplete : null
@ -189,9 +185,7 @@ class AnimationQueue {
await this.delay(this.timing.flipDuration); await this.delay(this.timing.flipDuration);
} }
// Step 2: Quick crossfade swap. // Step 2: Quick crossfade swap
// 150ms is short enough to feel instant but long enough for the eye to
// register the transition. Shorter looks like a glitch, longer looks laggy.
handCard.classList.add('fade-out'); handCard.classList.add('fade-out');
heldCard.classList.add('fade-out'); heldCard.classList.add('fade-out');
await this.delay(150); await this.delay(150);

View File

@ -1,4 +1,3 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Golf Card Game - Client Application // Golf Card Game - Client Application
// Debug logging - set to true to see detailed state/animation logs // Debug logging - set to true to see detailed state/animation logs
@ -31,14 +30,7 @@ class GolfGame {
this.soundEnabled = true; this.soundEnabled = true;
this.audioCtx = null; this.audioCtx = null;
// --- Animation coordination flags --- // Swap animation state
// These flags form a system: they block renderGame() from touching the discard pile
// while an animation is in flight. If any flag gets stuck true, the discard pile
// freezes and the UI looks broken. Every flag MUST be cleared in every code path:
// animation callbacks, error handlers, fallbacks, and the `your_turn` safety net.
// If you're debugging a frozen discard pile, check these first.
// Swap animation state — local player's swap defers state updates until animation completes
this.swapAnimationInProgress = false; this.swapAnimationInProgress = false;
this.swapAnimationCardEl = null; this.swapAnimationCardEl = null;
this.swapAnimationFront = null; this.swapAnimationFront = null;
@ -52,19 +44,19 @@ class GolfGame {
// Animation lock - prevent overlapping animations on same elements // Animation lock - prevent overlapping animations on same elements
this.animatingPositions = new Set(); this.animatingPositions = new Set();
// Blocks discard update: opponent swap animation in progress // Track opponent swap animation in progress (to apply swap-out class after render)
this.opponentSwapAnimation = null; // { playerId, position } this.opponentSwapAnimation = null; // { playerId, position }
// Blocks held card display: draw pulse animation hasn't finished yet // Track draw pulse animation in progress (defer held card display until pulse completes)
this.drawPulseAnimation = false; this.drawPulseAnimation = false;
// Blocks discard update: local player discarding drawn card to pile // Track local discard animation in progress (prevent renderGame from updating discard)
this.localDiscardAnimating = false; this.localDiscardAnimating = false;
// Blocks discard update: opponent discarding without swap // Track opponent discard animation in progress (prevent renderGame from updating discard)
this.opponentDiscardAnimating = false; this.opponentDiscardAnimating = false;
// Blocks discard update + suppresses flip prompts: deal animation in progress // Track deal animation in progress (suppress flip prompts until dealing complete)
this.dealAnimationInProgress = false; this.dealAnimationInProgress = false;
// Track round winners for visual highlight // Track round winners for visual highlight
@ -82,7 +74,6 @@ class GolfGame {
this.initCardTooltips(); this.initCardTooltips();
this.bindEvents(); this.bindEvents();
this.initMobileDetection(); this.initMobileDetection();
this.initDesktopScorecard();
this.checkUrlParams(); this.checkUrlParams();
} }
@ -113,11 +104,9 @@ class GolfGame {
this.isMobile = e.matches; this.isMobile = e.matches;
document.body.classList.toggle('mobile-portrait', e.matches); document.body.classList.toggle('mobile-portrait', e.matches);
setAppHeight(); setAppHeight();
// Close any open drawers/overlays on layout change // Close any open drawers on layout change
if (!e.matches) { if (!e.matches) {
this.closeDrawers(); this.closeDrawers();
} else {
this.closeDesktopScorecard();
} }
}; };
mql.addEventListener('change', update); mql.addEventListener('change', update);
@ -158,31 +147,6 @@ class GolfGame {
if (bottomBar) bottomBar.classList.remove('hidden'); if (bottomBar) bottomBar.classList.remove('hidden');
} }
initDesktopScorecard() {
if (!this.desktopScorecardBtn) return;
this.desktopScorecardBtn.addEventListener('click', () => {
const isOpen = this.desktopScorecardOverlay.classList.contains('open');
if (isOpen) {
this.closeDesktopScorecard();
} else {
this.desktopScorecardOverlay.classList.add('open');
this.desktopScorecardBtn.classList.add('active');
this.desktopScorecardBackdrop.classList.add('visible');
}
});
this.desktopScorecardBackdrop.addEventListener('click', () => {
this.closeDesktopScorecard();
});
}
closeDesktopScorecard() {
if (this.desktopScorecardOverlay) this.desktopScorecardOverlay.classList.remove('open');
if (this.desktopScorecardBtn) this.desktopScorecardBtn.classList.remove('active');
if (this.desktopScorecardBackdrop) this.desktopScorecardBackdrop.classList.remove('visible');
}
initAudio() { initAudio() {
// Initialize audio context on first user interaction // Initialize audio context on first user interaction
const initCtx = () => { const initCtx = () => {
@ -415,7 +379,7 @@ class GolfGame {
// Only show tooltips on your turn // Only show tooltips on your turn
if (!this.isMyTurn() && !this.gameState?.waiting_for_initial_flip) return; if (!this.isMyTurn() && !this.gameState?.waiting_for_initial_flip) return;
const value = this.getCardPointValueForTooltip(cardData); const value = this.getCardPointValue(cardData);
const special = this.getCardSpecialNote(cardData); const special = this.getCardSpecialNote(cardData);
let content = `<span class="tooltip-value ${value < 0 ? 'negative' : ''}">${value} pts</span>`; let content = `<span class="tooltip-value ${value < 0 ? 'negative' : ''}">${value} pts</span>`;
@ -445,15 +409,14 @@ class GolfGame {
if (this.tooltip) this.tooltip.classList.add('hidden'); if (this.tooltip) this.tooltip.classList.add('hidden');
} }
getCardPointValueForTooltip(cardData) { getCardPointValue(cardData) {
const values = this.gameState?.card_values || this.getDefaultCardValues(); const values = this.gameState?.card_values || this.getDefaultCardValues();
const rules = this.gameState?.scoring_rules || {}; return values[cardData.rank] ?? 0;
return this.getCardPointValue(cardData, values, rules);
} }
getCardSpecialNote(cardData) { getCardSpecialNote(cardData) {
const rank = cardData.rank; const rank = cardData.rank;
const value = this.getCardPointValueForTooltip(cardData); const value = this.getCardPointValue(cardData);
if (value < 0) return 'Negative - keep it!'; if (value < 0) return 'Negative - keep it!';
if (rank === 'K' && value === 0) return 'Safe card'; if (rank === 'K' && value === 0) return 'Safe card';
if (rank === 'K' && value === -2) return 'Super King!'; if (rank === 'K' && value === -2) return 'Super King!';
@ -573,13 +536,6 @@ class GolfGame {
this.gameUsername = document.getElementById('game-username'); this.gameUsername = document.getElementById('game-username');
this.gameLogoutBtn = document.getElementById('game-logout-btn'); this.gameLogoutBtn = document.getElementById('game-logout-btn');
this.authBar = document.getElementById('auth-bar'); this.authBar = document.getElementById('auth-bar');
// Desktop scorecard overlay elements
this.desktopScorecardBtn = document.getElementById('desktop-scorecard-btn');
this.desktopScorecardOverlay = document.getElementById('desktop-scorecard-overlay');
this.desktopScorecardBackdrop = document.getElementById('desktop-scorecard-backdrop');
this.desktopStandingsList = document.getElementById('desktop-standings-list');
this.desktopScoreTable = document.getElementById('desktop-score-table')?.querySelector('tbody');
} }
bindEvents() { bindEvents() {
@ -861,39 +817,16 @@ class GolfGame {
hasDrawn: newState.has_drawn_card hasDrawn: newState.has_drawn_card
}); });
// V3_03: Intercept round_over transition to defer card reveals. // V3_03: Intercept round_over transition to defer card reveals
// The problem: the last turn's swap animation flips a card, and then
// the round-end reveal animation would flip it again. We snapshot the
// old state, patch it to mark the swap position as already face-up,
// and use that as the "before" for the reveal animation.
const roundJustEnded = oldState?.phase !== 'round_over' && const roundJustEnded = oldState?.phase !== 'round_over' &&
newState.phase === 'round_over'; newState.phase === 'round_over';
if (roundJustEnded && oldState) { if (roundJustEnded && oldState) {
// Update state first so animations can read new card data // Save pre-reveal state for the reveal animation
this.gameState = newState; this.preRevealState = JSON.parse(JSON.stringify(oldState));
// Fire animations for the last turn (swap/discard) before deferring
try {
this.triggerAnimationsForStateChange(oldState, newState);
} catch (e) {
console.error('Animation error on round end:', e);
}
// Build preRevealState from oldState, but mark swap position as
// already handled so reveal animation doesn't double-flip it.
// Without this patch, the card visually flips twice in a row.
const preReveal = JSON.parse(JSON.stringify(oldState));
if (this.opponentSwapAnimation) {
const { playerId, position } = this.opponentSwapAnimation;
const player = preReveal.players.find(p => p.id === playerId);
if (player?.cards[position]) {
player.cards[position].face_up = true;
}
}
this.preRevealState = preReveal;
this.postRevealState = newState; this.postRevealState = newState;
// Update state but DON'T render yet - reveal animation will handle it
this.gameState = newState;
break; break;
} }
@ -990,16 +923,16 @@ class GolfGame {
this.displayHeldCard(data.card, true); this.displayHeldCard(data.card, true);
this.renderGame(); this.renderGame();
} }
this.showToast('Swap with a card or discard', 'your-turn', 3000); this.showToast('Swap with a card or discard', '', 3000);
break; break;
case 'can_flip': case 'can_flip':
this.waitingForFlip = true; this.waitingForFlip = true;
this.flipIsOptional = data.optional || false; this.flipIsOptional = data.optional || false;
if (this.flipIsOptional) { if (this.flipIsOptional) {
this.showToast('Flip a card or skip', 'your-turn', 3000); this.showToast('Flip a card or skip', '', 3000);
} else { } else {
this.showToast('Flip a face-down card', 'your-turn', 3000); this.showToast('Flip a face-down card', '', 3000);
} }
this.renderGame(); this.renderGame();
break; break;
@ -1380,16 +1313,14 @@ class GolfGame {
this.heldCardFloating.classList.add('hidden'); this.heldCardFloating.classList.add('hidden');
this.heldCardFloating.style.cssText = ''; this.heldCardFloating.style.cssText = '';
// Three-part race guard. All three are needed, and they protect different things: // Pre-emptively skip the flip animation - the server may broadcast the new state
// 1. skipNextDiscardFlip: prevents the CSS flip-in animation from firing // before our animation completes, and we don't want renderGame() to trigger
// (it starts at opacity:0, which causes a visible flash) // the flip-in animation (which starts with opacity: 0, causing a flash)
// 2. lastDiscardKey: prevents renderGame() from detecting a "change" to the
// discard pile and re-rendering it mid-animation
// 3. localDiscardAnimating: blocks renderGame() from touching the discard DOM
// entirely until our animation callback fires
// Remove any one of these and you get a different flavor of visual glitch.
this.skipNextDiscardFlip = true; this.skipNextDiscardFlip = true;
// Also update lastDiscardKey so renderGame() won't see a "change"
this.lastDiscardKey = `${discardedCard.rank}-${discardedCard.suit}`; this.lastDiscardKey = `${discardedCard.rank}-${discardedCard.suit}`;
// Block renderGame from updating discard during animation (prevents race condition)
this.localDiscardAnimating = true; this.localDiscardAnimating = true;
// Animate held card to discard using anime.js // Animate held card to discard using anime.js
@ -1500,8 +1431,11 @@ class GolfGame {
this.swapAnimationCardEl = handCardEl; this.swapAnimationCardEl = handCardEl;
this.swapAnimationHandCardEl = handCardEl; this.swapAnimationHandCardEl = handCardEl;
// Hide discard button during animation (held card hidden later by onStart) // Hide originals during animation
this.discardBtn.classList.add('hidden'); handCardEl.classList.add('swap-out');
if (this.heldCardFloating) {
this.heldCardFloating.style.visibility = 'hidden';
}
// Store drawn card data before clearing // Store drawn card data before clearing
const drawnCardData = this.drawnCard; const drawnCardData = this.drawnCard;
@ -1524,12 +1458,6 @@ class GolfGame {
{ {
rotation: 0, rotation: 0,
wasHandFaceDown: false, wasHandFaceDown: false,
onStart: () => {
handCardEl.classList.add('swap-out');
if (this.heldCardFloating) {
this.heldCardFloating.style.visibility = 'hidden';
}
},
onComplete: () => { onComplete: () => {
handCardEl.classList.remove('swap-out'); handCardEl.classList.remove('swap-out');
if (this.heldCardFloating) { if (this.heldCardFloating) {
@ -1593,12 +1521,6 @@ class GolfGame {
{ {
rotation: 0, rotation: 0,
wasHandFaceDown: true, wasHandFaceDown: true,
onStart: () => {
if (handCardEl) handCardEl.classList.add('swap-out');
if (this.heldCardFloating) {
this.heldCardFloating.style.visibility = 'hidden';
}
},
onComplete: () => { onComplete: () => {
if (handCardEl) handCardEl.classList.remove('swap-out'); if (handCardEl) handCardEl.classList.remove('swap-out');
if (this.heldCardFloating) { if (this.heldCardFloating) {
@ -1652,28 +1574,12 @@ class GolfGame {
if (this.pendingGameState) { if (this.pendingGameState) {
const oldState = this.gameState; const oldState = this.gameState;
const newState = this.pendingGameState; this.gameState = this.pendingGameState;
this.pendingGameState = null; this.pendingGameState = null;
this.checkForNewPairs(oldState, this.gameState);
// Check if the deferred state is a round_over transition
const roundJustEnded = oldState?.phase !== 'round_over' &&
newState.phase === 'round_over';
if (roundJustEnded && oldState) {
// Same intercept as the game_state handler: store pre/post
// reveal states so runRoundEndReveal can animate the reveal
this.gameState = newState;
const preReveal = JSON.parse(JSON.stringify(oldState));
this.preRevealState = preReveal;
this.postRevealState = newState;
// Don't renderGame - let the reveal sequence handle it
} else {
this.gameState = newState;
this.checkForNewPairs(oldState, newState);
this.renderGame(); this.renderGame();
} }
} }
}
flipCard(position) { flipCard(position) {
this.send({ type: 'flip_card', position }); this.send({ type: 'flip_card', position });
@ -2157,16 +2063,6 @@ class GolfGame {
async runRoundEndReveal(scores, rankings) { async runRoundEndReveal(scores, rankings) {
const T = window.TIMING?.reveal || {}; const T = window.TIMING?.reveal || {};
// preRevealState may not be set yet if the game_state was deferred
// (e.g., local swap animation was in progress). Wait briefly for it.
if (!this.preRevealState) {
const waitStart = Date.now();
while (!this.preRevealState && Date.now() - waitStart < 3000) {
await this.delay(100);
}
}
const oldState = this.preRevealState; const oldState = this.preRevealState;
const newState = this.postRevealState || this.gameState; const newState = this.postRevealState || this.gameState;
@ -2176,35 +2072,22 @@ class GolfGame {
return; return;
} }
// Compute what needs revealing (before renderGame changes the DOM) // First, render the game with the OLD state (pre-reveal) so cards show face-down
this.gameState = newState;
// But render with pre-reveal card visuals
this.revealAnimationInProgress = true;
// Render game to show current layout (opponents, etc)
this.renderGame();
// Compute what needs revealing
const revealsByPlayer = this.getCardsToReveal(oldState, newState); const revealsByPlayer = this.getCardsToReveal(oldState, newState);
// Get reveal order: knocker first, then clockwise // Get reveal order: knocker first, then clockwise
const knockerId = newState.finisher_id; const knockerId = newState.finisher_id;
const revealOrder = this.getRevealOrder(newState.players, knockerId); const revealOrder = this.getRevealOrder(newState.players, knockerId);
// Wait for the last player's animation (swap/discard/draw) to finish // Initial pause
// so the final play is visible before the reveal sequence starts
const maxWait = 3000;
const start = Date.now();
while (Date.now() - start < maxWait) {
if (!this.isDrawAnimating && !this.opponentSwapAnimation &&
!this.opponentDiscardAnimating && !this.localDiscardAnimating &&
!this.swapAnimationInProgress) {
break;
}
await this.delay(100);
}
// Extra pause so the final play registers visually before we
// re-render the board (renderGame below resets card positions)
await this.delay(T.lastPlayPause || 2500);
// Now render with pre-reveal state (face-down cards) for the reveal sequence
this.gameState = newState;
this.revealAnimationInProgress = true;
this.renderGame();
this.setStatus('Revealing cards...', 'reveal'); this.setStatus('Revealing cards...', 'reveal');
await this.delay(T.initialPause || 500); await this.delay(T.initialPause || 500);
@ -2472,13 +2355,7 @@ class GolfGame {
}; };
} }
// Fire-and-forget animation triggers based on state diffs. // Fire-and-forget animation triggers based on state changes
// Two-step detection:
// STEP 1: Did someone draw? (drawn_card goes null -> something)
// STEP 2: Did someone finish their turn? (discard pile changed + turn advanced)
// Critical: if STEP 1 detects a draw-from-discard, STEP 2 must be skipped.
// The discard pile changed because a card was REMOVED, not ADDED. Without this
// suppression, we'd fire a phantom discard animation for a card nobody discarded.
triggerAnimationsForStateChange(oldState, newState) { triggerAnimationsForStateChange(oldState, newState) {
if (!oldState) return; if (!oldState) return;
@ -2537,13 +2414,8 @@ class GolfGame {
this.opponentDiscardAnimating = false; this.opponentDiscardAnimating = false;
// Set isDrawAnimating to block renderGame from updating discard pile // Set isDrawAnimating to block renderGame from updating discard pile
this.isDrawAnimating = true; this.isDrawAnimating = true;
// Force discard DOM to show the card being drawn before animation starts
// (previous animation may have blocked renderGame from updating it)
if (oldDiscard) {
this.updateDiscardPileDisplay(oldDiscard);
}
console.log('[DEBUG] Opponent draw from discard - setting isDrawAnimating=true'); console.log('[DEBUG] Opponent draw from discard - setting isDrawAnimating=true');
window.drawAnimations.animateDrawDiscard(oldDiscard || drawnCard, () => { window.drawAnimations.animateDrawDiscard(drawnCard, () => {
console.log('[DEBUG] Opponent draw from discard complete - clearing isDrawAnimating'); console.log('[DEBUG] Opponent draw from discard complete - clearing isDrawAnimating');
this.isDrawAnimating = false; this.isDrawAnimating = false;
onAnimComplete(); onAnimComplete();
@ -2553,7 +2425,7 @@ class GolfGame {
this.opponentSwapAnimation = null; this.opponentSwapAnimation = null;
this.opponentDiscardAnimating = false; this.opponentDiscardAnimating = false;
this.isDrawAnimating = true; this.isDrawAnimating = true;
console.log('[DEBUG] Opponent draw from deck - setting isDrawAnimating=true, drawnCard:', drawnCard ? `${drawnCard.rank} of ${drawnCard.suit}` : 'NULL', 'discardTop:', newDiscard ? `${newDiscard.rank} of ${newDiscard.suit}` : 'EMPTY'); console.log('[DEBUG] Opponent draw from deck - setting isDrawAnimating=true');
window.drawAnimations.animateDrawDeck(drawnCard, () => { window.drawAnimations.animateDrawDeck(drawnCard, () => {
console.log('[DEBUG] Opponent draw from deck complete - clearing isDrawAnimating'); console.log('[DEBUG] Opponent draw from deck complete - clearing isDrawAnimating');
this.isDrawAnimating = false; this.isDrawAnimating = false;
@ -2586,18 +2458,18 @@ class GolfGame {
} }
// STEP 2: Detect when someone FINISHES their turn (discard changes, turn advances) // STEP 2: Detect when someone FINISHES their turn (discard changes, turn advances)
// Skip if we just detected a draw — see comment at top of function. // Skip if we just detected a draw - the discard change was from REMOVING a card, not adding one
if (discardChanged && wasOtherPlayer && !justDetectedDraw) { if (discardChanged && wasOtherPlayer && !justDetectedDraw) {
// Figure out if the previous player SWAPPED (a card in their hand changed) // Check if the previous player actually SWAPPED (has a new face-up card)
// or just discarded their drawn card (hand is identical). // vs just discarding the drawn card (no hand change)
// Three cases to detect a swap:
// Case 1: face-down -> face-up (normal swap into hidden position)
// Case 2: both face-up but different card (swap into already-revealed position)
// Case 3: card identity null -> known (race condition: face_up flag lagging behind)
const oldPlayer = oldState.players.find(p => p.id === previousPlayerId); const oldPlayer = oldState.players.find(p => p.id === previousPlayerId);
const newPlayer = newState.players.find(p => p.id === previousPlayerId); const newPlayer = newState.players.find(p => p.id === previousPlayerId);
if (oldPlayer && newPlayer) { if (oldPlayer && newPlayer) {
// Find the position that changed
// Could be: face-down -> face-up (new reveal)
// Or: different card at same position (replaced visible card)
// Or: card identity became known (null -> value, indicates swap)
let swappedPosition = -1; let swappedPosition = -1;
let wasFaceUp = false; // Track if old card was already face-up let wasFaceUp = false; // Track if old card was already face-up
@ -2634,7 +2506,6 @@ class GolfGame {
const cardsIdentical = wasOtherPlayer && JSON.stringify(oldPlayer.cards) === JSON.stringify(newPlayer.cards); const cardsIdentical = wasOtherPlayer && JSON.stringify(oldPlayer.cards) === JSON.stringify(newPlayer.cards);
if (swappedPosition >= 0 && wasOtherPlayer) { if (swappedPosition >= 0 && wasOtherPlayer) {
console.log('[DEBUG] Swap detected:', { playerId: previousPlayerId, position: swappedPosition, wasFaceUp, newDiscard: newDiscard?.rank });
// Opponent swapped - animate from the actual position that changed // Opponent swapped - animate from the actual position that changed
this.fireSwapAnimation(previousPlayerId, newDiscard, swappedPosition, wasFaceUp); this.fireSwapAnimation(previousPlayerId, newDiscard, swappedPosition, wasFaceUp);
// Show CPU swap announcement // Show CPU swap announcement
@ -2931,6 +2802,9 @@ class GolfGame {
return; return;
} }
// Hide the source card during animation
sourceCardEl.classList.add('swap-out');
// Use unified swap animation // Use unified swap animation
if (window.cardAnimations) { if (window.cardAnimations) {
const heldRect = window.cardAnimations.getHoldingRect(); const heldRect = window.cardAnimations.getHoldingRect();
@ -2943,32 +2817,23 @@ class GolfGame {
{ {
rotation: sourceRotation, rotation: sourceRotation,
wasHandFaceDown: !wasFaceUp, wasHandFaceDown: !wasFaceUp,
onStart: () => {
sourceCardEl.classList.add('swap-out');
},
onComplete: () => { onComplete: () => {
if (sourceCardEl) sourceCardEl.classList.remove('swap-out'); sourceCardEl.classList.remove('swap-out');
this.opponentSwapAnimation = null; this.opponentSwapAnimation = null;
this.opponentDiscardAnimating = false; this.opponentDiscardAnimating = false;
console.log('[DEBUG] Swap animation complete - clearing opponentSwapAnimation and opponentDiscardAnimating'); console.log('[DEBUG] Swap animation complete - clearing opponentSwapAnimation and opponentDiscardAnimating');
// Don't re-render during reveal animation - it handles its own rendering
if (!this.revealAnimationInProgress) {
this.renderGame(); this.renderGame();
} }
} }
}
); );
} else { } else {
// Fallback // Fallback
setTimeout(() => { setTimeout(() => {
if (sourceCardEl) sourceCardEl.classList.remove('swap-out'); sourceCardEl.classList.remove('swap-out');
this.opponentSwapAnimation = null; this.opponentSwapAnimation = null;
this.opponentDiscardAnimating = false; this.opponentDiscardAnimating = false;
console.log('[DEBUG] Swap animation fallback complete - clearing flags'); console.log('[DEBUG] Swap animation fallback complete - clearing flags');
// Don't re-render during reveal animation - it handles its own rendering
if (!this.revealAnimationInProgress) {
this.renderGame(); this.renderGame();
}
}, 500); }, 500);
} }
} }
@ -2990,11 +2855,6 @@ class GolfGame {
if (window.cardAnimations) { if (window.cardAnimations) {
window.cardAnimations.animateInitialFlip(cardEl, cardData, () => { window.cardAnimations.animateInitialFlip(cardEl, cardData, () => {
this.animatingPositions.delete(key); this.animatingPositions.delete(key);
// Unhide the current card element (may have been rebuilt by renderGame)
const currentCards = this.playerCards.querySelectorAll('.card');
if (currentCards[position]) {
currentCards[position].style.visibility = '';
}
}); });
} else { } else {
// Fallback if card animations not available // Fallback if card animations not available
@ -3099,7 +2959,7 @@ class GolfGame {
this.hideToast(); this.hideToast();
} else { } else {
const remaining = requiredFlips - uniquePositions.length; const remaining = requiredFlips - uniquePositions.length;
this.showToast(`Select ${remaining} more card${remaining > 1 ? 's' : ''} to flip`, 'your-turn', 5000); this.showToast(`Select ${remaining} more card${remaining > 1 ? 's' : ''} to flip`, '', 5000);
} }
return; return;
} }
@ -4057,11 +3917,7 @@ class GolfGame {
// Not holding - show normal discard pile // Not holding - show normal discard pile
this.discard.classList.remove('picked-up'); this.discard.classList.remove('picked-up');
// The discard pile is touched by four different animation paths. // Skip discard update during any discard-related animation - animation handles the visual
// Each flag represents a different in-flight animation that "owns" the discard DOM.
// renderGame() must not update the discard while any of these are active, or you'll
// see the card content flash/change underneath the animation overlay.
// Priority order doesn't matter — any one of them is reason enough to skip.
const skipReason = this.localDiscardAnimating ? 'localDiscardAnimating' : const skipReason = this.localDiscardAnimating ? 'localDiscardAnimating' :
this.opponentSwapAnimation ? 'opponentSwapAnimation' : this.opponentSwapAnimation ? 'opponentSwapAnimation' :
this.opponentDiscardAnimating ? 'opponentDiscardAnimating' : this.opponentDiscardAnimating ? 'opponentDiscardAnimating' :
@ -4085,9 +3941,7 @@ class GolfGame {
const discardCard = this.gameState.discard_top; const discardCard = this.gameState.discard_top;
const cardKey = `${discardCard.rank}-${discardCard.suit}`; const cardKey = `${discardCard.rank}-${discardCard.suit}`;
// Only animate discard flip during active gameplay, not at round/game end. // Only animate discard flip during active gameplay, not at round/game end
// lastDiscardKey is pre-set by discardDrawn() to prevent a false "change"
// detection when the server confirms what we already animated locally.
const isActivePlay = this.gameState.phase !== 'round_over' && const isActivePlay = this.gameState.phase !== 'round_over' &&
this.gameState.phase !== 'game_over'; this.gameState.phase !== 'game_over';
const shouldAnimate = isActivePlay && this.lastDiscardKey && const shouldAnimate = isActivePlay && this.lastDiscardKey &&
@ -4275,13 +4129,7 @@ class GolfGame {
cardEl.firstChild.addEventListener('click', () => this.handleCardClick(index)); cardEl.firstChild.addEventListener('click', () => this.handleCardClick(index));
// V3_13: Bind tooltip events for face-up cards // V3_13: Bind tooltip events for face-up cards
this.bindCardTooltipEvents(cardEl.firstChild, displayCard); this.bindCardTooltipEvents(cardEl.firstChild, displayCard);
const appendedCard = cardEl.firstChild; this.playerCards.appendChild(cardEl.firstChild);
this.playerCards.appendChild(appendedCard);
// Hide card if flip animation overlay is active on this position
if (this.animatingPositions.has(`local-${index}`)) {
appendedCard.style.visibility = 'hidden';
}
}); });
} }
@ -4391,11 +4239,6 @@ class GolfGame {
`; `;
this.scoreTable.appendChild(tr); this.scoreTable.appendChild(tr);
}); });
// Mirror to desktop overlay
if (this.desktopScoreTable) {
this.desktopScoreTable.innerHTML = this.scoreTable.innerHTML;
}
} }
updateStandings() { updateStandings() {
@ -4433,7 +4276,7 @@ class GolfGame {
return `<div class="rank-row ${holesRank === 0 && p.rounds_won > 0 ? 'leader' : ''}"><span class="rank-pos">${medal}</span><span class="rank-name">${name}</span><span class="rank-val">${p.rounds_won} wins</span></div>`; return `<div class="rank-row ${holesRank === 0 && p.rounds_won > 0 ? 'leader' : ''}"><span class="rank-pos">${medal}</span><span class="rank-name">${name}</span><span class="rank-val">${p.rounds_won} wins</span></div>`;
}).join(''); }).join('');
const standingsContent = ` this.standingsList.innerHTML = `
<div class="standings-section"> <div class="standings-section">
<div class="standings-title">By Score</div> <div class="standings-title">By Score</div>
${pointsHtml} ${pointsHtml}
@ -4443,10 +4286,6 @@ class GolfGame {
${holesHtml} ${holesHtml}
</div> </div>
`; `;
this.standingsList.innerHTML = standingsContent;
if (this.desktopStandingsList) {
this.desktopStandingsList.innerHTML = standingsContent;
}
} }
renderCard(card, clickable, selected) { renderCard(card, clickable, selected) {
@ -4526,11 +4365,6 @@ class GolfGame {
this.scoreTable.appendChild(tr); this.scoreTable.appendChild(tr);
}); });
// Mirror to desktop overlay
if (this.desktopScoreTable) {
this.desktopScoreTable.innerHTML = this.scoreTable.innerHTML;
}
// Show rankings announcement only for final results // Show rankings announcement only for final results
const existingAnnouncement = document.getElementById('rankings-announcement'); const existingAnnouncement = document.getElementById('rankings-announcement');
if (existingAnnouncement) existingAnnouncement.remove(); if (existingAnnouncement) existingAnnouncement.remove();
@ -4846,14 +4680,11 @@ class AuthManager {
this.signupFormContainer = document.getElementById('signup-form-container'); this.signupFormContainer = document.getElementById('signup-form-container');
this.signupForm = document.getElementById('signup-form'); this.signupForm = document.getElementById('signup-form');
this.signupInviteCode = document.getElementById('signup-invite-code'); this.signupInviteCode = document.getElementById('signup-invite-code');
this.inviteCodeGroup = document.getElementById('invite-code-group');
this.inviteCodeHint = document.getElementById('invite-code-hint');
this.signupUsername = document.getElementById('signup-username'); this.signupUsername = document.getElementById('signup-username');
this.signupEmail = document.getElementById('signup-email'); this.signupEmail = document.getElementById('signup-email');
this.signupPassword = document.getElementById('signup-password'); this.signupPassword = document.getElementById('signup-password');
this.signupError = document.getElementById('signup-error'); this.signupError = document.getElementById('signup-error');
this.showSignupLink = document.getElementById('show-signup'); this.showSignupLink = document.getElementById('show-signup');
this.signupInfo = null; // populated by fetchSignupInfo()
this.showLoginLink = document.getElementById('show-login'); this.showLoginLink = document.getElementById('show-login');
this.showForgotLink = document.getElementById('show-forgot'); this.showForgotLink = document.getElementById('show-forgot');
this.forgotFormContainer = document.getElementById('forgot-form-container'); this.forgotFormContainer = document.getElementById('forgot-form-container');
@ -4902,9 +4733,6 @@ class AuthManager {
// Check URL for reset token or invite code on page load // Check URL for reset token or invite code on page load
this.checkResetToken(); this.checkResetToken();
this.checkInviteCode(); this.checkInviteCode();
// Fetch signup availability info (metered open signups)
this.fetchSignupInfo();
} }
showModal(form = 'login') { showModal(form = 'login') {
@ -5069,44 +4897,6 @@ class AuthManager {
} }
} }
async fetchSignupInfo() {
try {
const resp = await fetch('/api/auth/signup-info');
if (resp.ok) {
this.signupInfo = await resp.json();
this.updateInviteCodeField();
}
} catch (err) {
// Fail silently — invite field stays required by default
}
}
updateInviteCodeField() {
if (!this.signupInfo || !this.signupInviteCode) return;
const { invite_required, open_signups_enabled, remaining_today, unlimited } = this.signupInfo;
if (invite_required) {
this.signupInviteCode.required = true;
this.signupInviteCode.placeholder = 'Invite Code (required)';
if (this.inviteCodeHint) this.inviteCodeHint.textContent = '';
} else if (open_signups_enabled) {
this.signupInviteCode.required = false;
this.signupInviteCode.placeholder = 'Invite Code (optional)';
if (this.inviteCodeHint) {
if (unlimited) {
this.inviteCodeHint.textContent = 'Open registration — no invite needed';
} else if (remaining_today !== null && remaining_today > 0) {
this.inviteCodeHint.textContent = `${remaining_today} open signup${remaining_today !== 1 ? 's' : ''} left today`;
} else if (remaining_today === 0) {
this.signupInviteCode.required = true;
this.signupInviteCode.placeholder = 'Invite Code (required)';
this.inviteCodeHint.textContent = 'Daily signups full — invite code required';
}
}
}
}
async handleForgotPassword(e) { async handleForgotPassword(e) {
e.preventDefault(); e.preventDefault();
this.clearErrors(); this.clearErrors();

View File

@ -1,4 +1,3 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// CardAnimations - Unified anime.js-based animation system // CardAnimations - Unified anime.js-based animation system
// Replaces draw-animations.js and handles ALL card animations // Replaces draw-animations.js and handles ALL card animations
@ -44,14 +43,10 @@ class CardAnimations {
const discardRect = this.getDiscardRect(); const discardRect = this.getDiscardRect();
if (!deckRect || !discardRect) return null; if (!deckRect || !discardRect) return null;
// Center the held card between deck and discard pile
const centerX = (deckRect.left + deckRect.right + discardRect.left + discardRect.right) / 4; const centerX = (deckRect.left + deckRect.right + discardRect.left + discardRect.right) / 4;
const cardWidth = deckRect.width; const cardWidth = deckRect.width;
const cardHeight = deckRect.height; const cardHeight = deckRect.height;
const isMobilePortrait = document.body.classList.contains('mobile-portrait'); const 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 * (isMobilePortrait ? 0.48 : 0.35);
return { return {
@ -225,7 +220,6 @@ class CardAnimations {
} }
_animateDrawDeckCard(cardData, deckRect, holdingRect, onComplete) { _animateDrawDeckCard(cardData, deckRect, holdingRect, onComplete) {
console.log('[DEBUG] _animateDrawDeckCard called with cardData:', cardData ? `${cardData.rank} of ${cardData.suit}` : 'NULL');
const deckColor = this.getDeckColor(); const deckColor = this.getDeckColor();
const animCard = this.createAnimCard(deckRect, true, deckColor); const animCard = this.createAnimCard(deckRect, true, deckColor);
animCard.dataset.animating = 'true'; // Mark as actively animating animCard.dataset.animating = 'true'; // Mark as actively animating
@ -234,9 +228,6 @@ class CardAnimations {
if (cardData) { if (cardData) {
this.setCardContent(animCard, cardData); this.setCardContent(animCard, cardData);
// Debug: verify what was actually set on the front face
const front = animCard.querySelector('.draw-anim-front');
console.log('[DEBUG] Draw anim card front content:', front?.innerHTML);
} }
this.playSound('draw-deck'); this.playSound('draw-deck');
@ -425,7 +416,6 @@ class CardAnimations {
} }
// Animate initial flip at game start - smooth flip only, no lift // Animate initial flip at game start - smooth flip only, no lift
// Uses overlay sized to match the source card exactly
animateInitialFlip(cardElement, cardData, onComplete) { animateInitialFlip(cardElement, cardData, onComplete) {
if (!cardElement) { if (!cardElement) {
if (onComplete) onComplete(); if (onComplete) onComplete();
@ -439,16 +429,8 @@ class CardAnimations {
const animCard = this.createAnimCard(rect, true, deckColor); const animCard = this.createAnimCard(rect, true, deckColor);
this.setCardContent(animCard, cardData); this.setCardContent(animCard, cardData);
// Match the front face styling to player hand cards (not deck/discard cards) // Hide original card during animation
const front = animCard.querySelector('.draw-anim-front'); cardElement.style.opacity = '0';
if (front) {
front.style.background = 'linear-gradient(145deg, #fff 0%, #f5f5f5 100%)';
front.style.border = '2px solid #ddd';
front.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.3)';
}
// Hide original card during animation (overlay covers it)
cardElement.style.visibility = 'hidden';
const inner = animCard.querySelector('.draw-anim-inner'); const inner = animCard.querySelector('.draw-anim-inner');
const duration = window.TIMING?.card?.flip || 320; const duration = window.TIMING?.card?.flip || 320;
@ -463,19 +445,16 @@ class CardAnimations {
begin: () => this.playSound('flip'), begin: () => this.playSound('flip'),
complete: () => { complete: () => {
animCard.remove(); animCard.remove();
cardElement.style.visibility = ''; cardElement.style.opacity = '1';
if (onComplete) onComplete(); if (onComplete) onComplete();
} }
}); });
// Register a no-op entry so cancelAll() can find and stop this animation.
// The actual anime.js instance doesn't need to be tracked (fire-and-forget),
// but we need SOMETHING in the map or cleanup won't know we're animating.
this.activeAnimations.set(`initialFlip-${Date.now()}`, { pause: () => {} }); this.activeAnimations.set(`initialFlip-${Date.now()}`, { pause: () => {} });
} catch (e) { } catch (e) {
console.error('Initial flip animation error:', e); console.error('Initial flip animation error:', e);
animCard.remove(); animCard.remove();
cardElement.style.visibility = ''; cardElement.style.opacity = '1';
if (onComplete) onComplete(); if (onComplete) onComplete();
} }
} }
@ -780,32 +759,26 @@ class CardAnimations {
const id = 'turnPulse'; const id = 'turnPulse';
this.stopTurnPulse(element); this.stopTurnPulse(element);
// Quick shake animation - target cards only, not labels // Quick shake animation
const T = window.TIMING?.turnPulse || {};
const cards = element.querySelectorAll(':scope > .pile-wrapper > .card, :scope > .pile-wrapper > .discard-stack > #discard');
const doShake = () => { const doShake = () => {
if (!this.activeAnimations.has(id)) return; if (!this.activeAnimations.has(id)) return;
anime({ anime({
targets: cards.length ? cards : element, targets: element,
translateX: [0, -6, 6, -4, 3, 0], translateX: [0, -6, 6, -4, 3, 0],
duration: T.duration || 300, duration: 300,
easing: 'easeInOutQuad' easing: 'easeInOutQuad'
}); });
}; };
// Two-phase timing: wait initialDelay, then shake on an interval. // Delay first shake by 5 seconds, then repeat every 2 seconds
// 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(() => { const timeout = setTimeout(() => {
if (!this.activeAnimations.has(id)) return; if (!this.activeAnimations.has(id)) return;
doShake(); doShake();
const interval = setInterval(doShake, T.interval || 3000); const interval = setInterval(doShake, 2000);
const entry = this.activeAnimations.get(id); const entry = this.activeAnimations.get(id);
if (entry) entry.interval = interval; if (entry) entry.interval = interval;
}, T.initialDelay || 5000); }, 5000);
this.activeAnimations.set(id, { timeout }); this.activeAnimations.set(id, { timeout });
} }
@ -1106,7 +1079,7 @@ class CardAnimations {
// heldRect: position of the held card (or null to use default holding position) // heldRect: position of the held card (or null to use default holding position)
// options: { rotation, wasHandFaceDown, onComplete } // options: { rotation, wasHandFaceDown, onComplete }
animateUnifiedSwap(handCardData, heldCardData, handRect, heldRect, options = {}) { animateUnifiedSwap(handCardData, heldCardData, handRect, heldRect, options = {}) {
const { rotation = 0, wasHandFaceDown = false, onComplete, onStart } = options; const { rotation = 0, wasHandFaceDown = false, onComplete } = options;
const T = window.TIMING?.swap || { lift: 100, arc: 320, settle: 100 }; const T = window.TIMING?.swap || { lift: 100, arc: 320, settle: 100 };
const discardRect = this.getDiscardRect(); const discardRect = this.getDiscardRect();
@ -1126,27 +1099,27 @@ class CardAnimations {
return; return;
} }
// Collision detection: if a draw animation is still in flight (its overlay cards // Wait for any in-progress draw animation to complete
// are still in the DOM), we can't start the swap yet — both animations touch the // Check if there's an active draw animation by looking for overlay cards
// same visual space. 350ms is enough for the draw to finish its arc and land.
// This happens when the server sends the swap state update before the draw
// animation's callback fires (network is faster than anime.js, sometimes).
const existingDrawCards = document.querySelectorAll('.draw-anim-card[data-animating="true"]'); const existingDrawCards = document.querySelectorAll('.draw-anim-card[data-animating="true"]');
if (existingDrawCards.length > 0) { if (existingDrawCards.length > 0) {
// Draw animation still in progress - wait a bit and retry
setTimeout(() => { setTimeout(() => {
// Clean up the draw animation overlay
existingDrawCards.forEach(el => { existingDrawCards.forEach(el => {
delete el.dataset.animating; delete el.dataset.animating;
el.remove(); el.remove();
}); });
this._runUnifiedSwap(handCardData, heldCardData, handRect, heldRect, discardRect, T, rotation, wasHandFaceDown, onComplete, onStart); // Now run the swap animation
}, 350); this._runUnifiedSwap(handCardData, heldCardData, handRect, heldRect, discardRect, T, rotation, wasHandFaceDown, onComplete);
}, 100);
return; return;
} }
this._runUnifiedSwap(handCardData, heldCardData, handRect, heldRect, discardRect, T, rotation, wasHandFaceDown, onComplete, onStart); this._runUnifiedSwap(handCardData, heldCardData, handRect, heldRect, discardRect, T, rotation, wasHandFaceDown, onComplete);
} }
_runUnifiedSwap(handCardData, heldCardData, handRect, heldRect, discardRect, T, rotation, wasHandFaceDown, onComplete, onStart) { _runUnifiedSwap(handCardData, heldCardData, handRect, heldRect, discardRect, T, rotation, wasHandFaceDown, onComplete) {
// Create the two traveling cards // Create the two traveling cards
const travelingHand = this.createCardFromData(handCardData, handRect, rotation); const travelingHand = this.createCardFromData(handCardData, handRect, rotation);
const travelingHeld = this.createCardFromData(heldCardData, heldRect, 0); const travelingHeld = this.createCardFromData(heldCardData, heldRect, 0);
@ -1155,9 +1128,6 @@ class CardAnimations {
document.body.appendChild(travelingHand); document.body.appendChild(travelingHand);
document.body.appendChild(travelingHeld); document.body.appendChild(travelingHeld);
// Now that overlays cover the originals, hide them
if (onStart) onStart();
this.playSound('card'); this.playSound('card');
// If hand card was face-down, flip it first // If hand card was face-down, flip it first
@ -1223,9 +1193,6 @@ class CardAnimations {
], ],
width: discardRect.width, width: discardRect.width,
height: discardRect.height, height: discardRect.height,
// Counter-rotate from the card's grid tilt back to 0. The -3 intermediate
// value adds a slight overshoot that makes the arc feel physical.
// Do not "simplify" this to [rotation, 0]. It will look robotic.
rotate: [rotation, rotation - 3, 0], rotate: [rotation, rotation - 3, 0],
duration: T.arc, duration: T.arc,
easing: this.getEasing('arc'), easing: this.getEasing('arc'),

View File

@ -1,4 +1,3 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// CardManager - Manages persistent card DOM elements // CardManager - Manages persistent card DOM elements
// Cards are REAL elements that exist in ONE place and move between locations // Cards are REAL elements that exist in ONE place and move between locations
@ -101,14 +100,12 @@ class CardManager {
} }
} }
// Get the deck color class for a card based on its deck_id. // Get the deck color class for a card based on its deck_id
// Reads from window.currentDeckColors, which app.js sets from game state.
// This global coupling is intentional — card-manager shouldn't know about
// game state directly, and passing it through every call site isn't worth it.
getDeckColorClass(cardData) { getDeckColorClass(cardData) {
if (!cardData || cardData.deck_id === undefined || cardData.deck_id === null) { if (!cardData || cardData.deck_id === undefined || cardData.deck_id === null) {
return null; return null;
} }
// Get deck colors from game state (set by app.js)
const deckColors = window.currentDeckColors || ['red', 'blue', 'gold']; const deckColors = window.currentDeckColors || ['red', 'blue', 'gold'];
const colorName = deckColors[cardData.deck_id] || deckColors[0] || 'red'; const colorName = deckColors[cardData.deck_id] || deckColors[0] || 'red';
return `deck-${colorName}`; return `deck-${colorName}`;
@ -129,10 +126,7 @@ class CardManager {
cardEl.style.width = `${rect.width}px`; cardEl.style.width = `${rect.width}px`;
cardEl.style.height = `${rect.height}px`; cardEl.style.height = `${rect.height}px`;
// On mobile, scale font proportional to card width so rank/suit fit. // 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')) { if (document.body.classList.contains('mobile-portrait')) {
cardEl.style.fontSize = `${rect.width * 0.35}px`; cardEl.style.fontSize = `${rect.width * 0.35}px`;
} else { } else {
@ -241,9 +235,7 @@ class CardManager {
await this.delay(flipDuration); await this.delay(flipDuration);
} }
// Step 2: Move card to discard. // Step 2: Move card to discard
// The +50ms buffer accounts for CSS transition timing jitter — without it,
// we occasionally remove the 'moving' class before the transition finishes.
cardEl.classList.add('moving'); cardEl.classList.add('moving');
this.positionCard(cardEl, discardRect); this.positionCard(cardEl, discardRect);
await this.delay(duration + 50); await this.delay(duration + 50);

View File

@ -59,9 +59,9 @@
<!-- Outer edge highlight --> <!-- Outer edge highlight -->
<circle cx="50" cy="44" r="46" fill="none" stroke="#ffffff" stroke-width="0.5" opacity="0.5"/> <circle cx="50" cy="44" r="46" fill="none" stroke="#ffffff" stroke-width="0.5" opacity="0.5"/>
<!-- Card suits - 2x2 grid --> <!-- Card suits - single row, larger -->
<text x="36" y="40" font-family="Arial, sans-serif" font-size="28" font-weight="bold" fill="#1a1a1a" text-anchor="middle">&#9827;</text> <text x="22" y="51" font-family="Arial, sans-serif" font-size="32" font-weight="bold" fill="#1a1a1a" text-anchor="middle">&#9827;</text>
<text x="64" y="40" font-family="Arial, sans-serif" font-size="28" font-weight="bold" fill="#cc0000" text-anchor="middle">&#9830;</text> <text x="41" y="51" font-family="Arial, sans-serif" font-size="32" font-weight="bold" fill="#cc0000" text-anchor="middle">&#9830;</text>
<text x="36" y="64" font-family="Arial, sans-serif" font-size="28" font-weight="bold" fill="#cc0000" text-anchor="middle">&#9829;</text> <text x="59" y="51" font-family="Arial, sans-serif" font-size="32" font-weight="bold" fill="#1a1a1a" text-anchor="middle">&#9824;</text>
<text x="64" y="64" font-family="Arial, sans-serif" font-size="28" font-weight="bold" fill="#1a1a1a" text-anchor="middle">&#9824;</text> <text x="77" y="51" font-family="Arial, sans-serif" font-size="32" font-weight="bold" fill="#cc0000" text-anchor="middle">&#9829;</text>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@ -16,10 +16,10 @@
<!-- Lobby Screen --> <!-- Lobby Screen -->
<div id="lobby-screen" class="screen active"> <div id="lobby-screen" class="screen active">
<h1><span class="logo-row"><img src="golfball-logo.svg" alt="" class="golfball-logo"><span class="golfer-container"><span class="golfer-swing">🏌️</span><span class="kicked-ball"></span></span></span> <span class="golf-title">GolfCards<span class="golf-title-tld">.club</span></span></h1> <h1><img src="golfball-logo.svg" alt="" class="golfball-logo"><span class="golfer-swing">🏌️</span><span class="kicked-ball"></span> <span class="golf-title">Golf</span></h1>
<p class="subtitle">6-Card Golf Card Game <button id="rules-btn" class="btn btn-small btn-rules">Rules</button> <button id="leaderboard-btn" class="btn btn-small leaderboard-btn">Leaderboard</button></p> <p class="subtitle">6-Card Golf Card Game <button id="rules-btn" class="btn btn-small btn-rules">Rules</button> <button id="leaderboard-btn" class="btn btn-small leaderboard-btn">Leaderboard</button></p>
<div class="alpha-banner">Beta Testing - Bear with us while, stuff.</div> <div class="alpha-banner">Alpha &mdash; Things may break. Stats may be wiped.</div>
<!-- Auth prompt for unauthenticated users --> <!-- Auth prompt for unauthenticated users -->
<div id="auth-prompt" class="auth-prompt"> <div id="auth-prompt" class="auth-prompt">
@ -53,8 +53,6 @@
</div> </div>
<p id="lobby-error" class="error"></p> <p id="lobby-error" class="error"></p>
<footer class="app-footer">v3.1.6 &copy; Aaron D. Lee</footer>
</div> </div>
<!-- Matchmaking Screen --> <!-- Matchmaking Screen -->
@ -82,16 +80,16 @@
<div class="waiting-layout"> <div class="waiting-layout">
<div class="waiting-left-col"> <div class="waiting-left-col">
<div class="players-list"> <div class="players-list">
<div class="players-list-header">
<h3>Players</h3> <h3>Players</h3>
<div id="cpu-controls-section" class="cpu-controls hidden">
<span class="cpu-controls-label">CPU:</span>
<button id="remove-cpu-btn" class="cpu-ctrl-btn btn-danger" title="Remove CPU"></button>
<button id="add-cpu-btn" class="cpu-ctrl-btn btn-success" title="Add CPU">+</button>
</div>
</div>
<ul id="players-list"></ul> <ul id="players-list"></ul>
</div> </div>
<div id="cpu-controls-section" class="cpu-controls-section hidden">
<h4>Add CPU Opponents</h4>
<div class="cpu-controls">
<button id="remove-cpu-btn" class="btn btn-small btn-danger" title="Remove last CPU"></button>
<button id="add-cpu-btn" class="btn btn-small btn-success" title="Add CPU player">+</button>
</div>
</div>
<button id="leave-room-btn" class="btn btn-danger">Leave Room</button> <button id="leave-room-btn" class="btn btn-danger">Leave Room</button>
</div> </div>
@ -286,8 +284,6 @@
<p id="waiting-message" class="info">Waiting for host to start the game...</p> <p id="waiting-message" class="info">Waiting for host to start the game...</p>
</div> </div>
<footer class="app-footer">v3.1.6 &copy; Aaron D. Lee</footer>
</div> </div>
<!-- Game Screen --> <!-- Game Screen -->
@ -893,9 +889,8 @@ TOTAL: 0 + 8 + 16 = 24 points</pre>
<div id="signup-form-container" class="hidden"> <div id="signup-form-container" class="hidden">
<h3>Sign Up</h3> <h3>Sign Up</h3>
<form id="signup-form"> <form id="signup-form">
<div class="form-group" id="invite-code-group"> <div class="form-group">
<input type="text" id="signup-invite-code" placeholder="Invite Code"> <input type="text" id="signup-invite-code" placeholder="Invite Code" required>
<small id="invite-code-hint" class="form-hint"></small>
</div> </div>
<div class="form-group"> <div class="form-group">
<input type="text" id="signup-username" placeholder="Username" required minlength="3" maxlength="20"> <input type="text" id="signup-username" placeholder="Username" required minlength="3" maxlength="20">

View File

@ -1,4 +1,3 @@
// SPDX-License-Identifier: GPL-3.0-or-later
/** /**
* Leaderboard component for Golf game. * Leaderboard component for Golf game.
* Handles leaderboard display, metric switching, and player stats modal. * Handles leaderboard display, metric switching, and player stats modal.

View File

@ -1,4 +1,3 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Golf Card Game - Replay Viewer // Golf Card Game - Replay Viewer
class ReplayViewer { class ReplayViewer {

View File

@ -1,4 +1,3 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// StateDiffer - Detects what changed between game states // StateDiffer - Detects what changed between game states
// Generates movement instructions for the animation queue // Generates movement instructions for the animation queue

View File

@ -42,25 +42,15 @@ body {
/* Lobby Screen */ /* Lobby Screen */
#lobby-screen { #lobby-screen {
max-width: 550px; max-width: 400px;
margin: 0 auto; margin: 0 auto;
padding: 20px; padding: 20px;
text-align: center; text-align: center;
} }
/* App footer (lobby & waiting room) */
.app-footer {
margin-top: 2rem;
padding: 1rem 0;
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.35);
text-align: center;
}
/* Golf title - golf ball with dimples and shine */ /* Golf title - golf ball with dimples and shine */
.golf-title { .golf-title {
display: block; font-size: 1.3em;
font-size: 1.05em;
font-weight: 800; font-weight: 800;
letter-spacing: 0.02em; letter-spacing: 0.02em;
/* Shiny gradient like a golf ball surface */ /* Shiny gradient like a golf ball surface */
@ -87,72 +77,15 @@ body {
filter: drop-shadow(1px 1px 1px rgba(0,0,0,0.15)); filter: drop-shadow(1px 1px 1px rgba(0,0,0,0.15));
} }
.golf-title-tld {
font-size: 0.45em;
font-weight: 600;
letter-spacing: 0.15em;
}
/* Golf ball logo with card suits */ /* Golf ball logo with card suits */
.golfball-logo { .golfball-logo {
width: 1.1em; width: 1.1em;
height: 1.1em; height: 1.1em;
vertical-align: middle; vertical-align: middle;
margin-right: 8px; margin-right: 18px;
filter: drop-shadow(1px 2px 2px rgba(0,0,0,0.25)); filter: drop-shadow(1px 2px 2px rgba(0,0,0,0.25));
} }
#lobby-screen h1 {
display: inline-grid;
grid-template-columns: auto;
justify-items: start;
text-align: left;
row-gap: 0;
}
.logo-row {
margin-bottom: 0;
}
/* Golfer + ball container */
.golfer-container {
position: relative;
display: inline-block;
margin-left: -2px;
}
#lobby-game-controls,
#auth-prompt {
max-width: 400px;
margin-left: auto;
margin-right: auto;
}
@media (max-width: 500px) {
#lobby-screen h1 {
display: block;
text-align: center;
text-indent: -18px;
}
.logo-row {
display: inline;
margin-bottom: 0;
}
.golf-title {
display: inline;
}
.golfball-logo {
margin-right: 6px;
}
.golfer-swing {
margin-left: 0;
margin-right: 10px;
}
.golfer-container {
margin-left: 15px;
}
}
/* Golfer swing animation */ /* Golfer swing animation */
.golfer-swing { .golfer-swing {
display: inline-block; display: inline-block;
@ -191,46 +124,44 @@ body {
.kicked-ball { .kicked-ball {
display: inline-block; display: inline-block;
font-size: 0.2em; font-size: 0.2em;
position: absolute; position: relative;
right: -8px;
bottom: 30%;
opacity: 0; opacity: 0;
animation: ball-kicked 0.7s linear forwards; animation: ball-kicked 0.7s linear forwards;
animation-delay: 0.72s; animation-delay: 0.72s;
} }
/* Trajectory: parabolic arc from golfer's front foot, up and to the right */ /* Trajectory: y = 0.0124x² - 1.42x (parabola with peak at x=57) */
@keyframes ball-kicked { @keyframes ball-kicked {
0% { 0% {
transform: translate(0, 0) scale(1); transform: translate(-12px, 8px) scale(1);
opacity: 1; opacity: 1;
} }
15% { 15% {
transform: translate(20px, -24px) scale(1); transform: translate(8px, -16px) scale(1);
opacity: 1; opacity: 1;
} }
30% { 30% {
transform: translate(40px, -39px) scale(1); transform: translate(28px, -31px) scale(1);
opacity: 1; opacity: 1;
} }
45% { 45% {
transform: translate(60px, -46px) scale(0.95); transform: translate(48px, -38px) scale(0.95);
opacity: 1; opacity: 1;
} }
55% { 55% {
transform: translate(75px, -46px) scale(0.9); transform: translate(63px, -38px) scale(0.9);
opacity: 1; opacity: 1;
} }
70% { 70% {
transform: translate(95px, -35px) scale(0.85); transform: translate(83px, -27px) scale(0.85);
opacity: 0.9; opacity: 0.9;
} }
85% { 85% {
transform: translate(115px, -14px) scale(0.75); transform: translate(103px, -6px) scale(0.75);
opacity: 0.6; opacity: 0.6;
} }
100% { 100% {
transform: translate(130px, 17px) scale(0.65); transform: translate(118px, 25px) scale(0.65);
opacity: 0; opacity: 0;
} }
} }
@ -277,11 +208,10 @@ body {
background: rgba(0,0,0,0.2); background: rgba(0,0,0,0.2);
border-radius: 10px; border-radius: 10px;
padding: 15px; padding: 15px;
margin-bottom: 0;
} }
.waiting-left-col .players-list h3 { .waiting-left-col .players-list h3 {
margin: 0; margin: 0 0 10px 0;
font-size: 1rem; font-size: 1rem;
} }
@ -400,36 +330,29 @@ body {
.deck-cyan { background: linear-gradient(135deg, #00bcd4 0%, #0097a7 100%); } .deck-cyan { background: linear-gradient(135deg, #00bcd4 0%, #0097a7 100%); }
.deck-brown { background: linear-gradient(135deg, #8b4513 0%, #5d2f0d 100%); } .deck-brown { background: linear-gradient(135deg, #8b4513 0%, #5d2f0d 100%); }
/* Players List Header with inline CPU controls */ /* CPU Controls Section - below players list */
.players-list-header { .cpu-controls-section {
display: flex; background: rgba(0,0,0,0.2);
justify-content: space-between; border-radius: 8px;
align-items: center; padding: 10px 12px;
margin-bottom: 8px;
} }
.cpu-ctrl-btn { .cpu-controls-section h4 {
width: 28px; margin: 0 0 6px 0;
height: 28px; font-size: 0.8rem;
border: none; color: #f4a460;
border-radius: 50%; }
color: white;
font-size: 1.1rem; .cpu-controls-section .cpu-controls {
display: flex;
gap: 6px;
}
.cpu-controls-section .cpu-controls .btn {
flex: 1;
padding: 6px 0;
font-size: 1rem;
font-weight: bold; font-weight: bold;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.15s;
line-height: 1;
}
.cpu-ctrl-btn:hover {
opacity: 0.85;
}
.cpu-ctrl-btn:active {
opacity: 0.7;
} }
#waiting-message { #waiting-message {
@ -440,76 +363,12 @@ body {
/* Mobile: stack vertically */ /* Mobile: stack vertically */
@media (max-width: 700px) { @media (max-width: 700px) {
#waiting-screen {
padding: 10px 15px;
}
.waiting-layout { .waiting-layout {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 10px;
padding-top: 72px;
}
.waiting-left-col {
gap: 10px;
}
.waiting-left-col .players-list {
padding: 12px;
}
.players-list li {
padding: 8px 10px;
margin-bottom: 6px;
}
#waiting-screen .settings {
padding: 15px;
}
#waiting-screen .settings h3 {
margin-bottom: 10px;
}
#leave-room-btn {
padding: 10px 16px;
font-size: 0.9rem;
} }
.basic-settings-row { .basic-settings-row {
grid-template-columns: auto minmax(80px, auto) 1fr; grid-template-columns: 1fr 1fr;
gap: 8px;
}
.basic-settings-row .form-group label {
font-size: 0.75rem;
}
.stepper-control {
gap: 4px;
padding: 4px 4px;
}
.stepper-btn {
width: 24px;
height: 24px;
font-size: 1rem;
}
.stepper-value {
min-width: 18px;
font-size: 0.95rem;
}
.basic-settings-row select {
padding: 6px 2px;
font-size: 0.85rem;
text-align: center;
}
.deck-color-selector select {
min-width: 0;
} }
} }
@ -517,7 +376,7 @@ body {
.room-code-banner { .room-code-banner {
position: fixed; position: fixed;
top: 0; top: 0;
left: 7px; left: 20px;
z-index: 100; z-index: 100;
background: linear-gradient(180deg, #d4845a 0%, #c4723f 50%, #b8663a 100%); background: linear-gradient(180deg, #d4845a 0%, #c4723f 50%, #b8663a 100%);
padding: 10px 14px 18px; padding: 10px 14px 18px;
@ -809,15 +668,7 @@ input::placeholder {
.cpu-controls { .cpu-controls {
display: flex; display: flex;
align-items: center; gap: 10px;
gap: 4px;
}
.cpu-controls-label {
font-size: 0.8rem;
color: #f4a460;
font-weight: 600;
margin-right: 2px;
} }
.checkbox-group { .checkbox-group {
@ -1814,12 +1665,6 @@ input::placeholder {
text-align: center; text-align: center;
white-space: nowrap; white-space: nowrap;
color: #fff; color: #fff;
background: linear-gradient(135deg, #4a6741 0%, #3d5a35 100%);
}
/* Empty status - hide completely */
.status-message:empty {
display: none;
} }
.status-message.your-turn { .status-message.your-turn {
@ -1839,19 +1684,6 @@ input::placeholder {
color: #fff; color: #fff;
} }
/* Round/game over status */
.status-message.round-over,
.status-message.game-over {
background: linear-gradient(135deg, #f0c040 0%, #d4a017 100%);
color: #2d3436;
}
/* Reveal status */
.status-message.reveal {
background: linear-gradient(135deg, #8b7eb8 0%, #6b5b95 100%);
color: #fff;
}
/* Final turn badge - enhanced V3 with countdown */ /* Final turn badge - enhanced V3 with countdown */
.final-turn-badge { .final-turn-badge {
display: flex; display: flex;
@ -1859,8 +1691,8 @@ input::placeholder {
gap: 6px; gap: 6px;
background: linear-gradient(135deg, #ff6b35 0%, #d63031 100%); background: linear-gradient(135deg, #ff6b35 0%, #d63031 100%);
color: #fff; color: #fff;
padding: 6px 16px; padding: 6px 14px;
border-radius: 4px; border-radius: 8px;
font-weight: 700; font-weight: 700;
letter-spacing: 0.05em; letter-spacing: 0.05em;
white-space: nowrap; white-space: nowrap;
@ -1869,7 +1701,7 @@ input::placeholder {
} }
.final-turn-badge .final-turn-text { .final-turn-badge .final-turn-text {
font-size: 0.9rem; font-size: 0.85rem;
} }
.final-turn-badge.hidden { .final-turn-badge.hidden {
@ -3497,7 +3329,7 @@ input::placeholder {
.auth-bar { .auth-bar {
position: fixed; position: fixed;
top: 10px; top: 10px;
right: 7px; right: 15px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
@ -3605,13 +3437,6 @@ input::placeholder {
width: 100%; width: 100%;
} }
.form-hint {
display: block;
margin-top: 4px;
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.45);
}
.auth-switch { .auth-switch {
text-align: center; text-align: center;
margin-top: 15px; margin-top: 15px;
@ -4970,10 +4795,10 @@ body.screen-shake {
.scoresheet-content { .scoresheet-content {
background: linear-gradient(145deg, #1a472a 0%, #0d3320 100%); background: linear-gradient(145deg, #1a472a 0%, #0d3320 100%);
border-radius: 16px; border-radius: 16px;
padding: 14px 18px; padding: 24px 28px;
max-width: 520px; max-width: 520px;
width: 92%; width: 92%;
max-height: 90vh; max-height: 85vh;
overflow-y: auto; overflow-y: auto;
box-shadow: box-shadow:
0 16px 50px rgba(0, 0, 0, 0.6), 0 16px 50px rgba(0, 0, 0, 0.6),
@ -4985,23 +4810,23 @@ body.screen-shake {
.ss-header { .ss-header {
text-align: center; text-align: center;
font-size: 1rem; font-size: 1.1rem;
font-weight: 700; font-weight: 700;
color: #f4a460; color: #f4a460;
margin-bottom: 10px; margin-bottom: 18px;
letter-spacing: 0.05em; letter-spacing: 0.05em;
} }
.ss-players { .ss-players {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 14px;
} }
.ss-player-row { .ss-player-row {
background: rgba(0, 0, 0, 0.2); background: rgba(0, 0, 0, 0.2);
border-radius: 10px; border-radius: 10px;
padding: 8px 10px; padding: 12px 14px;
border: 1px solid rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.06);
} }
@ -5009,7 +4834,7 @@ body.screen-shake {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
margin-bottom: 4px; margin-bottom: 8px;
} }
.ss-player-name { .ss-player-name {
@ -5073,10 +4898,10 @@ body.screen-shake {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 32px; width: 36px;
height: 24px; height: 28px;
border-radius: 3px; border-radius: 3px;
font-size: 0.68rem; font-size: 0.72rem;
font-weight: 700; font-weight: 700;
line-height: 1; line-height: 1;
background: #f5f0e8; background: #f5f0e8;
@ -5151,9 +4976,9 @@ body.screen-shake {
.ss-next-btn { .ss-next-btn {
display: block; display: block;
width: 100%; width: 100%;
margin-top: 10px; margin-top: 18px;
padding: 8px; padding: 10px;
font-size: 0.9rem; font-size: 0.95rem;
} }
/* --- V3_11: Swap Animation --- */ /* --- V3_11: Swap Animation --- */
@ -5177,21 +5002,14 @@ body.screen-shake {
} }
body.mobile-portrait { body.mobile-portrait {
height: var(--app-height, 100vh);
overflow: hidden;
overscroll-behavior: contain; overscroll-behavior: contain;
touch-action: manipulation; touch-action: manipulation;
} }
body.mobile-portrait #app { body.mobile-portrait #app {
padding: 0; padding: 0;
}
/* Lock viewport only when game screen is active (allow scrolling on rules, lobby, etc.) */
body.mobile-portrait:has(#game-screen.active) {
height: var(--app-height, 100vh);
overflow: hidden;
}
body.mobile-portrait:has(#game-screen.active) #app {
height: var(--app-height, 100vh); height: var(--app-height, 100vh);
overflow: hidden; overflow: hidden;
} }
@ -5272,10 +5090,6 @@ body.mobile-portrait #matchmaking-screen {
margin-top: 50px; margin-top: 50px;
} }
body.mobile-portrait .header-col-center {
justify-content: flex-start;
}
body.mobile-portrait .status-message { body.mobile-portrait .status-message {
font-size: 1.02rem; font-size: 1.02rem;
white-space: nowrap; white-space: nowrap;
@ -5300,14 +5114,11 @@ body.mobile-portrait .mute-btn {
} }
body.mobile-portrait .final-turn-badge { body.mobile-portrait .final-turn-badge {
padding: 6px 16px; font-size: 0.6rem;
padding: 2px 6px;
white-space: nowrap; white-space: nowrap;
} }
body.mobile-portrait .final-turn-badge .final-turn-text {
font-size: 1.02rem;
}
/* --- Mobile: Game table — opponents pinned top, rest centered in remaining space --- */ /* --- Mobile: Game table — opponents pinned top, rest centered in remaining space --- */
body.mobile-portrait .game-table { body.mobile-portrait .game-table {
display: flex; display: flex;
@ -5715,12 +5526,11 @@ body.mobile-portrait #lobby-screen {
} }
body.mobile-portrait #waiting-screen { body.mobile-portrait #waiting-screen {
padding: 10px 15px; padding: 10px 12px;
overflow-y: auto; overflow-y: auto;
max-height: 100dvh; max-height: 100dvh;
} }
/* --- Mobile: Compact scoresheet modal --- */ /* --- Mobile: Compact scoresheet modal --- */
body.mobile-portrait .scoresheet-content { body.mobile-portrait .scoresheet-content {
padding: 14px 16px; padding: 14px 16px;
@ -5781,7 +5591,6 @@ body.mobile-portrait .ss-next-btn {
font-size: 0.85rem; font-size: 0.85rem;
} }
/* --- Mobile: Very short screens (e.g. iPhone SE) --- */ /* --- Mobile: Very short screens (e.g. iPhone SE) --- */
@media (max-height: 600px) { @media (max-height: 600px) {
body.mobile-portrait .opponents-row { body.mobile-portrait .opponents-row {

View File

@ -1,4 +1,3 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Centralized timing configuration for all animations and pauses // Centralized timing configuration for all animations and pauses
// Edit these values to tune the feel of card animations and CPU gameplay // Edit these values to tune the feel of card animations and CPU gameplay
@ -78,7 +77,6 @@ const TIMING = {
// V3_03: Round end reveal timing // V3_03: Round end reveal timing
reveal: { reveal: {
lastPlayPause: 2000, // Pause after last play animation before reveals
voluntaryWindow: 2000, // Time for players to flip their own cards voluntaryWindow: 2000, // Time for players to flip their own cards
initialPause: 250, // Pause before auto-reveals start initialPause: 250, // Pause before auto-reveals start
cardStagger: 50, // Between cards in same hand cardStagger: 50, // Between cards in same hand
@ -130,13 +128,6 @@ const TIMING = {
pulseDelay: 200, // Delay before card appears (pulse visible first) pulseDelay: 200, // Delay before card appears (pulse visible first)
}, },
// Turn pulse (deck shake)
turnPulse: {
initialDelay: 5000, // Delay before first shake
interval: 5400, // Time between shakes
duration: 300, // Shake animation duration
},
// V3_17: Knock notification // V3_17: Knock notification
knock: { knock: {
statusDuration: 2500, // How long the knock status message persists statusDuration: 2500, // How long the knock status message persists

View File

@ -17,7 +17,6 @@
services: services:
app: app:
restart: unless-stopped
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
@ -29,19 +28,11 @@ services:
- RESEND_API_KEY=${RESEND_API_KEY:-} - RESEND_API_KEY=${RESEND_API_KEY:-}
- EMAIL_FROM=${EMAIL_FROM:-Golf Cards <noreply@contact.golfcards.club>} - EMAIL_FROM=${EMAIL_FROM:-Golf Cards <noreply@contact.golfcards.club>}
- SENTRY_DSN=${SENTRY_DSN:-} - SENTRY_DSN=${SENTRY_DSN:-}
- ENVIRONMENT=${ENVIRONMENT:-production} - ENVIRONMENT=production
- LOG_LEVEL=${LOG_LEVEL:-WARNING} - LOG_LEVEL=INFO
- LOG_LEVEL_GAME=${LOG_LEVEL_GAME:-}
- LOG_LEVEL_AI=${LOG_LEVEL_AI:-}
- LOG_LEVEL_HANDLERS=${LOG_LEVEL_HANDLERS:-}
- LOG_LEVEL_ROOM=${LOG_LEVEL_ROOM:-}
- LOG_LEVEL_AUTH=${LOG_LEVEL_AUTH:-}
- LOG_LEVEL_STORES=${LOG_LEVEL_STORES:-}
- BASE_URL=${BASE_URL:-https://golf.example.com} - BASE_URL=${BASE_URL:-https://golf.example.com}
- RATE_LIMIT_ENABLED=true - RATE_LIMIT_ENABLED=true
- INVITE_ONLY=true - 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_USERNAME=${BOOTSTRAP_ADMIN_USERNAME:-}
- BOOTSTRAP_ADMIN_PASSWORD=${BOOTSTRAP_ADMIN_PASSWORD:-} - BOOTSTRAP_ADMIN_PASSWORD=${BOOTSTRAP_ADMIN_PASSWORD:-}
- MATCHMAKING_ENABLED=true - MATCHMAKING_ENABLED=true
@ -51,6 +42,10 @@ services:
redis: redis:
condition: service_healthy condition: service_healthy
deploy: deploy:
replicas: 1
restart_policy:
condition: on-failure
max_attempts: 3
resources: resources:
limits: limits:
memory: 256M memory: 256M
@ -61,7 +56,7 @@ services:
- web - web
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.docker.network=traefik_web" - "traefik.docker.network=golfgame_web"
- "traefik.http.routers.golf.rule=Host(`${DOMAIN:-golf.example.com}`)" - "traefik.http.routers.golf.rule=Host(`${DOMAIN:-golf.example.com}`)"
- "traefik.http.routers.golf.entrypoints=websecure" - "traefik.http.routers.golf.entrypoints=websecure"
- "traefik.http.routers.golf.tls=true" - "traefik.http.routers.golf.tls=true"
@ -81,7 +76,6 @@ services:
- "traefik.http.services.golf.loadbalancer.sticky.cookie.name=golf_server" - "traefik.http.services.golf.loadbalancer.sticky.cookie.name=golf_server"
postgres: postgres:
restart: unless-stopped
image: postgres:16-alpine image: postgres:16-alpine
environment: environment:
POSTGRES_DB: golf POSTGRES_DB: golf
@ -104,7 +98,6 @@ services:
memory: 64M memory: 64M
redis: redis:
restart: unless-stopped
image: redis:7-alpine image: redis:7-alpine
command: redis-server --appendonly yes --maxmemory 32mb --maxmemory-policy allkeys-lru command: redis-server --appendonly yes --maxmemory 32mb --maxmemory-policy allkeys-lru
volumes: volumes:
@ -123,14 +116,45 @@ services:
reservations: reservations:
memory: 16M memory: 16M
traefik:
image: traefik:v3.6
environment:
- DOCKER_API_VERSION=1.44
command:
- "--api.dashboard=true"
- "--api.insecure=true"
- "--accesslog=true"
- "--log.level=WARN"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
- "--entrypoints.web.http.redirections.entryPoint.to=websecure"
- "--entrypoints.web.http.redirections.entryPoint.scheme=https"
- "--entrypoints.websecure.address=:443"
- "--certificatesresolvers.letsencrypt.acme.httpchallenge=true"
- "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"
- "--certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL}"
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- letsencrypt:/letsencrypt
networks:
- web
deploy:
resources:
limits:
memory: 64M
volumes: volumes:
postgres_data: postgres_data:
redis_data: redis_data:
letsencrypt:
networks: networks:
internal: internal:
driver: bridge driver: bridge
web: web:
name: traefik_web driver: bridge
external: true

View File

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

View File

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

View File

@ -1,6 +1,6 @@
# V3.17: Mobile Portrait Layout # V3.17: Mobile Portrait Layout
**Version:** 3.1.6 **Version:** 3.1.1
**Commits:** `4fcdf13`, `fb3bd53` **Commits:** `4fcdf13`, `fb3bd53`
## Overview ## Overview

View File

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

View File

@ -1,10 +1,10 @@
[project] [project]
name = "golfgame" name = "golfgame"
version = "3.1.6" version = "3.1.1"
description = "6-Card Golf card game with AI opponents" description = "6-Card Golf card game with AI opponents"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
license = {text = "GPL-3.0-or-later"} license = {text = "MIT"}
authors = [ authors = [
{name = "alee"} {name = "alee"}
] ]
@ -13,7 +13,7 @@ classifiers = [
"Development Status :: 3 - Alpha", "Development Status :: 3 - Alpha",
"Framework :: FastAPI", "Framework :: FastAPI",
"Intended Audience :: End Users/Desktop", "Intended Audience :: End Users/Desktop",
"License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", "License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.12",

View File

@ -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."

View File

@ -7,24 +7,6 @@ PORT=8000
DEBUG=true DEBUG=true
LOG_LEVEL=DEBUG LOG_LEVEL=DEBUG
# Per-module log level overrides (optional)
# These override LOG_LEVEL for specific modules.
# LOG_LEVEL_GAME=DEBUG # Core game logic
# LOG_LEVEL_AI=DEBUG # AI decisions (very verbose at DEBUG)
# LOG_LEVEL_HANDLERS=DEBUG # WebSocket message handlers
# LOG_LEVEL_ROOM=DEBUG # Room/lobby management
# LOG_LEVEL_AUTH=DEBUG # Auth stack (auth, routers.auth, services.auth_service)
# LOG_LEVEL_STORES=DEBUG # Database/Redis operations
# --- Preset examples ---
# Staging (debug game logic, quiet everything else):
# LOG_LEVEL=INFO
# LOG_LEVEL_GAME=DEBUG
# LOG_LEVEL_AI=DEBUG
#
# Production (minimal logging):
# LOG_LEVEL=WARNING
# Environment (development, staging, production) # Environment (development, staging, production)
# Affects logging format, security headers (HSTS), etc. # Affects logging format, security headers (HSTS), etc.
ENVIRONMENT=development ENVIRONMENT=development

View File

@ -1,4 +1,3 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""AI personalities for CPU players in Golf.""" """AI personalities for CPU players in Golf."""
import logging import logging
@ -44,9 +43,8 @@ CPU_TIMING = {
# Delay before CPU "looks at" the discard pile # Delay before CPU "looks at" the discard pile
"initial_look": (0.3, 0.5), "initial_look": (0.3, 0.5),
# Brief pause after draw broadcast - let draw animation complete # Brief pause after draw broadcast - let draw animation complete
# Must be >= client draw animation duration (~1.09s for deck, ~0.4s for discard) # Must be >= client draw animation duration (~1s for deck, ~0.4s for discard)
# Extra margin prevents swap message from arriving before draw flip completes "post_draw_settle": 1.1,
"post_draw_settle": 1.3,
# Consideration time after drawing (before swap/discard decision) # Consideration time after drawing (before swap/discard decision)
"post_draw_consider": (0.2, 0.4), "post_draw_consider": (0.2, 0.4),
# Variance multiplier range for chaotic personality players # Variance multiplier range for chaotic personality players
@ -56,15 +54,17 @@ CPU_TIMING = {
"post_action_pause": (0.5, 0.7), "post_action_pause": (0.5, 0.7),
} }
# Thinking time ranges by card difficulty (seconds). # Thinking time ranges by card difficulty (seconds)
# Yes, these are all identical. That's intentional — the categories exist so we
# CAN tune them independently later, but right now a uniform 0.15-0.3s feels
# natural enough. The structure is the point, not the current values.
THINKING_TIME = { THINKING_TIME = {
# Obviously good cards (Jokers, Kings, 2s, Aces) - easy take
"easy_good": (0.15, 0.3), "easy_good": (0.15, 0.3),
# Obviously bad cards (10s, Jacks, Queens) - easy pass
"easy_bad": (0.15, 0.3), "easy_bad": (0.15, 0.3),
# Medium difficulty (3, 4, 8, 9)
"medium": (0.15, 0.3), "medium": (0.15, 0.3),
# Hardest decisions (5, 6, 7 - middle of range)
"hard": (0.15, 0.3), "hard": (0.15, 0.3),
# No discard available - quick decision
"no_card": (0.15, 0.3), "no_card": (0.15, 0.3),
} }
@ -799,9 +799,7 @@ class GolfAI:
ai_log(f" >> TAKE: {discard_card.rank.value} for four-of-a-kind ({rank_count} visible)") ai_log(f" >> TAKE: {discard_card.rank.value} for four-of-a-kind ({rank_count} visible)")
return True return True
# Take card if it could make a column pair (but NOT for negative value cards). # Take card if it could make a column pair (but NOT for negative value cards)
# Why exclude negatives: a Joker (-2) paired in a column scores 0, which is
# worse than keeping it unpaired at -2. Same logic for 2s with default values.
if discard_value > 0: if discard_value > 0:
for i, card in enumerate(player.cards): for i, card in enumerate(player.cards):
pair_pos = (i + 3) % 6 if i < 3 else i - 3 pair_pos = (i + 3) % 6 if i < 3 else i - 3
@ -1032,11 +1030,7 @@ class GolfAI:
if not creates_negative_pair: if not creates_negative_pair:
expected_hidden = EXPECTED_HIDDEN_VALUE expected_hidden = EXPECTED_HIDDEN_VALUE
point_gain = expected_hidden - drawn_value point_gain = expected_hidden - drawn_value
# Personality discount: swap_threshold ranges 0-8, so this maps to 0.5-1.0. discount = 0.5 + (profile.swap_threshold / 16) # Range: 0.5 to 1.0
# Conservative players (low threshold) discount heavily — they need a bigger
# point gain to justify swapping into the unknown. Aggressive players take
# the swap at closer to face value.
discount = 0.5 + (profile.swap_threshold / 16)
return point_gain * discount return point_gain * discount
return 0.0 return 0.0
@ -1257,6 +1251,8 @@ class GolfAI:
"""If player has exactly 1 face-down card, decide the best go-out swap. """If player has exactly 1 face-down card, decide the best go-out swap.
Returns position to swap into, or None to fall through to normal scoring. Returns position to swap into, or None to fall through to normal scoring.
Uses a sentinel value of -1 (converted to None by caller) is not needed -
we return None to indicate "no early decision, continue normal flow".
""" """
options = game.options options = game.options
face_down_positions = hidden_positions(player) face_down_positions = hidden_positions(player)
@ -1305,28 +1301,12 @@ class GolfAI:
max_acceptable_go_out = 14 + int(profile.aggression * 4) max_acceptable_go_out = 14 + int(profile.aggression * 4)
# Check opponent scores - don't go out if we'd lose badly
opponent_min = estimate_opponent_min_score(player, game, optimistic=False)
# Aggressive players tolerate a bigger gap; conservative ones less
opponent_margin = 4 + int(profile.aggression * 4) # 4-8 points
opponent_cap = opponent_min + opponent_margin
# Use the more restrictive of the two thresholds
effective_max = min(max_acceptable_go_out, opponent_cap)
ai_log(f" Go-out safety check: visible_base={visible_score}, " ai_log(f" Go-out safety check: visible_base={visible_score}, "
f"score_if_swap={score_if_swap}, score_if_flip={score_if_flip}, " f"score_if_swap={score_if_swap}, score_if_flip={score_if_flip}, "
f"max_acceptable={max_acceptable_go_out}, opponent_min={opponent_min}, " f"max_acceptable={max_acceptable_go_out}")
f"opponent_cap={opponent_cap}, effective_max={effective_max}")
# High-card safety: don't swap 8+ into hidden position unless it makes a pair
creates_pair = (last_partner.face_up and last_partner.rank == drawn_card.rank)
if drawn_value >= HIGH_CARD_THRESHOLD and not creates_pair:
ai_log(f" >> GO-OUT: high card ({drawn_value}) into hidden, preferring flip")
return None # Fall through to normal scoring (will flip)
# If BOTH options are bad, choose the better one # If BOTH options are bad, choose the better one
if score_if_swap > effective_max and score_if_flip > effective_max: if score_if_swap > max_acceptable_go_out and score_if_flip > max_acceptable_go_out:
if score_if_swap <= score_if_flip: if score_if_swap <= score_if_flip:
ai_log(f" >> SAFETY: both options bad, but swap ({score_if_swap}) " ai_log(f" >> SAFETY: both options bad, but swap ({score_if_swap}) "
f"<= flip ({score_if_flip}), forcing swap") f"<= flip ({score_if_flip}), forcing swap")
@ -1342,7 +1322,7 @@ class GolfAI:
return None return None
# If swap is good, prefer it (known outcome vs unknown flip) # If swap is good, prefer it (known outcome vs unknown flip)
elif score_if_swap <= effective_max and score_if_swap <= score_if_flip: elif score_if_swap <= max_acceptable_go_out and score_if_swap <= score_if_flip:
ai_log(f" >> SAFETY: swap gives acceptable score {score_if_swap}") ai_log(f" >> SAFETY: swap gives acceptable score {score_if_swap}")
return last_pos return last_pos
@ -1364,11 +1344,7 @@ class GolfAI:
if not face_down or random.random() >= 0.5: if not face_down or random.random() >= 0.5:
return None return None
# SAFETY: Don't randomly go out with a bad score. # SAFETY: Don't randomly go out with a bad score
# This duplicates some logic from project_score() on purpose — project_score()
# is designed for strategic decisions with weighted estimates, but here we need
# a hard pass/fail check with exact pair math. Close enough isn't good enough
# when the downside is accidentally ending the round at 30 points.
if len(face_down) == 1: if len(face_down) == 1:
last_pos = face_down[0] last_pos = face_down[0]
projected = drawn_value projected = drawn_value
@ -1763,23 +1739,9 @@ class GolfAI:
expected_hidden_total = len(face_down) * EXPECTED_HIDDEN_VALUE expected_hidden_total = len(face_down) * EXPECTED_HIDDEN_VALUE
projected_score = visible_score + expected_hidden_total projected_score = visible_score + expected_hidden_total
# Hard cap: never knock with projected score > 10
if projected_score > 10:
ai_log(f" Knock rejected: projected score {projected_score:.1f} > 10 hard cap")
return False
# Tighter threshold: range 5 to 9 based on aggression # Tighter threshold: range 5 to 9 based on aggression
max_acceptable = 5 + int(profile.aggression * 4) max_acceptable = 5 + int(profile.aggression * 4)
# Check opponent threat - don't knock if an opponent likely beats us
opponent_min = estimate_opponent_min_score(player, game, optimistic=False)
if opponent_min < projected_score:
# Opponent is likely beating us - penalize threshold
threat_margin = projected_score - opponent_min
max_acceptable -= int(threat_margin * 0.75)
ai_log(f" Knock threat penalty: opponent est {opponent_min}, "
f"margin {threat_margin:.1f}, threshold now {max_acceptable}")
# Exception: if all opponents are showing terrible scores, relax threshold # Exception: if all opponents are showing terrible scores, relax threshold
all_opponents_bad = all( all_opponents_bad = all(
sum(get_ai_card_value(c, game.options) for c in p.cards if c.face_up) >= 25 sum(get_ai_card_value(c, game.options) for c in p.cards if c.face_up) >= 25
@ -1790,14 +1752,12 @@ class GolfAI:
if projected_score <= max_acceptable: if projected_score <= max_acceptable:
# Scale knock chance by how good the projected score is # Scale knock chance by how good the projected score is
if projected_score <= 4: if projected_score <= 5:
knock_chance = profile.aggression * 0.35 # Max 35% knock_chance = profile.aggression * 0.3 # Max 30%
elif projected_score <= 6: elif projected_score <= 7:
knock_chance = profile.aggression * 0.15 # Max 15% knock_chance = profile.aggression * 0.15 # Max 15%
elif projected_score <= 8: else:
knock_chance = profile.aggression * 0.06 # Max 6% knock_chance = profile.aggression * 0.05 # Max 5% (very rare)
else: # 9-10
knock_chance = profile.aggression * 0.02 # Max 2% (very rare)
if random.random() < knock_chance: if random.random() < knock_chance:
ai_log(f" Knock early: taking the gamble! (projected {projected_score:.1f})") ai_log(f" Knock early: taking the gamble! (projected {projected_score:.1f})")
@ -1972,8 +1932,7 @@ def _log_cpu_action(logger, game_id: Optional[str], cpu_player: Player, game: Ga
async def process_cpu_turn( async def process_cpu_turn(
game: Game, cpu_player: Player, broadcast_callback, game_id: Optional[str] = None, game: Game, cpu_player: Player, broadcast_callback, game_id: Optional[str] = None
reveal_callback=None,
) -> None: ) -> None:
"""Process a complete turn for a CPU player. """Process a complete turn for a CPU player.
@ -2007,8 +1966,10 @@ async def process_cpu_turn(
await asyncio.sleep(thinking_time) await asyncio.sleep(thinking_time)
ai_log(f"{cpu_player.name} done thinking, making decision") ai_log(f"{cpu_player.name} done thinking, making decision")
# Check if we should try to go out early
GolfAI.should_go_out_early(cpu_player, game, profile)
# Check if we should knock early (flip all remaining cards at once) # Check if we should knock early (flip all remaining cards at once)
# (Opponent threat logic consolidated into should_knock_early)
if GolfAI.should_knock_early(game, cpu_player, profile): if GolfAI.should_knock_early(game, cpu_player, profile):
if game.knock_early(cpu_player.id): if game.knock_early(cpu_player.id):
_log_cpu_action(logger, game_id, cpu_player, game, _log_cpu_action(logger, game_id, cpu_player, game,
@ -2091,13 +2052,6 @@ async def process_cpu_turn(
if swap_pos is not None: if swap_pos is not None:
old_card = cpu_player.cards[swap_pos] old_card = cpu_player.cards[swap_pos]
# Reveal the face-down card before swapping
if not old_card.face_up and reveal_callback:
await reveal_callback(
cpu_player.id, swap_pos,
{"rank": old_card.rank.value, "suit": old_card.suit.value},
)
await asyncio.sleep(1.0)
game.swap_card(cpu_player.id, swap_pos) game.swap_card(cpu_player.id, swap_pos)
_log_cpu_action(logger, game_id, cpu_player, game, _log_cpu_action(logger, game_id, cpu_player, game,
action="swap", card=drawn, position=swap_pos, action="swap", card=drawn, position=swap_pos,

View File

@ -1,4 +1,3 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Authentication and user management for Golf game. Authentication and user management for Golf game.

View File

@ -1,4 +1,3 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Centralized configuration for Golf game server. Centralized configuration for Golf game server.
@ -143,18 +142,11 @@ class ServerConfig:
MAX_PLAYERS_PER_ROOM: int = 6 MAX_PLAYERS_PER_ROOM: int = 6
ROOM_TIMEOUT_MINUTES: int = 60 ROOM_TIMEOUT_MINUTES: int = 60
ROOM_CODE_LENGTH: int = 4 ROOM_CODE_LENGTH: int = 4
ROOM_IDLE_TIMEOUT_SECONDS: int = 300 # 5 minutes of inactivity
# Security (for future auth system) # Security (for future auth system)
SECRET_KEY: str = "" SECRET_KEY: str = ""
INVITE_ONLY: bool = True INVITE_ONLY: bool = 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 (for first-time setup when INVITE_ONLY=true)
BOOTSTRAP_ADMIN_USERNAME: str = "" BOOTSTRAP_ADMIN_USERNAME: str = ""
BOOTSTRAP_ADMIN_PASSWORD: str = "" BOOTSTRAP_ADMIN_PASSWORD: str = ""
@ -200,11 +192,8 @@ class ServerConfig:
MAX_PLAYERS_PER_ROOM=get_env_int("MAX_PLAYERS_PER_ROOM", 6), MAX_PLAYERS_PER_ROOM=get_env_int("MAX_PLAYERS_PER_ROOM", 6),
ROOM_TIMEOUT_MINUTES=get_env_int("ROOM_TIMEOUT_MINUTES", 60), ROOM_TIMEOUT_MINUTES=get_env_int("ROOM_TIMEOUT_MINUTES", 60),
ROOM_CODE_LENGTH=get_env_int("ROOM_CODE_LENGTH", 4), ROOM_CODE_LENGTH=get_env_int("ROOM_CODE_LENGTH", 4),
ROOM_IDLE_TIMEOUT_SECONDS=get_env_int("ROOM_IDLE_TIMEOUT_SECONDS", 300),
SECRET_KEY=get_env("SECRET_KEY", ""), SECRET_KEY=get_env("SECRET_KEY", ""),
INVITE_ONLY=get_env_bool("INVITE_ONLY", True), INVITE_ONLY=get_env_bool("INVITE_ONLY", 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_USERNAME=get_env("BOOTSTRAP_ADMIN_USERNAME", ""),
BOOTSTRAP_ADMIN_PASSWORD=get_env("BOOTSTRAP_ADMIN_PASSWORD", ""), BOOTSTRAP_ADMIN_PASSWORD=get_env("BOOTSTRAP_ADMIN_PASSWORD", ""),
MATCHMAKING_ENABLED=get_env_bool("MATCHMAKING_ENABLED", True), MATCHMAKING_ENABLED=get_env_bool("MATCHMAKING_ENABLED", True),

View File

@ -1,4 +1,3 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Card value constants for 6-Card Golf. Card value constants for 6-Card Golf.

View File

@ -1,4 +1,3 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Game logic for 6-Card Golf. Game logic for 6-Card Golf.
@ -359,13 +358,6 @@ class Player:
jack_pairs = 0 # Track paired Jacks for Wolfpack bonus jack_pairs = 0 # Track paired Jacks for Wolfpack bonus
paired_ranks: list[Rank] = [] # Track all paired ranks for four-of-a-kind paired_ranks: list[Rank] = [] # Track all paired ranks for four-of-a-kind
# Evaluation order matters here. We check special-case pairs BEFORE the
# default "pairs cancel to 0" rule, because house rules can override that:
# 1. Eagle Eye joker pairs -> -4 (better than 0, exit early)
# 2. Negative pairs keep value -> sum of negatives (worse than 0, exit early)
# 3. Normal pairs -> 0 (skip both cards)
# 4. Non-matching -> sum both values
# Bonuses (wolfpack, four-of-a-kind) are applied after all columns are scored.
for col in range(3): for col in range(3):
top_idx = col top_idx = col
bottom_idx = col + 3 bottom_idx = col + 3
@ -783,17 +775,9 @@ class Game:
for i, player in enumerate(self.players): for i, player in enumerate(self.players):
if player.id == player_id: if player.id == player_id:
removed = self.players.pop(i) removed = self.players.pop(i)
if self.players:
# Adjust dealer_idx if needed after removal # Adjust dealer_idx if needed after removal
if self.dealer_idx >= len(self.players): if self.players and self.dealer_idx >= len(self.players):
self.dealer_idx = 0 self.dealer_idx = 0
# Adjust current_player_index after removal
if i < self.current_player_index:
# Removed player was before current: shift back
self.current_player_index -= 1
elif self.current_player_index >= len(self.players):
# Removed player was at/after current and index is now OOB
self.current_player_index = 0
self._emit("player_left", player_id=player_id, reason=reason) self._emit("player_left", player_id=player_id, reason=reason)
return removed return removed
return None return None
@ -816,8 +800,6 @@ class Game:
def current_player(self) -> Optional[Player]: def current_player(self) -> Optional[Player]:
"""Get the player whose turn it currently is.""" """Get the player whose turn it currently is."""
if self.players: if self.players:
if self.current_player_index >= len(self.players):
self.current_player_index = self.current_player_index % len(self.players)
return self.players[self.current_player_index] return self.players[self.current_player_index]
return None return None
@ -950,8 +932,7 @@ class Game:
if self.current_round > 1: if self.current_round > 1:
self.dealer_idx = (self.dealer_idx + 1) % len(self.players) self.dealer_idx = (self.dealer_idx + 1) % len(self.players)
# "Left of dealer goes first" — standard card game convention. # First player is to the left of dealer (next in order)
# In our circular list, "left" is the next index.
self.current_player_index = (self.dealer_idx + 1) % len(self.players) self.current_player_index = (self.dealer_idx + 1) % len(self.players)
# Emit round_started event with deck seed and all dealt cards # Emit round_started event with deck seed and all dealt cards
@ -1434,9 +1415,6 @@ class Game:
Args: Args:
player: The player whose turn just ended. player: The player whose turn just ended.
""" """
# This method and _next_turn() are tightly coupled. _check_end_turn populates
# players_with_final_turn BEFORE calling _next_turn(), which reads it to decide
# whether the round is over. Reordering these calls will break end-of-round logic.
if player.all_face_up() and self.finisher_id is None: if player.all_face_up() and self.finisher_id is None:
self.finisher_id = player.id self.finisher_id = player.id
self.phase = GamePhase.FINAL_TURN self.phase = GamePhase.FINAL_TURN
@ -1453,8 +1431,7 @@ class Game:
Advance to the next player's turn. Advance to the next player's turn.
In FINAL_TURN phase, tracks which players have had their final turn In FINAL_TURN phase, tracks which players have had their final turn
and ends the round when everyone has played. Depends on _check_end_turn() and ends the round when everyone has played.
having already added the current player to players_with_final_turn.
""" """
if self.phase == GamePhase.FINAL_TURN: if self.phase == GamePhase.FINAL_TURN:
next_index = (self.current_player_index + 1) % len(self.players) next_index = (self.current_player_index + 1) % len(self.players)
@ -1497,10 +1474,6 @@ class Game:
player.calculate_score(self.options) player.calculate_score(self.options)
# --- Apply House Rule Bonuses/Penalties --- # --- Apply House Rule Bonuses/Penalties ---
# Order matters. Blackjack converts 21->0 first, so knock penalty checks
# against the post-blackjack score. Knock penalty before knock bonus so they
# can stack (you get penalized AND rewarded, net +5). Underdog before tied shame
# so the -3 bonus can create new ties that then get punished. It's mean by design.
# Blackjack: exact score of 21 becomes 0 # Blackjack: exact score of 21 becomes 0
if self.options.blackjack: if self.options.blackjack:
@ -1624,10 +1597,6 @@ class Game:
""" """
current = self.current_player() current = self.current_player()
# Card visibility has three cases:
# 1. Round/game over: all cards revealed to everyone (reveal=True)
# 2. Your own cards: always revealed to you (is_self=True)
# 3. Opponent cards mid-game: only face-up cards shown, hidden cards are redacted
players_data = [] players_data = []
for player in self.players: for player in self.players:
reveal = self.phase in (GamePhase.ROUND_OVER, GamePhase.GAME_OVER) reveal = self.phase in (GamePhase.ROUND_OVER, GamePhase.GAME_OVER)

View File

@ -1,4 +1,3 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Game Analyzer for 6-Card Golf AI decisions. Game Analyzer for 6-Card Golf AI decisions.

View File

@ -1,4 +1,3 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""WebSocket message handlers for the Golf card game. """WebSocket message handlers for the Golf card game.
Each handler corresponds to a single message type from the client. Each handler corresponds to a single message type from the client.
@ -70,7 +69,6 @@ async def handle_create_room(data: dict, ctx: ConnectionContext, *, room_manager
player_name = ctx.authenticated_user.username if ctx.authenticated_user else data.get("player_name", "Player") player_name = ctx.authenticated_user.username if ctx.authenticated_user else data.get("player_name", "Player")
room = room_manager.create_room() room = room_manager.create_room()
room.add_player(ctx.player_id, player_name, ctx.websocket, ctx.auth_user_id) room.add_player(ctx.player_id, player_name, ctx.websocket, ctx.auth_user_id)
room.touch()
ctx.current_room = room ctx.current_room = room
await ctx.websocket.send_json({ await ctx.websocket.send_json({
@ -116,7 +114,6 @@ async def handle_join_room(data: dict, ctx: ConnectionContext, *, room_manager,
return return
room.add_player(ctx.player_id, player_name, ctx.websocket, ctx.auth_user_id) room.add_player(ctx.player_id, player_name, ctx.websocket, ctx.auth_user_id)
room.touch()
ctx.current_room = room ctx.current_room = room
await ctx.websocket.send_json({ await ctx.websocket.send_json({
@ -192,7 +189,6 @@ async def handle_remove_cpu(data: dict, ctx: ConnectionContext, **kw) -> None:
async def handle_start_game(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None: async def handle_start_game(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
if not ctx.current_room: if not ctx.current_room:
return return
ctx.current_room.touch()
room_player = ctx.current_room.get_player(ctx.player_id) room_player = ctx.current_room.get_player(ctx.player_id)
if not room_player or not room_player.is_host: if not room_player or not room_player.is_host:
@ -239,7 +235,6 @@ async def handle_start_game(data: dict, ctx: ConnectionContext, *, broadcast_gam
async def handle_flip_initial(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None: async def handle_flip_initial(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
if not ctx.current_room: if not ctx.current_room:
return return
ctx.current_room.touch()
positions = data.get("positions", []) positions = data.get("positions", [])
async with ctx.current_room.game_lock: async with ctx.current_room.game_lock:
@ -255,7 +250,6 @@ async def handle_flip_initial(data: dict, ctx: ConnectionContext, *, broadcast_g
async def handle_draw(data: dict, ctx: ConnectionContext, *, broadcast_game_state, **kw) -> None: async def handle_draw(data: dict, ctx: ConnectionContext, *, broadcast_game_state, **kw) -> None:
if not ctx.current_room: if not ctx.current_room:
return return
ctx.current_room.touch()
source = data.get("source", "deck") source = data.get("source", "deck")
async with ctx.current_room.game_lock: async with ctx.current_room.game_lock:
@ -283,7 +277,6 @@ async def handle_draw(data: dict, ctx: ConnectionContext, *, broadcast_game_stat
async def handle_swap(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None: async def handle_swap(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
if not ctx.current_room: if not ctx.current_room:
return return
ctx.current_room.touch()
position = data.get("position", 0) position = data.get("position", 0)
async with ctx.current_room.game_lock: async with ctx.current_room.game_lock:
@ -291,18 +284,6 @@ async def handle_swap(data: dict, ctx: ConnectionContext, *, broadcast_game_stat
player = ctx.current_room.game.get_player(ctx.player_id) player = ctx.current_room.game.get_player(ctx.player_id)
old_card = player.cards[position] if player and 0 <= position < len(player.cards) else None old_card = player.cards[position] if player and 0 <= position < len(player.cards) else None
# Capture old card info BEFORE the swap mutates the player's hand.
# game.swap_card() overwrites player.cards[position] in place, so if we
# read it after, we'd get the new card. The client needs the old card data
# to animate the outgoing card correctly.
old_was_face_down = old_card and not old_card.face_up if old_card else False
old_card_data = None
if old_card and old_was_face_down:
old_card_data = {
"rank": old_card.rank.value if old_card.rank else None,
"suit": old_card.suit.value if old_card.suit else None,
}
discarded = ctx.current_room.game.swap_card(ctx.player_id, position) discarded = ctx.current_room.game.swap_card(ctx.player_id, position)
if discarded: if discarded:
@ -322,7 +303,6 @@ async def handle_swap(data: dict, ctx: ConnectionContext, *, broadcast_game_stat
async def handle_discard(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None: async def handle_discard(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
if not ctx.current_room: if not ctx.current_room:
return return
ctx.current_room.touch()
async with ctx.current_room.game_lock: async with ctx.current_room.game_lock:
drawn_card = ctx.current_room.game.drawn_card drawn_card = ctx.current_room.game.drawn_card
@ -369,7 +349,6 @@ async def handle_cancel_draw(data: dict, ctx: ConnectionContext, *, broadcast_ga
async def handle_flip_card(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None: async def handle_flip_card(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
if not ctx.current_room: if not ctx.current_room:
return return
ctx.current_room.touch()
position = data.get("position", 0) position = data.get("position", 0)
async with ctx.current_room.game_lock: async with ctx.current_room.game_lock:
@ -391,7 +370,6 @@ async def handle_flip_card(data: dict, ctx: ConnectionContext, *, broadcast_game
async def handle_skip_flip(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None: async def handle_skip_flip(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
if not ctx.current_room: if not ctx.current_room:
return return
ctx.current_room.touch()
async with ctx.current_room.game_lock: async with ctx.current_room.game_lock:
player = ctx.current_room.game.get_player(ctx.player_id) player = ctx.current_room.game.get_player(ctx.player_id)
@ -408,7 +386,6 @@ async def handle_skip_flip(data: dict, ctx: ConnectionContext, *, broadcast_game
async def handle_flip_as_action(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None: async def handle_flip_as_action(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
if not ctx.current_room: if not ctx.current_room:
return return
ctx.current_room.touch()
position = data.get("position", 0) position = data.get("position", 0)
async with ctx.current_room.game_lock: async with ctx.current_room.game_lock:
@ -429,7 +406,6 @@ async def handle_flip_as_action(data: dict, ctx: ConnectionContext, *, broadcast
async def handle_knock_early(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None: async def handle_knock_early(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
if not ctx.current_room: if not ctx.current_room:
return return
ctx.current_room.touch()
async with ctx.current_room.game_lock: async with ctx.current_room.game_lock:
player = ctx.current_room.game.get_player(ctx.player_id) player = ctx.current_room.game.get_player(ctx.player_id)
@ -448,7 +424,6 @@ async def handle_knock_early(data: dict, ctx: ConnectionContext, *, broadcast_ga
async def handle_next_round(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None: async def handle_next_round(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
if not ctx.current_room: if not ctx.current_room:
return return
ctx.current_room.touch()
room_player = ctx.current_room.get_player(ctx.player_id) room_player = ctx.current_room.get_player(ctx.player_id)
if not room_player or not room_player.is_host: if not room_player or not room_player.is_host:
@ -492,7 +467,6 @@ async def handle_leave_game(data: dict, ctx: ConnectionContext, *, handle_player
async def handle_end_game(data: dict, ctx: ConnectionContext, *, room_manager, cleanup_room_profiles, **kw) -> None: async def handle_end_game(data: dict, ctx: ConnectionContext, *, room_manager, cleanup_room_profiles, **kw) -> None:
if not ctx.current_room: if not ctx.current_room:
return return
ctx.current_room.touch()
room_player = ctx.current_room.get_player(ctx.player_id) room_player = ctx.current_room.get_player(ctx.player_id)
if not room_player or not room_player.is_host: if not room_player or not room_player.is_host:

View File

@ -1,4 +1,3 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Structured logging configuration for Golf game server. Structured logging configuration for Golf game server.
@ -149,39 +148,6 @@ class DevelopmentFormatter(logging.Formatter):
return output return output
# Per-module log level overrides via env vars.
# Key: env var suffix, Value: list of Python logger names to apply to.
MODULE_LOGGER_MAP = {
"GAME": ["game"],
"AI": ["ai"],
"HANDLERS": ["handlers"],
"ROOM": ["room"],
"AUTH": ["auth", "routers.auth", "services.auth_service"],
"STORES": ["stores"],
}
def _apply_module_overrides() -> dict[str, str]:
"""
Apply per-module log level overrides from LOG_LEVEL_{MODULE} env vars.
Returns:
Dict of module name -> level for any overrides that were applied.
"""
active = {}
for module, logger_names in MODULE_LOGGER_MAP.items():
env_val = os.environ.get(f"LOG_LEVEL_{module}", "").upper()
if not env_val:
continue
level = getattr(logging, env_val, None)
if level is None:
continue
active[module] = env_val
for name in logger_names:
logging.getLogger(name).setLevel(level)
return active
def setup_logging( def setup_logging(
level: str = "INFO", level: str = "INFO",
environment: str = "development", environment: str = "development",
@ -216,19 +182,12 @@ def setup_logging(
logging.getLogger("websockets").setLevel(logging.WARNING) logging.getLogger("websockets").setLevel(logging.WARNING)
logging.getLogger("asyncio").setLevel(logging.WARNING) logging.getLogger("asyncio").setLevel(logging.WARNING)
# Apply per-module overrides from env vars
overrides = _apply_module_overrides()
# Log startup # Log startup
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.info( logger.info(
f"Logging configured: level={level}, environment={environment}", f"Logging configured: level={level}, environment={environment}",
extra={"level": level, "environment": environment}, extra={"level": level, "environment": environment},
) )
if overrides:
logger.info(
f"Per-module log level overrides: {', '.join(f'{m}={l}' for m, l in overrides.items())}",
)
class ContextLogger(logging.LoggerAdapter): class ContextLogger(logging.LoggerAdapter):

View File

@ -1,4 +1,3 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""FastAPI WebSocket server for Golf card game.""" """FastAPI WebSocket server for Golf card game."""
import asyncio import asyncio
@ -65,7 +64,6 @@ _matchmaking_service = None
_replay_service = None _replay_service = None
_spectator_manager = None _spectator_manager = None
_leaderboard_refresh_task = None _leaderboard_refresh_task = None
_room_cleanup_task = None
_redis_client = None _redis_client = None
_rate_limiter = None _rate_limiter = None
_shutdown_event = asyncio.Event() _shutdown_event = asyncio.Event()
@ -85,74 +83,8 @@ async def _periodic_leaderboard_refresh():
logger.error(f"Leaderboard refresh failed: {e}") logger.error(f"Leaderboard refresh failed: {e}")
async def _periodic_room_cleanup():
"""Periodic task to clean up rooms idle for longer than ROOM_IDLE_TIMEOUT_SECONDS."""
import time
while True:
try:
await asyncio.sleep(60)
now = time.time()
timeout = config.ROOM_IDLE_TIMEOUT_SECONDS
stale_rooms = [
room for room in room_manager.rooms.values()
if now - room.last_activity > timeout
]
for room in stale_rooms:
logger.info(
f"Cleaning up stale room {room.code} "
f"(idle {int(now - room.last_activity)}s, "
f"{len(room.players)} players)"
)
# Cancel CPU turn task
if room.cpu_turn_task:
room.cpu_turn_task.cancel()
try:
await room.cpu_turn_task
except (asyncio.CancelledError, Exception):
pass
room.cpu_turn_task = None
# Notify and close human WebSocket connections
for player in list(room.players.values()):
if player.websocket and not player.is_cpu:
try:
await player.websocket.send_json({
"type": "room_expired",
"message": "Room closed due to inactivity",
})
await player.websocket.close(code=4002, reason="Room expired")
except Exception:
pass
# Mark game as abandoned in DB
if room.game_log_id:
try:
async with _user_store.pool.acquire() as conn:
await conn.execute(
"UPDATE games_v2 SET status = 'abandoned', completed_at = NOW() WHERE id = $1 AND status = 'active'",
room.game_log_id,
)
logger.info(f"Marked game {room.game_log_id} as abandoned in DB")
except Exception as e:
logger.error(f"Failed to mark game {room.game_log_id} as abandoned: {e}")
# Clean up players and profiles
room_code = room.code
for cpu in list(room.get_cpu_players()):
room.remove_player(cpu.id)
cleanup_room_profiles(room_code)
room_manager.remove_room(room_code)
if stale_rooms:
logger.info(f"Cleaned up {len(stale_rooms)} stale room(s)")
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Room cleanup failed: {e}")
async def _init_redis(): async def _init_redis():
"""Initialize Redis client, rate limiter, and signup limiter.""" """Initialize Redis client and rate limiter."""
global _redis_client, _rate_limiter global _redis_client, _rate_limiter
try: try:
_redis_client = redis.from_url(config.REDIS_URL, decode_responses=False) _redis_client = redis.from_url(config.REDIS_URL, decode_responses=False)
@ -163,17 +95,6 @@ async def _init_redis():
from services.ratelimit import get_rate_limiter from services.ratelimit import get_rate_limiter
_rate_limiter = await get_rate_limiter(_redis_client) _rate_limiter = await get_rate_limiter(_redis_client)
logger.info("Rate limiter initialized") logger.info("Rate limiter initialized")
# Initialize signup limiter for metered open signups
if config.DAILY_OPEN_SIGNUPS != 0 or config.DAILY_SIGNUPS_PER_IP > 0:
from services.ratelimit import get_signup_limiter
signup_limiter = await get_signup_limiter(_redis_client)
from routers.auth import set_signup_limiter
set_signup_limiter(signup_limiter)
logger.info(
f"Signup limiter initialized "
f"(daily={config.DAILY_OPEN_SIGNUPS}, per_ip={config.DAILY_SIGNUPS_PER_IP})"
)
except Exception as e: except Exception as e:
logger.warning(f"Redis connection failed: {e} - rate limiting disabled") logger.warning(f"Redis connection failed: {e} - rate limiting disabled")
_redis_client = None _redis_client = None
@ -322,14 +243,6 @@ async def _shutdown_services():
reset_all_profiles() reset_all_profiles()
logger.info("All rooms and CPU profiles cleaned up") logger.info("All rooms and CPU profiles cleaned up")
if _room_cleanup_task:
_room_cleanup_task.cancel()
try:
await _room_cleanup_task
except asyncio.CancelledError:
pass
logger.info("Room cleanup task stopped")
if _leaderboard_refresh_task: if _leaderboard_refresh_task:
_leaderboard_refresh_task.cancel() _leaderboard_refresh_task.cancel()
try: try:
@ -388,26 +301,6 @@ async def lifespan(app: FastAPI):
room_manager=room_manager, room_manager=room_manager,
) )
# Mark any orphaned active games as abandoned (in-memory state lost on restart)
if _user_store:
try:
async with _user_store.pool.acquire() as conn:
result = await conn.execute(
"UPDATE games_v2 SET status = 'abandoned', completed_at = NOW() WHERE status = 'active'"
)
# PostgreSQL returns command tags like "UPDATE 3" — the last word is
# the affected row count. This is a documented protocol behavior.
count = int(result.split()[-1]) if result else 0
if count > 0:
logger.info(f"Marked {count} orphaned active game(s) as abandoned on startup")
except Exception as e:
logger.error(f"Failed to clean up orphaned games on startup: {e}")
# Start periodic room cleanup
global _room_cleanup_task
_room_cleanup_task = asyncio.create_task(_periodic_room_cleanup())
logger.info(f"Room cleanup task started (timeout={config.ROOM_IDLE_TIMEOUT_SECONDS}s)")
logger.info(f"Golf server started (environment={config.ENVIRONMENT})") logger.info(f"Golf server started (environment={config.ENVIRONMENT})")
yield yield
@ -432,7 +325,7 @@ async def _close_all_websockets():
app = FastAPI( app = FastAPI(
title="Golf Card Game", title="Golf Card Game",
debug=config.DEBUG, debug=config.DEBUG,
version="3.2.0", version="3.1.1",
lifespan=lifespan, lifespan=lifespan,
) )
@ -616,8 +509,6 @@ async def reset_cpu_profiles():
return {"status": "ok", "message": "All CPU profiles reset"} return {"status": "ok", "message": "All CPU profiles reset"}
# Per-user game limit. Prevents a single account from creating dozens of rooms
# and exhausting server memory. 4 is generous — most people play 1 at a time.
MAX_CONCURRENT_GAMES = 4 MAX_CONCURRENT_GAMES = 4
@ -658,10 +549,6 @@ async def websocket_endpoint(websocket: WebSocket):
else: else:
logger.debug(f"WebSocket connected anonymously as {connection_id}") logger.debug(f"WebSocket connected anonymously as {connection_id}")
# player_id = connection_id by design. Originally these were separate concepts
# (connection vs game identity), but in practice a player IS their connection.
# Reconnection creates a new connection_id, and the room layer handles the
# identity mapping. Keeping both fields lets handlers be explicit about intent.
ctx = ConnectionContext( ctx = ConnectionContext(
websocket=websocket, websocket=websocket,
connection_id=connection_id, connection_id=connection_id,
@ -765,7 +652,7 @@ async def broadcast_game_state(room: Room):
# Check for round over # Check for round over
if room.game.phase == GamePhase.ROUND_OVER: if room.game.phase == GamePhase.ROUND_OVER:
scores = [ scores = [
{"id": p.id, "name": p.name, "score": p.score, "total": p.total_score, "rounds_won": p.rounds_won} {"name": p.name, "score": p.score, "total": p.total_score, "rounds_won": p.rounds_won}
for p in room.game.players for p in room.game.players
] ]
# Build rankings # Build rankings
@ -774,7 +661,6 @@ async def broadcast_game_state(room: Room):
await player.websocket.send_json({ await player.websocket.send_json({
"type": "round_over", "type": "round_over",
"scores": scores, "scores": scores,
"finisher_id": room.game.finisher_id,
"round": room.game.current_round, "round": room.game.current_round,
"total_rounds": room.game.num_rounds, "total_rounds": room.game.num_rounds,
"rankings": { "rankings": {
@ -864,32 +750,14 @@ async def _run_cpu_chain(room: Room):
if not room_player or not room_player.is_cpu: if not room_player or not room_player.is_cpu:
return return
room.touch() # Brief pause before CPU starts - animations are faster now
# 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) await asyncio.sleep(0.25)
# Run CPU turn # Run CPU turn
async def broadcast_cb(): async def broadcast_cb():
await broadcast_game_state(room) await broadcast_game_state(room)
async def reveal_cb(player_id, position, card_data): await process_cpu_turn(room.game, current, broadcast_cb, game_id=room.game_log_id)
reveal_msg = {
"type": "card_revealed",
"player_id": player_id,
"position": position,
"card": card_data,
}
for pid, p in room.players.items():
if not p.is_cpu and p.websocket:
try:
await p.websocket.send_json(reveal_msg)
except Exception:
pass
await process_cpu_turn(room.game, current, broadcast_cb, game_id=room.game_log_id, reveal_callback=reveal_cb)
async def handle_player_leave(room: Room, player_id: str): async def handle_player_leave(room: Room, player_id: str):
@ -906,8 +774,7 @@ async def handle_player_leave(room: Room, player_id: str):
room_code = room.code room_code = room.code
room_player = room.remove_player(player_id) room_player = room.remove_player(player_id)
# Check both is_empty() AND human_player_count() — CPU players keep rooms # If no human players left, clean up the room entirely
# technically non-empty, but a room with only CPUs is an abandoned room.
if room.is_empty() or room.human_player_count() == 0: if room.is_empty() or room.human_player_count() == 0:
# Remove all remaining CPU players to release their profiles # Remove all remaining CPU players to release their profiles
for cpu in list(room.get_cpu_players()): for cpu in list(room.get_cpu_players()):
@ -944,18 +811,7 @@ if os.path.exists(client_path):
return FileResponse(os.path.join(client_path, "index.html")) return FileResponse(os.path.join(client_path, "index.html"))
# Mount static files for everything else (JS, CSS, SVG, etc.) # Mount static files for everything else (JS, CSS, SVG, etc.)
# Wrap StaticFiles to reject WebSocket requests gracefully instead of app.mount("/", StaticFiles(directory=client_path), name="static")
# crashing with AssertionError (starlette asserts scope["type"] == "http").
static_files = StaticFiles(directory=client_path)
async def safe_static_files(scope, receive, send):
if scope["type"] != "http":
if scope["type"] == "websocket":
await send({"type": "websocket.close", "code": 1000})
return
await static_files(scope, receive, send)
app.mount("/", safe_static_files, name="static")
def run(): def run():

View File

@ -1,4 +1,3 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Middleware components for Golf game server. Middleware components for Golf game server.

View File

@ -1,4 +1,3 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Rate limiting middleware for FastAPI. Rate limiting middleware for FastAPI.
@ -82,14 +81,10 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
# Generate client key # Generate client key
client_key = self.limiter.get_client_key(request, user_id) client_key = self.limiter.get_client_key(request, user_id)
# Check rate limit (fail closed for auth endpoints) # Check rate limit
endpoint_key = self._get_endpoint_key(path) endpoint_key = self._get_endpoint_key(path)
full_key = f"{endpoint_key}:{client_key}" full_key = f"{endpoint_key}:{client_key}"
is_auth_endpoint = path.startswith("/api/auth")
if is_auth_endpoint:
allowed, info = await self.limiter.is_allowed_strict(full_key, limit, window)
else:
allowed, info = await self.limiter.is_allowed(full_key, limit, window) allowed, info = await self.limiter.is_allowed(full_key, limit, window)
# Build response # Build response

View File

@ -1,4 +1,3 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Request ID middleware for request tracing. Request ID middleware for request tracing.

View File

@ -1,4 +1,3 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Security headers middleware for FastAPI. Security headers middleware for FastAPI.

View File

@ -1,4 +1,3 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""Models package for Golf game V2.""" """Models package for Golf game V2."""
from .events import EventType, GameEvent from .events import EventType, GameEvent

View File

@ -1,4 +1,3 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Event definitions for Golf game event sourcing. Event definitions for Golf game event sourcing.

View File

@ -1,4 +1,3 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Game state rebuilder for event sourcing. Game state rebuilder for event sourcing.

View File

@ -1,4 +1,3 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
User-related models for Golf game authentication. User-related models for Golf game authentication.

View File

@ -1,4 +1,3 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Room management for multiplayer Golf games. Room management for multiplayer Golf games.
@ -15,7 +14,6 @@ A Room contains:
import asyncio import asyncio
import random import random
import string import string
import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Optional from typing import Optional
@ -72,11 +70,6 @@ class Room:
game_log_id: Optional[str] = None game_log_id: Optional[str] = None
game_lock: asyncio.Lock = field(default_factory=asyncio.Lock) game_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
cpu_turn_task: Optional[asyncio.Task] = None cpu_turn_task: Optional[asyncio.Task] = None
last_activity: float = field(default_factory=time.time)
def touch(self) -> None:
"""Update last_activity timestamp to mark room as active."""
self.last_activity = time.time()
def add_player( def add_player(
self, self,
@ -99,9 +92,6 @@ class Room:
Returns: Returns:
The created RoomPlayer object. The created RoomPlayer object.
""" """
# First player in becomes host. On reconnection, the player gets a new
# connection_id, so they rejoin as a "new" player — host status may shift
# if the original host disconnected and someone else was promoted.
is_host = len(self.players) == 0 is_host = len(self.players) == 0
room_player = RoomPlayer( room_player = RoomPlayer(
id=player_id, id=player_id,
@ -177,9 +167,7 @@ class Room:
if room_player.is_cpu: if room_player.is_cpu:
release_profile(room_player.name, self.code) release_profile(room_player.name, self.code)
# Assign new host if needed. next(iter(...)) gives us the first value in # Assign new host if needed
# insertion order (Python 3.7+ dict guarantee). This means the longest-tenured
# player becomes host, which is the least surprising behavior.
if room_player.is_host and self.players: if room_player.is_host and self.players:
next_host = next(iter(self.players.values())) next_host = next(iter(self.players.values()))
next_host.is_host = True next_host.is_host = True

View File

@ -1,4 +1,3 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""Routers package for Golf game API.""" """Routers package for Golf game API."""
from .auth import router as auth_router from .auth import router as auth_router

View File

@ -1,4 +1,3 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Admin API router for Golf game V2. Admin API router for Golf game V2.

View File

@ -1,4 +1,3 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Authentication API router for Golf game V2. Authentication API router for Golf game V2.
@ -6,7 +5,6 @@ Provides endpoints for user registration, login, password management,
and session handling. and session handling.
""" """
import hashlib
import logging import logging
from typing import Optional from typing import Optional
@ -17,7 +15,6 @@ from config import config
from models.user import User from models.user import User
from services.auth_service import AuthService from services.auth_service import AuthService
from services.admin_service import AdminService from services.admin_service import AdminService
from services.ratelimit import SignupLimiter
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -118,7 +115,6 @@ class SessionResponse(BaseModel):
# These will be set by main.py during startup # These will be set by main.py during startup
_auth_service: Optional[AuthService] = None _auth_service: Optional[AuthService] = None
_admin_service: Optional[AdminService] = None _admin_service: Optional[AdminService] = None
_signup_limiter: Optional[SignupLimiter] = None
def set_auth_service(service: AuthService) -> None: def set_auth_service(service: AuthService) -> None:
@ -133,12 +129,6 @@ def set_admin_service_for_auth(service: AdminService) -> None:
_admin_service = service _admin_service = service
def set_signup_limiter(limiter: SignupLimiter) -> None:
"""Set the signup limiter instance (called from main.py)."""
global _signup_limiter
_signup_limiter = limiter
def get_auth_service_dep() -> AuthService: def get_auth_service_dep() -> AuthService:
"""Dependency to get auth service.""" """Dependency to get auth service."""
if _auth_service is None: if _auth_service is None:
@ -221,51 +211,15 @@ async def register(
auth_service: AuthService = Depends(get_auth_service_dep), auth_service: AuthService = Depends(get_auth_service_dep),
): ):
"""Register a new user account.""" """Register a new user account."""
has_invite = bool(request_body.invite_code) # Validate invite code when invite-only mode is enabled
is_open_signup = not has_invite if config.INVITE_ONLY:
client_ip = get_client_ip(request) if not request_body.invite_code:
ip_hash = hashlib.sha256(client_ip.encode()).hexdigest()[:16] if client_ip else "unknown" raise HTTPException(status_code=400, detail="Invite code required")
# --- 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: if not _admin_service:
raise HTTPException(status_code=503, detail="Admin service not initialized") raise HTTPException(status_code=503, detail="Admin service not initialized")
if not await _admin_service.validate_invite_code(request_body.invite_code): if not await _admin_service.validate_invite_code(request_body.invite_code):
raise HTTPException(status_code=400, detail="Invalid or expired invite code") raise HTTPException(status_code=400, detail="Invalid or expired invite code")
else:
# No invite code — check if open signups are allowed
if config.INVITE_ONLY and config.DAILY_OPEN_SIGNUPS == 0:
raise HTTPException(status_code=400, detail="Invite code required")
# Check daily open signup limit
if config.DAILY_OPEN_SIGNUPS != 0 and _signup_limiter:
daily_allowed, daily_remaining = await _signup_limiter.check_daily_limit(
config.DAILY_OPEN_SIGNUPS
)
if not daily_allowed:
raise HTTPException(
status_code=429,
detail="Daily signup limit reached. Please try again tomorrow or use an invite code.",
)
elif config.DAILY_OPEN_SIGNUPS != 0 and not _signup_limiter:
# Signup limiter requires Redis — fail closed
raise HTTPException(
status_code=503,
detail="Registration temporarily unavailable. Please try again later.",
)
# --- Create the account ---
result = await auth_service.register( result = await auth_service.register(
username=request_body.username, username=request_body.username,
password=request_body.password, password=request_body.password,
@ -275,19 +229,12 @@ async def register(
if not result.success: if not result.success:
raise HTTPException(status_code=400, detail=result.error) raise HTTPException(status_code=400, detail=result.error)
# --- Post-registration bookkeeping --- # Consume the invite code after successful registration
# Consume invite code if used if config.INVITE_ONLY and request_body.invite_code:
if has_invite and _admin_service:
await _admin_service.use_invite_code(request_body.invite_code) await _admin_service.use_invite_code(request_body.invite_code)
# Increment signup counters
if _signup_limiter:
if is_open_signup and config.DAILY_OPEN_SIGNUPS != 0:
await _signup_limiter.increment_daily()
if config.DAILY_SIGNUPS_PER_IP > 0:
await _signup_limiter.increment_ip(ip_hash)
if result.requires_verification: if result.requires_verification:
# Return user info but note they need to verify
return { return {
"user": _user_to_response(result.user), "user": _user_to_response(result.user),
"token": "", "token": "",
@ -300,7 +247,7 @@ async def register(
username=request_body.username, username=request_body.username,
password=request_body.password, password=request_body.password,
device_info=get_device_info(request), device_info=get_device_info(request),
ip_address=client_ip, ip_address=get_client_ip(request),
) )
if not login_result.success: if not login_result.success:
@ -313,32 +260,6 @@ async def register(
} }
@router.get("/signup-info")
async def signup_info():
"""
Public endpoint: returns signup availability info.
Tells the client whether invite codes are required,
and how many open signup slots remain today.
"""
open_signups_enabled = config.DAILY_OPEN_SIGNUPS != 0
invite_required = config.INVITE_ONLY and not open_signups_enabled
unlimited = config.DAILY_OPEN_SIGNUPS < 0
remaining = None
if open_signups_enabled and not unlimited and _signup_limiter:
daily_count = await _signup_limiter.get_daily_count()
remaining = max(0, config.DAILY_OPEN_SIGNUPS - daily_count)
return {
"invite_required": invite_required,
"open_signups_enabled": open_signups_enabled,
"daily_limit": config.DAILY_OPEN_SIGNUPS if not unlimited else None,
"remaining_today": remaining,
"unlimited": unlimited,
}
@router.post("/verify-email") @router.post("/verify-email")
async def verify_email( async def verify_email(
request_body: VerifyEmailRequest, request_body: VerifyEmailRequest,

View File

@ -1,4 +1,3 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Health check endpoints for production deployment. Health check endpoints for production deployment.

View File

@ -1,4 +1,3 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Replay API router for Golf game. Replay API router for Golf game.

View File

@ -1,4 +1,3 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Stats and Leaderboards API router for Golf game. Stats and Leaderboards API router for Golf game.

View File

@ -1,4 +1,3 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Score distribution analysis for Golf AI. Score distribution analysis for Golf AI.

View File

@ -1,5 +1,4 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Create an admin user for the Golf game. Create an admin user for the Golf game.

View File

@ -1,4 +1,3 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""Services package for Golf game V2 business logic.""" """Services package for Golf game V2 business logic."""
from .recovery_service import RecoveryService, RecoveryResult from .recovery_service import RecoveryService, RecoveryResult

View File

@ -1,4 +1,3 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Admin service for Golf game. Admin service for Golf game.

View File

@ -1,4 +1,3 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Authentication service for Golf game. Authentication service for Golf game.

View File

@ -1,4 +1,3 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Email service for Golf game authentication. Email service for Golf game authentication.

View File

@ -1,4 +1,3 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
PostgreSQL-backed game logging for AI decision analysis. PostgreSQL-backed game logging for AI decision analysis.

View File

@ -1,4 +1,3 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Matchmaking service for public skill-based games. Matchmaking service for public skill-based games.

View File

@ -1,4 +1,3 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Redis-based rate limiter service. Redis-based rate limiter service.
@ -92,42 +91,9 @@ class RateLimiter:
except redis.RedisError as e: except redis.RedisError as e:
# If Redis is unavailable, fail open (allow request) # If Redis is unavailable, fail open (allow request)
# For auth-critical paths, callers should use fail_closed=True
logger.error(f"Rate limiter Redis error: {e}") logger.error(f"Rate limiter Redis error: {e}")
return True, {"remaining": limit, "reset": window_seconds, "limit": limit} return True, {"remaining": limit, "reset": window_seconds, "limit": limit}
async def is_allowed_strict(
self,
key: str,
limit: int,
window_seconds: int,
) -> tuple[bool, dict]:
"""
Like is_allowed but fails closed (denies) when Redis is unavailable.
Use for security-critical paths like auth endpoints.
"""
now = int(time.time())
window_key = f"ratelimit:{key}:{now // window_seconds}"
try:
async with self.redis.pipeline(transaction=True) as pipe:
pipe.incr(window_key)
pipe.expire(window_key, window_seconds + 1)
results = await pipe.execute()
current_count = results[0]
remaining = max(0, limit - current_count)
reset = window_seconds - (now % window_seconds)
return current_count <= limit, {
"remaining": remaining,
"reset": reset,
"limit": limit,
}
except redis.RedisError as e:
logger.error(f"Rate limiter Redis error (fail-closed): {e}")
return False, {"remaining": 0, "reset": window_seconds, "limit": limit}
def get_client_key( def get_client_key(
self, self,
request: Request | WebSocket, request: Request | WebSocket,
@ -231,110 +197,8 @@ class ConnectionMessageLimiter:
self.timestamps = [] self.timestamps = []
class SignupLimiter:
"""
Daily signup metering for public beta.
Tracks two counters in Redis:
- Global daily open signups (no invite code)
- Per-IP daily signups (with or without invite code)
Keys auto-expire after 24 hours.
"""
def __init__(self, redis_client: redis.Redis):
self.redis = redis_client
def _today_key(self, prefix: str) -> str:
"""Generate a Redis key scoped to today's date (UTC)."""
from datetime import datetime, timezone
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
return f"signup:{prefix}:{today}"
async def check_daily_limit(self, daily_limit: int) -> tuple[bool, int]:
"""
Check if global daily open signup limit allows another registration.
Args:
daily_limit: Max open signups per day. -1 = unlimited, 0 = disabled.
Returns:
Tuple of (allowed, remaining). remaining is -1 when unlimited.
"""
if daily_limit == 0:
return False, 0
if daily_limit < 0:
return True, -1
key = self._today_key("daily_open")
try:
count = await self.redis.get(key)
current = int(count) if count else 0
remaining = max(0, daily_limit - current)
return current < daily_limit, remaining
except redis.RedisError as e:
logger.error(f"Signup limiter Redis error (daily check): {e}")
return False, 0 # Fail closed
async def check_ip_limit(self, ip_hash: str, ip_limit: int) -> tuple[bool, int]:
"""
Check if per-IP daily signup limit allows another registration.
Args:
ip_hash: Hashed client IP.
ip_limit: Max signups per IP per day. 0 = unlimited.
Returns:
Tuple of (allowed, remaining).
"""
if ip_limit <= 0:
return True, -1
key = self._today_key(f"ip:{ip_hash}")
try:
count = await self.redis.get(key)
current = int(count) if count else 0
remaining = max(0, ip_limit - current)
return current < ip_limit, remaining
except redis.RedisError as e:
logger.error(f"Signup limiter Redis error (IP check): {e}")
return False, 0 # Fail closed
async def increment_daily(self) -> None:
"""Increment the global daily open signup counter."""
key = self._today_key("daily_open")
try:
async with self.redis.pipeline(transaction=True) as pipe:
pipe.incr(key)
pipe.expire(key, 86400 + 60) # 24h + 1min buffer
await pipe.execute()
except redis.RedisError as e:
logger.error(f"Signup limiter Redis error (daily incr): {e}")
async def increment_ip(self, ip_hash: str) -> None:
"""Increment the per-IP daily signup counter."""
key = self._today_key(f"ip:{ip_hash}")
try:
async with self.redis.pipeline(transaction=True) as pipe:
pipe.incr(key)
pipe.expire(key, 86400 + 60)
await pipe.execute()
except redis.RedisError as e:
logger.error(f"Signup limiter Redis error (IP incr): {e}")
async def get_daily_count(self) -> int:
"""Get current daily open signup count."""
key = self._today_key("daily_open")
try:
count = await self.redis.get(key)
return int(count) if count else 0
except redis.RedisError:
return 0
# Global rate limiter instance # Global rate limiter instance
_rate_limiter: Optional[RateLimiter] = None _rate_limiter: Optional[RateLimiter] = None
_signup_limiter: Optional[SignupLimiter] = None
async def get_rate_limiter(redis_client: redis.Redis) -> RateLimiter: async def get_rate_limiter(redis_client: redis.Redis) -> RateLimiter:
@ -353,16 +217,7 @@ async def get_rate_limiter(redis_client: redis.Redis) -> RateLimiter:
return _rate_limiter return _rate_limiter
async def get_signup_limiter(redis_client: redis.Redis) -> SignupLimiter:
"""Get or create the global signup limiter instance."""
global _signup_limiter
if _signup_limiter is None:
_signup_limiter = SignupLimiter(redis_client)
return _signup_limiter
def close_rate_limiter(): def close_rate_limiter():
"""Close the global rate limiter.""" """Close the global rate limiter."""
global _rate_limiter, _signup_limiter global _rate_limiter
_rate_limiter = None _rate_limiter = None
_signup_limiter = None

View File

@ -1,4 +1,3 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Glicko-2 rating service for Golf game matchmaking. Glicko-2 rating service for Golf game matchmaking.

View File

@ -1,4 +1,3 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Game recovery service for rebuilding active games from event store. Game recovery service for rebuilding active games from event store.

View File

@ -1,4 +1,3 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Replay service for Golf game. Replay service for Golf game.

View File

@ -1,4 +1,3 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Spectator manager for Golf game. Spectator manager for Golf game.

View File

@ -1,4 +1,3 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Stats service for Golf game leaderboards and achievements. Stats service for Golf game leaderboards and achievements.

View File

@ -1,4 +1,3 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Golf AI Simulation Runner Golf AI Simulation Runner

View File

@ -1,4 +1,3 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""Stores package for Golf game V2 persistence.""" """Stores package for Golf game V2 persistence."""
from .event_store import EventStore, ConcurrencyError from .event_store import EventStore, ConcurrencyError

View File

@ -1,4 +1,3 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
PostgreSQL-backed event store for Golf game. PostgreSQL-backed event store for Golf game.

View File

@ -1,4 +1,3 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Redis pub/sub for cross-server game events. Redis pub/sub for cross-server game events.

View File

@ -1,4 +1,3 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Redis-backed live game state cache. Redis-backed live game state cache.

View File

@ -1,4 +1,3 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
PostgreSQL-backed user store for Golf game authentication. PostgreSQL-backed user store for Golf game authentication.

View File

@ -1,4 +1,3 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Test suite for AI decision sub-functions extracted from ai.py. Test suite for AI decision sub-functions extracted from ai.py.

View File

@ -1,4 +1,3 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Tests for the GameAnalyzer decision evaluation logic. Tests for the GameAnalyzer decision evaluation logic.

View File

@ -1,4 +1,3 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Tests for the authentication system. Tests for the authentication system.

View File

@ -1,4 +1,3 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Test suite for 6-Card Golf game rules. Test suite for 6-Card Golf game rules.

View File

@ -1,4 +1,3 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Test suite for WebSocket message handlers. Test suite for WebSocket message handlers.

View File

@ -1,4 +1,3 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
House Rules Testing Suite House Rules Testing Suite

View File

@ -1,4 +1,3 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Test for the original Maya bug: Test for the original Maya bug:

View File

@ -1,4 +1,3 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Test suite for Room and RoomManager CRUD operations. Test suite for Room and RoomManager CRUD operations.

View File

@ -1,4 +1,3 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Test suite for V3 features in 6-Card Golf. Test suite for V3 features in 6-Card Golf.

View File

@ -1,2 +1 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""Tests package for Golf game.""" """Tests package for Golf game."""

View File

@ -1,4 +1,3 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Tests for event sourcing and state replay. Tests for event sourcing and state replay.

View File

@ -1,4 +1,3 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Tests for V2 Persistence & Recovery components. Tests for V2 Persistence & Recovery components.

View File

@ -1,4 +1,3 @@
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Tests for the replay service. Tests for the replay service.

View File

@ -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"]

View File

@ -1 +0,0 @@
"""TUI client for the Golf card game."""

View File

@ -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()

View File

@ -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()

View File

@ -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)})

View File

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

View File

@ -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"]),
)

View File

@ -1 +0,0 @@
"""Screen modules for the TUI client."""

View File

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

View File

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

View File

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

View File

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

View File

@ -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())

View File

@ -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;
}

View File

@ -1 +0,0 @@
"""Widget modules for the TUI client."""

View File

@ -1,179 +0,0 @@
"""Single card widget using Unicode box-drawing with Rich color markup."""
from __future__ import annotations
from textual.widgets import Static
from tui_client.models import CardData
# Web UI card back colors mapped to terminal hex equivalents
BACK_COLORS: dict[str, str] = {
"red": "#c41e3a",
"blue": "#2e5cb8",
"green": "#228b22",
"gold": "#daa520",
"purple": "#6a0dad",
"teal": "#008b8b",
"pink": "#db7093",
"slate": "#4a5568",
"orange": "#e67e22",
"cyan": "#00bcd4",
"brown": "#8b4513",
"yellow": "#daa520",
}
# Face-up card text colors (matching web UI)
SUIT_RED = "#ff4444" # hearts, diamonds — bright red
SUIT_BLACK = "#ffffff" # clubs, spades — white for dark terminal bg
JOKER_COLOR = "#9b59b6" # purple
BORDER_COLOR = "#888888" # card border
EMPTY_COLOR = "#555555" # empty card slot
POSITION_COLOR = "#f0e68c" # pale yellow — distinct from suits and card backs
HIGHLIGHT_COLOR = "#ffaa00" # bright amber — initial flip / attention
FLASH_COLOR = "#00ffff" # bright cyan — swap/discard flash
def _back_color_for_card(card: CardData, deck_colors: list[str] | None = None) -> str:
"""Get the hex color for a face-down card's back based on deck_id."""
if deck_colors and card.deck_id is not None and card.deck_id < len(deck_colors):
name = deck_colors[card.deck_id]
else:
name = "red"
return BACK_COLORS.get(name, BACK_COLORS["red"])
def _top_border(position: int | None, d: str, color: str, highlight: bool = False) -> str:
"""Top border line, with position number replacing ┌ when present."""
if position is not None:
if highlight:
hc = HIGHLIGHT_COLOR
return f"[bold {hc}]{position}[/][{d}{color}]───┐[/{d}{color}]"
return f"[{d}{color}]{position}───┐[/{d}{color}]"
return f"[{d}{color}]┌───┐[/{d}{color}]"
def render_card(
card: CardData | None,
selected: bool = False,
position: int | None = None,
deck_colors: list[str] | None = None,
dim: bool = False,
highlight: bool = False,
flash: bool = False,
connect_top: bool = False,
connect_bottom: bool = False,
) -> str:
"""Render a card as a 4-line Rich-markup string.
Face-up: Face-down: Empty:
1
A
connect_top/connect_bottom merge borders for matched column pairs.
"""
d = "dim " if dim else ""
bc = FLASH_COLOR if flash else HIGHLIGHT_COLOR if highlight else BORDER_COLOR
bot = f"[{d}{bc}]├───┤[/{d}{bc}]" if connect_bottom else f"[{d}{bc}]└───┘[/{d}{bc}]"
# Empty slot
if card is None:
c = EMPTY_COLOR
top_line = f"[{d}{c}]├───┤[/{d}{c}]" if connect_top else f"[{d}{c}]┌───┐[/{d}{c}]"
bot_line = f"[{d}{c}]├───┤[/{d}{c}]" if connect_bottom else f"[{d}{c}]└───┘[/{d}{c}]"
return (
f"{top_line}\n"
f"[{d}{c}]│ │[/{d}{c}]\n"
f"[{d}{c}]│ │[/{d}{c}]\n"
f"{bot_line}"
)
if connect_top:
top = f"[{d}{bc}]├───┤[/{d}{bc}]"
else:
top = _top_border(position, d, bc, highlight=highlight)
# Face-down card with colored back
if not card.face_up:
back = _back_color_for_card(card, deck_colors)
return (
f"{top}\n"
f"[{d}{bc}]│[/{d}{bc}][{d}{back}]▓▒▓[/{d}{back}][{d}{bc}]│[/{d}{bc}]\n"
f"[{d}{bc}]│[/{d}{bc}][{d}{back}]▒▓▒[/{d}{back}][{d}{bc}]│[/{d}{bc}]\n"
f"{bot}"
)
# Joker
if card.is_joker:
jc = JOKER_COLOR
icon = "🐉" if card.suit == "hearts" else "👹"
return (
f"{top}\n"
f"[{d}{bc}]│[/{d}{bc}][{d}{jc}] {icon}[/{d}{jc}][{d}{bc}]│[/{d}{bc}]\n"
f"[{d}{bc}]│[/{d}{bc}][{d}{jc}]JKR[/{d}{jc}][{d}{bc}]│[/{d}{bc}]\n"
f"{bot}"
)
# Face-up normal card
fc = SUIT_RED if card.is_red else SUIT_BLACK
b = "bold " if dim else ""
rank = card.display_rank
suit = card.display_suit
rank_line = f"{rank:^3}"
suit_line = f"{suit:^3}"
return (
f"{top}\n"
f"[{d}{bc}]│[/{d}{bc}][{b}{d}{fc}]{rank_line}[/{b}{d}{fc}][{d}{bc}]│[/{d}{bc}]\n"
f"[{d}{bc}]│[/{d}{bc}][{b}{d}{fc}]{suit_line}[/{b}{d}{fc}][{d}{bc}]│[/{d}{bc}]\n"
f"{bot}"
)
class CardWidget(Static):
"""A single card display widget."""
def __init__(
self,
card: CardData | None = None,
selected: bool = False,
position: int | None = None,
matched: bool = False,
deck_colors: list[str] | None = None,
**kwargs,
):
super().__init__(**kwargs)
self._card = card
self._selected = selected
self._position = position
self._matched = matched
self._deck_colors = deck_colors
def on_mount(self) -> None:
self._refresh_display()
def update_card(
self,
card: CardData | None,
selected: bool = False,
matched: bool = False,
deck_colors: list[str] | None = None,
) -> None:
self._card = card
self._selected = selected
self._matched = matched
if deck_colors is not None:
self._deck_colors = deck_colors
self._refresh_display()
def _refresh_display(self) -> None:
text = render_card(
self._card,
self._selected,
self._position,
deck_colors=self._deck_colors,
dim=self._matched,
)
self.update(text)

View File

@ -1,222 +0,0 @@
"""2x3 card grid for one player's hand."""
from __future__ import annotations
from textual.events import Click
from textual.message import Message
from textual.widgets import Static
from tui_client.models import CardData, PlayerData
from tui_client.widgets.card import render_card
def _check_column_match(cards: list[CardData]) -> list[bool]:
"""Check which cards are in matched columns (both face-up, same rank).
Cards layout: [0][1][2]
[3][4][5]
Columns: (0,3), (1,4), (2,5)
"""
matched = [False] * 6
if len(cards) < 6:
return matched
for col in range(3):
top = cards[col]
bot = cards[col + 3]
if (
top.face_up
and bot.face_up
and top.rank is not None
and top.rank == bot.rank
):
matched[col] = True
matched[col + 3] = True
return matched
def _render_card_lines(
cards: list[CardData],
*,
is_local: bool = False,
deck_colors: list[str] | None = None,
matched: list[bool] | None = None,
highlight: bool = False,
flash_position: int | None = None,
) -> list[str]:
"""Render the 2x3 card grid as a list of text lines (no box).
Matched columns use connected borders () instead of separate
/ to avoid an extra connector row.
"""
if matched is None:
matched = _check_column_match(cards)
lines: list[str] = []
for row_idx, row_start in enumerate((0, 3)):
row_line_parts: list[list[str]] = []
for i in range(3):
idx = row_start + i
card = cards[idx] if idx < len(cards) else None
pos = idx + 1 if is_local else None
# Top row cards: connect_bottom if matched
# Bottom row cards: connect_top if matched
cb = matched[idx] if row_idx == 0 else False
ct = matched[idx] if row_idx == 1 else False
text = render_card(
card,
position=pos,
deck_colors=deck_colors,
dim=matched[idx],
highlight=highlight,
flash=(flash_position == idx),
connect_bottom=cb,
connect_top=ct,
)
card_lines = text.split("\n")
while len(row_line_parts) < len(card_lines):
row_line_parts.append([])
for ln_idx, ln in enumerate(card_lines):
row_line_parts[ln_idx].append(ln)
for parts in row_line_parts:
lines.append(" ".join(parts))
return lines
class HandWidget(Static):
"""Displays a player's 2x3 card grid as rich text, wrapped in a player box."""
class CardClicked(Message):
"""Posted when a card position is clicked in the local hand."""
def __init__(self, position: int) -> None:
super().__init__()
self.position = position
def __init__(
self,
player: PlayerData | None = None,
is_local: bool = False,
deck_colors: list[str] | None = None,
**kwargs,
):
super().__init__(**kwargs)
self._player = player
self._is_local = is_local
self._deck_colors = deck_colors
# State flags for the player box
self._is_current_turn: bool = False
self._is_knocker: bool = False
self._is_dealer: bool = False
self._highlight: bool = False
self._flash_position: int | None = None
self._box_width: int = 0
def update_player(
self,
player: PlayerData,
deck_colors: list[str] | None = None,
*,
is_current_turn: bool = False,
is_knocker: bool = False,
is_dealer: bool = False,
highlight: bool = False,
flash_position: int | None = None,
) -> None:
self._player = player
if deck_colors is not None:
self._deck_colors = deck_colors
self._is_current_turn = is_current_turn
self._is_knocker = is_knocker
self._is_dealer = is_dealer
self._highlight = highlight
self._flash_position = flash_position
self._refresh()
def on_mount(self) -> None:
self._refresh()
def on_click(self, event: Click) -> None:
"""Map click coordinates to card position (0-5)."""
if not self._is_local or not self._box_width:
return
# The content is centered in the widget — compute the x offset
x_offset = max(0, (self.size.width - self._box_width) // 2)
x = event.x - x_offset
y = event.y
# Box layout:
# Line 0: top border
# Lines 1-4: row 0 cards (4 lines each)
# Lines 5-8: row 1 cards
# Line 9: bottom border
#
# Content x: │ <space> then cards at x offsets 2, 8, 14 (each 5 wide, 1 gap)
# Determine column from x (content starts at x=2 inside box)
# Card 0: x 2-6, Card 1: x 8-12, Card 2: x 14-18
col = -1
if 2 <= x <= 6:
col = 0
elif 8 <= x <= 12:
col = 1
elif 14 <= x <= 18:
col = 2
if col < 0:
return
# Determine row from y
# y=0: top border, y=1..4: row 0, y=5..8: row 1, y=9: bottom border
row = -1
if 1 <= y <= 4:
row = 0
elif 5 <= y <= 8:
row = 1
if row < 0:
return
position = row * 3 + col
self.post_message(self.CardClicked(position))
def _refresh(self) -> None:
if not self._player or not self._player.cards:
self.update("")
return
from tui_client.widgets.player_box import _visible_len, render_player_box
cards = self._player.cards
matched = _check_column_match(cards)
card_lines = _render_card_lines(
cards,
is_local=self._is_local,
deck_colors=self._deck_colors,
matched=matched,
highlight=self._highlight,
flash_position=self._flash_position,
)
# Use visible_score (computed from face-up cards) during play,
# server-provided score at round/game over
display_score = self._player.score if self._player.score is not None else self._player.visible_score
box_lines = render_player_box(
self._player.name,
score=display_score,
total_score=self._player.total_score,
content_lines=card_lines,
is_current_turn=self._is_current_turn,
is_knocker=self._is_knocker,
is_dealer=self._is_dealer,
is_local=self._is_local,
)
# Store box width for click coordinate mapping
if box_lines:
self._box_width = _visible_len(box_lines[0])
self.update("\n".join(box_lines))

View File

@ -1,131 +0,0 @@
"""Deck + discard + held card area."""
from __future__ import annotations
import re
from dataclasses import replace
from textual.events import Click
from textual.message import Message
from textual.widgets import Static
from tui_client.models import CardData, GameState
from tui_client.widgets.card import render_card
# Fixed column width for each card section (card is 5 wide)
_COL_WIDTH = 12
# Lime green for the held card highlight
_HOLDING_COLOR = "#80ff00"
def _pad_center(text: str, width: int) -> str:
"""Center-pad a plain or Rich-markup string to *width* visible chars."""
visible = re.sub(r"\[.*?\]", "", text)
pad = max(0, width - len(visible))
left = pad // 2
right = pad - left
return " " * left + text + " " * right
class PlayAreaWidget(Static):
"""Displays the deck, discard pile, and held card.
Layout order: DECK [HOLDING] DISCARD
HOLDING only appears when the player has drawn a card.
"""
class DeckClicked(Message):
"""Posted when the deck is clicked."""
class DiscardClicked(Message):
"""Posted when the discard pile is clicked."""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._state: GameState | None = None
self._local_player_id: str = ""
self._has_holding: bool = False
self._discard_flash: bool = False
def update_state(self, state: GameState, local_player_id: str = "", discard_flash: bool = False) -> None:
self._state = state
self._discard_flash = discard_flash
if local_player_id:
self._local_player_id = local_player_id
self._refresh()
def on_mount(self) -> None:
self._refresh()
def on_click(self, event: Click) -> None:
"""Map click to deck or discard column."""
# Content is always 3 columns wide; account for centering within widget
content_width = 3 * _COL_WIDTH
x_offset = max(0, (self.content_size.width - content_width) // 2)
x = event.x - x_offset
# Layout: DECK (col 0..11) | HOLDING (col 12..23) | DISCARD (col 24..35)
if 0 <= x < _COL_WIDTH:
self.post_message(self.DeckClicked())
elif 2 * _COL_WIDTH <= x < 3 * _COL_WIDTH:
self.post_message(self.DiscardClicked())
def _refresh(self) -> None:
if not self._state:
self.update("")
return
state = self._state
# Deck card (face-down)
deck_card = CardData(face_up=False, deck_id=0)
deck_text = render_card(deck_card, deck_colors=state.deck_colors)
deck_lines = deck_text.split("\n")
# Discard card
discard_text = render_card(state.discard_top, deck_colors=state.deck_colors, flash=self._discard_flash)
discard_lines = discard_text.split("\n")
# Held card — show for any player holding
held_lines = None
is_local_holding = False
if state.has_drawn_card and state.drawn_card:
revealed = replace(state.drawn_card, face_up=True)
held_text = render_card(revealed, deck_colors=state.deck_colors)
held_lines = held_text.split("\n")
is_local_holding = state.drawn_player_id == self._local_player_id
self._has_holding = held_lines is not None
# Always render 3 columns so the box stays a fixed width
num_card_lines = max(len(deck_lines), len(discard_lines))
lines = []
for i in range(num_card_lines):
d = deck_lines[i] if i < len(deck_lines) else " "
c = discard_lines[i] if i < len(discard_lines) else " "
row = _pad_center(d, _COL_WIDTH)
if held_lines:
h = held_lines[i] if i < len(held_lines) else " "
row += _pad_center(h, _COL_WIDTH)
else:
row += " " * _COL_WIDTH
row += _pad_center(c, _COL_WIDTH)
lines.append(row)
# Labels row — always 3 columns
deck_label = f"DECK [dim]{state.deck_remaining}[/dim]"
discard_label = "DISCARD"
label = _pad_center(deck_label, _COL_WIDTH)
if held_lines:
if is_local_holding:
holding_label = f"[bold {_HOLDING_COLOR}]HOLDING[/]"
else:
holding_label = "[dim]HOLDING[/dim]"
label += _pad_center(holding_label, _COL_WIDTH)
else:
label += " " * _COL_WIDTH
label += _pad_center(discard_label, _COL_WIDTH)
lines.append(label)
self.update("\n".join(lines))

Some files were not shown because too many files have changed in this diff Show More