118 Commits

Author SHA1 Message Date
adlee-was-taken
3ca52eb7d1 Bump version to 3.1.6, update docs
Update version across pyproject.toml, FastAPI app, HTML footers,
and V3.17 doc. Mark kicked-ball bug as resolved in docs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 01:59:54 -05:00
adlee-was-taken
3c63af91f2 Bump mobile logo-golfer gap from 12px to 15px
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 01:56:43 -05:00
adlee-was-taken
5fcf8bab60 Fix logo-golfer spacing: source order bug, tighten landscape, widen mobile
Base .golfer-container rule was after the mobile @media override,
clobbering it. Moved base rule before the media query. Landscape
gets -2px (snug), mobile gets 12px.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 01:55:28 -05:00
adlee-was-taken
8bc8595b39 Adjust logo-golfer spacing: tighter landscape, more room on mobile
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 01:54:08 -05:00
adlee-was-taken
7c58543ec8 Tighten landscape logo-golfer gap, alternate suit colors on ball logo
Reduce golfer-container margin from 10px to 4px in landscape (2-row)
mode while keeping 10px on mobile. Swap bottom suits to checkerboard
pattern: club/diamond on top, heart/spade on bottom.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 01:52:25 -05:00
adlee-was-taken
4b00094140 Add spacing between logo ball and golfer container
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 01:48:44 -05:00
adlee-was-taken
65d6598a51 Fix kicked ball launching from golfer's back foot at narrow viewports
Wrap golfer+ball in a positioned container so the ball is absolutely
anchored to the golfer's front foot, independent of inline flow/viewport.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 01:47:06 -05:00
adlee-was-taken
baa471307e Tune lobby header: 2x2 suit grid, mobile spacing, tighter row gap
- Rearrange golf ball SVG suits from single row to 2x2 grid
- Add even spacing between logo, golfer, and title in mobile view
- Remove row-gap between logo row and title row in landscape view

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 00:35:25 -05:00
adlee-was-taken
26778e4b02 Fix lobby header: use inline-grid for logo/title layout
The 749px media query was triggering at mid-range widths, collapsing
the two-row logo+title into a single line. Fix by:
- Using inline-grid on h1 for bulletproof two-row layout
- Lowering single-line breakpoint from 749px to 500px
- Widening lobby container to 550px for title to fit naturally
- Constraining game controls to 400px max-width

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 00:22:34 -05:00
adlee-was-taken
cce2d661a2 Fix logo-row centering at mid-range widths (750-1120px)
Change .logo-row from inline-block to block so the golf ball logo
always left-aligns flush with the G, regardless of viewport width.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 23:26:52 -05:00
adlee-was-taken
1b748470a0 Use width:fit-content on h1 for bulletproof logo-title alignment
The h1 shrinks to its widest child (GolfCards.club), centers via
margin auto, and text-align left aligns both lines within it.
No breakpoint-dependent transforms needed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 23:14:56 -05:00
adlee-was-taken
d32ae83ce2 Nudge mobile header line 18px left via text-indent
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 23:07:58 -05:00
adlee-was-taken
e542cadedf Bump mobile single-line breakpoint to 749px to cover all phones
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 23:06:39 -05:00
adlee-was-taken
cd2d7535e3 Replace translateX hack with text-align left on h1 for logo alignment
Logo and title naturally left-align within the centered lobby box.
Mobile (<480px) gets text-align center + inline display for single line.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 23:02:17 -05:00
adlee-was-taken
4dff1da875 Only trigger single-line mode at <=449px, shift everywhere else
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:59:51 -05:00
adlee-was-taken
8f21a40a6a Make logo-row inline on mobile for single-line header layout
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:58:28 -05:00
adlee-was-taken
0ae999aca6 Revert flex approach, use default translateX with max-width reset for mobile
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:57:49 -05:00
adlee-was-taken
a87cd7f4b0 Use inline-flex column on h1 to left-align logo row with title
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:56:38 -05:00
adlee-was-taken
eb072dbfb4 Set logo-row shift to 750px breakpoint (mobile ends ~750px)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:54:10 -05:00
adlee-was-taken
4c16147ace Set logo-row shift breakpoint to 900px to match actual layout break
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:51:40 -05:00
adlee-was-taken
cac1e26bac Split the difference: logo-row shift at 600px breakpoint
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:49:21 -05:00
adlee-was-taken
31dcb70fc8 Bump logo-row shift breakpoint to 768px so mobile stays centered
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:47:31 -05:00
adlee-was-taken
15339d390f Use min-width breakpoint for logo shift, tighten logo-title gap
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:45:38 -05:00
adlee-was-taken
c523b144f5 Tighten logo-golfer gap and shift row further left on landscape
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:40:52 -05:00
adlee-was-taken
0f3ae992f9 Wrap logo+golfer in .logo-row and translateX left on landscape
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:36:00 -05:00
adlee-was-taken
ce6b276c11 Increase logo left shift to -3.5rem to align with GolfCards text
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:33:16 -05:00
adlee-was-taken
231e666407 Fix logo shift direction: move left on landscape, not right
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:31:38 -05:00
adlee-was-taken
7842de3a96 Shift logo+golfer group right on landscape via margin-left
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:29:47 -05:00
adlee-was-taken
aab41c5413 Restore logo-golfer-ball order in header
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:23:49 -05:00
adlee-was-taken
625320992e Move golfer emoji left of logo, make .club inline
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:22:53 -05:00
adlee-was-taken
61713f28c8 Style GolfCards title with .club on second line
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:13:40 -05:00
adlee-was-taken
0eac6d443c Rename lobby title from Golf to GolfCards.Club
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:11:39 -05:00
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
21 changed files with 1312 additions and 243 deletions

