86 Commits

Author SHA1 Message Date
adlee-was-taken
dc936d7e1c Add v3.1.5 footer with copyright to lobby and waiting room
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 21:08:39 -05:00
adlee-was-taken
1cdf1cf281 Tune round-end pause and reduce deck shake frequency
Cut lastPlayPause to 2s and increase shake interval by 80% (3s→5.4s)
so the draw/discard nudge feels less nagging.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 21:06:17 -05:00
adlee-was-taken
17f7d8ce7a Fix draw-swap animation race and smarter CPU go-out decisions
Increase post_draw_settle timing (1.1s→1.3s) and swap retry delay
(100ms→350ms) to prevent draw animation from being cut short by the
arriving swap animation. Also make CPU go-out decisions consider
opponent scores and avoid swapping high cards (8+) into hidden slots.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 20:57:59 -05:00
adlee-was-taken
9a5bc888cb Compact scoresheet modal to reduce scrolling with 4 players
Tighten padding, gaps, card sizes, and margins across all scoresheet
elements so the full modal fits without scrolling on most viewports.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 20:41:03 -05:00
adlee-was-taken
3dcad3dfdf Fix round-end reveal timing: pause after last play, handle deferred state
Two issues fixed:

1. renderGame() was called before the lastPlayPause delay, causing the
   board to jump to final card positions while the swap animation was
   still visually playing. Moved renderGame() to after the wait+pause.

2. When the local player makes the final play, their swap animation
   defers the round_over game_state to pendingGameState. The deferred
   state bypassed the round-end intercept, so preRevealState was never
   set — causing the scoresheet to appear immediately without the
   reveal animation. Now completeSwapAnimation checks for round_over
   transitions and sets preRevealState. Also added a wait loop in
   runRoundEndReveal for robustness.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 20:35:02 -05:00
adlee-was-taken
b129aa4f29 Fix opponent draw-from-discard animation showing wrong card
Force discard pile DOM update before draw animation starts to prevent
stale card display when previous swap animation blocked renderGame.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 20:26:24 -05:00
adlee-was-taken
86697dd454 Compact mobile lobby layout with inline CPU controls
- Move CPU +/- buttons inline into Players header row with "CPU:" label
- Tighten vertical spacing for mobile stacked layout (≤700px)
- Fit Decks/Holes/Card Backs settings in single row on mobile
- Reduce room code banner and auth bar edge margins
- Consistent spacing across all stacked viewport widths

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 20:15:37 -05:00
adlee-was-taken
77cbefc30c Improve initial card flip animation appearance
Style the flip overlay's front face to match player hand cards (gradient
background, proper border/shadow) instead of using generic card-front
styles. Hide the underlying card during the animation so the green table
shows through the flip rather than a white card peeking behind it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 19:29:09 -05:00
adlee-was-taken
e2c7a55dac Fix held card displacement in landscape and tooltip crash
The turn pulse shake was targeting .discard-stack, which is an ancestor of
#held-card-floating. A CSS transform on any ancestor breaks position:fixed,
causing the held card to render far from the deck area. Now target #discard
directly instead.

Also fix duplicate getCardPointValue methods — the 3-arg scoring version
shadowed the 1-arg tooltip version, leaving cardValues undefined on hover.

Add staging deploy script (rsync working tree, no git pull needed).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 19:16:20 -05:00
adlee-was-taken
8d5b2ee655 Fix AI knock decisions and improve round-end animations
Fix dumb AI knocks (e.g. Maya knocking on 13 points) by adding opponent
threat checks and a hard cap of 10 to should_knock_early(). Remove dead
should_go_out_early() call whose return value was never used. Retune knock
chance tiers to be more conservative at higher projected scores.

On the client side, fix round-end reveal sequencing so the last player's
swap/discard animation plays before the reveal sequence starts, and prevent
re-renders from clobbering swap animations during reveals. Also make the
turn-pulse shake configurable via timing-config and target only cards.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 19:07:57 -05:00
adlee-was-taken
06b15f002d Add internal/ to .gitignore for local deployment docs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 17:42:31 -05:00
adlee-was-taken
76f80f3f44 Add docker-compose.staging.yml for 512MB staging droplet
Mirrors production config with reduced memory limits (128M app, 96M
postgres, 32M redis, 48M traefik) and staging defaults. Rate limiting
disabled for easier testing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 17:39:17 -05:00
adlee-was-taken
0a9993a82f Pass per-module log level env vars through docker-compose.prod.yml
LOG_LEVEL and ENVIRONMENT were hardcoded, overriding .env values.
Now uses ${VAR:-default} so .env settings are respected.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 17:15:10 -05:00
adlee-was-taken
e463d929e3 Add per-module log level overrides for staging/production
Support LOG_LEVEL_{MODULE} env vars (GAME, AI, HANDLERS, ROOM, AUTH,
STORES) to override the global log level for specific modules. Active
overrides are logged at startup. Includes staging/production presets
in .env.example files and a V3.18 stub doc for PostgreSQL storage
efficiency investigation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 17:12:11 -05:00
adlee-was-taken
1b923838e0 Fix typo: Bare -> Bear
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 21:35:50 -05:00
adlee-was-taken
4503198021 Update banner text to beta testing
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 21:34:32 -05:00
adlee-was-taken
cb49fd545b Add gradient backgrounds to all status messages, match final-turn badge size
- Default gradient on base .status-message (dark green)
- Add round-over/game-over (gold) and reveal (purple) gradient styles
- Tag action prompts (swap, flip, discard) as your-turn type
- Match final-turn badge font size and padding to status message on mobile
- Hide status message when empty

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 21:31:20 -05:00
adlee-was-taken
cb311ec0da Move status message to left side of header on mobile
Align header-col-center to flex-start on mobile so the status and
final-turn badges sit flush left. Match final-turn-badge border-radius
and padding to status-message for consistent shape.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 21:26:31 -05:00
adlee-was-taken
873bdfc75a Left-align status message on mobile portrait
Mute button in header-right throws off visual centering of the status
text. Left-aligning looks intentional rather than off-center.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 21:22:52 -05:00
adlee-was-taken
bd41afbca8 Fix mobile scroll on rules screen
overflow:hidden on body.mobile-portrait was blocking scroll on all
screens. Scope it to only when the game screen is active using :has().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 21:18:18 -05:00
adlee-was-taken
21985b7e9b Route all lobby transitions through showLobby() for animation cleanup
game_ended, queue_left, and cancelMatchmaking were calling
showScreen('lobby') directly, bypassing the cancelAll() cleanup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 21:12:39 -05:00
adlee-was-taken
56305424ff Thorough animation cleanup when leaving game
- Tag deal container with class so cleanup() can find and remove it
- Remove .traveling-card and .deal-anim-container overlays in cleanup()
- Restore opacity/visibility on cards hidden mid-animation
- Reset all animation flags (dealAnimationInProgress, etc.) in showLobby()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 21:09:37 -05:00
adlee-was-taken
0bfe9d5f9f Cancel animations on game leave to prevent overlay flash on lobby
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 21:06:11 -05:00
adlee-was-taken
a0bb28d5eb Fix opponent swap animation instant shrink on mobile portrait
Let overlay card start at deck size and smoothly scale down to opponent
card size during the arc, instead of instantly shrinking before animating.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 21:00:20 -05:00
adlee-was-taken
55006d6ff4 Fix bottom bar width: add align-self: stretch to override parent center
Parent #game-screen has align-items: center which shrink-wraps flex
children. Adding align-self: stretch makes the bottom bar span the
full screen width so space-between can distribute items properly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:43:40 -05:00
adlee-was-taken
adcc59b6fc Spread bottom bar items with space-between
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:40:57 -05:00
adlee-was-taken
7e0c006f5e Revert bottom bar to original working state
Remove all flush-edge styling (negative margins, half-pills, border
removal). Restore original padding, justify-content, and pill shapes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:38:18 -05:00
adlee-was-taken
02f9b3c44d Fix layout: restore 12px padding, use negative margins for flush edges
Zero padding was breaking game layout. Keep 12px padding for layout
stability and use margin-left: -12px / margin-right: -12px on the
edge items to push them flush against screen edges.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:36:18 -05:00
adlee-was-taken
9f75cdb0dc Pin Hole and End Game flush to screen edges with half-pill shape
Zero horizontal padding on bottom bar, remove border on flush side,
use half-rounded pill shape so they sit against the screen edges.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:34:27 -05:00
adlee-was-taken
519d08a2a6 Fix layout: move rules drawer out of game-layout, restore bottom bar padding
Reverts flush-edge pill styling and restores horizontal padding to prevent
clipping. Rules drawer is now a sibling of bottom-bar, not inside game-layout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:32:32 -05:00
adlee-was-taken
9419cb562e Move rules drawer inside game-layout to fix layout breakage
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:28:36 -05:00
adlee-was-taken
17c8e574ab Pin Hole and End Game buttons flush to screen edges on mobile
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:26:18 -05:00
adlee-was-taken
94edb685a7 Move dealer chip to bottom-left of player panel on mobile, pin bottom bar edges
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:22:54 -05:00
adlee-was-taken
6b7d6c459e Remove redundant Scores button, rename Standings to Scorecard
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:19:47 -05:00
adlee-was-taken
1de282afc2 Change mobile rules pill default text from "S" to "RULES"
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:17:41 -05:00
adlee-was-taken
9b0a8295eb Add mobile rules indicator pill and drawer
Shows "S" (standard) or "!" (house rules) in the mobile bottom bar.
Tapping opens a drawer with the full active rules list.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:14:09 -05:00
adlee-was-taken
28a0f90374 Restore dealer chip to 38px and shift further out
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:05:54 -05:00
adlee-was-taken
0df451aa99 Enlarge local dealer chip to 34px and nudge further out
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:03:37 -05:00
adlee-was-taken
8d7b024525 Adjust local player dealer chip size and position
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:00:46 -05:00
adlee-was-taken
9c08b4735a Shrink local player dealer chip in desktop mode
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 19:58:53 -05:00
adlee-was-taken
49916e6a6c Remove top padding above game header in desktop mode
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 19:53:41 -05:00
adlee-was-taken
e0641de449 Move knocker OUT badge to bottom-right on mobile portrait
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 19:49:43 -05:00
adlee-was-taken
e2a90c0f34 Fix knocker highlight not showing on opponents
markKnocker() was called before opponent areas were rebuilt by
innerHTML='', so the is-knocker class and OUT badge were immediately
destroyed. Move markKnocker to after opponent areas are created.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 19:37:33 -05:00
adlee-was-taken
86f5222746 Enhance knocker highlight with glowing box-shadow animation
Makes the red border on the knocker's area more visible, especially
for opponents on mobile where the area is small.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 19:26:09 -05:00
adlee-was-taken
60997e8ad4 Compact final results for mobile, delay turn shake hint
Final results modal: keep BY POINTS and BY HOLES side-by-side on
mobile, compact spacing, buttons side-by-side, bottom padding for
mobile bar overlay.

Turn shake: delay 5s before first shake, 300ms every 2s.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 19:21:45 -05:00
adlee-was-taken
3e133b17c0 Delay turn shake hint by 5s, reduce to 300ms every 2s
Less aggressive draw hint: waits 5 seconds before first shake,
then shakes for 300ms every 2 seconds with slightly less movement.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 19:13:40 -05:00
adlee-was-taken
9866fb8e92 Move discard button below held card on mobile portrait
Position the button centered beneath the held card instead of to the
right side. Reset writing-mode to horizontal and add width:auto.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:39:33 -05:00
adlee-was-taken
4a5cfb68f1 Set held card offset to 0.48 on mobile portrait
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:35:58 -05:00
adlee-was-taken
ebb00f613c Lower held card offset to 0.55 on mobile portrait
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:34:00 -05:00
adlee-was-taken
98aa0823ed Set held card mobile portrait offset back to 0.65
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:33:36 -05:00
adlee-was-taken
4a3d62e26e Nudge held card up slightly to clear DRAW/DISCARD labels
Increase mobile portrait overlap offset from 0.65 to 0.8.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:32:46 -05:00
adlee-was-taken
d958258066 Lower held card position to just above the labels on mobile portrait
Reduce overlap offset from 1.15 to 0.65 so the held card sits at the
DRAW/DISCARD label level rather than up in the opponents area.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:30:33 -05:00
adlee-was-taken
26bc151458 Move held card to gap above deck area on mobile portrait
Position the held card a full card height above the deck (1.15x offset)
so it sits in the space between opponents and the draw/discard piles.
All three position calculations (app.js x2, card-animations.js) are
synced so draw animations land at the correct held position.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:27:45 -05:00
adlee-was-taken
0d5c0c613d Add DRAW and DISCARD labels above deck and discard piles
Wrap each pile in a .pile-wrapper with a small label above it.
Fix direct child selectors that broke with the new wrapper nesting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:23:38 -05:00
adlee-was-taken
e9692de6c6 Add top padding to table-center on mobile portrait for held card clearance
Increase top padding from 5px to 20px so the deck/discard sit lower,
giving the held card more breathing room from the opponents row above.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:20:21 -05:00
adlee-was-taken
3414bfad1a Sync held card position across all animation paths for mobile portrait
Update getHoldingRect() in card-animations.js and the second held card
positioning path in app.js to use the same reduced overlap offset on
mobile portrait. All three places that compute the held position now
use 0.15 on mobile-portrait vs 0.35 on desktop/landscape.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:12:46 -05:00
adlee-was-taken
ecad259db2 Lower held card position and add opponent row padding on mobile
Reduce held card overlap offset from 0.35 to 0.15 on mobile portrait
so it doesn't cover the second row of opponents. Increase bottom
padding on opponents row from 6px to 12px for more breathing room.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:10:11 -05:00
adlee-was-taken
932e9ca4ef Enhance Your Turn status gradient to be more visible
Widen the green gradient range to match the visual pop of the
opponent turn purple gradient.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:03:40 -05:00
adlee-was-taken
10825e8b82 Add opponent-turn class to status message in renderGame
The status was set without a type class in renderGame(), overriding
the styled version from updateStatusFromGameState() on every state
update. Now the purple background shows consistently for opponent turns.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:00:38 -05:00
adlee-was-taken
53abde53ac Anchor back buttons to top-left corner of header
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:48:16 -05:00
adlee-was-taken
d7ba3154a1 Scope container margin-top to mobile-portrait only
Remove margin-top from base rules/leaderboard/matchmaking styles so
desktop and landscape layouts are unaffected. The 50px top margin is
now only applied via the mobile-portrait override.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:45:36 -05:00
adlee-was-taken
197595fc4d Fix mobile-portrait override resetting container margin-top to 0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:44:35 -05:00
adlee-was-taken
e38d8c1561 Add margin-top to matchmaking screen to clear auth bar
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:42:39 -05:00
adlee-was-taken
afb4869b21 Add margin-top to rules and leaderboard containers to clear auth bar
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:42:10 -05:00
adlee-was-taken
c6769f9257 Fix back button width and add border to leaderboard header
Override width:100% from .btn base class with width:auto on both
back buttons. Add padding-bottom and border-bottom to leaderboard
header to match rules page styling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:39:51 -05:00
adlee-was-taken
8657a0501f Move Back button into header on Rules and Leaderboard pages
Position the button absolutely on the left side of the header,
vertically centered with the title. Remove mobile fixed-position
override that placed it in the top bar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:37:13 -05:00
adlee-was-taken
730ba9c462 Fix portrait back buttons: fixed top-left, push containers down
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:33:25 -05:00
adlee-was-taken
1ba80606a7 Add top padding to rules/leaderboard screens in portrait mode
Prevents auth bar from overlapping back buttons. Back buttons
align to start instead of stretching full width.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:28:22 -05:00
adlee-was-taken
3261e6ee26 Make CPU turn chain fire-and-forget so end game is instant
The CPU turn chain was awaited inline inside game_lock, blocking the
WebSocket message loop. The end_game message couldn't be processed
until all CPU turns finished. Now check_and_run_cpu_turn launches a
background task and returns immediately, keeping the message loop
responsive. The end_game and leave handlers cancel the task on demand.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:26:58 -05:00
adlee-was-taken
de3495635b Cancel CPU turns immediately when host ends game
Convert CPU turn chain to a cancellable asyncio.Task tracked on Room,
so ending the game or leaving no longer blocks waiting for CPU sleeps.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:21:22 -05:00
adlee-was-taken
4c23f2b4a9 Increase mobile portrait opponent row gap to 9px
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:16:32 -05:00
adlee-was-taken
7b071afdfb Apply flush header with gradient to desktop/landscape view too
Remove game-screen padding and replace solid dark header background
with subtle dark-to-transparent gradient matching mobile treatment.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:12:15 -05:00
adlee-was-taken
c7fb85d281 Remove desktop 10px padding from game-screen on mobile
The desktop #game-screen.active had padding:10px that was never
overridden in the mobile portrait styles.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:11:02 -05:00
adlee-was-taken
118912dd13 Add subtle dark gradient to mobile header for status bar visibility
Replaces background:none with a dark-to-transparent gradient so the
status message and mute button are visible against the green felt.
Reverts mute button circle in favor of the gradient approach.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:08:57 -05:00
adlee-was-taken
0e594a5e28 Add dark circle background behind mute button on mobile
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:06:08 -05:00
adlee-was-taken
a6ec72d72c Remove dark background from mobile header for flush appearance
The desktop game-header background was still showing on mobile,
creating a visible dark band with padding around the status bar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:05:18 -05:00
adlee-was-taken
e2f353d4ab Make mobile header flush with page edges and add spacing below
Remove left/right/top padding from notification bar so it spans edge
to edge, and increase bottom margin from 4px to 8px for more breathing
room before the opponents row.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:01:36 -05:00
adlee-was-taken
e601eb04c9 Add alpha notice banner to lobby screen
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 16:52:22 -05:00
adlee-was-taken
6c771810f7 Distribute space evenly between draw pile and player hand on mobile
Replace margin:auto on table-center with space-evenly on player-row
so the draw pile and player cards are equally spaced vertically.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 16:50:55 -05:00
adlee-was-taken
dbad7037d1 Fix dealer chip and status bar clipping on mobile edges
Increase horizontal padding on game-table (4px to 10px) and header
(8px to 12px) to prevent edge clipping. Change opponents-row overflow
to visible so dealer chips aren't cut off.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 16:50:05 -05:00
adlee-was-taken
21362ba125 Fix pair chime not playing for local player's own swaps
The swap animation defers state updates to pendingGameState, which
bypassed checkForNewPairs entirely. Now pair detection runs when the
deferred state is applied after the swap animation completes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 16:47:43 -05:00
adlee-was-taken
2dcdaf2b49 Remove turns remaining counter from FINAL TURN badge
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 16:46:03 -05:00
adlee-was-taken
1fa13bbe3b Play pair sound before element check and add pair detection debug log
Sound was gated behind element check which may fail during swap
animation when card DOM elements are replaced by overlays.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 16:41:17 -05:00
adlee-was-taken
a76fd8da32 Hide bottom bar during scoresheet modal and compact mobile layout
Bottom bar is hidden when the hole results modal opens and restored
when dismissed. Also adds mobile-specific compact styles for the
scoresheet: smaller cards, tighter spacing, reduced padding.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 16:34:16 -05:00
adlee-was-taken
634d101f2c Play pair chime sound for all players including local player
firePairCelebration was only doing the visual animation but not playing
the pair sound. The sound was only played during score tallying.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 16:31:00 -05:00
adlee-was-taken
28c9882b17 Add www.golfcards.club cert and redirect to bare domain
Traefik gets a separate cert for www subdomain and uses
redirectregex middleware to 301 redirect to bare domain.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 14:48:31 -05:00
17 changed files with 1153 additions and 221 deletions

View File

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

3
.gitignore vendored
View File

@@ -201,6 +201,9 @@ pyvenv.cfg
# Personal notes
lookfah.md
# Internal docs (deployment info, credentials references, etc.)
internal/
# Ruff stuff:
.ruff_cache/

View File