View File

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

3
.gitignore vendored
View File

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

View File

@@ -379,7 +379,7 @@ class GolfGame {
// Only show tooltips on your turn // Only show tooltips on your turn
if (!this.isMyTurn() && !this.gameState?.waiting_for_initial_flip) return; if (!this.isMyTurn() && !this.gameState?.waiting_for_initial_flip) return;
const value = this.getCardPointValue(cardData); const value = this.getCardPointValueForTooltip(cardData);
const special = this.getCardSpecialNote(cardData); const special = this.getCardSpecialNote(cardData);
let content = `<span class="tooltip-value ${value < 0 ? 'negative' : ''}">${value} pts</span>`; let content = `<span class="tooltip-value ${value < 0 ? 'negative' : ''}">${value} pts</span>`;
@@ -409,14 +409,15 @@ class GolfGame {
if (this.tooltip) this.tooltip.classList.add('hidden'); if (this.tooltip) this.tooltip.classList.add('hidden');
} }
getCardPointValue(cardData) { getCardPointValueForTooltip(cardData) {
const values = this.gameState?.card_values || this.getDefaultCardValues(); 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) { getCardSpecialNote(cardData) {
const rank = cardData.rank; const rank = cardData.rank;
const value = this.getCardPointValue(cardData); const value = this.getCardPointValueForTooltip(cardData);
if (value < 0) return 'Negative - keep it!'; if (value < 0) return 'Negative - keep it!';
if (rank === 'K' && value === 0) return 'Safe card'; if (rank === 'K' && value === 0) return 'Safe card';
if (rank === 'K' && value === -2) return 'Super King!'; if (rank === 'K' && value === -2) return 'Super King!';
@@ -822,11 +823,29 @@ class GolfGame {
newState.phase === 'round_over'; newState.phase === 'round_over';
if (roundJustEnded && oldState) { if (roundJustEnded && oldState) {
// Save pre-reveal state for the reveal animation // Update state first so animations can read new card data
this.preRevealState = JSON.parse(JSON.stringify(oldState));
this.postRevealState = newState;
// Update state but DON'T render yet - reveal animation will handle it
this.gameState = newState; 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; break;
} }
@@ -923,16 +942,16 @@ class GolfGame {
this.displayHeldCard(data.card, true); this.displayHeldCard(data.card, true);
this.renderGame(); this.renderGame();
} }
this.showToast('Swap with a card or discard', '', 3000); this.showToast('Swap with a card or discard', 'your-turn', 3000);
break; break;
case 'can_flip': case 'can_flip':
this.waitingForFlip = true; this.waitingForFlip = true;
this.flipIsOptional = data.optional || false; this.flipIsOptional = data.optional || false;
if (this.flipIsOptional) { if (this.flipIsOptional) {
this.showToast('Flip a card or skip', '', 3000); this.showToast('Flip a card or skip', 'your-turn', 3000);
} else { } else {
this.showToast('Flip a face-down card', '', 3000); this.showToast('Flip a face-down card', 'your-turn', 3000);
} }
this.renderGame(); this.renderGame();
break; break;
@@ -950,7 +969,7 @@ class GolfGame {
// Host ended the game or player was kicked // Host ended the game or player was kicked
this._intentionalClose = true; this._intentionalClose = true;
if (this.ws) this.ws.close(); if (this.ws) this.ws.close();
this.showScreen('lobby'); this.showLobby();
if (data.reason) { if (data.reason) {
this.showError(data.reason); this.showError(data.reason);
} }
@@ -975,7 +994,7 @@ class GolfGame {
case 'queue_left': case 'queue_left':
this.stopMatchmakingTimer(); this.stopMatchmakingTimer();
this.showScreen('lobby'); this.showLobby();
break; break;
case 'error': case 'error':
@@ -995,7 +1014,7 @@ class GolfGame {
cancelMatchmaking() { cancelMatchmaking() {
this.send({ type: 'queue_leave' }); this.send({ type: 'queue_leave' });
this.stopMatchmakingTimer(); this.stopMatchmakingTimer();
this.showScreen('lobby'); this.showLobby();
} }
startMatchmakingTimer() { startMatchmakingTimer() {
@@ -1431,8 +1450,9 @@ class GolfGame {
this.swapAnimationCardEl = handCardEl; this.swapAnimationCardEl = handCardEl;
this.swapAnimationHandCardEl = handCardEl; this.swapAnimationHandCardEl = handCardEl;
// Hide originals during animation // Hide originals and UI during animation
handCardEl.classList.add('swap-out'); handCardEl.classList.add('swap-out');
this.discardBtn.classList.add('hidden');
if (this.heldCardFloating) { if (this.heldCardFloating) {
this.heldCardFloating.style.visibility = 'hidden'; this.heldCardFloating.style.visibility = 'hidden';
} }
@@ -1573,11 +1593,29 @@ class GolfGame {
this.heldCardFloating.classList.add('hidden'); this.heldCardFloating.classList.add('hidden');
if (this.pendingGameState) { if (this.pendingGameState) {
this.gameState = this.pendingGameState; const oldState = this.gameState;
const newState = this.pendingGameState;
this.pendingGameState = null; 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(); this.renderGame();
} }
} }
}
flipCard(position) { flipCard(position) {
this.send({ type: 'flip_card', position }); this.send({ type: 'flip_card', position });
@@ -1789,6 +1827,10 @@ class GolfGame {
document.body.appendChild(modal); document.body.appendChild(modal);
this.setStatus('Hole complete'); 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 // Bind next button
const nextBtn = document.getElementById('ss-next-btn'); const nextBtn = document.getElementById('ss-next-btn');
nextBtn.addEventListener('click', () => { nextBtn.addEventListener('click', () => {
@@ -1918,6 +1960,10 @@ class GolfGame {
this.clearScoresheetCountdown(); this.clearScoresheetCountdown();
const modal = document.getElementById('scoresheet-modal'); const modal = document.getElementById('scoresheet-modal');
if (modal) modal.remove(); if (modal) modal.remove();
// Restore bottom bar
const bottomBar = document.getElementById('mobile-bottom-bar');
if (bottomBar) bottomBar.classList.remove('hidden');
} }
// --- V3_02: Dealing Animation --- // --- V3_02: Dealing Animation ---
@@ -2053,6 +2099,16 @@ class GolfGame {
async runRoundEndReveal(scores, rankings) { async runRoundEndReveal(scores, rankings) {
const T = window.TIMING?.reveal || {}; const T = window.TIMING?.reveal || {};
// preRevealState may not be set yet if the game_state was deferred
// (e.g., local swap animation was in progress). Wait briefly for it.
if (!this.preRevealState) {
const waitStart = Date.now();
while (!this.preRevealState && Date.now() - waitStart < 3000) {
await this.delay(100);
}
}
const oldState = this.preRevealState; const oldState = this.preRevealState;
const newState = this.postRevealState || this.gameState; const newState = this.postRevealState || this.gameState;
@@ -2062,22 +2118,35 @@ class GolfGame {
return; return;
} }
// First, render the game with the OLD state (pre-reveal) so cards show face-down // Compute what needs revealing (before renderGame changes the DOM)
this.gameState = newState;
// But render with pre-reveal card visuals
this.revealAnimationInProgress = true;
// Render game to show current layout (opponents, etc)
this.renderGame();
// Compute what needs revealing
const revealsByPlayer = this.getCardsToReveal(oldState, newState); const revealsByPlayer = this.getCardsToReveal(oldState, newState);
// Get reveal order: knocker first, then clockwise // Get reveal order: knocker first, then clockwise
const knockerId = newState.finisher_id; const knockerId = newState.finisher_id;
const revealOrder = this.getRevealOrder(newState.players, knockerId); const revealOrder = this.getRevealOrder(newState.players, knockerId);
// 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'); this.setStatus('Revealing cards...', 'reveal');
await this.delay(T.initialPause || 500); await this.delay(T.initialPause || 500);
@@ -2404,8 +2473,13 @@ class GolfGame {
this.opponentDiscardAnimating = false; this.opponentDiscardAnimating = false;
// Set isDrawAnimating to block renderGame from updating discard pile // Set isDrawAnimating to block renderGame from updating discard pile
this.isDrawAnimating = true; this.isDrawAnimating = true;
// Force discard DOM to show the card being drawn before animation starts
// (previous animation may have blocked renderGame from updating it)
if (oldDiscard) {
this.updateDiscardPileDisplay(oldDiscard);
}
console.log('[DEBUG] Opponent draw from discard - setting isDrawAnimating=true'); console.log('[DEBUG] Opponent draw from discard - setting isDrawAnimating=true');
window.drawAnimations.animateDrawDiscard(drawnCard, () => { window.drawAnimations.animateDrawDiscard(oldDiscard || drawnCard, () => {
console.log('[DEBUG] Opponent draw from discard complete - clearing isDrawAnimating'); console.log('[DEBUG] Opponent draw from discard complete - clearing isDrawAnimating');
this.isDrawAnimating = false; this.isDrawAnimating = false;
onAnimComplete(); onAnimComplete();
@@ -2415,7 +2489,7 @@ class GolfGame {
this.opponentSwapAnimation = null; this.opponentSwapAnimation = null;
this.opponentDiscardAnimating = false; this.opponentDiscardAnimating = false;
this.isDrawAnimating = true; this.isDrawAnimating = true;
console.log('[DEBUG] Opponent draw from deck - setting isDrawAnimating=true'); 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, () => { window.drawAnimations.animateDrawDeck(drawnCard, () => {
console.log('[DEBUG] Opponent draw from deck complete - clearing isDrawAnimating'); console.log('[DEBUG] Opponent draw from deck complete - clearing isDrawAnimating');
this.isDrawAnimating = false; this.isDrawAnimating = false;
@@ -2496,6 +2570,7 @@ class GolfGame {
const cardsIdentical = wasOtherPlayer && JSON.stringify(oldPlayer.cards) === JSON.stringify(newPlayer.cards); const cardsIdentical = wasOtherPlayer && JSON.stringify(oldPlayer.cards) === JSON.stringify(newPlayer.cards);
if (swappedPosition >= 0 && wasOtherPlayer) { if (swappedPosition >= 0 && wasOtherPlayer) {
console.log('[DEBUG] Swap detected:', { playerId: previousPlayerId, position: swappedPosition, wasFaceUp, newDiscard: newDiscard?.rank });
// Opponent swapped - animate from the actual position that changed // Opponent swapped - animate from the actual position that changed
this.fireSwapAnimation(previousPlayerId, newDiscard, swappedPosition, wasFaceUp); this.fireSwapAnimation(previousPlayerId, newDiscard, swappedPosition, wasFaceUp);
// Show CPU swap announcement // Show CPU swap announcement
@@ -2599,6 +2674,7 @@ class GolfGame {
} }
firePairCelebration(playerId, pos1, pos2) { firePairCelebration(playerId, pos1, pos2) {
this.playSound('pair');
const elements = this.getCardElements(playerId, pos1, pos2); const elements = this.getCardElements(playerId, pos1, pos2);
if (elements.length < 2) return; if (elements.length < 2) return;
@@ -2796,16 +2872,7 @@ class GolfGame {
// Use unified swap animation // Use unified swap animation
if (window.cardAnimations) { if (window.cardAnimations) {
// For opponent swaps, size the held card to match the opponent card const heldRect = window.cardAnimations.getHoldingRect();
// 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;
window.cardAnimations.animateUnifiedSwap( window.cardAnimations.animateUnifiedSwap(
discardCard, // handCardData - card going to discard discardCard, // handCardData - card going to discard
@@ -2816,22 +2883,28 @@ class GolfGame {
rotation: sourceRotation, rotation: sourceRotation,
wasHandFaceDown: !wasFaceUp, wasHandFaceDown: !wasFaceUp,
onComplete: () => { onComplete: () => {
sourceCardEl.classList.remove('swap-out'); if (sourceCardEl) sourceCardEl.classList.remove('swap-out');
this.opponentSwapAnimation = null; this.opponentSwapAnimation = null;
this.opponentDiscardAnimating = false; this.opponentDiscardAnimating = false;
console.log('[DEBUG] Swap animation complete - clearing opponentSwapAnimation and opponentDiscardAnimating'); console.log('[DEBUG] Swap animation complete - clearing opponentSwapAnimation and opponentDiscardAnimating');
// Don't re-render during reveal animation - it handles its own rendering
if (!this.revealAnimationInProgress) {
this.renderGame(); this.renderGame();
} }
} }
}
); );
} else { } else {
// Fallback // Fallback
setTimeout(() => { setTimeout(() => {
sourceCardEl.classList.remove('swap-out'); if (sourceCardEl) sourceCardEl.classList.remove('swap-out');
this.opponentSwapAnimation = null; this.opponentSwapAnimation = null;
this.opponentDiscardAnimating = false; this.opponentDiscardAnimating = false;
console.log('[DEBUG] Swap animation fallback complete - clearing flags'); console.log('[DEBUG] Swap animation fallback complete - clearing flags');
// Don't re-render during reveal animation - it handles its own rendering
if (!this.revealAnimationInProgress) {
this.renderGame(); this.renderGame();
}
}, 500); }, 500);
} }
} }
@@ -2853,6 +2926,11 @@ class GolfGame {
if (window.cardAnimations) { if (window.cardAnimations) {
window.cardAnimations.animateInitialFlip(cardEl, cardData, () => { window.cardAnimations.animateInitialFlip(cardEl, cardData, () => {
this.animatingPositions.delete(key); this.animatingPositions.delete(key);
// Unhide the current card element (may have been rebuilt by renderGame)
const currentCards = this.playerCards.querySelectorAll('.card');
if (currentCards[position]) {
currentCards[position].style.visibility = '';
}
}); });
} else { } else {
// Fallback if card animations not available // Fallback if card animations not available
@@ -2957,7 +3035,7 @@ class GolfGame {
this.hideToast(); this.hideToast();
} else { } else {
const remaining = requiredFlips - uniquePositions.length; const remaining = requiredFlips - uniquePositions.length;
this.showToast(`Select ${remaining} more card${remaining > 1 ? 's' : ''} to flip`, '', 5000); this.showToast(`Select ${remaining} more card${remaining > 1 ? 's' : ''} to flip`, 'your-turn', 5000);
} }
return; return;
} }
@@ -3055,6 +3133,14 @@ class GolfGame {
} }
showLobby() { 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.showScreen(this.lobbyScreen);
this.lobbyError.textContent = ''; this.lobbyError.textContent = '';
this.roomCode = null; this.roomCode = null;
@@ -3127,6 +3213,24 @@ class GolfGame {
`<span class="rule-tag rule-more" title="${tooltip}">+${moreCount} more</span>`; `<span class="rule-tag rule-more" title="${tooltip}">+${moreCount} more</span>`;
} }
this.activeRulesBar.classList.remove('hidden'); 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 // V3_14: Map display names to rule keys
@@ -3438,15 +3542,6 @@ class GolfGame {
// Toggle game area class for border pulse // Toggle game area class for border pulse
this.gameScreen.classList.add('final-turn-active'); 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 // Show badge
this.finalTurnBadge.classList.remove('hidden'); this.finalTurnBadge.classList.remove('hidden');
@@ -3542,7 +3637,9 @@ class GolfGame {
const cardHeight = deckRect.height; const cardHeight = deckRect.height;
// Position card centered, overlapping both piles (lower than before) // 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 cardLeft = centerX - cardWidth / 2;
const cardTop = deckRect.top - overlapOffset; const cardTop = deckRect.top - overlapOffset;
this.heldCardFloating.style.left = `${cardLeft}px`; this.heldCardFloating.style.left = `${cardLeft}px`;
@@ -3554,11 +3651,21 @@ class GolfGame {
this.heldCardFloating.style.fontSize = `${cardWidth * 0.35}px`; this.heldCardFloating.style.fontSize = `${cardWidth * 0.35}px`;
} }
// Position discard button attached to right side of held card // Position discard button
const buttonLeft = cardLeft + cardWidth; // Right edge of card (no gap) if (isMobilePortrait) {
const buttonTop = cardTop + cardHeight * 0.3; // Vertically centered on card // 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.left = `${buttonLeft}px`;
this.discardBtn.style.top = `${buttonTop}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 === '★') { if (card.rank === '★') {
this.heldCardFloating.classList.add('joker'); this.heldCardFloating.classList.add('joker');
@@ -3604,7 +3711,8 @@ class GolfGame {
const centerX = (deckRect.left + deckRect.right + discardRect.left + discardRect.right) / 4; const centerX = (deckRect.left + deckRect.right + discardRect.left + discardRect.right) / 4;
const cardWidth = deckRect.width; const cardWidth = deckRect.width;
const cardHeight = deckRect.height; const cardHeight = deckRect.height;
const 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 cardLeft = centerX - cardWidth / 2;
const cardTop = deckRect.top - overlapOffset; const cardTop = deckRect.top - overlapOffset;
@@ -3776,14 +3884,19 @@ class GolfGame {
if (mobileTotal) mobileTotal.textContent = this.gameState.total_rounds; if (mobileTotal) mobileTotal.textContent = this.gameState.total_rounds;
// Show/hide final turn badge with enhanced urgency // 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'; const isFinalTurn = this.gameState.phase === 'final_turn';
if (isFinalTurn) { 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 { } else {
this.finalTurnBadge.classList.add('hidden'); this.finalTurnBadge.classList.add('hidden');
this.gameScreen.classList.remove('final-turn-active'); this.gameScreen.classList.remove('final-turn-active');
this.finalTurnAnnounced = false; this.finalTurnAnnounced = false;
this.clearKnockerMark();
} }
// Toggle not-my-turn class to disable hover effects when it's not player's turn // 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; : this.gameState.current_player_id;
const displayedPlayer = this.gameState.players.find(p => p.id === displayedPlayerId); const displayedPlayer = this.gameState.players.find(p => p.id === displayedPlayerId);
if (displayedPlayer && displayedPlayerId !== this.playerId) { 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) // Update player header (name + score like opponents)
@@ -4092,7 +4205,13 @@ class GolfGame {
cardEl.firstChild.addEventListener('click', () => this.handleCardClick(index)); cardEl.firstChild.addEventListener('click', () => this.handleCardClick(index));
// V3_13: Bind tooltip events for face-up cards // V3_13: Bind tooltip events for face-up cards
this.bindCardTooltipEvents(cardEl.firstChild, displayCard); this.bindCardTooltipEvents(cardEl.firstChild, displayCard);
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 // Update scoreboard panel
this.updateScorePanel(); 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 // Initialize anime.js hover listeners on newly created cards
if (window.cardAnimations) { if (window.cardAnimations) {
window.cardAnimations.initHoverListeners(this.playerCards); 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 centerX = (deckRect.left + deckRect.right + discardRect.left + discardRect.right) / 4;
const cardWidth = deckRect.width; const cardWidth = deckRect.width;
const cardHeight = deckRect.height; const cardHeight = deckRect.height;
const overlapOffset = cardHeight * 0.35; const isMobilePortrait = document.body.classList.contains('mobile-portrait');
const overlapOffset = cardHeight * (isMobilePortrait ? 0.48 : 0.35);
return { return {
left: centerX - cardWidth / 2, left: centerX - cardWidth / 2,
@@ -155,12 +156,20 @@ class CardAnimations {
} }
this.activeAnimations.clear(); this.activeAnimations.clear();
// Remove all animation card elements (including those marked as animating) // Remove all animation overlay elements
document.querySelectorAll('.draw-anim-card').forEach(el => { document.querySelectorAll('.draw-anim-card, .traveling-card, .deal-anim-container').forEach(el => {
delete el.dataset.animating; delete el.dataset.animating;
el.remove(); el.remove();
}); });
// Restore visibility on any cards hidden during animations
document.querySelectorAll('.card[style*="opacity: 0"], .card[style*="opacity:0"]').forEach(el => {
el.style.opacity = '';
});
document.querySelectorAll('.card[style*="visibility: hidden"], .card[style*="visibility:hidden"]').forEach(el => {
el.style.visibility = '';
});
// Restore discard pile visibility if it was hidden during animation // Restore discard pile visibility if it was hidden during animation
const discardPile = document.getElementById('discard'); const discardPile = document.getElementById('discard');
if (discardPile && discardPile.style.opacity === '0') { if (discardPile && discardPile.style.opacity === '0') {
@@ -211,6 +220,7 @@ class CardAnimations {
} }
_animateDrawDeckCard(cardData, deckRect, holdingRect, onComplete) { _animateDrawDeckCard(cardData, deckRect, holdingRect, onComplete) {
console.log('[DEBUG] _animateDrawDeckCard called with cardData:', cardData ? `${cardData.rank} of ${cardData.suit}` : 'NULL');
const deckColor = this.getDeckColor(); const deckColor = this.getDeckColor();
const animCard = this.createAnimCard(deckRect, true, deckColor); const animCard = this.createAnimCard(deckRect, true, deckColor);
animCard.dataset.animating = 'true'; // Mark as actively animating animCard.dataset.animating = 'true'; // Mark as actively animating
@@ -219,6 +229,9 @@ class CardAnimations {
if (cardData) { if (cardData) {
this.setCardContent(animCard, cardData); this.setCardContent(animCard, cardData);
// Debug: verify what was actually set on the front face
const front = animCard.querySelector('.draw-anim-front');
console.log('[DEBUG] Draw anim card front content:', front?.innerHTML);
} }
this.playSound('draw-deck'); this.playSound('draw-deck');
@@ -407,6 +420,7 @@ class CardAnimations {
} }
// Animate initial flip at game start - smooth flip only, no lift // Animate initial flip at game start - smooth flip only, no lift
// Uses overlay sized to match the source card exactly
animateInitialFlip(cardElement, cardData, onComplete) { animateInitialFlip(cardElement, cardData, onComplete) {
if (!cardElement) { if (!cardElement) {
if (onComplete) onComplete(); if (onComplete) onComplete();
@@ -420,8 +434,16 @@ class CardAnimations {
const animCard = this.createAnimCard(rect, true, deckColor); const animCard = this.createAnimCard(rect, true, deckColor);
this.setCardContent(animCard, cardData); this.setCardContent(animCard, cardData);
// Hide original card during animation // Match the front face styling to player hand cards (not deck/discard cards)
cardElement.style.opacity = '0'; 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 inner = animCard.querySelector('.draw-anim-inner');
const duration = window.TIMING?.card?.flip || 320; const duration = window.TIMING?.card?.flip || 320;
@@ -436,7 +458,7 @@ class CardAnimations {
begin: () => this.playSound('flip'), begin: () => this.playSound('flip'),
complete: () => { complete: () => {
animCard.remove(); animCard.remove();
cardElement.style.opacity = '1'; cardElement.style.visibility = '';
if (onComplete) onComplete(); if (onComplete) onComplete();
} }
}); });
@@ -445,7 +467,7 @@ class CardAnimations {
} catch (e) { } catch (e) {
console.error('Initial flip animation error:', e); console.error('Initial flip animation error:', e);
animCard.remove(); animCard.remove();
cardElement.style.opacity = '1'; cardElement.style.visibility = '';
if (onComplete) onComplete(); if (onComplete) onComplete();
} }
} }
@@ -750,28 +772,36 @@ class CardAnimations {
const id = 'turnPulse'; const id = 'turnPulse';
this.stopTurnPulse(element); 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 = () => { const doShake = () => {
if (!this.activeAnimations.has(id)) return; if (!this.activeAnimations.has(id)) return;
anime({ anime({
targets: element, targets: cards.length ? cards : element,
translateX: [0, -8, 8, -6, 4, 0], translateX: [0, -6, 6, -4, 3, 0],
duration: 400, duration: T.duration || 300,
easing: 'easeInOutQuad' 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(); doShake();
const interval = setInterval(doShake, 3000); const interval = setInterval(doShake, T.interval || 3000);
this.activeAnimations.set(id, { interval }); const entry = this.activeAnimations.get(id);
if (entry) entry.interval = interval;
}, T.initialDelay || 5000);
this.activeAnimations.set(id, { timeout });
} }
stopTurnPulse(element) { stopTurnPulse(element) {
const id = 'turnPulse'; const id = 'turnPulse';
const existing = this.activeAnimations.get(id); const existing = this.activeAnimations.get(id);
if (existing) { if (existing) {
if (existing.timeout) clearTimeout(existing.timeout);
if (existing.interval) clearInterval(existing.interval); if (existing.interval) clearInterval(existing.interval);
if (existing.pause) existing.pause(); if (existing.pause) existing.pause();
this.activeAnimations.delete(id); this.activeAnimations.delete(id);
@@ -1097,7 +1127,7 @@ class CardAnimations {
}); });
// Now run the swap animation // Now run the swap animation
this._runUnifiedSwap(handCardData, heldCardData, handRect, heldRect, discardRect, T, rotation, wasHandFaceDown, onComplete); this._runUnifiedSwap(handCardData, heldCardData, handRect, heldRect, discardRect, T, rotation, wasHandFaceDown, onComplete);
}, 100); }, 350);
return; return;
} }
@@ -1515,6 +1545,7 @@ class CardAnimations {
// Create container for animation cards // Create container for animation cards
const container = document.createElement('div'); const container = document.createElement('div');
container.className = 'deal-anim-container';
container.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;pointer-events:none;z-index:1000;'; container.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;pointer-events:none;z-index:1000;';
document.body.appendChild(container); document.body.appendChild(container);

View File

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

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

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,77 @@
# BUG: Kicked ball animation starts from golfer's back foot
## Problem
The `⚪` kicked ball animation (`.kicked-ball`) appears to launch from the golfer's **back foot** (left side) instead of the **front foot** (right side). The golfer faces right in both landscape (two-row) and mobile (single-line) views due to `scaleX(-1)`.
## What we want
The ball should appear at the golfer's front foot (right side) and arc up and to the right — matching the "good" landscape behavior seen at wide desktop widths (~1100px+).
## Good reference
- Video: `good.mp4` (landscape wide view)
- Extracted frames: `/tmp/golf-frames-good/`
- Frame 025: Ball clearly appears to the RIGHT of the golfer, arcing up-right
## Bad behavior
- Videos: `Screencast_20260224_005555.mp4`, `Screencast_20260224_013326.mp4`
- The ball appears to the LEFT of the golfer (between the golf ball logo and golfer emoji)
- Happens at the user's phone viewport width (two-row layout, inline-grid)
## Root cause analysis
### The scaleX(-1) offset problem
The golfer emoji (`.golfer-swing`) has `transform: scaleX(-1)` which flips it visually. This means:
- The golfer's **layout box** occupies the same inline flow position
- But the **visual** left/right is flipped — the front foot (visually on the right) is at the LEFT edge of the layout box
- The `.kicked-ball` span comes right after `.golfer-swing` in inline flow, so its natural position is at the **right edge** of the golfer's layout box
- But due to `scaleX(-1)`, the right edge of the layout box is the golfer's **visual back** (left side)
- So `translate(0, 0)` places the ball at the golfer's back, not front
### CSS translate values tested
| Start X | Result |
|---------|--------|
| `-30px` (original) | Ball appears way behind golfer (further left) |
| `+20px` | Ball still appears to LEFT of golfer, but slightly closer |
| `+80px` | Not confirmed (staging 404 during test) |
### Key finding: The kicked-ball's natural position needs ~60-80px positive X offset to reach the golfer's visual front foot
The golfer emoji is roughly 30-40px wide at this viewport. Since `scaleX(-1)` flips the visual, the ball needs to translate **past the entire emoji width** to reach the visual front.
### Media query issues encountered
1. First attempt: Added `ball-kicked-mobile` keyframes with `@media (max-width: 500px)` override
2. **CSS source order bug**: The mobile override at line 144 was being overridden by the base `.kicked-ball` rule at line 216 (later = higher priority at equal specificity)
3. Moved override after base rule — still didn't work
4. Added `!important` — still didn't work
5. Raised breakpoint from 500px to 768px, then 1200px — still no visible change
6. **Breakthrough**: Added `outline: 3px solid red; background: yellow` debug styles to base `.kicked-ball` — these DID appear, confirming CSS was loading
7. Changed base `ball-kicked` keyframes from `-30px` to `+20px` — ball DID move, confirming the base keyframes are what's being used
8. The mobile override keyframes may never have been applied (unclear if `ball-kicked-mobile` was actually used)
### What the Chrome extension Claude analysis said
> "The breakpoint is 500px, but the viewport is above 500px. At 700px+, ball-kicked-mobile never kicks in — it still uses the desktop ball-kicked animation. But the layout at this width has already shifted to a more centered layout which changes where .kicked-ball is positioned relative to the golfer."
## Suggested fix approach
1. **Don't use separate mobile keyframes** — just fix the base `ball-kicked` to work at all viewport widths
2. The starting X needs to be **much larger positive** (60-80px) to account for `scaleX(-1)` placing the natural position at the golfer's visual back
3. Alternatively, restructure the HTML: move `.kicked-ball` BEFORE `.golfer-swing` in the DOM, so its natural inline position is at the golfer's visual front (since scaleX(-1) flips left/right)
4. Or use `position: absolute` on `.kicked-ball` and position it relative to the golfer container explicitly
## Files involved
- `client/style.css``.kicked-ball`, `@keyframes ball-kicked`, `.golfer-swing`
- `client/index.html` — line 19: `<span class="golfer-swing">🏌️</span><span class="kicked-ball">⚪</span>`
## Resolution (v3.1.6)
**Fixed** by wrapping `.golfer-swing` + `.kicked-ball` in a `.golfer-container` span with `position: relative`, and changing `.kicked-ball` from `position: relative` to `position: absolute; right: -8px; bottom: 30%`. This anchors the ball to the golfer's front foot regardless of viewport width or inline flow layout.
Also fixed a **CSS source order bug** where the base `.golfer-container` rule was defined after the `@media (max-width: 500px)` override, clobbering the mobile margin-left value.

View File

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

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)

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "golfgame" name = "golfgame"
version = "3.1.1" version = "3.1.6"
description = "6-Card Golf card game with AI opponents" description = "6-Card Golf card game with AI opponents"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"

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

View File

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

View File

@@ -229,7 +229,7 @@ async def handle_start_game(data: dict, ctx: ConnectionContext, *, broadcast_gam
"game_state": game_state, "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: 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: async with ctx.current_room.game_lock:
if ctx.current_room.game.flip_initial_cards(ctx.player_id, positions): if ctx.current_room.game.flip_initial_cards(ctx.player_id, positions):
await broadcast_game_state(ctx.current_room) await broadcast_game_state(ctx.current_room)
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 broadcast_game_state(ctx.current_room)
await asyncio.sleep(1.0) 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: 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: else:
await asyncio.sleep(0.5) await asyncio.sleep(0.5)
await check_and_run_cpu_turn(ctx.current_room) check_and_run_cpu_turn(ctx.current_room)
else: else:
logger.debug("Player discarded, waiting 0.5s before CPU turn") logger.debug("Player discarded, waiting 0.5s before CPU turn")
await asyncio.sleep(0.5) await asyncio.sleep(0.5)
logger.debug("Post-discard delay complete, checking for CPU turn") logger.debug("Post-discard delay complete, checking for CPU turn")
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: 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 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: 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 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: 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 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: 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 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: 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, "game_state": game_state,
}) })
await check_and_run_cpu_turn(ctx.current_room) check_and_run_cpu_turn(ctx.current_room)
else: else:
await broadcast_game_state(ctx.current_room) 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"}) await ctx.websocket.send_json({"type": "error", "message": "Only the host can end the game"})
return return
# Cancel any running CPU turn task so the game ends immediately
if ctx.current_room.cpu_turn_task:
ctx.current_room.cpu_turn_task.cancel()
try:
await ctx.current_room.cpu_turn_task
except (asyncio.CancelledError, Exception):
pass
ctx.current_room.cpu_turn_task = None
await ctx.current_room.broadcast({ await ctx.current_room.broadcast({
"type": "game_ended", "type": "game_ended",
"reason": "Host ended the game", "reason": "Host ended the game",

View File

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

View File

@@ -325,7 +325,7 @@ async def _close_all_websockets():
app = FastAPI( app = FastAPI(
title="Golf Card Game", title="Golf Card Game",
debug=config.DEBUG, debug=config.DEBUG,
version="3.1.1", version="3.1.6",
lifespan=lifespan, lifespan=lifespan,
) )
@@ -705,8 +705,40 @@ async def broadcast_game_state(room: Room):
}) })
async def check_and_run_cpu_turn(room: Room): def check_and_run_cpu_turn(room: Room):
"""Check if current player is CPU and run their turn.""" """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): if room.game.phase not in (GamePhase.PLAYING, GamePhase.FINAL_TURN):
return 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) 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): async def handle_player_leave(room: Room, player_id: str):
"""Handle a player leaving a room.""" """Handle a player leaving a room."""
# Cancel any running CPU turn task before cleanup
if room.cpu_turn_task:
room.cpu_turn_task.cancel()
try:
await room.cpu_turn_task
except (asyncio.CancelledError, Exception):
pass
room.cpu_turn_task = None
room_code = room.code room_code = room.code
room_player = room.remove_player(player_id) room_player = room.remove_player(player_id)

View File

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