@@ -379,7 +379,7 @@ class GolfGame {
// Only show tooltips on your turn
if (!this.isMyTurn() && !this.gameState?.waiting_for_initial_flip) return;
const value = this.getCardPointValue(cardData);
const value = this.getCardPointValueForTooltip(cardData);
const special = this.getCardSpecialNote(cardData);
let content = `<span class="tooltip-value ${value < 0 ? 'negative' : ''}">${value} pts</span>`;
@@ -409,14 +409,15 @@ class GolfGame {
if (this.tooltip) this.tooltip.classList.add('hidden');
}
getCardPointValue(cardData) {
getCardPointValueForTooltip(cardData) {
const values = this.gameState?.card_values || this.getDefaultCardValues();
return values[cardData.rank] ?? 0;
const rules = this.gameState?.scoring_rules || {};
return this.getCardPointValue(cardData, values, rules);
}
getCardSpecialNote(cardData) {
const rank = cardData.rank;
const value = this.getCardPointValue(cardData);
const value = this.getCardPointValueForTooltip(cardData);
if (value < 0) return 'Negative - keep it!';
if (rank === 'K' && value === 0) return 'Safe card';
if (rank === 'K' && value === -2) return 'Super King!';
@@ -822,11 +823,29 @@ class GolfGame {
newState.phase === 'round_over';
if (roundJustEnded && oldState) {
// Save pre-reveal state for the reveal animation
this.preRevealState = JSON.parse(JSON.stringify(oldState));
this.postRevealState = newState;
// Update state but DON'T render yet - reveal animation will handle it
// Update state first so animations can read new card data
this.gameState = newState;
// 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
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;
break;
}
@@ -923,16 +942,16 @@ class GolfGame {
this.displayHeldCard(data.card, true);
this.renderGame();
}
this.showToast('Swap with a card or discard', '', 3000);
this.showToast('Swap with a card or discard', 'your-turn', 3000);
break;
case 'can_flip':
this.waitingForFlip = true;
this.flipIsOptional = data.optional || false;
if (this.flipIsOptional) {
this.showToast('Flip a card or skip', '', 3000);
this.showToast('Flip a card or skip', 'your-turn', 3000);
} else {
this.showToast('Flip a face-down card', '', 3000);
this.showToast('Flip a face-down card', 'your-turn', 3000);
}
this.renderGame();
break;
@@ -950,7 +969,7 @@ class GolfGame {
// Host ended the game or player was kicked
this._intentionalClose = true;
if (this.ws) this.ws.close();
this.showScreen('lobby');
this.showLobby();
if (data.reason) {
this.showError(data.reason);
}
@@ -975,7 +994,7 @@ class GolfGame {
case 'queue_left':
this.stopMatchmakingTimer();
this.showScreen('lobby');
this.showLobby();
break;
case 'error':
@@ -995,7 +1014,7 @@ class GolfGame {
cancelMatchmaking() {
this.send({ type: 'queue_leave' });
this.stopMatchmakingTimer();
this.showScreen('lobby');
this.showLobby();
}
startMatchmakingTimer() {
@@ -1431,8 +1450,9 @@ class GolfGame {
this.swapAnimationCardEl = handCardEl;
this.swapAnimationHandCardEl = handCardEl;
// Hide originals during animation
// Hide originals and UI during animation
handCardEl.classList.add('swap-out');
this.discardBtn.classList.add('hidden');
if (this.heldCardFloating) {
this.heldCardFloating.style.visibility = 'hidden';
}
@@ -1573,11 +1593,29 @@ class GolfGame {
this.heldCardFloating.classList.add('hidden');
if (this.pendingGameState) {
this.gameState = this.pendingGameState;
const oldState = this.gameState;
const newState = this.pendingGameState;
this.pendingGameState = null;
// 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();
}
}
}
flipCard(position) {
this.send({ type: 'flip_card', position });
@@ -1789,6 +1827,10 @@ class GolfGame {
document.body.appendChild(modal);
this.setStatus('Hole complete');
// Hide bottom bar so it doesn't overlay the modal
const bottomBar = document.getElementById('mobile-bottom-bar');
if (bottomBar) bottomBar.classList.add('hidden');
// Bind next button
const nextBtn = document.getElementById('ss-next-btn');
nextBtn.addEventListener('click', () => {
@@ -1918,6 +1960,10 @@ class GolfGame {
this.clearScoresheetCountdown();
const modal = document.getElementById('scoresheet-modal');
if (modal) modal.remove();
// Restore bottom bar
const bottomBar = document.getElementById('mobile-bottom-bar');
if (bottomBar) bottomBar.classList.remove('hidden');
}
// --- V3_02: Dealing Animation ---
@@ -2053,6 +2099,16 @@ class GolfGame {
async runRoundEndReveal(scores, rankings) {
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 newState = this.postRevealState || this.gameState;
@@ -2062,22 +2118,35 @@ class GolfGame {
return;
}
// 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
// Compute what needs revealing (before renderGame changes the DOM)
const revealsByPlayer = this.getCardsToReveal(oldState, newState);
// Get reveal order: knocker first, then clockwise
const knockerId = newState.finisher_id;
const revealOrder = this.getRevealOrder(newState.players, knockerId);
// Initial pause
// Wait for the last player's animation (swap/discard/draw) to finish
// 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');
await this.delay(T.initialPause || 500);
@@ -2404,8 +2473,13 @@ class GolfGame {
this.opponentDiscardAnimating = false;
// Set isDrawAnimating to block renderGame from updating discard pile
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');
window.drawAnimations.animateDrawDiscard(drawnCard, () => {
window.drawAnimations.animateDrawDiscard(oldDiscard || drawnCard, () => {
console.log('[DEBUG] Opponent draw from discard complete - clearing isDrawAnimating');
this.isDrawAnimating = false;
onAnimComplete();
@@ -2415,7 +2489,7 @@ class GolfGame {
this.opponentSwapAnimation = null;
this.opponentDiscardAnimating = false;
this.isDrawAnimating = true;
console.log('[DEBUG] Opponent draw from deck - setting 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');
window.drawAnimations.animateDrawDeck(drawnCard, () => {
console.log('[DEBUG] Opponent draw from deck complete - clearing isDrawAnimating');
this.isDrawAnimating = false;
@@ -2496,6 +2570,7 @@ class GolfGame {
const cardsIdentical = wasOtherPlayer && JSON.stringify(oldPlayer.cards) === JSON.stringify(newPlayer.cards);
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
this.fireSwapAnimation(previousPlayerId, newDiscard, swappedPosition, wasFaceUp);
// Show CPU swap announcement
@@ -2599,6 +2674,7 @@ class GolfGame {
}
firePairCelebration(playerId, pos1, pos2) {
this.playSound('pair');
const elements = this.getCardElements(playerId, pos1, pos2);
if (elements.length < 2) return;
@@ -2796,16 +2872,7 @@ class GolfGame {
// Use unified swap animation
if (window.cardAnimations) {
// For opponent swaps, size the held card to match the opponent card
// rather than the deck size (default holding rect uses deck dimensions,
// which looks oversized next to small opponent cards on mobile)
const holdingRect = window.cardAnimations.getHoldingRect();
const heldRect = holdingRect ? {
left: holdingRect.left,
top: holdingRect.top,
width: sourceRect.width,
height: sourceRect.height
} : null;
const heldRect = window.cardAnimations.getHoldingRect();
window.cardAnimations.animateUnifiedSwap(
discardCard, // handCardData - card going to discard
@@ -2816,22 +2883,28 @@ class GolfGame {
rotation: sourceRotation,
wasHandFaceDown: !wasFaceUp,
onComplete: () => {
sourceCardEl.classList.remove('swap-out');
if (sourceCardEl) sourceCardEl.classList.remove('swap-out');
this.opponentSwapAnimation = null;
this.opponentDiscardAnimating = false;
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();
}
}
}
);
} else {
// Fallback
setTimeout(() => {
sourceCardEl.classList.remove('swap-out');
if (sourceCardEl) sourceCardEl.classList.remove('swap-out');
this.opponentSwapAnimation = null;
this.opponentDiscardAnimating = false;
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();
}
}, 500);
}
}
@@ -2853,6 +2926,11 @@ class GolfGame {
if (window.cardAnimations) {
window.cardAnimations.animateInitialFlip(cardEl, cardData, () => {
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 {
// Fallback if card animations not available
@@ -2957,7 +3035,7 @@ class GolfGame {
this.hideToast();
} else {
const remaining = requiredFlips - uniquePositions.length;
this.showToast(`Select ${remaining} more card${remaining > 1 ? 's' : ''} to flip`, '', 5000);
this.showToast(`Select ${remaining} more card${remaining > 1 ? 's' : ''} to flip`, 'your-turn', 5000);
}
return;
}
@@ -3055,6 +3133,14 @@ class GolfGame {
}
showLobby() {
if (window.cardAnimations) {
window.cardAnimations.cancelAll();
}
this.dealAnimationInProgress = false;
this.isDrawAnimating = false;
this.localDiscardAnimating = false;
this.opponentDiscardAnimating = false;
this.opponentSwapAnimation = false;
this.showScreen(this.lobbyScreen);
this.lobbyError.textContent = '';
this.roomCode = null;
@@ -3127,6 +3213,24 @@ class GolfGame {
`<span class="rule-tag rule-more" title="${tooltip}">+${moreCount} more</span>`;
}
this.activeRulesBar.classList.remove('hidden');
// Update mobile rules indicator
const mobileRulesBtn = document.getElementById('mobile-rules-btn');
const mobileRulesIcon = document.getElementById('mobile-rules-icon');
const mobileRulesContent = document.getElementById('mobile-rules-content');
if (mobileRulesBtn && mobileRulesIcon && mobileRulesContent) {
const isHouseRules = rules.length > 0;
mobileRulesIcon.textContent = isHouseRules ? '!' : 'RULES';
mobileRulesBtn.classList.toggle('house-rules', isHouseRules);
if (!isHouseRules) {
mobileRulesContent.innerHTML = '<div class="mobile-rules-content-list"><span class="rule-tag standard">Standard Rules</span></div>';
} else {
const tagHtml = (unrankedTag ? '<span class="rule-tag unranked">Unranked</span>' : '') +
rules.map(renderTag).join('');
mobileRulesContent.innerHTML = `<div class="mobile-rules-content-list">${tagHtml}</div>`;
}
}
}
// V3_14: Map display names to rule keys
@@ -3438,15 +3542,6 @@ class GolfGame {
// Toggle game area class for border pulse
this.gameScreen.classList.add('final-turn-active');
// Calculate remaining turns
const remaining = this.countRemainingTurns();
// Update badge content
const remainingEl = this.finalTurnBadge.querySelector('.final-turn-remaining');
if (remainingEl) {
remainingEl.textContent = remaining === 1 ? '1 turn left' : `${remaining} turns left`;
}
// Show badge
this.finalTurnBadge.classList.remove('hidden');
@@ -3542,7 +3637,9 @@ class GolfGame {
const cardHeight = deckRect.height;
// Position card centered, overlapping both piles (lower than before)
const overlapOffset = cardHeight * 0.35; // More overlap = lower position
// On mobile portrait, place held card fully above the deck/discard area
const isMobilePortrait = document.body.classList.contains('mobile-portrait');
const overlapOffset = cardHeight * (isMobilePortrait ? 0.48 : 0.35);
const cardLeft = centerX - cardWidth / 2;
const cardTop = deckRect.top - overlapOffset;
this.heldCardFloating.style.left = `${cardLeft}px`;
@@ -3554,11 +3651,21 @@ class GolfGame {
this.heldCardFloating.style.fontSize = `${cardWidth * 0.35}px`;
}
// Position discard button attached to right side of held card
const buttonLeft = cardLeft + cardWidth; // Right edge of card (no gap)
const buttonTop = cardTop + cardHeight * 0.3; // Vertically centered on card
// Position discard button
if (isMobilePortrait) {
// Below the held card, centered
const btnRect = this.discardBtn.getBoundingClientRect();
const buttonLeft = cardLeft + (cardWidth - (btnRect.width || 70)) / 2;
const buttonTop = cardTop + cardHeight + 4;
this.discardBtn.style.left = `${buttonLeft}px`;
this.discardBtn.style.top = `${buttonTop}px`;
} else {
// Right side of held card (desktop)
const buttonLeft = cardLeft + cardWidth;
const buttonTop = cardTop + cardHeight * 0.3;
this.discardBtn.style.left = `${buttonLeft}px`;
this.discardBtn.style.top = `${buttonTop}px`;
}
if (card.rank === '★') {
this.heldCardFloating.classList.add('joker');
@@ -3604,7 +3711,8 @@ class GolfGame {
const centerX = (deckRect.left + deckRect.right + discardRect.left + discardRect.right) / 4;
const cardWidth = deckRect.width;
const cardHeight = deckRect.height;
const overlapOffset = cardHeight * 0.35;
const isMobilePortrait = document.body.classList.contains('mobile-portrait');
const overlapOffset = cardHeight * (isMobilePortrait ? 0.48 : 0.35);
const cardLeft = centerX - cardWidth / 2;
const cardTop = deckRect.top - overlapOffset;
@@ -3776,14 +3884,19 @@ class GolfGame {
if (mobileTotal) mobileTotal.textContent = this.gameState.total_rounds;
// Show/hide final turn badge with enhanced urgency
// Note: markKnocker() is deferred until after opponent areas are rebuilt below
const isFinalTurn = this.gameState.phase === 'final_turn';
if (isFinalTurn) {
this.updateFinalTurnDisplay();
this.gameScreen.classList.add('final-turn-active');
this.finalTurnBadge.classList.remove('hidden');
if (!this.finalTurnAnnounced) {
this.playSound('alert');
this.finalTurnAnnounced = true;
}
} else {
this.finalTurnBadge.classList.add('hidden');
this.gameScreen.classList.remove('final-turn-active');
this.finalTurnAnnounced = false;
this.clearKnockerMark();
}
// Toggle not-my-turn class to disable hover effects when it's not player's turn
@@ -3805,7 +3918,7 @@ class GolfGame {
: this.gameState.current_player_id;
const displayedPlayer = this.gameState.players.find(p => p.id === displayedPlayerId);
if (displayedPlayer && displayedPlayerId !== this.playerId) {
this.setStatus(`${displayedPlayer.name}'s turn`);
this.setStatus(`${displayedPlayer.name}'s turn`, 'opponent-turn');
}
// Update player header (name + score like opponents)
@@ -4092,7 +4205,13 @@ class GolfGame {
cardEl.firstChild.addEventListener('click', () => this.handleCardClick(index));
// V3_13: Bind tooltip events for face-up cards
this.bindCardTooltipEvents(cardEl.firstChild, displayCard);
this.playerCards.appendChild(cardEl.firstChild);
const appendedCard = 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';
}
});
}
@@ -4149,6 +4268,13 @@ class GolfGame {
// Update scoreboard panel
this.updateScorePanel();
// Mark knocker AFTER opponent areas are rebuilt (otherwise innerHTML='' wipes it)
if (this.gameState.phase === 'final_turn') {
this.markKnocker(this.gameState.finisher_id);
} else {
this.clearKnockerMark();
}
// Initialize anime.js hover listeners on newly created cards
if (window.cardAnimations) {
window.cardAnimations.initHoverListeners(this.playerCards);

View File

@@ -46,7 +46,8 @@ class CardAnimations {
const centerX = (deckRect.left + deckRect.right + discardRect.left + discardRect.right) / 4;
const cardWidth = deckRect.width;
const cardHeight = deckRect.height;
const overlapOffset = cardHeight * 0.35;
const isMobilePortrait = document.body.classList.contains('mobile-portrait');
const overlapOffset = cardHeight * (isMobilePortrait ? 0.48 : 0.35);
return {
left: centerX - cardWidth / 2,
@@ -155,12 +156,20 @@ class CardAnimations {
}
this.activeAnimations.clear();
// Remove all animation card elements (including those marked as animating)
document.querySelectorAll('.draw-anim-card').forEach(el => {
// Remove all animation overlay elements
document.querySelectorAll('.draw-anim-card, .traveling-card, .deal-anim-container').forEach(el => {
delete el.dataset.animating;
el.remove();
});
// Restore visibility on any cards hidden during animations
document.querySelectorAll('.card[style*="opacity: 0"], .card[style*="opacity:0"]').forEach(el => {
el.style.opacity = '';
});
document.querySelectorAll('.card[style*="visibility: hidden"], .card[style*="visibility:hidden"]').forEach(el => {
el.style.visibility = '';
});
// Restore discard pile visibility if it was hidden during animation
const discardPile = document.getElementById('discard');
if (discardPile && discardPile.style.opacity === '0') {
@@ -211,6 +220,7 @@ class CardAnimations {
}
_animateDrawDeckCard(cardData, deckRect, holdingRect, onComplete) {
console.log('[DEBUG] _animateDrawDeckCard called with cardData:', cardData ? `${cardData.rank} of ${cardData.suit}` : 'NULL');
const deckColor = this.getDeckColor();
const animCard = this.createAnimCard(deckRect, true, deckColor);
animCard.dataset.animating = 'true'; // Mark as actively animating
@@ -219,6 +229,9 @@ class CardAnimations {
if (cardData) {
this.setCardContent(animCard, cardData);
// Debug: verify what was actually set on the front face
const front = animCard.querySelector('.draw-anim-front');
console.log('[DEBUG] Draw anim card front content:', front?.innerHTML);
}
this.playSound('draw-deck');
@@ -407,6 +420,7 @@ class CardAnimations {
}
// Animate initial flip at game start - smooth flip only, no lift
// Uses overlay sized to match the source card exactly
animateInitialFlip(cardElement, cardData, onComplete) {
if (!cardElement) {
if (onComplete) onComplete();
@@ -420,8 +434,16 @@ class CardAnimations {
const animCard = this.createAnimCard(rect, true, deckColor);
this.setCardContent(animCard, cardData);
// Hide original card during animation
cardElement.style.opacity = '0';
// Match the front face styling to player hand cards (not deck/discard cards)
const front = animCard.querySelector('.draw-anim-front');
if (front) {
front.style.background = 'linear-gradient(145deg, #fff 0%, #f5f5f5 100%)';
front.style.border = '2px solid #ddd';
front.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.3)';
}
// Hide original card during animation (overlay covers it)
cardElement.style.visibility = 'hidden';
const inner = animCard.querySelector('.draw-anim-inner');
const duration = window.TIMING?.card?.flip || 320;
@@ -436,7 +458,7 @@ class CardAnimations {
begin: () => this.playSound('flip'),
complete: () => {
animCard.remove();
cardElement.style.opacity = '1';
cardElement.style.visibility = '';
if (onComplete) onComplete();
}
});
@@ -445,7 +467,7 @@ class CardAnimations {
} catch (e) {
console.error('Initial flip animation error:', e);
animCard.remove();
cardElement.style.opacity = '1';
cardElement.style.visibility = '';
if (onComplete) onComplete();
}
}
@@ -750,28 +772,36 @@ class CardAnimations {
const id = 'turnPulse';
this.stopTurnPulse(element);
// Quick shake animation
// Quick shake animation - target cards only, not labels
const T = window.TIMING?.turnPulse || {};
const cards = element.querySelectorAll(':scope > .pile-wrapper > .card, :scope > .pile-wrapper > .discard-stack > #discard');
const doShake = () => {
if (!this.activeAnimations.has(id)) return;
anime({
targets: element,
translateX: [0, -8, 8, -6, 4, 0],
duration: 400,
targets: cards.length ? cards : element,
translateX: [0, -6, 6, -4, 3, 0],
duration: T.duration || 300,
easing: 'easeInOutQuad'
});
};
// Do initial shake, then repeat every 3 seconds
// Delay first shake, then repeat at interval
const timeout = setTimeout(() => {
if (!this.activeAnimations.has(id)) return;
doShake();
const interval = setInterval(doShake, 3000);
this.activeAnimations.set(id, { interval });
const interval = setInterval(doShake, T.interval || 3000);
const entry = this.activeAnimations.get(id);
if (entry) entry.interval = interval;
}, T.initialDelay || 5000);
this.activeAnimations.set(id, { timeout });
}
stopTurnPulse(element) {
const id = 'turnPulse';
const existing = this.activeAnimations.get(id);
if (existing) {
if (existing.timeout) clearTimeout(existing.timeout);
if (existing.interval) clearInterval(existing.interval);
if (existing.pause) existing.pause();
this.activeAnimations.delete(id);
@@ -1097,7 +1127,7 @@ class CardAnimations {
});
// Now run the swap animation
this._runUnifiedSwap(handCardData, heldCardData, handRect, heldRect, discardRect, T, rotation, wasHandFaceDown, onComplete);
}, 100);
}, 350);
return;
}
@@ -1515,6 +1545,7 @@ class CardAnimations {
// Create container for animation cards
const container = document.createElement('div');
container.className = 'deal-anim-container';
container.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;pointer-events:none;z-index:1000;';
document.body.appendChild(container);

View File

@@ -19,6 +19,8 @@
<h1><img src="golfball-logo.svg" alt="" class="golfball-logo"><span class="golfer-swing">🏌️</span><span class="kicked-ball"></span> <span class="golf-title">Golf</span></h1>
<p class="subtitle">6-Card Golf Card Game <button id="rules-btn" class="btn btn-small btn-rules">Rules</button> <button id="leaderboard-btn" class="btn btn-small leaderboard-btn">Leaderboard</button></p>
<div class="alpha-banner">Beta Testing - Bear with us while, stuff.</div>
<!-- Auth prompt for unauthenticated users -->
<div id="auth-prompt" class="auth-prompt">
<p>Log in or sign up to play.</p>
@@ -51,6 +53,8 @@
</div>
<p id="lobby-error" class="error"></p>
<footer class="app-footer">v3.1.5 &copy; Aaron D. Lee</footer>
</div>
<!-- Matchmaking Screen -->
@@ -78,16 +82,16 @@
<div class="waiting-layout">
<div class="waiting-left-col">
<div class="players-list">
<div class="players-list-header">
<h3>Players</h3>
<div id="cpu-controls-section" class="cpu-controls hidden">
<span class="cpu-controls-label">CPU:</span>
<button id="remove-cpu-btn" class="cpu-ctrl-btn btn-danger" title="Remove CPU"></button>
<button id="add-cpu-btn" class="cpu-ctrl-btn btn-success" title="Add CPU">+</button>
</div>
</div>
<ul id="players-list"></ul>
</div>
<div id="cpu-controls-section" class="cpu-controls-section hidden">
<h4>Add CPU Opponents</h4>
<div class="cpu-controls">
<button id="remove-cpu-btn" class="btn btn-small btn-danger" title="Remove last CPU"></button>
<button id="add-cpu-btn" class="btn btn-small btn-success" title="Add CPU player">+</button>
</div>
</div>
<button id="leave-room-btn" class="btn btn-danger">Leave Room</button>
</div>
@@ -282,6 +286,8 @@
<p id="waiting-message" class="info">Waiting for host to start the game...</p>
</div>
<footer class="app-footer">v3.1.5 &copy; Aaron D. Lee</footer>
</div>
<!-- Game Screen -->
@@ -303,7 +309,6 @@
<div id="final-turn-badge" class="final-turn-badge hidden">
<span class="final-turn-icon"></span>
<span class="final-turn-text">FINAL TURN</span>
<span class="final-turn-remaining"></span>
</div>
</div>
<div class="header-col header-col-right">
@@ -327,7 +332,12 @@
</div>
<span class="held-label">Holding</span>
</div>
<div class="pile-wrapper">
<span class="pile-label">DRAW</span>
<div id="deck" class="card card-back"></div>
</div>
<div class="pile-wrapper">
<span class="pile-label">DISCARD</span>
<div class="discard-stack">
<div id="discard" class="card">
<span id="discard-content"></span>
@@ -342,6 +352,7 @@
</div>
</div>
</div>
</div>
<div class="player-section">
<div class="player-area">
@@ -397,16 +408,23 @@
<tbody></tbody>
</table>
</div>
</div>
<!-- Mobile bottom bar (hidden on desktop) -->
<div id="mobile-bottom-bar">
<div class="mobile-round-info">Hole <span id="mobile-current-round">1</span>/<span id="mobile-total-rounds">9</span></div>
<button class="mobile-bar-btn" data-drawer="standings-panel">Standings</button>
<button class="mobile-bar-btn" data-drawer="scoreboard">Scores</button>
<button class="mobile-bar-btn mobile-rules-btn" id="mobile-rules-btn" data-drawer="rules-drawer"><span id="mobile-rules-icon">RULES</span></button>
<button class="mobile-bar-btn" data-drawer="standings-panel">Scorecard</button>
<button id="mobile-leave-btn" class="mobile-bar-btn mobile-leave-btn">End Game</button>
</div>
<!-- Mobile rules drawer -->
<div id="rules-drawer" class="side-panel rules-drawer-panel">
<h4>Active Rules</h4>
<div id="mobile-rules-content"></div>
</div>
<!-- Drawer backdrop for mobile -->
<div id="drawer-backdrop" class="drawer-backdrop"></div>
</div>
@@ -414,9 +432,8 @@
<!-- Rules Screen -->
<div id="rules-screen" class="screen">
<div class="rules-container">
<button id="rules-back-btn" class="btn rules-back-btn">« Back</button>
<div class="rules-header">
<button id="rules-back-btn" class="btn rules-back-btn">« Back</button>
<h1><span class="golfer-logo">🏌️</span> <span class="golf-title">Golf Rules</span></h1>
<p class="rules-subtitle">6-Card Golf Card Game - Complete Guide</p>
</div>
@@ -732,9 +749,8 @@ TOTAL: 0 + 8 + 16 = 24 points</pre>
<!-- Leaderboard Screen -->
<div id="leaderboard-screen" class="screen">
<div class="leaderboard-container">
<button id="leaderboard-back-btn" class="btn leaderboard-back-btn">&laquo; Back</button>
<div class="leaderboard-header">
<button id="leaderboard-back-btn" class="btn leaderboard-back-btn">&laquo; Back</button>
<h1>Leaderboard</h1>
<p class="leaderboard-subtitle">Top players ranked by performance</p>
</div>

View File

@@ -48,6 +48,15 @@ body {
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 {
font-size: 1.3em;
@@ -208,10 +217,11 @@ body {
background: rgba(0,0,0,0.2);
border-radius: 10px;
padding: 15px;
margin-bottom: 0;
}
.waiting-left-col .players-list h3 {
margin: 0 0 10px 0;
margin: 0;
font-size: 1rem;
}
@@ -330,29 +340,36 @@ body {
.deck-cyan { background: linear-gradient(135deg, #00bcd4 0%, #0097a7 100%); }
.deck-brown { background: linear-gradient(135deg, #8b4513 0%, #5d2f0d 100%); }
/* CPU Controls Section - below players list */
.cpu-controls-section {
background: rgba(0,0,0,0.2);
border-radius: 8px;
padding: 10px 12px;
}
.cpu-controls-section h4 {
margin: 0 0 6px 0;
font-size: 0.8rem;
color: #f4a460;
}
.cpu-controls-section .cpu-controls {
/* Players List Header with inline CPU controls */
.players-list-header {
display: flex;
gap: 6px;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.cpu-controls-section .cpu-controls .btn {
flex: 1;
padding: 6px 0;
font-size: 1rem;
.cpu-ctrl-btn {
width: 28px;
height: 28px;
border: none;
border-radius: 50%;
color: white;
font-size: 1.1rem;
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 {
@@ -363,12 +380,76 @@ body {
/* Mobile: stack vertically */
@media (max-width: 700px) {
#waiting-screen {
padding: 10px 15px;
}
.waiting-layout {
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 {
grid-template-columns: 1fr 1fr;
grid-template-columns: auto minmax(80px, auto) 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;
}
}
@@ -376,7 +457,7 @@ body {
.room-code-banner {
position: fixed;
top: 0;
left: 20px;
left: 7px;
z-index: 100;
background: linear-gradient(180deg, #d4845a 0%, #c4723f 50%, #b8663a 100%);
padding: 10px 14px 18px;
@@ -445,7 +526,21 @@ h1 {
.subtitle {
text-align: center;
opacity: 0.8;
margin-bottom: 40px;
margin-bottom: 20px;
}
.alpha-banner {
text-align: center;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.05em;
color: rgba(255, 200, 100, 0.9);
background: rgba(244, 164, 96, 0.1);
border: 1px solid rgba(244, 164, 96, 0.25);
border-radius: 20px;
padding: 5px 16px;
margin: 0 auto 30px;
max-width: 320px;
}
h2 {
@@ -654,7 +749,15 @@ input::placeholder {
.cpu-controls {
display: flex;
gap: 10px;
align-items: center;
gap: 4px;
}
.cpu-controls-label {
font-size: 0.8rem;
color: #f4a460;
font-weight: 600;
margin-right: 2px;
}
.checkbox-group {
@@ -722,8 +825,8 @@ input::placeholder {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
padding: 10px 20px;
background: rgba(0,0,0,0.35);
padding: 6px 12px;
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.25) 0%, transparent 100%);
font-size: 0.9rem;
width: 100vw;
margin-left: calc(-50vw + 50%);
@@ -1110,6 +1213,20 @@ input::placeholder {
align-items: flex-start;
}
.pile-wrapper {
display: flex;
flex-direction: column;
align-items: center;
}
.pile-label {
font-size: 0.55rem;
font-weight: 700;
letter-spacing: 0.1em;
color: rgba(255, 255, 255, 0.4);
margin-bottom: 3px;
}
/* Gentle pulse when it's your turn to draw - handled by anime.js */
/* The .your-turn-to-draw class triggers anime.js startTurnPulse() */
@@ -1637,10 +1754,16 @@ input::placeholder {
text-align: center;
white-space: nowrap;
color: #fff;
background: linear-gradient(135deg, #4a6741 0%, #3d5a35 100%);
}
/* Empty status - hide completely */
.status-message:empty {
display: none;
}
.status-message.your-turn {
background: linear-gradient(135deg, #b5d484 0%, #9ab973 100%);
background: linear-gradient(135deg, #c8e6a0 0%, #8fbf5a 100%);
color: #2d3436;
}
@@ -1656,6 +1779,19 @@ input::placeholder {
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 {
display: flex;
@@ -1663,8 +1799,8 @@ input::placeholder {
gap: 6px;
background: linear-gradient(135deg, #ff6b35 0%, #d63031 100%);
color: #fff;
padding: 6px 14px;
border-radius: 8px;
padding: 6px 16px;
border-radius: 4px;
font-weight: 700;
letter-spacing: 0.05em;
white-space: nowrap;
@@ -1673,7 +1809,7 @@ input::placeholder {
}
.final-turn-badge .final-turn-text {
font-size: 0.85rem;
font-size: 0.9rem;
}
.final-turn-badge.hidden {
@@ -1716,7 +1852,7 @@ input::placeholder {
flex-direction: column;
align-items: center;
position: relative;
padding: 10px;
padding: 0;
}
.game-layout {
@@ -2784,26 +2920,63 @@ input::placeholder {
/* Mobile adjustments for final results modal */
@media (max-width: 500px) {
.final-results-content {
padding: 20px 25px;
padding: 15px 12px;
width: 95%;
}
.final-results-content h2 {
font-size: 1.5rem;
font-size: 1.3rem;
margin-bottom: 10px;
}
.double-victory-banner {
padding: 8px 12px;
font-size: 0.95rem;
margin-bottom: 12px;
}
.final-rankings {
flex-direction: column;
gap: 15px;
flex-direction: row;
gap: 8px;
margin-bottom: 15px;
}
.final-ranking-section {
padding: 8px;
}
.final-ranking-section h3 {
font-size: 0.7rem;
margin-bottom: 6px;
padding-bottom: 4px;
}
.final-rank-row {
font-size: 0.8rem;
padding: 4px 5px;
margin-bottom: 2px;
}
.final-rank-row .rank-pos {
width: 22px;
font-size: 0.9rem;
padding: 6px 8px;
}
.final-rank-row .rank-val {
font-size: 0.8rem;
}
.final-actions {
flex-direction: row;
gap: 8px;
padding-bottom: 60px;
}
.final-actions .btn {
min-width: 120px;
padding: 12px 20px;
min-width: 0;
flex: 1;
padding: 10px 12px;
font-size: 0.9rem;
}
}
@@ -2840,12 +3013,15 @@ input::placeholder {
/* Rules back button */
.rules-back-btn {
position: absolute;
left: 0;
top: 0;
width: auto;
padding: 4px 12px;
font-size: 0.8rem;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.3);
color: rgba(255, 255, 255, 0.7);
margin-bottom: 15px;
}
.rules-back-btn:hover {
@@ -2861,6 +3037,7 @@ input::placeholder {
/* Rules header */
.rules-header {
position: relative;
text-align: center;
margin-bottom: 25px;
padding-bottom: 20px;
@@ -3260,7 +3437,7 @@ input::placeholder {
.auth-bar {
position: fixed;
top: 10px;
right: 15px;
right: 7px;
display: flex;
align-items: center;
gap: 10px;
@@ -3275,7 +3452,11 @@ input::placeholder {
display: none;
}
/* Hide global auth-bar when game screen is active */
/* Hide global auth-bar and remove top padding when game screen is active */
#app:has(#game-screen.active) {
padding-top: 0;
}
#app:has(#game-screen.active) > .auth-bar {
display: none !important;
}
@@ -3497,11 +3678,15 @@ input::placeholder {
border-radius: 12px;
padding: 20px 25px;
border: 1px solid rgba(255, 255, 255, 0.1);
margin-top: 0;
}
.leaderboard-header {
position: relative;
text-align: center;
margin-bottom: 20px;
padding-bottom: 20px;
border-bottom: 2px solid rgba(244, 164, 96, 0.3);
}
.leaderboard-header h1 {
@@ -3518,12 +3703,15 @@ input::placeholder {
/* Leaderboard back button */
.leaderboard-back-btn {
position: absolute;
left: 0;
top: 0;
width: auto;
padding: 4px 12px;
font-size: 0.8rem;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.3);
color: rgba(255, 255, 255, 0.7);
margin-bottom: 15px;
}
.leaderboard-back-btn:hover {
@@ -4292,6 +4480,15 @@ input::placeholder {
text-shadow: 0 1px 1px rgba(255, 255, 255, 0.3);
}
.player-area .dealer-chip {
width: 38px;
height: 38px;
font-size: 18px;
border-width: 2px;
bottom: -22px;
left: -22px;
}
/* --- V3_03: Round End Reveal --- */
.reveal-prompt {
position: fixed;
@@ -4373,6 +4570,13 @@ input::placeholder {
.opponent-area.is-knocker {
border: 2px solid #ff6b35;
border-radius: 8px;
box-shadow: 0 0 12px 3px rgba(255, 107, 53, 0.4);
animation: knocker-glow 2s ease-in-out infinite;
}
@keyframes knocker-glow {
0%, 100% { box-shadow: 0 0 12px 3px rgba(255, 107, 53, 0.4); }
50% { box-shadow: 0 0 20px 6px rgba(255, 107, 53, 0.6); }
}
.knocker-badge {
@@ -4699,10 +4903,10 @@ body.screen-shake {
.scoresheet-content {
background: linear-gradient(145deg, #1a472a 0%, #0d3320 100%);
border-radius: 16px;
padding: 24px 28px;
padding: 14px 18px;
max-width: 520px;
width: 92%;
max-height: 85vh;
max-height: 90vh;
overflow-y: auto;
box-shadow:
0 16px 50px rgba(0, 0, 0, 0.6),
@@ -4714,23 +4918,23 @@ body.screen-shake {
.ss-header {
text-align: center;
font-size: 1.1rem;
font-size: 1rem;
font-weight: 700;
color: #f4a460;
margin-bottom: 18px;
margin-bottom: 10px;
letter-spacing: 0.05em;
}
.ss-players {
display: flex;
flex-direction: column;
gap: 14px;
gap: 8px;
}
.ss-player-row {
background: rgba(0, 0, 0, 0.2);
border-radius: 10px;
padding: 12px 14px;
padding: 8px 10px;
border: 1px solid rgba(255, 255, 255, 0.06);
}
@@ -4738,7 +4942,7 @@ body.screen-shake {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
margin-bottom: 4px;
}
.ss-player-name {
@@ -4802,10 +5006,10 @@ body.screen-shake {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 28px;
width: 32px;
height: 24px;
border-radius: 3px;
font-size: 0.72rem;
font-size: 0.68rem;
font-weight: 700;
line-height: 1;
background: #f5f0e8;
@@ -4880,9 +5084,9 @@ body.screen-shake {
.ss-next-btn {
display: block;
width: 100%;
margin-top: 18px;
padding: 10px;
font-size: 0.95rem;
margin-top: 10px;
padding: 8px;
font-size: 0.9rem;
}
/* --- V3_11: Swap Animation --- */
@@ -4906,14 +5110,21 @@ body.screen-shake {
}
body.mobile-portrait {
height: var(--app-height, 100vh);
overflow: hidden;
overscroll-behavior: contain;
touch-action: manipulation;
}
body.mobile-portrait #app {
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);
overflow: hidden;
}
@@ -4926,6 +5137,7 @@ body.mobile-portrait #game-screen.active {
overflow: hidden;
margin-left: 0;
width: 100%;
padding: 0;
display: flex;
flex-direction: column;
}
@@ -4952,14 +5164,15 @@ body.mobile-portrait .game-header {
display: flex;
flex-direction: row;
align-items: center;
padding: 4px 8px;
padding-top: calc(4px + env(safe-area-inset-top, 0px));
padding: 6px 8px;
padding-top: calc(6px + env(safe-area-inset-top, 0px));
font-size: 0.75rem;
min-height: 32px;
min-height: 0;
width: 100%;
margin-left: 0;
gap: 4px;
margin-bottom: 4px;
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.25) 0%, transparent 100%);
}
body.mobile-portrait .header-col-left {
@@ -4986,6 +5199,16 @@ body.mobile-portrait .game-header #leave-game-btn {
display: none !important;
}
body.mobile-portrait .rules-container,
body.mobile-portrait .leaderboard-container,
body.mobile-portrait #matchmaking-screen {
margin-top: 50px;
}
body.mobile-portrait .header-col-center {
justify-content: flex-start;
}
body.mobile-portrait .status-message {
font-size: 1.02rem;
white-space: nowrap;
@@ -5005,16 +5228,19 @@ body.mobile-portrait #leave-game-btn {
}
body.mobile-portrait .mute-btn {
font-size: 0.85rem;
font-size: 0.95rem;
padding: 2px;
}
body.mobile-portrait .final-turn-badge {
font-size: 0.6rem;
padding: 2px 6px;
padding: 6px 16px;
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 --- */
body.mobile-portrait .game-table {
display: flex;
@@ -5023,8 +5249,9 @@ body.mobile-portrait .game-table {
justify-content: flex-start;
gap: 0 !important;
flex: 1 1 0%;
overflow: hidden;
padding: 0 4px;
overflow-x: clip;
overflow-y: hidden;
padding: 0 10px;
min-height: 0;
max-height: 100%;
}
@@ -5035,10 +5262,10 @@ body.mobile-portrait .opponents-row {
flex-wrap: wrap;
justify-content: center;
align-items: flex-start;
gap: 4px 10px;
gap: 9px 10px;
min-height: 0 !important;
padding: 2px 4px 6px;
overflow: hidden;
padding: 2px 4px 12px;
overflow: visible;
flex-shrink: 0;
}
@@ -5047,7 +5274,7 @@ body.mobile-portrait .player-row {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
justify-content: space-evenly;
gap: 10px;
width: 100%;
flex: 1 1 0%;
@@ -5103,6 +5330,11 @@ body.mobile-portrait .opponent-area .card {
border-radius: 3px;
}
body.mobile-portrait .opponent-area .knocker-badge {
top: auto;
bottom: -10px;
}
body.mobile-portrait .opponent-showing {
font-size: 0.85rem;
padding: 0px 3px;
@@ -5111,9 +5343,8 @@ body.mobile-portrait .opponent-showing {
/* --- Mobile: Deck/Discard area centered --- */
body.mobile-portrait .table-center {
padding: 5px 10px;
padding: 20px 10px 5px;
border-radius: 8px;
margin: auto 0;
}
body.mobile-portrait .deck-area {
@@ -5121,7 +5352,7 @@ body.mobile-portrait .deck-area {
align-items: flex-start;
}
body.mobile-portrait .deck-area > .card,
body.mobile-portrait .deck-area .card,
body.mobile-portrait #deck,
body.mobile-portrait #discard {
width: 64px !important;
@@ -5144,7 +5375,8 @@ body.mobile-portrait #discard-btn {
position: fixed;
writing-mode: horizontal-tb;
text-orientation: initial;
padding: 8px 16px;
width: auto;
padding: 6px 18px;
font-size: 0.8rem;
border-radius: 8px;
}
@@ -5168,8 +5400,8 @@ body.mobile-portrait .player-area .dealer-chip {
height: 22px;
font-size: 11px;
border-width: 2px;
bottom: auto;
top: -8px;
top: auto;
bottom: -8px;
left: -8px;
}
@@ -5291,8 +5523,9 @@ body.mobile-portrait .game-buttons {
/* --- Mobile: Bottom bar --- */
body.mobile-portrait #mobile-bottom-bar {
display: flex;
justify-content: center;
justify-content: space-between;
align-items: center;
align-self: stretch;
gap: 8px;
background: none;
flex-shrink: 0;
@@ -5301,7 +5534,7 @@ body.mobile-portrait #mobile-bottom-bar {
z-index: 900;
}
/* Hole indicator — pinned left with pill background */
/* Hole indicator — pinned left */
body.mobile-portrait #mobile-bottom-bar .mobile-round-info {
margin-right: auto;
color: rgba(255, 255, 255, 0.9);
@@ -5354,6 +5587,59 @@ body.mobile-portrait #mobile-bottom-bar .mobile-bar-btn.active {
box-shadow: 0 2px 12px rgba(244, 164, 96, 0.4);
}
/* --- Mobile: Rules indicator button --- */
body.mobile-portrait #mobile-bottom-bar .mobile-rules-btn {
padding: 4px 9px;
font-size: 0.77rem;
font-weight: 700;
min-width: unset;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.15);
color: rgba(255, 255, 255, 0.7);
}
body.mobile-portrait #mobile-bottom-bar .mobile-rules-btn.house-rules {
background: rgba(244, 164, 96, 0.25);
border-color: rgba(244, 164, 96, 0.4);
color: #f4a460;
}
/* --- Mobile: Rules drawer content --- */
#rules-drawer {
display: none;
}
body.mobile-portrait #rules-drawer {
display: block;
}
body.mobile-portrait .rules-drawer-panel .mobile-rules-content-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
body.mobile-portrait .rules-drawer-panel .rule-tag {
background: rgba(244, 164, 96, 0.3);
color: #f4a460;
padding: 4px 10px;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 600;
}
body.mobile-portrait .rules-drawer-panel .rule-tag.standard {
background: rgba(255, 255, 255, 0.15);
color: rgba(255, 255, 255, 0.7);
}
body.mobile-portrait .rules-drawer-panel .rule-tag.unranked {
background: rgba(220, 80, 80, 0.3);
color: #f08080;
border: 1px solid rgba(220, 80, 80, 0.4);
}
/* --- Mobile: Non-game screens --- */
body.mobile-portrait #lobby-screen {
padding: 55px 12px 15px;
@@ -5362,11 +5648,73 @@ body.mobile-portrait #lobby-screen {
}
body.mobile-portrait #waiting-screen {
padding: 10px 12px;
padding: 10px 15px;
overflow-y: auto;
max-height: 100dvh;
}
/* --- Mobile: Compact scoresheet modal --- */
body.mobile-portrait .scoresheet-content {
padding: 14px 16px;
max-height: 90vh;
max-height: var(--app-height, 90vh);
}
body.mobile-portrait .ss-header {
font-size: 0.95rem;
margin-bottom: 10px;
}
body.mobile-portrait .ss-players {
gap: 8px;
}
body.mobile-portrait .ss-player-row {
padding: 8px 10px;
}
body.mobile-portrait .ss-player-header {
margin-bottom: 4px;
}
body.mobile-portrait .ss-player-name {
font-size: 0.8rem;
}
body.mobile-portrait .ss-mini-card {
width: 30px;
height: 22px;
font-size: 0.6rem;
}
body.mobile-portrait .ss-columns {
gap: 6px;
margin-bottom: 4px;
}
body.mobile-portrait .ss-column {
gap: 2px;
padding: 3px 4px;
}
body.mobile-portrait .ss-col-score {
font-size: 0.6rem;
}
body.mobile-portrait .ss-scores {
font-size: 0.7rem;
gap: 12px;
margin-top: 2px;
}
body.mobile-portrait .ss-next-btn {
margin-top: 10px;
padding: 8px;
font-size: 0.85rem;
}
/* --- Mobile: Very short screens (e.g. iPhone SE) --- */
@media (max-height: 600px) {
body.mobile-portrait .opponents-row {
@@ -5389,7 +5737,7 @@ body.mobile-portrait #waiting-screen {
padding: 3px 8px;
}
body.mobile-portrait .deck-area > .card,
body.mobile-portrait .deck-area .card,
body.mobile-portrait #deck,
body.mobile-portrait #discard {
width: 60px !important;

View File

@@ -77,6 +77,7 @@ const TIMING = {
// V3_03: Round end reveal timing
reveal: {
lastPlayPause: 2000, // Pause after last play animation before reveals
voluntaryWindow: 2000, // Time for players to flip their own cards
initialPause: 250, // Pause before auto-reveals start
cardStagger: 50, // Between cards in same hand
@@ -128,6 +129,13 @@ const TIMING = {
pulseDelay: 200, // Delay before card appears (pulse visible first)
},
// Turn pulse (deck shake)
turnPulse: {
initialDelay: 5000, // Delay before first shake
interval: 5400, // Time between shakes
duration: 300, // Shake animation duration
},
// V3_17: Knock notification
knock: {
statusDuration: 2500, // How long the knock status message persists

View File

@@ -28,8 +28,14 @@ services:
- RESEND_API_KEY=${RESEND_API_KEY:-}
- EMAIL_FROM=${EMAIL_FROM:-Golf Cards <noreply@contact.golfcards.club>}
- SENTRY_DSN=${SENTRY_DSN:-}
- ENVIRONMENT=production
- LOG_LEVEL=INFO
- ENVIRONMENT=${ENVIRONMENT:-production}
- 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://golf.example.com}
- RATE_LIMIT_ENABLED=true
- INVITE_ONLY=true
@@ -61,6 +67,15 @@ services:
- "traefik.http.routers.golf.entrypoints=websecure"
- "traefik.http.routers.golf.tls=true"
- "traefik.http.routers.golf.tls.certresolver=letsencrypt"
# www -> bare domain redirect
- "traefik.http.routers.golf-www.rule=Host(`www.${DOMAIN:-golf.example.com}`)"
- "traefik.http.routers.golf-www.entrypoints=websecure"
- "traefik.http.routers.golf-www.tls=true"
- "traefik.http.routers.golf-www.tls.certresolver=letsencrypt"
- "traefik.http.routers.golf-www.middlewares=www-redirect"
- "traefik.http.middlewares.www-redirect.redirectregex.regex=^https://www\\.(.+)"
- "traefik.http.middlewares.www-redirect.redirectregex.replacement=https://$${1}"
- "traefik.http.middlewares.www-redirect.redirectregex.permanent=true"
- "traefik.http.services.golf.loadbalancer.server.port=8000"
# WebSocket sticky sessions
- "traefik.http.services.golf.loadbalancer.sticky.cookie=true"

146
docker-compose.staging.yml Normal file
View File

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

@@ -0,0 +1,57 @@
# V3.18: PostgreSQL Game Data Storage Efficiency
**Status:** Planning
**Priority:** Medium
**Category:** Infrastructure / Performance
## Problem
Per-move game logging stores full `hand_state` and `visible_opponents` JSONB on every move. For a typical 6-player, 9-hole game this generates significant redundant data since most of each player's hand doesn't change between moves.
## Areas to Investigate
### 1. Delta Encoding for Move Data
Store only what changed from the previous move instead of full state snapshots.
- First move of each round stores full state (baseline)
- Subsequent moves store only changed positions (e.g., `{"player_0": {"pos_2": "5H"}}`)
- Replay reconstruction applies deltas sequentially
- Trade-off: simpler queries vs. storage savings
### 2. PostgreSQL TOAST and Compression
- TOAST already compresses large JSONB values automatically
- Measure actual on-disk size vs. logical size for typical game data
- Consider whether explicit compression (e.g., storing gzipped blobs) adds meaningful savings over TOAST
### 3. Retention Policy
- Archive completed games older than N days to a separate table or cold storage
- Configurable retention period via env var (e.g., `GAME_LOG_RETENTION_DAYS`)
- Keep aggregate stats even after pruning raw move data
### 4. Move Logging Toggle
- Env var `GAME_LOGGING_ENABLED=true|false` to disable move-level logging entirely
- Useful for non-analysis environments (dev, load testing)
- Game outcomes and stats would still be recorded
### 5. Batch Inserts
- Buffer moves in memory and flush periodically instead of per-move INSERT
- Reduces database round-trips during active games
- Risk: data loss if server crashes mid-game (acceptable for non-critical move logs)
## Measurements Needed
Before optimizing, measure current impact:
- Average JSONB size per move (bytes)
- Average moves per game
- Total storage per game (moves + overhead)
- Query patterns: how often is per-move data actually read?
## Dependencies
- None (independent infrastructure improvement)

23
scripts/deploy-staging.sh Executable file
View File

@@ -0,0 +1,23 @@
#!/bin/bash
set -e
DROPLET="root@129.212.150.189"
REMOTE_DIR="/opt/golfgame"
echo "Syncing to staging ($DROPLET)..."
rsync -az --delete \
--exclude='.git' \
--exclude='__pycache__' \
--exclude='node_modules' \
--exclude='.env' \
--exclude='internal/' \
server/ "$DROPLET:$REMOTE_DIR/server/"
rsync -az --delete \
--exclude='.git' \
--exclude='__pycache__' \
--exclude='node_modules' \
client/ "$DROPLET:$REMOTE_DIR/client/"
echo "Rebuilding app container..."
ssh $DROPLET "cd $REMOTE_DIR && docker compose -f docker-compose.staging.yml up -d --build app"
echo "Staging deploy complete."

View File

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

View File

@@ -43,8 +43,9 @@ CPU_TIMING = {
# Delay before CPU "looks at" the discard pile
"initial_look": (0.3, 0.5),
# Brief pause after draw broadcast - let draw animation complete
# Must be >= client draw animation duration (~1s for deck, ~0.4s for discard)
"post_draw_settle": 1.1,
# Must be >= client draw animation duration (~1.09s for deck, ~0.4s for discard)
# Extra margin prevents swap message from arriving before draw flip completes
"post_draw_settle": 1.3,
# Consideration time after drawing (before swap/discard decision)
"post_draw_consider": (0.2, 0.4),
# Variance multiplier range for chaotic personality players
@@ -1301,12 +1302,28 @@ class GolfAI:
max_acceptable_go_out = 14 + int(profile.aggression * 4)
# Check opponent scores - don't go out if we'd lose badly
opponent_min = estimate_opponent_min_score(player, game, optimistic=False)
# Aggressive players tolerate a bigger gap; conservative ones less
opponent_margin = 4 + int(profile.aggression * 4) # 4-8 points
opponent_cap = opponent_min + opponent_margin
# Use the more restrictive of the two thresholds
effective_max = min(max_acceptable_go_out, opponent_cap)
ai_log(f" Go-out safety check: visible_base={visible_score}, "
f"score_if_swap={score_if_swap}, score_if_flip={score_if_flip}, "
f"max_acceptable={max_acceptable_go_out}")
f"max_acceptable={max_acceptable_go_out}, opponent_min={opponent_min}, "
f"opponent_cap={opponent_cap}, effective_max={effective_max}")
# High-card safety: don't swap 8+ into hidden position unless it makes a pair
creates_pair = (last_partner.face_up and last_partner.rank == drawn_card.rank)
if drawn_value >= HIGH_CARD_THRESHOLD and not creates_pair:
ai_log(f" >> GO-OUT: high card ({drawn_value}) into hidden, preferring flip")
return None # Fall through to normal scoring (will flip)
# If BOTH options are bad, choose the better one
if score_if_swap > max_acceptable_go_out and score_if_flip > max_acceptable_go_out:
if score_if_swap > effective_max and score_if_flip > effective_max:
if score_if_swap <= score_if_flip:
ai_log(f" >> SAFETY: both options bad, but swap ({score_if_swap}) "
f"<= flip ({score_if_flip}), forcing swap")
@@ -1322,7 +1339,7 @@ class GolfAI:
return None
# If swap is good, prefer it (known outcome vs unknown flip)
elif score_if_swap <= max_acceptable_go_out and score_if_swap <= score_if_flip:
elif score_if_swap <= effective_max and score_if_swap <= score_if_flip:
ai_log(f" >> SAFETY: swap gives acceptable score {score_if_swap}")
return last_pos
@@ -1739,9 +1756,23 @@ class GolfAI:
expected_hidden_total = len(face_down) * EXPECTED_HIDDEN_VALUE
projected_score = visible_score + expected_hidden_total
# Hard cap: never knock with projected score > 10
if projected_score > 10:
ai_log(f" Knock rejected: projected score {projected_score:.1f} > 10 hard cap")
return False
# Tighter threshold: range 5 to 9 based on aggression
max_acceptable = 5 + int(profile.aggression * 4)
# Check opponent threat - don't knock if an opponent likely beats us
opponent_min = estimate_opponent_min_score(player, game, optimistic=False)
if opponent_min < projected_score:
# Opponent is likely beating us - penalize threshold
threat_margin = projected_score - opponent_min
max_acceptable -= int(threat_margin * 0.75)
ai_log(f" Knock threat penalty: opponent est {opponent_min}, "
f"margin {threat_margin:.1f}, threshold now {max_acceptable}")
# Exception: if all opponents are showing terrible scores, relax threshold
all_opponents_bad = all(
sum(get_ai_card_value(c, game.options) for c in p.cards if c.face_up) >= 25
@@ -1752,12 +1783,14 @@ class GolfAI:
if projected_score <= max_acceptable:
# Scale knock chance by how good the projected score is
if projected_score <= 5:
knock_chance = profile.aggression * 0.3 # Max 30%
elif projected_score <= 7:
if projected_score <= 4:
knock_chance = profile.aggression * 0.35 # Max 35%
elif projected_score <= 6:
knock_chance = profile.aggression * 0.15 # Max 15%
else:
knock_chance = profile.aggression * 0.05 # Max 5% (very rare)
elif projected_score <= 8:
knock_chance = profile.aggression * 0.06 # Max 6%
else: # 9-10
knock_chance = profile.aggression * 0.02 # Max 2% (very rare)
if random.random() < knock_chance:
ai_log(f" Knock early: taking the gamble! (projected {projected_score:.1f})")
@@ -1934,7 +1967,11 @@ def _log_cpu_action(logger, game_id: Optional[str], cpu_player: Player, game: Ga
async def process_cpu_turn(
game: Game, cpu_player: Player, broadcast_callback, game_id: Optional[str] = None
) -> None:
"""Process a complete turn for a CPU player."""
"""Process a complete turn for a CPU player.
May raise asyncio.CancelledError if the game is ended mid-turn.
The caller (check_and_run_cpu_turn) handles cancellation.
"""
import asyncio
from services.game_logger import get_logger
@@ -1962,10 +1999,8 @@ async def process_cpu_turn(
await asyncio.sleep(thinking_time)
ai_log(f"{cpu_player.name} done thinking, making decision")
# Check if we should try to go out early
GolfAI.should_go_out_early(cpu_player, game, profile)
# Check if we should knock early (flip all remaining cards at once)
# (Opponent threat logic consolidated into should_knock_early)
if GolfAI.should_knock_early(game, cpu_player, profile):
if game.knock_early(cpu_player.id):
_log_cpu_action(logger, game_id, cpu_player, game,

View File

@@ -229,7 +229,7 @@ async def handle_start_game(data: dict, ctx: ConnectionContext, *, broadcast_gam
"game_state": game_state,
})
await check_and_run_cpu_turn(ctx.current_room)
check_and_run_cpu_turn(ctx.current_room)
async def handle_flip_initial(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
@@ -240,7 +240,7 @@ async def handle_flip_initial(data: dict, ctx: ConnectionContext, *, broadcast_g
async with ctx.current_room.game_lock:
if ctx.current_room.game.flip_initial_cards(ctx.player_id, positions):
await broadcast_game_state(ctx.current_room)
await check_and_run_cpu_turn(ctx.current_room)
check_and_run_cpu_turn(ctx.current_room)
# ---------------------------------------------------------------------------
@@ -297,7 +297,7 @@ async def handle_swap(data: dict, ctx: ConnectionContext, *, broadcast_game_stat
await broadcast_game_state(ctx.current_room)
await asyncio.sleep(1.0)
await check_and_run_cpu_turn(ctx.current_room)
check_and_run_cpu_turn(ctx.current_room)
async def handle_discard(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
@@ -329,12 +329,12 @@ async def handle_discard(data: dict, ctx: ConnectionContext, *, broadcast_game_s
})
else:
await asyncio.sleep(0.5)
await check_and_run_cpu_turn(ctx.current_room)
check_and_run_cpu_turn(ctx.current_room)
else:
logger.debug("Player discarded, waiting 0.5s before CPU turn")
await asyncio.sleep(0.5)
logger.debug("Post-discard delay complete, checking for CPU turn")
await check_and_run_cpu_turn(ctx.current_room)
check_and_run_cpu_turn(ctx.current_room)
async def handle_cancel_draw(data: dict, ctx: ConnectionContext, *, broadcast_game_state, **kw) -> None:
@@ -364,7 +364,7 @@ async def handle_flip_card(data: dict, ctx: ConnectionContext, *, broadcast_game
)
await broadcast_game_state(ctx.current_room)
await check_and_run_cpu_turn(ctx.current_room)
check_and_run_cpu_turn(ctx.current_room)
async def handle_skip_flip(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
@@ -380,7 +380,7 @@ async def handle_skip_flip(data: dict, ctx: ConnectionContext, *, broadcast_game
)
await broadcast_game_state(ctx.current_room)
await check_and_run_cpu_turn(ctx.current_room)
check_and_run_cpu_turn(ctx.current_room)
async def handle_flip_as_action(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
@@ -400,7 +400,7 @@ async def handle_flip_as_action(data: dict, ctx: ConnectionContext, *, broadcast
)
await broadcast_game_state(ctx.current_room)
await check_and_run_cpu_turn(ctx.current_room)
check_and_run_cpu_turn(ctx.current_room)
async def handle_knock_early(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
@@ -418,7 +418,7 @@ async def handle_knock_early(data: dict, ctx: ConnectionContext, *, broadcast_ga
)
await broadcast_game_state(ctx.current_room)
await check_and_run_cpu_turn(ctx.current_room)
check_and_run_cpu_turn(ctx.current_room)
async def handle_next_round(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
@@ -443,7 +443,7 @@ async def handle_next_round(data: dict, ctx: ConnectionContext, *, broadcast_gam
"game_state": game_state,
})
await check_and_run_cpu_turn(ctx.current_room)
check_and_run_cpu_turn(ctx.current_room)
else:
await broadcast_game_state(ctx.current_room)
@@ -473,6 +473,15 @@ async def handle_end_game(data: dict, ctx: ConnectionContext, *, room_manager, c
await ctx.websocket.send_json({"type": "error", "message": "Only the host can end the game"})
return
# Cancel any running CPU turn task so the game ends immediately
if ctx.current_room.cpu_turn_task:
ctx.current_room.cpu_turn_task.cancel()
try:
await ctx.current_room.cpu_turn_task
except (asyncio.CancelledError, Exception):
pass
ctx.current_room.cpu_turn_task = None
await ctx.current_room.broadcast({
"type": "game_ended",
"reason": "Host ended the game",

View File

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

View File

@@ -705,8 +705,40 @@ async def broadcast_game_state(room: Room):
})
async def check_and_run_cpu_turn(room: Room):
"""Check if current player is CPU and run their turn."""
def check_and_run_cpu_turn(room: Room):
"""Check if current player is CPU and start their turn as a background task.
The CPU turn chain runs as a fire-and-forget asyncio.Task stored on
room.cpu_turn_task. This allows the WebSocket message loop to remain
responsive so that end_game/leave messages can cancel the task immediately.
"""
if room.game.phase not in (GamePhase.PLAYING, GamePhase.FINAL_TURN):
return
current = room.game.current_player()
if not current:
return
room_player = room.get_player(current.id)
if not room_player or not room_player.is_cpu:
return
task = asyncio.create_task(_run_cpu_chain(room))
room.cpu_turn_task = task
def _on_done(t: asyncio.Task):
# Clear the reference when the task finishes (success, cancel, or error)
if room.cpu_turn_task is t:
room.cpu_turn_task = None
if not t.cancelled() and t.exception():
logger.error(f"CPU turn task error in room {room.code}: {t.exception()}")
task.add_done_callback(_on_done)
async def _run_cpu_chain(room: Room):
"""Run consecutive CPU turns until a human player's turn or game ends."""
while True:
if room.game.phase not in (GamePhase.PLAYING, GamePhase.FINAL_TURN):
return
@@ -727,12 +759,18 @@ async def check_and_run_cpu_turn(room: Room):
await process_cpu_turn(room.game, current, broadcast_cb, game_id=room.game_log_id)
# Check if next player is also CPU (chain CPU turns)
await check_and_run_cpu_turn(room)
async def handle_player_leave(room: Room, player_id: str):
"""Handle a player leaving a room."""
# Cancel any running CPU turn task before cleanup
if room.cpu_turn_task:
room.cpu_turn_task.cancel()
try:
await room.cpu_turn_task
except (asyncio.CancelledError, Exception):
pass
room.cpu_turn_task = None
room_code = room.code
room_player = room.remove_player(player_id)

View File

@@ -69,6 +69,7 @@ class Room:
settings: dict = field(default_factory=lambda: {"decks": 1, "rounds": 1})
game_log_id: Optional[str] = None
game_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
cpu_turn_task: Optional[asyncio.Task] = None
def add_player(
self,