114 Commits

Author SHA1 Message Date
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
adlee-was-taken
a1d8a127dc Add bottom margin to mobile player area for border breathing room
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 14:43:34 -05:00
adlee-was-taken
65b4af9831 Hide mobile bottom bar when drawer panels are open
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 14:38:14 -05:00
adlee-was-taken
8942238f9c Make mobile bottom bar flow in document instead of position fixed
Remove position:fixed from the bottom bar and make it a flex-shrink:0
child of the game screen flex column. This guarantees the game layout
gets exactly the remaining viewport height with no overlap, regardless
of how the browser calculates viewport units.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 14:35:09 -05:00
adlee-was-taken
7dc27fe882 Use window.innerHeight for mobile viewport height on Chrome Android
Set --app-height CSS custom property from window.innerHeight via JS,
which is the only reliable way to get the actual visible viewport on
Chrome Android. Falls back to 100vh if JS hasn't loaded.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 14:28:58 -05:00
adlee-was-taken
097f241c6f Fix Chrome Android viewport overflow with position fixed game screen
Use position:fixed with inset:0 for the game screen instead of
height-based sizing. This bypasses the Chrome Android 100vh bug where
vh includes space behind the dynamic URL bar. Also adds
-webkit-fill-available fallback on body.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 14:26:50 -05:00
adlee-was-taken
1c5d6b09e2 Fix Chrome Android player hand overlapping bottom bar
Add 100vh fallback before 100dvh, max-height constraints on every flex
container in the layout chain, and explicit flex-basis 0% to prevent
Chrome from letting flex children grow beyond viewport bounds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 14:22:07 -05:00
adlee-was-taken
889f8ce1cd Fix mobile opponents to fit 3 per row with calc-based flex-basis
Replaces fixed 120px width with calc((100% - 20px) / 3) so 3 opponents
always fit per row regardless of viewport width.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 14:14:43 -05:00
adlee-was-taken
b4e9390f16 Show both KNOCKED and LOW SCORE badges when knocker wins hole
Also fix opponent areas shifting between rows on mobile by giving them
a fixed 120px width so name/score changes don't cause reflow.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 13:54:18 -05:00
adlee-was-taken
94e2bdaaa7 Move player dealer chip to top-left corner on mobile
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 13:33:04 -05:00
adlee-was-taken
d322403764 Shrink and reposition player dealer chip on mobile
Reduces from 38px to 22px and pulls offset from -10px to -4px so it
no longer overlaps the bottom bar buttons.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 13:24:00 -05:00
adlee-was-taken
9c6ce255bd Fix mobile layout overflow into bottom bar
Add bottom padding to game-table to reserve space for the fixed bottom
bar, and overflow:hidden on player-row so content respects flex bounds.
Also centers draw pile with equal spacing and adds dealer chip clearance.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 13:19:26 -05:00
adlee-was-taken
06d52a9d2c Add top padding to mobile lobby screen to clear auth bar from logo
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 13:10:39 -05:00
adlee-was-taken
76cbd4ae22 Increase mobile bottom bar button fonts by 40% and status message by 20%
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 13:08:20 -05:00
adlee-was-taken
9b04bc85c2 Fix mobile bottom bar pinning by scaling elements individually
Remove transform: scale(0.75) from the bar container which broke the
full-width layout and margin-auto pinning. Instead shrink font sizes
and padding on individual buttons and round info by ~25%.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 13:06:11 -05:00
adlee-was-taken
2ccbfc8120 Increase mobile portrait status message font to match player names
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 13:03:19 -05:00
adlee-was-taken
1678077c53 Raise mobile bottom bar z-index and shrink by 25%
Bumps z-index from 500 to 900 so the bottom bar stays above side panel
drawers (600) but below card animation overlays (1000). Scales the bar
to 75% in mobile portrait to reduce visual footprint.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 13:01:46 -05:00
adlee-was-taken
0dbb2d13ed Shrink mobile player/deck cards and widen opponent hand spacing
Reduces card sizes from 72×101 to 64×90 on mobile portrait to prevent
overlap with bottom bar when opponents wrap to 2 rows. Increases
horizontal gap between opponent hands from 4px to 10px for better
readability of player chips.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 12:58:40 -05:00
adlee-was-taken
82e5226acc Update email from address and add deploy script
- Fix EMAIL_FROM to use contact.golfcards.club subdomain
- Add scripts/deploy.sh

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 00:36:38 -05:00
adlee-was-taken
b81874f5ba Fix CSP blocking admin panel buttons by removing inline onclick handlers
Replace onclick attributes with data-action/data-id attributes and
use event delegation. CSP script-src 'self' blocks inline handlers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 23:56:45 -05:00
adlee-was-taken
797d1e0280 Add copy invite link button and auto-populate invite code from URL
Admin panel gets "Copy Link" button on active invites that copies
a signup URL with ?invite= param. Client auto-opens signup form
with invite code pre-filled when visiting that link.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 23:54:06 -05:00
adlee-was-taken
538ca51ba5 Add forgot/reset password UI and Resend email config
- Forgot password form in auth modal with email input
- Reset password form handles token from email link
- /reset-password route serves index.html for SPA
- EMAIL_FROM env var in docker-compose
- Success/error feedback for both flows

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 23:51:58 -05:00
adlee-was-taken
9339abe19c Pin Hole indicator left and End Game right in mobile bottom bar
- Hole indicator: margin-right auto, pill background, brighter text (0.9 opacity)
- End Game: margin-left auto, pinned to right edge
- Standings/Scores stay centered between them

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 23:43:02 -05:00
adlee-was-taken
ac2d53b404 Move Hole indicator and End Game button to mobile bottom bar
- Add round info and leave button to mobile-bottom-bar HTML
- Hide .round-info and #leave-game-btn from header on mobile
- Style round info as subtle text, leave button as red-tinted pill
- Slim down bottom bar: smaller gaps/padding to fit 4 items
- Sync round numbers and leave text via JS (renderGame + bindEvents)
- Frees up header space, reduces mobile crowding

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 23:40:24 -05:00
adlee-was-taken
7e108a71f9 Max out mobile opponent text sizes for readability
- Card rank/suit: 0.8rem -> 1.05rem with tight line-height 1.05
- Names: 0.75rem -> 0.85rem
- Showing score: 0.7rem -> 0.85rem
- Short screen cards: 0.6rem -> 0.8rem
- Fills the 35x49px cards without overflowing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 23:28:21 -05:00
adlee-was-taken
7642d120e2 Increase mobile opponent text sizes for readability
- Opponent card rank/suit: 0.65rem -> 0.8rem (25% bump)
- Opponent names (h4): 0.6rem -> 0.75rem
- Opponent showing score: 0.55rem -> 0.7rem
- Short screen card font: 0.5rem -> 0.6rem
- Animation overlay font (JS 12.25px) closely matches CSS (12.8px)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 23:26:14 -05:00
adlee-was-taken
6ba0639d51 Fix opponent row fitting 3 per row on mobile
- Remove excessive 44px bottom padding on game-table (was eating vertical space)
- Tighten opponents-row: reduce gap 6px->4px, side padding 8px->4px
- Reduce opponent-area side padding 5px->4px
- Allow opponent areas to shrink (remove flex-shrink: 0)
- 3 opponents now fit in ~367px, well within 412px Pixel 10 Pro

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 23:23:19 -05:00
adlee-was-taken
3b9522fec3 Fix mobile bottom bar: pin to viewport bottom, remove background
- Position fixed bottom:0 so buttons are always at the very bottom
- Remove dark background that was picking up the green felt color
- Add bottom padding to game-table so player cards aren't hidden behind bar

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 23:21:32 -05:00
adlee-was-taken
aa2093d6c8 Polish mobile bottom bar buttons and drawer transitions
- Redesign bottom bar buttons as pill-shaped with subtle glass border
- Active state: warm gradient fill with glow shadow
- Tap feedback: scale(0.95) press effect
- Drawer panels: iOS-style cubic-bezier spring curve, drop shadow
- Backdrop transition matches drawer timing
- Darker bottom bar background with softer border for depth

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 23:17:53 -05:00
adlee-was-taken
3227c92d63 Wrap opponent row at 3 per line and bump opponent card size 10%
- Change opponents-row from nowrap to flex-wrap: wrap (max 3+2 layout)
- Opponent cards: 32x45px -> 35x49px, font 0.6 -> 0.65rem
- Short screen: 26x36px -> 29x40px, font 0.45 -> 0.5rem
- 3 opponents at 35px fits 369px, well within 375px iPhone width

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 23:16:40 -05:00
adlee-was-taken
b7b21d8378 Bump version to 3.1.1, add mobile portrait layout documentation
- Update version from 2.0.1 to 3.1.1 in pyproject.toml and server/main.py
- Add V3_17_MOBILE_PORTRAIT_LAYOUT.md documenting all mobile improvements:
  responsive layout, animation sizing fixes, compact header, bottom drawers
- Add V3_17 entry to V3 master plan

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 23:14:06 -05:00
adlee-was-taken
fb3bd53b0a Fix mobile animation card sizing and layout polish
- Fix animation overlay cards rendering at wrong size: base .card CSS
  (clamp 65px min) was overriding the inline dimensions set by JS.
  Add !important to .draw-anim-front/.draw-anim-back width/height: 100%
  so overlays always match their parent container size.
- Size opponent swap held card to match opponent card dimensions instead
  of defaulting to deck size (looked oversized on mobile)
- Shrink dealer chip on mobile (38px -> 20px) to fit opponent areas
- Make header more compact: smaller fonts, tighter gaps, nowrap on badges
- Bump deck/discard to 72x101px to match player card size on mobile
- Add spacing between header/opponents, and between deck area/player cards

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 23:11:39 -05:00
adlee-was-taken
4fcdf13f66 Fix mobile portrait layout: lobby overlap, deal animation, card font sizes
- Add renderGame() guard during deal animation to prevent DOM destruction
  mid-animation causing cards to pile up at wrong positions
- Push lobby content below fixed auth-bar (padding 15px -> 50px top)
- Scale player card font-size to 1.5rem/1.3rem for readable text on mobile
- Add full mobile portrait layout: bottom drawers, compact header, responsive
  card grid sizing, safe-area insets, and mobile detection via matchMedia
- Add cardFontSize() helper for consistent proportional font scaling
- Add mobile bottom bar with drawer toggles for standings/scores

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 22:52:44 -05:00
adlee-was-taken
6673e63241 Enable HTTPS-only with HTTP->HTTPS redirect
SSL cert issued via Let's Encrypt. Remove HTTP fallback router,
enable redirect, reduce Traefik log level to WARN.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 21:12:48 -05:00
adlee-was-taken
62e7d4e1dd Fix End Game showing false 'Connection lost' error
Add _intentionalClose flag to suppress error when server closes
WebSocket after game_ended broadcast. Clean transition to lobby.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 20:39:43 -05:00
adlee-was-taken
bae5d8da3c Wire authManager into GolfGame instance for WebSocket token auth
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 20:32:49 -05:00
adlee-was-taken
62e3dc0395 Allow ws:// in production CSP for pre-SSL WebSocket connections
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 20:30:29 -05:00
adlee-was-taken
bda88d8218 Add gap between login and signup buttons
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 20:27:29 -05:00
adlee-was-taken
b5a8e1fe7b Fix Traefik network resolution - use golfgame_web not internal
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 20:25:41 -05:00
adlee-was-taken
929ab0f320 Enable Traefik debug logging and access logs for troubleshooting
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 20:23:44 -05:00
adlee-was-taken
7026d86081 Link HTTP fallback router to golf service explicitly
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 20:22:40 -05:00
adlee-was-taken
b2ce6f5cf1 Add HTTP fallback route for pre-DNS testing, disable redirect temporarily
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 20:20:24 -05:00
adlee-was-taken
d4a39fe234 Upgrade Traefik to v3.6 for Docker Engine v29 API negotiation fix
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 20:18:20 -05:00
adlee-was-taken
9966fd9470 Set DOCKER_API_VERSION for Traefik compatibility with Docker Engine v29
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 20:16:00 -05:00
adlee-was-taken
050294754c Upgrade Traefik v2.10 to v3.3 for Docker Engine v29 compatibility
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 20:15:09 -05:00
adlee-was-taken
1856019a95 Fix Dockerfile WORKDIR for server relative imports
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 20:05:29 -05:00
adlee-was-taken
f68d0bc26d v3.1.0: Invite-gated auth, Glicko-2 ratings, matchmaking queue
- Enforce invite codes on registration (INVITE_ONLY=true by default)
- Bootstrap admin account for first-time setup
- Require authentication for WebSocket connections and room creation
- Add Glicko-2 rating system with multiplayer pairwise comparisons
- Add Redis-backed matchmaking queue with expanding rating window
- Auto-start matched games with standard rules after countdown
- Add "Find Game" button and matchmaking UI to client
- Add rating column to leaderboard
- Scale down docker-compose.prod.yml for 512MB droplet

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 20:02:10 -05:00
adlee-was-taken
c59c1e28e2 Smooth held card transition and scale font with card size
Remove scale(1.15) size jump on held card, keep gold border/glow highlight.
Set animation card font-size proportionally to card width so text matches
across deck, hand, and opponent card sizes. Animate font-size during swaps
so text scales smoothly as cards travel between different-sized positions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 00:14:27 -05:00
adlee-was-taken
bfa94830a7 Move V2_BUILD_PLAN.md to docs/v2/
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 23:59:48 -05:00
28 changed files with 3249 additions and 298 deletions

View File

@@ -55,7 +55,12 @@ ROOM_CODE_LENGTH=4
SECRET_KEY= SECRET_KEY=
# Enable invite-only mode (requires invitation to register) # Enable invite-only mode (requires invitation to register)
INVITE_ONLY=false INVITE_ONLY=true
# Bootstrap admin account (for first-time setup with INVITE_ONLY=true)
# Remove these after first login!
# BOOTSTRAP_ADMIN_USERNAME=admin
# BOOTSTRAP_ADMIN_PASSWORD=changeme12345
# Comma-separated list of admin email addresses # Comma-separated list of admin email addresses
ADMIN_EMAILS= ADMIN_EMAILS=
@@ -104,5 +109,13 @@ CARD_LUCKY_SWING_JOKER=-5 # Joker value when lucky_swing enabled
# Enable rate limiting (recommended for production) # Enable rate limiting (recommended for production)
# RATE_LIMIT_ENABLED=true # RATE_LIMIT_ENABLED=true
# Redis URL (required for matchmaking and rate limiting)
# REDIS_URL=redis://localhost:6379
# Base URL for email links # Base URL for email links
# BASE_URL=https://your-domain.com # BASE_URL=https://your-domain.com
# Matchmaking (skill-based public games)
MATCHMAKING_ENABLED=true
MATCHMAKING_MIN_PLAYERS=2
MATCHMAKING_MAX_PLAYERS=4

View File

@@ -33,5 +33,6 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
EXPOSE 8000 EXPOSE 8000
# Run with uvicorn # Run with uvicorn from the server directory (server uses relative imports)
CMD ["python", "-m", "uvicorn", "server.main:app", "--host", "0.0.0.0", "--port", "8000"] WORKDIR /app/server
CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -317,7 +317,7 @@ async function loadUsers() {
<td>${user.games_played} (${user.games_won} wins)</td> <td>${user.games_played} (${user.games_won} wins)</td>
<td>${formatDateShort(user.created_at)}</td> <td>${formatDateShort(user.created_at)}</td>
<td> <td>
<button class="btn btn-small" onclick="viewUser('${user.id}')">View</button> <button class="btn btn-small" data-action="view-user" data-id="${user.id}">View</button>
</td> </td>
</tr> </tr>
`; `;
@@ -404,7 +404,7 @@ async function loadGames() {
<td><span class="badge badge-${game.status === 'playing' ? 'success' : 'info'}">${game.status}</span></td> <td><span class="badge badge-${game.status === 'playing' ? 'success' : 'info'}">${game.status}</span></td>
<td>${formatDate(game.created_at)}</td> <td>${formatDate(game.created_at)}</td>
<td> <td>
<button class="btn btn-small btn-danger" onclick="promptEndGame('${game.game_id}')">End</button> <button class="btn btn-small btn-danger" data-action="end-game" data-id="${game.game_id}">End</button>
</td> </td>
</tr> </tr>
`; `;
@@ -454,7 +454,8 @@ async function loadInvites() {
<td>${status}</td> <td>${status}</td>
<td> <td>
${invite.is_active && !isExpired && invite.remaining_uses > 0 ${invite.is_active && !isExpired && invite.remaining_uses > 0
? `<button class="btn btn-small btn-danger" onclick="promptRevokeInvite('${invite.code}')">Revoke</button>` ? `<button class="btn btn-small" data-action="copy-invite" data-code="${escapeHtml(invite.code)}">Copy Link</button>
<button class="btn btn-small btn-danger" data-action="revoke-invite" data-code="${escapeHtml(invite.code)}">Revoke</button>`
: '-' : '-'
} }
</td> </td>
@@ -619,6 +620,16 @@ async function handleCreateInvite() {
} }
} }
function copyInviteLink(code) {
const link = `${window.location.origin}/?invite=${encodeURIComponent(code)}`;
navigator.clipboard.writeText(link).then(() => {
showToast('Invite link copied!', 'success');
}).catch(() => {
// Fallback: select text for manual copy
prompt('Copy this link:', link);
});
}
async function promptRevokeInvite(code) { async function promptRevokeInvite(code) {
if (!confirm(`Are you sure you want to revoke invite code ${code}?`)) return; if (!confirm(`Are you sure you want to revoke invite code ${code}?`)) return;
@@ -804,6 +815,18 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
}); });
// Delegated click handlers for dynamically-created buttons
document.addEventListener('click', (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const action = btn.dataset.action;
if (action === 'view-user') viewUser(btn.dataset.id);
else if (action === 'end-game') promptEndGame(btn.dataset.id);
else if (action === 'copy-invite') copyInviteLink(btn.dataset.code);
else if (action === 'revoke-invite') promptRevokeInvite(btn.dataset.code);
});
// Check auth on load // Check auth on load
checkAuth(); checkAuth();
}); });

File diff suppressed because it is too large Load Diff

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,
@@ -75,6 +76,13 @@ class CardAnimations {
return easings[type] || 'easeOutQuad'; return easings[type] || 'easeOutQuad';
} }
// Font size proportional to card width — consistent across all card types.
// Mobile uses a tighter ratio since cards are smaller and closer together.
cardFontSize(width) {
const ratio = document.body.classList.contains('mobile-portrait') ? 0.35 : 0.5;
return (width * ratio) + 'px';
}
// Create animated card element with 3D flip structure // Create animated card element with 3D flip structure
createAnimCard(rect, showBack = false, deckColor = null) { createAnimCard(rect, showBack = false, deckColor = null) {
const card = document.createElement('div'); const card = document.createElement('div');
@@ -92,6 +100,9 @@ class CardAnimations {
card.style.top = rect.top + 'px'; card.style.top = rect.top + 'px';
card.style.width = rect.width + 'px'; card.style.width = rect.width + 'px';
card.style.height = rect.height + 'px'; card.style.height = rect.height + 'px';
// Scale font-size proportionally to card width
const front = card.querySelector('.draw-anim-front');
if (front) front.style.fontSize = this.cardFontSize(rect.width);
} }
// Apply deck color to back // Apply deck color to back
@@ -145,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') {
@@ -448,10 +467,6 @@ class CardAnimations {
const deckColor = this.getDeckColor(); const deckColor = this.getDeckColor();
const animCard = this.createAnimCard(rect, true, deckColor); const animCard = this.createAnimCard(rect, true, deckColor);
// Match source card's font-size (opponent cards are smaller than default)
const srcFontSize = getComputedStyle(cardElement).fontSize;
const front = animCard.querySelector('.draw-anim-front');
if (front) front.style.fontSize = srcFontSize;
this.setCardContent(animCard, cardData); this.setCardContent(animCard, cardData);
// Apply rotation to match arch layout // Apply rotation to match arch layout
@@ -607,10 +622,6 @@ class CardAnimations {
const deckColor = this.getDeckColor(); const deckColor = this.getDeckColor();
const animCard = this.createAnimCard(rect, true, deckColor); const animCard = this.createAnimCard(rect, true, deckColor);
// Match source card's font-size (opponent cards are smaller than default)
const srcFontSize = getComputedStyle(sourceCardElement).fontSize;
const front = animCard.querySelector('.draw-anim-front');
if (front) front.style.fontSize = srcFontSize;
this.setCardContent(animCard, discardCard); this.setCardContent(animCard, discardCard);
if (rotation) { if (rotation) {
@@ -754,22 +765,28 @@ class CardAnimations {
anime({ anime({
targets: element, targets: element,
translateX: [0, -8, 8, -6, 4, 0], translateX: [0, -6, 6, -4, 3, 0],
duration: 400, duration: 300,
easing: 'easeInOutQuad' easing: 'easeInOutQuad'
}); });
}; };
// Do initial shake, then repeat every 3 seconds // Delay first shake by 5 seconds, then repeat every 2 seconds
doShake(); const timeout = setTimeout(() => {
const interval = setInterval(doShake, 3000); if (!this.activeAnimations.has(id)) return;
this.activeAnimations.set(id, { interval }); doShake();
const interval = setInterval(doShake, 2000);
const entry = this.activeAnimations.get(id);
if (entry) entry.interval = interval;
}, 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);
@@ -1164,6 +1181,9 @@ class CardAnimations {
}); });
// Hand card arcs to discard (apply counter-rotation to land flat) // Hand card arcs to discard (apply counter-rotation to land flat)
const handFront = travelingHand.querySelector('.draw-anim-front');
const heldFront = travelingHeld.querySelector('.draw-anim-front');
timeline.add({ timeline.add({
targets: travelingHand, targets: travelingHand,
left: discardRect.left, left: discardRect.left,
@@ -1178,6 +1198,16 @@ class CardAnimations {
easing: this.getEasing('arc'), easing: this.getEasing('arc'),
}, `-=${T.lift / 2}`); }, `-=${T.lift / 2}`);
// Scale hand card font to match discard size
if (handFront) {
timeline.add({
targets: handFront,
fontSize: this.cardFontSize(discardRect.width),
duration: T.arc,
easing: this.getEasing('arc'),
}, `-=${T.arc}`);
}
// Held card arcs to hand slot (apply rotation to match hand position) // Held card arcs to hand slot (apply rotation to match hand position)
timeline.add({ timeline.add({
targets: travelingHeld, targets: travelingHeld,
@@ -1193,6 +1223,16 @@ class CardAnimations {
easing: this.getEasing('arc'), easing: this.getEasing('arc'),
}, `-=${T.arc + T.lift / 2}`); }, `-=${T.arc + T.lift / 2}`);
// Scale held card font to match hand size
if (heldFront) {
timeline.add({
targets: heldFront,
fontSize: this.cardFontSize(handRect.width),
duration: T.arc,
easing: this.getEasing('arc'),
}, `-=${T.arc}`);
}
// Settle with gentle overshoot // Settle with gentle overshoot
timeline.add({ timeline.add({
targets: [travelingHand, travelingHeld], targets: [travelingHand, travelingHeld],
@@ -1404,6 +1444,9 @@ class CardAnimations {
card.style.top = rect.top + 'px'; card.style.top = rect.top + 'px';
card.style.width = rect.width + 'px'; card.style.width = rect.width + 'px';
card.style.height = rect.height + 'px'; card.style.height = rect.height + 'px';
// Scale font-size proportionally to card width
const front = card.querySelector('.draw-anim-front');
if (front) front.style.fontSize = this.cardFontSize(rect.width);
if (rotation) { if (rotation) {
card.style.transform = `rotate(${rotation}deg)`; card.style.transform = `rotate(${rotation}deg)`;
@@ -1444,9 +1487,8 @@ class CardAnimations {
try { try {
anime({ anime({
targets: element, targets: element,
scale: [0.5, 1.25, 1.15], opacity: [0, 1],
opacity: [0, 1, 1], duration: 200,
duration: 300,
easing: 'easeOutQuad' easing: 'easeOutQuad'
}); });
} catch (e) { } catch (e) {
@@ -1488,6 +1530,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

@@ -126,6 +126,13 @@ class CardManager {
cardEl.style.width = `${rect.width}px`; cardEl.style.width = `${rect.width}px`;
cardEl.style.height = `${rect.height}px`; cardEl.style.height = `${rect.height}px`;
// On mobile, scale font proportional to card width so rank/suit fit
if (document.body.classList.contains('mobile-portrait')) {
cardEl.style.fontSize = `${rect.width * 0.35}px`;
} else {
cardEl.style.fontSize = '';
}
if (animate) { if (animate) {
const moveDuration = window.TIMING?.card?.moving || 350; const moveDuration = window.TIMING?.card?.moving || 350;
setTimeout(() => cardEl.classList.remove('moving'), moveDuration); setTimeout(() => cardEl.classList.remove('moving'), moveDuration);

View File

@@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Golf Card Game</title> <title>Golf Card Game</title>
<link rel="stylesheet" href="style.css"> <link rel="stylesheet" href="style.css">
</head> </head>
@@ -19,35 +19,54 @@
<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><img src="golfball-logo.svg" alt="" class="golfball-logo"><span class="golfer-swing">🏌️</span><span class="kicked-ball"></span> <span class="golf-title">Golf</span></h1>
<p class="subtitle">6-Card Golf Card Game <button id="rules-btn" class="btn btn-small btn-rules">Rules</button> <button id="leaderboard-btn" class="btn btn-small leaderboard-btn">Leaderboard</button></p> <p class="subtitle">6-Card Golf Card Game <button id="rules-btn" class="btn btn-small btn-rules">Rules</button> <button id="leaderboard-btn" class="btn btn-small leaderboard-btn">Leaderboard</button></p>
<!-- Auth buttons for guests (hidden until auth check confirms not logged in) --> <div class="alpha-banner">Alpha &mdash; Things may break. Stats may be wiped.</div>
<div id="auth-buttons" class="auth-buttons hidden">
<button id="login-btn" class="btn btn-small">Login</button> <!-- Auth prompt for unauthenticated users -->
<button id="signup-btn" class="btn btn-small btn-primary">Sign Up</button> <div id="auth-prompt" class="auth-prompt">
<p>Log in or sign up to play.</p>
<div class="button-group">
<button id="login-btn" class="btn btn-primary">Login</button>
<button id="signup-btn" class="btn btn-secondary">Sign Up</button>
</div>
</div> </div>
<div class="form-group"> <!-- Game controls (shown only when authenticated) -->
<label for="player-name">Your Name</label> <div id="lobby-game-controls" class="hidden">
<input type="text" id="player-name" placeholder="Enter your name" maxlength="12"> <div class="button-group">
</div> <button id="find-game-btn" class="btn btn-primary">Find Game</button>
</div>
<div class="button-group"> <div class="divider">or</div>
<button id="create-room-btn" class="btn btn-primary">Create Room</button>
</div>
<div class="divider">or</div> <div class="button-group">
<button id="create-room-btn" class="btn btn-secondary">Create Private Room</button>
</div>
<div class="form-group"> <div class="form-group">
<label for="room-code">Room Code</label> <label for="room-code">Join Private Room</label>
<input type="text" id="room-code" placeholder="ABCD" maxlength="4"> <input type="text" id="room-code" placeholder="ABCD" maxlength="4">
</div> </div>
<div class="button-group"> <div class="button-group">
<button id="join-room-btn" class="btn btn-secondary">Join Room</button> <button id="join-room-btn" class="btn btn-secondary">Join Room</button>
</div>
</div> </div>
<p id="lobby-error" class="error"></p> <p id="lobby-error" class="error"></p>
</div> </div>
<!-- Matchmaking Screen -->
<div id="matchmaking-screen" class="screen">
<h2>Finding Game...</h2>
<div class="matchmaking-spinner"></div>
<p id="matchmaking-status">Searching for opponents...</p>
<p id="matchmaking-time" class="matchmaking-timer">0:00</p>
<p id="matchmaking-queue-info" class="matchmaking-info"></p>
<div class="button-group">
<button id="cancel-matchmaking-btn" class="btn btn-danger">Cancel</button>
</div>
</div>
<!-- Waiting Room Screen --> <!-- Waiting Room Screen -->
<div id="waiting-screen" class="screen"> <div id="waiting-screen" class="screen">
<div class="room-code-banner"> <div class="room-code-banner">
@@ -286,7 +305,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">
@@ -310,18 +328,24 @@
</div> </div>
<span class="held-label">Holding</span> <span class="held-label">Holding</span>
</div> </div>
<div id="deck" class="card card-back"></div> <div class="pile-wrapper">
<div class="discard-stack"> <span class="pile-label">DRAW</span>
<div id="discard" class="card"> <div id="deck" class="card card-back"></div>
<span id="discard-content"></span> </div>
<div class="pile-wrapper">
<span class="pile-label">DISCARD</span>
<div class="discard-stack">
<div id="discard" class="card">
<span id="discard-content"></span>
</div>
<!-- Floating held card (appears larger over discard when holding) -->
<div id="held-card-floating" class="card card-front held-card-floating hidden">
<span id="held-card-floating-content"></span>
</div>
<button id="discard-btn" class="btn btn-small hidden">Discard</button>
<button id="skip-flip-btn" class="btn btn-small btn-secondary hidden">Skip Flip</button>
<button id="knock-early-btn" class="btn btn-small btn-danger hidden">Knock!</button>
</div> </div>
<!-- Floating held card (appears larger over discard when holding) -->
<div id="held-card-floating" class="card card-front held-card-floating hidden">
<span id="held-card-floating-content"></span>
</div>
<button id="discard-btn" class="btn btn-small hidden">Discard</button>
<button id="skip-flip-btn" class="btn btn-small btn-secondary hidden">Skip Flip</button>
<button id="knock-early-btn" class="btn btn-small btn-danger hidden">Knock!</button>
</div> </div>
</div> </div>
</div> </div>
@@ -380,15 +404,32 @@
<tbody></tbody> <tbody></tbody>
</table> </table>
</div> </div>
</div> </div>
<!-- Mobile bottom bar (hidden on desktop) -->
<div id="mobile-bottom-bar">
<div class="mobile-round-info">Hole <span id="mobile-current-round">1</span>/<span id="mobile-total-rounds">9</span></div>
<button class="mobile-bar-btn mobile-rules-btn" id="mobile-rules-btn" data-drawer="rules-drawer"><span id="mobile-rules-icon">RULES</span></button>
<button class="mobile-bar-btn" data-drawer="standings-panel">Scorecard</button>
<button id="mobile-leave-btn" class="mobile-bar-btn mobile-leave-btn">End Game</button>
</div>
<!-- Mobile rules drawer -->
<div id="rules-drawer" class="side-panel rules-drawer-panel">
<h4>Active Rules</h4>
<div id="mobile-rules-content"></div>
</div>
<!-- Drawer backdrop for mobile -->
<div id="drawer-backdrop" class="drawer-backdrop"></div>
</div> </div>
<!-- Rules Screen --> <!-- Rules Screen -->
<div id="rules-screen" class="screen"> <div id="rules-screen" class="screen">
<div class="rules-container"> <div class="rules-container">
<button id="rules-back-btn" class="btn rules-back-btn">« Back</button>
<div class="rules-header"> <div class="rules-header">
<button id="rules-back-btn" class="btn rules-back-btn">« Back</button>
<h1><span class="golfer-logo">🏌️</span> <span class="golf-title">Golf Rules</span></h1> <h1><span class="golfer-logo">🏌️</span> <span class="golf-title">Golf Rules</span></h1>
<p class="rules-subtitle">6-Card Golf Card Game - Complete Guide</p> <p class="rules-subtitle">6-Card Golf Card Game - Complete Guide</p>
</div> </div>
@@ -704,9 +745,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>
@@ -717,6 +757,7 @@ TOTAL: 0 + 8 + 16 = 24 points</pre>
<button class="leaderboard-tab" data-metric="avg_score">Avg Score</button> <button class="leaderboard-tab" data-metric="avg_score">Avg Score</button>
<button class="leaderboard-tab" data-metric="knockouts">Knockouts</button> <button class="leaderboard-tab" data-metric="knockouts">Knockouts</button>
<button class="leaderboard-tab" data-metric="streak">Best Streak</button> <button class="leaderboard-tab" data-metric="streak">Best Streak</button>
<button class="leaderboard-tab" data-metric="rating">Rating</button>
</div> </div>
<div id="leaderboard-content"> <div id="leaderboard-content">
@@ -810,12 +851,47 @@ TOTAL: 0 + 8 + 16 = 24 points</pre>
<button type="submit" class="btn btn-primary btn-full">Login</button> <button type="submit" class="btn btn-primary btn-full">Login</button>
</form> </form>
<p class="auth-switch">Don't have an account? <a href="#" id="show-signup">Sign up</a></p> <p class="auth-switch">Don't have an account? <a href="#" id="show-signup">Sign up</a></p>
<p class="auth-switch"><a href="#" id="show-forgot">Forgot password?</a></p>
</div>
<!-- Forgot Password Form -->
<div id="forgot-form-container" class="hidden">
<h3>Reset Password</h3>
<p class="auth-hint">Enter your email and we'll send you a reset link.</p>
<form id="forgot-form">
<div class="form-group">
<input type="email" id="forgot-email" placeholder="Email" required>
</div>
<p id="forgot-error" class="error"></p>
<p id="forgot-success" class="success"></p>
<button type="submit" class="btn btn-primary btn-full">Send Reset Link</button>
</form>
<p class="auth-switch"><a href="#" id="forgot-back-login">Back to login</a></p>
</div>
<!-- Reset Password Form (from email link) -->
<div id="reset-form-container" class="hidden">
<h3>Set New Password</h3>
<form id="reset-form">
<div class="form-group">
<input type="password" id="reset-password" placeholder="New password" required minlength="8">
</div>
<div class="form-group">
<input type="password" id="reset-password-confirm" placeholder="Confirm password" required minlength="8">
</div>
<p id="reset-error" class="error"></p>
<p id="reset-success" class="success"></p>
<button type="submit" class="btn btn-primary btn-full">Reset Password</button>
</form>
</div> </div>
<!-- Signup Form --> <!-- Signup Form -->
<div id="signup-form-container" class="hidden"> <div id="signup-form-container" class="hidden">
<h3>Sign Up</h3> <h3>Sign Up</h3>
<form id="signup-form"> <form id="signup-form">
<div class="form-group">
<input type="text" id="signup-invite-code" placeholder="Invite Code" required>
</div>
<div class="form-group"> <div class="form-group">
<input type="text" id="signup-username" placeholder="Username" required minlength="3" maxlength="20"> <input type="text" id="signup-username" placeholder="Username" required minlength="3" maxlength="20">
</div> </div>

View File

@@ -26,6 +26,7 @@ class LeaderboardComponent {
avg_score: 'Avg Score', avg_score: 'Avg Score',
knockouts: 'Knockouts', knockouts: 'Knockouts',
streak: 'Best Streak', streak: 'Best Streak',
rating: 'Rating',
}; };
this.metricFormats = { this.metricFormats = {
@@ -34,6 +35,7 @@ class LeaderboardComponent {
avg_score: (v) => v.toFixed(1), avg_score: (v) => v.toFixed(1),
knockouts: (v) => v.toLocaleString(), knockouts: (v) => v.toLocaleString(),
streak: (v) => v.toLocaleString(), streak: (v) => v.toLocaleString(),
rating: (v) => Math.round(v).toLocaleString(),
}; };
this.init(); this.init();

File diff suppressed because it is too large Load Diff

View File

@@ -128,6 +128,18 @@ const TIMING = {
pulseDelay: 200, // Delay before card appears (pulse visible first) pulseDelay: 200, // Delay before card appears (pulse visible first)
}, },
// V3_17: Knock notification
knock: {
statusDuration: 2500, // How long the knock status message persists
},
// V3_17: Scoresheet modal
scoresheet: {
playerStagger: 150, // Delay between player row animations
columnStagger: 80, // Delay between column animations within a row
pairGlowDelay: 200, // Delay before paired columns glow
},
// Player swap animation steps - smooth continuous motion // Player swap animation steps - smooth continuous motion
playerSwap: { playerSwap: {
flipToReveal: 400, // Initial flip to show card flipToReveal: 400, // Initial flip to show card

View File

@@ -22,38 +22,54 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
environment: environment:
- POSTGRES_URL=postgresql://golf:${DB_PASSWORD}@postgres:5432/golf - POSTGRES_URL=postgresql://golf:${DB_PASSWORD}@postgres:5432/golf
- DATABASE_URL=postgresql://golf:${DB_PASSWORD}@postgres:5432/golf
- REDIS_URL=redis://redis:6379 - REDIS_URL=redis://redis:6379
- SECRET_KEY=${SECRET_KEY} - SECRET_KEY=${SECRET_KEY}
- RESEND_API_KEY=${RESEND_API_KEY:-} - RESEND_API_KEY=${RESEND_API_KEY:-}
- EMAIL_FROM=${EMAIL_FROM:-Golf Cards <noreply@contact.golfcards.club>}
- SENTRY_DSN=${SENTRY_DSN:-} - SENTRY_DSN=${SENTRY_DSN:-}
- ENVIRONMENT=production - ENVIRONMENT=production
- LOG_LEVEL=INFO - LOG_LEVEL=INFO
- 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
- BOOTSTRAP_ADMIN_USERNAME=${BOOTSTRAP_ADMIN_USERNAME:-}
- BOOTSTRAP_ADMIN_PASSWORD=${BOOTSTRAP_ADMIN_PASSWORD:-}
- MATCHMAKING_ENABLED=true
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
redis: redis:
condition: service_healthy condition: service_healthy
deploy: deploy:
replicas: 2 replicas: 1
restart_policy: restart_policy:
condition: on-failure condition: on-failure
max_attempts: 3 max_attempts: 3
resources: resources:
limits: limits:
memory: 512M
reservations:
memory: 256M memory: 256M
reservations:
memory: 64M
networks: networks:
- internal - internal
- web - web
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.docker.network=golfgame_web"
- "traefik.http.routers.golf.rule=Host(`${DOMAIN:-golf.example.com}`)" - "traefik.http.routers.golf.rule=Host(`${DOMAIN:-golf.example.com}`)"
- "traefik.http.routers.golf.entrypoints=websecure" - "traefik.http.routers.golf.entrypoints=websecure"
- "traefik.http.routers.golf.tls=true" - "traefik.http.routers.golf.tls=true"
- "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"
@@ -77,13 +93,13 @@ services:
deploy: deploy:
resources: resources:
limits: limits:
memory: 512M memory: 192M
reservations: reservations:
memory: 256M memory: 64M
redis: redis:
image: redis:7-alpine image: redis:7-alpine
command: redis-server --appendonly yes --maxmemory 128mb --maxmemory-policy allkeys-lru command: redis-server --appendonly yes --maxmemory 32mb --maxmemory-policy allkeys-lru
volumes: volumes:
- redis_data:/data - redis_data:/data
healthcheck: healthcheck:
@@ -96,14 +112,19 @@ services:
deploy: deploy:
resources: resources:
limits: limits:
memory: 192M
reservations:
memory: 64M memory: 64M
reservations:
memory: 16M
traefik: traefik:
image: traefik:v2.10 image: traefik:v3.6
environment:
- DOCKER_API_VERSION=1.44
command: command:
- "--api.dashboard=true" - "--api.dashboard=true"
- "--api.insecure=true"
- "--accesslog=true"
- "--log.level=WARN"
- "--providers.docker=true" - "--providers.docker=true"
- "--providers.docker.exposedbydefault=false" - "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80" - "--entrypoints.web.address=:80"
@@ -125,7 +146,7 @@ services:
deploy: deploy:
resources: resources:
limits: limits:
memory: 128M memory: 64M
volumes: volumes:
postgres_data: postgres_data:

View File

@@ -30,6 +30,7 @@ This plan is split into independent vertical slices ordered by priority and impa
| `V3_14_ACTIVE_RULES_CONTEXT.md` | Contextual rule highlighting | Low | Low | None | | `V3_14_ACTIVE_RULES_CONTEXT.md` | Contextual rule highlighting | Low | Low | None |
| `V3_15_DISCARD_PILE_HISTORY.md` | Show recent discards fanned | Low | Medium | None | | `V3_15_DISCARD_PILE_HISTORY.md` | Show recent discards fanned | Low | Medium | None |
| `V3_16_REALISTIC_CARD_SOUNDS.md` | Improved audio feedback | Nice | Medium | None | | `V3_16_REALISTIC_CARD_SOUNDS.md` | Improved audio feedback | Nice | Medium | None |
| `V3_17_MOBILE_PORTRAIT_LAYOUT.md` | Full mobile portrait layout + animation fixes | High | High | 02, 11 |
--- ---

View File

@@ -0,0 +1,117 @@
# V3.17: Mobile Portrait Layout
**Version:** 3.1.1
**Commits:** `4fcdf13`, `fb3bd53`
## Overview
Full mobile portrait layout for phones, triggered by JS `matchMedia` on narrow portrait screens (`max-width: 500px`, `orientation: portrait`). The desktop layout is completely untouched — all mobile rules are scoped under `body.mobile-portrait`.
## Key Features
### Responsive Game Layout
- Viewport fills 100dvh with no scroll; `overscroll-behavior: contain` prevents pull-to-refresh
- Game screen uses flexbox column: compact header → opponents row → player row → bottom bar
- Safe-area insets respected for notched devices (`env(safe-area-inset-top/bottom)`)
### Compact Header
- Single-row header with reduced font sizes (0.75rem) and tight gaps
- Non-essential items hidden on mobile: username display, logout button, active rules bar
- Status message, round info, final turn badge, and leave button all use `white-space: nowrap` with ellipsis overflow
### Opponent Cards
- Flat horizontal strip (no arch rotation) with horizontal scroll for 4+ opponents
- Cards scaled to 32x45px with 0.6rem font (26x36px on short screens)
- Dealer chip scaled from 38px to 20px diameter to fit compact opponent areas
- Showing score badge sized proportionally
### Deck/Discard Area
- Deck and discard cards match player card size (72x101px) for visual consistency
- Held card floating matches player card size with proportional font scaling
### Player Cards
- Fixed 72x101px cards with 1.5rem font in 3-column grid
- 60x84px with 1.3rem font on short screens (max-height: 600px)
- Font size set inline by `card-manager.js` proportional to card width (0.35x ratio on mobile)
### Side Panels as Bottom Drawers
- Standings and scoreboard panels slide up as bottom drawers from a mobile bottom bar
- Drawer backdrop overlay with tap-to-dismiss
- Drag handle visual indicator on each drawer
- Drawers auto-close on screen change or layout change back to desktop
### Short Screen Fallback
- `@media (max-height: 600px)` reduces all card sizes, gaps, and padding
- Opponent cards: 26x36px, deck/discard: 60x84px, player cards: 60x84px
## Animation Fixes
### Deal Animation Guard
- `renderGame()` returns early when `dealAnimationInProgress` is true
- Prevents WebSocket state updates from destroying card slot DOM elements mid-deal animation
- Cards were piling up at (0,0) because `getCardSlotRect()` read stale/null positions after `innerHTML = ''`
### Animation Overlay Card Sizing
- **Root cause:** Base `.card` CSS (`width: clamp(65px, 5.5vw, 100px)`) was leaking into animation overlay elements (`.draw-anim-front.card`), overriding the intended `width: 100%` inherited from the overlay container
- **Effect:** Opponent flip overlays appeared at 65px instead of 32px (too big); deck/discard draw overlays appeared at 65px instead of 72px (too small)
- **Fix:** Added `!important` to `.draw-anim-front/.draw-anim-back` `width` and `height` rules to ensure animation overlays always match their parent container's inline dimensions from JavaScript
### Opponent Swap Held Card Sizing
- `fireSwapAnimation()` now passes a `heldRect` sized to match the opponent card (32px) positioned at the holding location, instead of defaulting to deck dimensions (72px)
- The traveling held card no longer appears oversized relative to opponent cards during the swap arc
### Font Size Consistency
- `cardFontSize()` helper in `CardAnimations` uses 0.35x width ratio on mobile (vs 0.5x desktop)
- Applied consistently across all animation paths: `createAnimCard`, `createCardFromData`, and arc swap font transitions
- Held card floating gets inline font-size scaled to card width on mobile
## CSS Architecture
All mobile rules use the `body.mobile-portrait` scope:
```css
/* Applied by JS matchMedia, not CSS media query */
body.mobile-portrait .selector { ... }
/* Short screen fallback uses both */
@media (max-height: 600px) {
body.mobile-portrait .selector { ... }
}
```
Card sizing uses `!important` to override base `.card` clamp values:
```css
body.mobile-portrait .opponent-area .card {
width: 32px !important;
height: 45px !important;
}
```
Animation overlays use `!important` to override base `.card` leaking:
```css
.draw-anim-front,
.draw-anim-back {
width: 100% !important;
height: 100% !important;
}
```
## Files Modified
| File | Changes |
|------|---------|
| `client/style.css` | ~470 lines of mobile portrait CSS added at end of file |
| `client/app.js` | Mobile detection, drawer management, `renderGame()` guard, swap heldRect sizing, held card font scaling |
| `client/card-animations.js` | `cardFontSize()` helper, consistent font scaling across all animation paths |
| `client/card-manager.js` | Inline font-size on mobile for `updateCardElement()` |
| `client/index.html` | Mobile bottom bar, drawer backdrop, viewport-fit=cover |
## Testing
- **Desktop:** No visual changes — all rules scoped under `body.mobile-portrait`
- **Mobile portrait:** Verify game fits 100dvh, no scroll, cards properly sized
- **Deal animation:** Cards fly to correct grid positions (not piling up)
- **Draw/discard:** Animation overlay matches source card size
- **Opponent swap:** Flip and arc animations use opponent card dimensions
- **Short screens (iPhone SE):** All elements fit with reduced sizes
- **Orientation change:** Layout switches cleanly between mobile and desktop

View File

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

9
scripts/deploy.sh Executable file
View File

@@ -0,0 +1,9 @@
#!/bin/bash
set -e
DROPLET="root@165.245.152.51"
REMOTE_DIR="/opt/golfgame"
echo "Deploying to $DROPLET..."
ssh $DROPLET "cd $REMOTE_DIR && git pull origin main && docker compose -f docker-compose.prod.yml up -d --build app"
echo "Deploy complete."

View File

@@ -1934,7 +1934,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

View File

@@ -145,9 +145,18 @@ class ServerConfig:
# Security (for future auth system) # Security (for future auth system)
SECRET_KEY: str = "" SECRET_KEY: str = ""
INVITE_ONLY: bool = False INVITE_ONLY: bool = True
# Bootstrap admin (for first-time setup when INVITE_ONLY=true)
BOOTSTRAP_ADMIN_USERNAME: str = ""
BOOTSTRAP_ADMIN_PASSWORD: str = ""
ADMIN_EMAILS: list[str] = field(default_factory=list) ADMIN_EMAILS: list[str] = field(default_factory=list)
# Matchmaking
MATCHMAKING_ENABLED: bool = True
MATCHMAKING_MIN_PLAYERS: int = 2
MATCHMAKING_MAX_PLAYERS: int = 4
# Rate limiting # Rate limiting
RATE_LIMIT_ENABLED: bool = True RATE_LIMIT_ENABLED: bool = True
@@ -184,7 +193,12 @@ class ServerConfig:
ROOM_TIMEOUT_MINUTES=get_env_int("ROOM_TIMEOUT_MINUTES", 60), ROOM_TIMEOUT_MINUTES=get_env_int("ROOM_TIMEOUT_MINUTES", 60),
ROOM_CODE_LENGTH=get_env_int("ROOM_CODE_LENGTH", 4), ROOM_CODE_LENGTH=get_env_int("ROOM_CODE_LENGTH", 4),
SECRET_KEY=get_env("SECRET_KEY", ""), SECRET_KEY=get_env("SECRET_KEY", ""),
INVITE_ONLY=get_env_bool("INVITE_ONLY", False), INVITE_ONLY=get_env_bool("INVITE_ONLY", True),
BOOTSTRAP_ADMIN_USERNAME=get_env("BOOTSTRAP_ADMIN_USERNAME", ""),
BOOTSTRAP_ADMIN_PASSWORD=get_env("BOOTSTRAP_ADMIN_PASSWORD", ""),
MATCHMAKING_ENABLED=get_env_bool("MATCHMAKING_ENABLED", True),
MATCHMAKING_MIN_PLAYERS=get_env_int("MATCHMAKING_MIN_PLAYERS", 2),
MATCHMAKING_MAX_PLAYERS=get_env_int("MATCHMAKING_MAX_PLAYERS", 4),
ADMIN_EMAILS=admin_emails, ADMIN_EMAILS=admin_emails,
RATE_LIMIT_ENABLED=get_env_bool("RATE_LIMIT_ENABLED", True), RATE_LIMIT_ENABLED=get_env_bool("RATE_LIMIT_ENABLED", True),
SENTRY_DSN=get_env("SENTRY_DSN", ""), SENTRY_DSN=get_env("SENTRY_DSN", ""),

View File

@@ -12,6 +12,7 @@ from typing import Optional
from fastapi import WebSocket from fastapi import WebSocket
from config import config
from game import GamePhase, GameOptions from game import GamePhase, GameOptions
from ai import GolfAI, get_all_profiles from ai import GolfAI, get_all_profiles
from room import Room from room import Room
@@ -53,6 +54,10 @@ def log_human_action(room: Room, player, action: str, card=None, position=None,
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def handle_create_room(data: dict, ctx: ConnectionContext, *, room_manager, count_user_games, max_concurrent, **kw) -> None: async def handle_create_room(data: dict, ctx: ConnectionContext, *, room_manager, count_user_games, max_concurrent, **kw) -> None:
if config.INVITE_ONLY and not ctx.authenticated_user:
await ctx.websocket.send_json({"type": "error", "message": "You must be logged in to play"})
return
if ctx.auth_user_id and count_user_games(ctx.auth_user_id) >= max_concurrent: if ctx.auth_user_id and count_user_games(ctx.auth_user_id) >= max_concurrent:
await ctx.websocket.send_json({ await ctx.websocket.send_json({
"type": "error", "type": "error",
@@ -60,9 +65,8 @@ async def handle_create_room(data: dict, ctx: ConnectionContext, *, room_manager
}) })
return return
player_name = data.get("player_name", "Player") # Use authenticated username as player name
if ctx.authenticated_user and ctx.authenticated_user.display_name: player_name = ctx.authenticated_user.username if ctx.authenticated_user else data.get("player_name", "Player")
player_name = ctx.authenticated_user.display_name
room = room_manager.create_room() room = room_manager.create_room()
room.add_player(ctx.player_id, player_name, ctx.websocket, ctx.auth_user_id) room.add_player(ctx.player_id, player_name, ctx.websocket, ctx.auth_user_id)
ctx.current_room = room ctx.current_room = room
@@ -81,8 +85,13 @@ async def handle_create_room(data: dict, ctx: ConnectionContext, *, room_manager
async def handle_join_room(data: dict, ctx: ConnectionContext, *, room_manager, count_user_games, max_concurrent, **kw) -> None: async def handle_join_room(data: dict, ctx: ConnectionContext, *, room_manager, count_user_games, max_concurrent, **kw) -> None:
if config.INVITE_ONLY and not ctx.authenticated_user:
await ctx.websocket.send_json({"type": "error", "message": "You must be logged in to play"})
return
room_code = data.get("room_code", "").upper() room_code = data.get("room_code", "").upper()
player_name = data.get("player_name", "Player") # Use authenticated username as player name
player_name = ctx.authenticated_user.username if ctx.authenticated_user else data.get("player_name", "Player")
if ctx.auth_user_id and count_user_games(ctx.auth_user_id) >= max_concurrent: if ctx.auth_user_id and count_user_games(ctx.auth_user_id) >= max_concurrent:
await ctx.websocket.send_json({ await ctx.websocket.send_json({
@@ -104,8 +113,6 @@ async def handle_join_room(data: dict, ctx: ConnectionContext, *, room_manager,
await ctx.websocket.send_json({"type": "error", "message": "Game already in progress"}) await ctx.websocket.send_json({"type": "error", "message": "Game already in progress"})
return return
if ctx.authenticated_user and ctx.authenticated_user.display_name:
player_name = ctx.authenticated_user.display_name
room.add_player(ctx.player_id, player_name, ctx.websocket, ctx.auth_user_id) room.add_player(ctx.player_id, player_name, ctx.websocket, ctx.auth_user_id)
ctx.current_room = room ctx.current_room = room
@@ -222,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:
@@ -233,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)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -290,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:
@@ -322,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:
@@ -357,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:
@@ -373,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:
@@ -393,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:
@@ -411,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:
@@ -436,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)
@@ -466,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",
@@ -483,6 +499,65 @@ async def handle_end_game(data: dict, ctx: ConnectionContext, *, room_manager, c
# Handler dispatch table # Handler dispatch table
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Matchmaking handlers
# ---------------------------------------------------------------------------
async def handle_queue_join(data: dict, ctx: ConnectionContext, *, matchmaking_service=None, rating_service=None, **kw) -> None:
if not matchmaking_service:
await ctx.websocket.send_json({"type": "error", "message": "Matchmaking not available"})
return
if not ctx.authenticated_user:
await ctx.websocket.send_json({"type": "error", "message": "You must be logged in to find a game"})
return
# Get player's rating
rating = 1500.0
if rating_service:
try:
player_rating = await rating_service.get_rating(ctx.auth_user_id)
rating = player_rating.rating
except Exception:
pass
status = await matchmaking_service.join_queue(
user_id=ctx.auth_user_id,
username=ctx.authenticated_user.username,
rating=rating,
websocket=ctx.websocket,
connection_id=ctx.connection_id,
)
await ctx.websocket.send_json({
"type": "queue_joined",
**status,
})
async def handle_queue_leave(data: dict, ctx: ConnectionContext, *, matchmaking_service=None, **kw) -> None:
if not matchmaking_service or not ctx.auth_user_id:
return
removed = await matchmaking_service.leave_queue(ctx.auth_user_id)
await ctx.websocket.send_json({
"type": "queue_left",
"was_queued": removed,
})
async def handle_queue_status(data: dict, ctx: ConnectionContext, *, matchmaking_service=None, **kw) -> None:
if not matchmaking_service or not ctx.auth_user_id:
await ctx.websocket.send_json({"type": "queue_status", "in_queue": False})
return
status = await matchmaking_service.get_queue_status(ctx.auth_user_id)
await ctx.websocket.send_json({
"type": "queue_status",
**status,
})
HANDLERS = { HANDLERS = {
"create_room": handle_create_room, "create_room": handle_create_room,
"join_room": handle_join_room, "join_room": handle_join_room,
@@ -503,4 +578,7 @@ HANDLERS = {
"leave_room": handle_leave_room, "leave_room": handle_leave_room,
"leave_game": handle_leave_game, "leave_game": handle_leave_game,
"end_game": handle_end_game, "end_game": handle_end_game,
"queue_join": handle_queue_join,
"queue_leave": handle_queue_leave,
"queue_status": handle_queue_status,
} }

View File

@@ -59,6 +59,8 @@ _user_store = None
_auth_service = None _auth_service = None
_admin_service = None _admin_service = None
_stats_service = None _stats_service = None
_rating_service = None
_matchmaking_service = None
_replay_service = None _replay_service = None
_spectator_manager = None _spectator_manager = None
_leaderboard_refresh_task = None _leaderboard_refresh_task = None
@@ -101,7 +103,7 @@ async def _init_redis():
async def _init_database_services(): async def _init_database_services():
"""Initialize all PostgreSQL-dependent services.""" """Initialize all PostgreSQL-dependent services."""
global _user_store, _auth_service, _admin_service, _stats_service global _user_store, _auth_service, _admin_service, _stats_service, _rating_service, _matchmaking_service
global _replay_service, _spectator_manager, _leaderboard_refresh_task global _replay_service, _spectator_manager, _leaderboard_refresh_task
from stores.user_store import get_user_store from stores.user_store import get_user_store
@@ -109,7 +111,7 @@ async def _init_database_services():
from services.auth_service import get_auth_service from services.auth_service import get_auth_service
from services.admin_service import get_admin_service from services.admin_service import get_admin_service
from services.stats_service import StatsService, set_stats_service from services.stats_service import StatsService, set_stats_service
from routers.auth import set_auth_service from routers.auth import set_auth_service, set_admin_service_for_auth
from routers.admin import set_admin_service from routers.admin import set_admin_service
from routers.stats import set_stats_service as set_stats_router_service from routers.stats import set_stats_service as set_stats_router_service
from routers.stats import set_auth_service as set_stats_auth_service from routers.stats import set_auth_service as set_stats_auth_service
@@ -127,6 +129,7 @@ async def _init_database_services():
state_cache=None, state_cache=None,
) )
set_admin_service(_admin_service) set_admin_service(_admin_service)
set_admin_service_for_auth(_admin_service)
logger.info("Admin services initialized") logger.info("Admin services initialized")
# Stats + event store # Stats + event store
@@ -137,6 +140,23 @@ async def _init_database_services():
set_stats_auth_service(_auth_service) set_stats_auth_service(_auth_service)
logger.info("Stats services initialized") logger.info("Stats services initialized")
# Rating service (Glicko-2)
from services.rating_service import RatingService
_rating_service = RatingService(_user_store.pool)
logger.info("Rating service initialized")
# Matchmaking service
if config.MATCHMAKING_ENABLED:
from services.matchmaking import MatchmakingService, MatchmakingConfig
mm_config = MatchmakingConfig(
enabled=True,
min_players=config.MATCHMAKING_MIN_PLAYERS,
max_players=config.MATCHMAKING_MAX_PLAYERS,
)
_matchmaking_service = MatchmakingService(_redis_client, mm_config)
await _matchmaking_service.start(room_manager, broadcast_game_state)
logger.info("Matchmaking service initialized")
# Game logger # Game logger
_game_logger = GameLogger(_event_store) _game_logger = GameLogger(_event_store)
set_logger(_game_logger) set_logger(_game_logger)
@@ -165,12 +185,56 @@ async def _init_database_services():
logger.info("Leaderboard refresh task started") logger.info("Leaderboard refresh task started")
async def _bootstrap_admin():
"""Create bootstrap admin user if no admins exist yet."""
import bcrypt
from models.user import UserRole
# Check if any admin already exists
existing = await _user_store.get_user_by_username(config.BOOTSTRAP_ADMIN_USERNAME)
if existing:
return
# Check if any admin exists at all
async with _user_store.pool.acquire() as conn:
admin_count = await conn.fetchval(
"SELECT COUNT(*) FROM users_v2 WHERE role = 'admin' AND deleted_at IS NULL"
)
if admin_count > 0:
return
# Create the bootstrap admin
password_hash = bcrypt.hashpw(
config.BOOTSTRAP_ADMIN_PASSWORD.encode("utf-8"),
bcrypt.gensalt(),
).decode("utf-8")
user = await _user_store.create_user(
username=config.BOOTSTRAP_ADMIN_USERNAME,
password_hash=password_hash,
role=UserRole.ADMIN,
)
if user:
logger.warning(
f"Bootstrap admin '{config.BOOTSTRAP_ADMIN_USERNAME}' created. "
"Change the password and remove BOOTSTRAP_ADMIN_* env vars."
)
else:
logger.error("Failed to create bootstrap admin user")
async def _shutdown_services(): async def _shutdown_services():
"""Gracefully shut down all services.""" """Gracefully shut down all services."""
_shutdown_event.set() _shutdown_event.set()
await _close_all_websockets() await _close_all_websockets()
# Stop matchmaking
if _matchmaking_service:
await _matchmaking_service.stop()
await _matchmaking_service.cleanup()
# Clean up rooms and CPU profiles # Clean up rooms and CPU profiles
for room in list(room_manager.rooms.values()): for room in list(room_manager.rooms.values()):
for cpu in list(room.get_cpu_players()): for cpu in list(room.get_cpu_players()):
@@ -225,6 +289,10 @@ async def lifespan(app: FastAPI):
else: else:
logger.warning("POSTGRES_URL not configured - auth/admin/stats endpoints will not work") logger.warning("POSTGRES_URL not configured - auth/admin/stats endpoints will not work")
# Bootstrap admin user if needed (for first-time setup with INVITE_ONLY)
if config.POSTGRES_URL and config.BOOTSTRAP_ADMIN_USERNAME and config.BOOTSTRAP_ADMIN_PASSWORD:
await _bootstrap_admin()
# Set up health check dependencies # Set up health check dependencies
from routers.health import set_health_dependencies from routers.health import set_health_dependencies
set_health_dependencies( set_health_dependencies(
@@ -257,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="2.0.1", version="3.1.1",
lifespan=lifespan, lifespan=lifespan,
) )
@@ -458,7 +526,7 @@ def count_user_games(user_id: str) -> int:
async def websocket_endpoint(websocket: WebSocket): async def websocket_endpoint(websocket: WebSocket):
await websocket.accept() await websocket.accept()
# Extract token from query param for optional authentication # Extract token from query param for authentication
token = websocket.query_params.get("token") token = websocket.query_params.get("token")
authenticated_user = None authenticated_user = None
if token and _auth_service: if token and _auth_service:
@@ -467,6 +535,12 @@ async def websocket_endpoint(websocket: WebSocket):
except Exception as e: except Exception as e:
logger.debug(f"WebSocket auth failed: {e}") logger.debug(f"WebSocket auth failed: {e}")
# Reject unauthenticated connections when invite-only
if config.INVITE_ONLY and not authenticated_user:
await websocket.send_json({"type": "error", "message": "Authentication required. Please log in."})
await websocket.close(code=4001, reason="Authentication required")
return
connection_id = str(uuid.uuid4()) connection_id = str(uuid.uuid4())
auth_user_id = str(authenticated_user.id) if authenticated_user else None auth_user_id = str(authenticated_user.id) if authenticated_user else None
@@ -492,6 +566,8 @@ async def websocket_endpoint(websocket: WebSocket):
check_and_run_cpu_turn=check_and_run_cpu_turn, check_and_run_cpu_turn=check_and_run_cpu_turn,
handle_player_leave=handle_player_leave, handle_player_leave=handle_player_leave,
cleanup_room_profiles=cleanup_room_profiles, cleanup_room_profiles=cleanup_room_profiles,
matchmaking_service=_matchmaking_service,
rating_service=_rating_service,
) )
try: try:
@@ -534,6 +610,23 @@ async def _process_stats_safe(room: Room):
game_options=room.game.options, game_options=room.game.options,
) )
logger.debug(f"Stats processed for room {room.code}") logger.debug(f"Stats processed for room {room.code}")
# Update Glicko-2 ratings for human players
if _rating_service:
player_results = []
for game_player in room.game.players:
if game_player.id in player_user_ids:
player_results.append((
player_user_ids[game_player.id],
game_player.total_score,
))
if len(player_results) >= 2:
await _rating_service.update_ratings(
player_results=player_results,
is_standard_rules=room.game.options.is_standard_rules(),
)
except Exception as e: except Exception as e:
logger.error(f"Failed to process game stats: {e}") logger.error(f"Failed to process game stats: {e}")
@@ -612,8 +705,13 @@ 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): if room.game.phase not in (GamePhase.PLAYING, GamePhase.FINAL_TURN):
return return
@@ -625,21 +723,54 @@ async def check_and_run_cpu_turn(room: Room):
if not room_player or not room_player.is_cpu: if not room_player or not room_player.is_cpu:
return return
# Brief pause before CPU starts - animations are faster now task = asyncio.create_task(_run_cpu_chain(room))
await asyncio.sleep(0.25) room.cpu_turn_task = task
# Run CPU turn def _on_done(t: asyncio.Task):
async def broadcast_cb(): # Clear the reference when the task finishes (success, cancel, or error)
await broadcast_game_state(room) 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()}")
await process_cpu_turn(room.game, current, broadcast_cb, game_id=room.game_log_id) task.add_done_callback(_on_done)
# Check if next player is also CPU (chain CPU turns)
await check_and_run_cpu_turn(room) async def _run_cpu_chain(room: Room):
"""Run consecutive CPU turns until a human player's turn or game ends."""
while True:
if room.game.phase not in (GamePhase.PLAYING, GamePhase.FINAL_TURN):
return
current = room.game.current_player()
if not current:
return
room_player = room.get_player(current.id)
if not room_player or not room_player.is_cpu:
return
# Brief pause before CPU starts - animations are faster now
await asyncio.sleep(0.25)
# Run CPU turn
async def broadcast_cb():
await broadcast_game_state(room)
await process_cpu_turn(room.game, current, broadcast_cb, game_id=room.game_log_id)
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)
@@ -675,6 +806,10 @@ if os.path.exists(client_path):
async def serve_replay_page(share_code: str): async def serve_replay_page(share_code: str):
return FileResponse(os.path.join(client_path, "index.html")) return FileResponse(os.path.join(client_path, "index.html"))
@app.get("/reset-password")
async def serve_reset_password_page():
return FileResponse(os.path.join(client_path, "index.html"))
# Mount static files for everything else (JS, CSS, SVG, etc.) # Mount static files for everything else (JS, CSS, SVG, etc.)
app.mount("/", StaticFiles(directory=client_path), name="static") app.mount("/", StaticFiles(directory=client_path), name="static")

View File

@@ -110,8 +110,10 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
# Add WebSocket URLs # Add WebSocket URLs
if self.environment == "production": if self.environment == "production":
connect_sources.append(f"ws://{host}")
connect_sources.append(f"wss://{host}") connect_sources.append(f"wss://{host}")
for allowed_host in self.allowed_hosts: for allowed_host in self.allowed_hosts:
connect_sources.append(f"ws://{allowed_host}")
connect_sources.append(f"wss://{allowed_host}") connect_sources.append(f"wss://{allowed_host}")
else: else:
# Development - allow ws:// and wss:// # Development - allow ws:// and wss://

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,

View File

@@ -11,8 +11,10 @@ from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Header, Request from fastapi import APIRouter, Depends, HTTPException, Header, Request
from pydantic import BaseModel, EmailStr from pydantic import BaseModel, EmailStr
from config import config
from models.user import User from models.user import User
from services.auth_service import AuthService from services.auth_service import AuthService
from services.admin_service import AdminService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -29,6 +31,7 @@ class RegisterRequest(BaseModel):
username: str username: str
password: str password: str
email: Optional[str] = None email: Optional[str] = None
invite_code: Optional[str] = None
class LoginRequest(BaseModel): class LoginRequest(BaseModel):
@@ -111,6 +114,7 @@ class SessionResponse(BaseModel):
# These will be set by main.py during startup # These will be set by main.py during startup
_auth_service: Optional[AuthService] = None _auth_service: Optional[AuthService] = None
_admin_service: Optional[AdminService] = None
def set_auth_service(service: AuthService) -> None: def set_auth_service(service: AuthService) -> None:
@@ -119,6 +123,12 @@ def set_auth_service(service: AuthService) -> None:
_auth_service = service _auth_service = service
def set_admin_service_for_auth(service: AdminService) -> None:
"""Set the admin service instance for invite code validation (called from main.py)."""
global _admin_service
_admin_service = service
def get_auth_service_dep() -> AuthService: def get_auth_service_dep() -> AuthService:
"""Dependency to get auth service.""" """Dependency to get auth service."""
if _auth_service is None: if _auth_service is None:
@@ -201,6 +211,15 @@ async def register(
auth_service: AuthService = Depends(get_auth_service_dep), auth_service: AuthService = Depends(get_auth_service_dep),
): ):
"""Register a new user account.""" """Register a new user account."""
# Validate invite code when invite-only mode is enabled
if config.INVITE_ONLY:
if not request_body.invite_code:
raise HTTPException(status_code=400, detail="Invite code required")
if not _admin_service:
raise HTTPException(status_code=503, detail="Admin service not initialized")
if not await _admin_service.validate_invite_code(request_body.invite_code):
raise HTTPException(status_code=400, detail="Invalid or expired invite code")
result = await auth_service.register( result = await auth_service.register(
username=request_body.username, username=request_body.username,
password=request_body.password, password=request_body.password,
@@ -210,6 +229,10 @@ async def register(
if not result.success: if not result.success:
raise HTTPException(status_code=400, detail=result.error) raise HTTPException(status_code=400, detail=result.error)
# Consume the invite code after successful registration
if config.INVITE_ONLY and request_body.invite_code:
await _admin_service.use_invite_code(request_body.invite_code)
if result.requires_verification: if result.requires_verification:
# Return user info but note they need to verify # Return user info but note they need to verify
return { return {

View File

@@ -155,7 +155,7 @@ async def require_user(
@router.get("/leaderboard", response_model=LeaderboardResponse) @router.get("/leaderboard", response_model=LeaderboardResponse)
async def get_leaderboard( async def get_leaderboard(
metric: str = Query("wins", pattern="^(wins|win_rate|avg_score|knockouts|streak)$"), metric: str = Query("wins", pattern="^(wins|win_rate|avg_score|knockouts|streak|rating)$"),
limit: int = Query(50, ge=1, le=100), limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0), offset: int = Query(0, ge=0),
service: StatsService = Depends(get_stats_service_dep), service: StatsService = Depends(get_stats_service_dep),
@@ -226,7 +226,7 @@ async def get_player_stats(
@router.get("/players/{user_id}/rank", response_model=PlayerRankResponse) @router.get("/players/{user_id}/rank", response_model=PlayerRankResponse)
async def get_player_rank( async def get_player_rank(
user_id: str, user_id: str,
metric: str = Query("wins", pattern="^(wins|win_rate|avg_score|knockouts|streak)$"), metric: str = Query("wins", pattern="^(wins|win_rate|avg_score|knockouts|streak|rating)$"),
service: StatsService = Depends(get_stats_service_dep), service: StatsService = Depends(get_stats_service_dep),
): ):
"""Get player's rank on a leaderboard.""" """Get player's rank on a leaderboard."""
@@ -346,7 +346,7 @@ async def get_my_stats(
@router.get("/me/rank", response_model=PlayerRankResponse) @router.get("/me/rank", response_model=PlayerRankResponse)
async def get_my_rank( async def get_my_rank(
metric: str = Query("wins", pattern="^(wins|win_rate|avg_score|knockouts|streak)$"), metric: str = Query("wins", pattern="^(wins|win_rate|avg_score|knockouts|streak|rating)$"),
user: User = Depends(require_user), user: User = Depends(require_user),
service: StatsService = Depends(get_stats_service_dep), service: StatsService = Depends(get_stats_service_dep),
): ):

View File

@@ -0,0 +1,393 @@
"""
Matchmaking service for public skill-based games.
Uses Redis sorted sets to maintain a queue of players looking for games,
grouped by rating. A background task periodically scans the queue and
creates matches when enough similar-skill players are available.
"""
import asyncio
import json
import logging
import time
from dataclasses import dataclass
from typing import Optional
from fastapi import WebSocket
logger = logging.getLogger(__name__)
@dataclass
class QueuedPlayer:
"""A player waiting in the matchmaking queue."""
user_id: str
username: str
rating: float
queued_at: float # time.time()
connection_id: str
@dataclass
class MatchmakingConfig:
"""Configuration for the matchmaking system."""
enabled: bool = True
min_players: int = 2
max_players: int = 4
initial_rating_window: int = 100 # +/- rating range to start
expand_interval: int = 15 # seconds between range expansions
expand_amount: int = 50 # rating points to expand by
max_rating_window: int = 500 # maximum +/- range
match_check_interval: float = 3.0 # seconds between match attempts
countdown_seconds: int = 5 # countdown before matched game starts
class MatchmakingService:
"""
Manages the matchmaking queue and creates matches.
Players join the queue with their rating. A background task
periodically scans for groups of similarly-rated players and
creates games when matches are found.
"""
def __init__(self, redis_client, config: Optional[MatchmakingConfig] = None):
self.redis = redis_client
self.config = config or MatchmakingConfig()
self._queue: dict[str, QueuedPlayer] = {} # user_id -> QueuedPlayer
self._websockets: dict[str, WebSocket] = {} # user_id -> WebSocket
self._connection_ids: dict[str, str] = {} # user_id -> connection_id
self._running = False
self._task: Optional[asyncio.Task] = None
async def join_queue(
self,
user_id: str,
username: str,
rating: float,
websocket: WebSocket,
connection_id: str,
) -> dict:
"""
Add a player to the matchmaking queue.
Returns:
Queue status dict.
"""
if user_id in self._queue:
return {"position": self._get_position(user_id), "queue_size": len(self._queue)}
player = QueuedPlayer(
user_id=user_id,
username=username,
rating=rating,
queued_at=time.time(),
connection_id=connection_id,
)
self._queue[user_id] = player
self._websockets[user_id] = websocket
self._connection_ids[user_id] = connection_id
# Also add to Redis for persistence across restarts
if self.redis:
try:
await self.redis.zadd("matchmaking:queue", {user_id: rating})
await self.redis.hset(
"matchmaking:players",
user_id,
json.dumps({
"username": username,
"rating": rating,
"queued_at": player.queued_at,
"connection_id": connection_id,
}),
)
except Exception as e:
logger.warning(f"Redis matchmaking write failed: {e}")
position = self._get_position(user_id)
logger.info(f"Player {username} ({user_id[:8]}) joined queue (rating={rating:.0f}, pos={position})")
return {"position": position, "queue_size": len(self._queue)}
async def leave_queue(self, user_id: str) -> bool:
"""Remove a player from the matchmaking queue."""
if user_id not in self._queue:
return False
player = self._queue.pop(user_id, None)
self._websockets.pop(user_id, None)
self._connection_ids.pop(user_id, None)
if self.redis:
try:
await self.redis.zrem("matchmaking:queue", user_id)
await self.redis.hdel("matchmaking:players", user_id)
except Exception as e:
logger.warning(f"Redis matchmaking remove failed: {e}")
if player:
logger.info(f"Player {player.username} ({user_id[:8]}) left queue")
return True
async def get_queue_status(self, user_id: str) -> dict:
"""Get current queue status for a player."""
if user_id not in self._queue:
return {"in_queue": False}
player = self._queue[user_id]
wait_time = time.time() - player.queued_at
current_window = self._get_rating_window(wait_time)
return {
"in_queue": True,
"position": self._get_position(user_id),
"queue_size": len(self._queue),
"wait_time": int(wait_time),
"rating_window": current_window,
}
async def find_matches(self, room_manager, broadcast_game_state_fn) -> list[dict]:
"""
Scan the queue and create matches.
Returns:
List of match info dicts for matches created.
"""
if len(self._queue) < self.config.min_players:
return []
matches_created = []
matched_user_ids = set()
# Sort players by rating
sorted_players = sorted(self._queue.values(), key=lambda p: p.rating)
for player in sorted_players:
if player.user_id in matched_user_ids:
continue
wait_time = time.time() - player.queued_at
window = self._get_rating_window(wait_time)
# Find compatible players
candidates = []
for other in sorted_players:
if other.user_id == player.user_id or other.user_id in matched_user_ids:
continue
if abs(other.rating - player.rating) <= window:
candidates.append(other)
# Include the player themselves
group = [player] + candidates
if len(group) >= self.config.min_players:
# Take up to max_players
match_group = group[:self.config.max_players]
matched_user_ids.update(p.user_id for p in match_group)
# Create the match
match_info = await self._create_match(match_group, room_manager)
if match_info:
matches_created.append(match_info)
return matches_created
async def _create_match(self, players: list[QueuedPlayer], room_manager) -> Optional[dict]:
"""
Create a room for matched players and notify them.
Returns:
Match info dict, or None if creation failed.
"""
try:
# Create room
room = room_manager.create_room()
# Add all matched players to the room
for player in players:
ws = self._websockets.get(player.user_id)
if not ws:
continue
room.add_player(
player.connection_id,
player.username,
ws,
player.user_id,
)
# Remove matched players from queue
for player in players:
await self.leave_queue(player.user_id)
# Notify all matched players
match_info = {
"room_code": room.code,
"players": [
{"username": p.username, "rating": round(p.rating)}
for p in players
],
}
for player in players:
ws = self._websockets.get(player.user_id)
if ws:
try:
await ws.send_json({
"type": "queue_matched",
"room_code": room.code,
"players": match_info["players"],
"countdown": self.config.countdown_seconds,
})
except Exception as e:
logger.warning(f"Failed to notify matched player {player.user_id[:8]}: {e}")
# Also send room_joined to each player so the client switches screens
for player in players:
ws = self._websockets.get(player.user_id)
if ws:
try:
await ws.send_json({
"type": "room_joined",
"room_code": room.code,
"player_id": player.connection_id,
"authenticated": True,
})
# Send player list
await ws.send_json({
"type": "player_joined",
"players": room.player_list(),
})
except Exception:
pass
avg_rating = sum(p.rating for p in players) / len(players)
logger.info(
f"Match created: room={room.code}, "
f"players={[p.username for p in players]}, "
f"avg_rating={avg_rating:.0f}"
)
# Schedule auto-start after countdown
asyncio.create_task(self._auto_start_game(room, self.config.countdown_seconds))
return match_info
except Exception as e:
logger.error(f"Failed to create match: {e}")
return None
async def _auto_start_game(self, room, countdown: int):
"""Auto-start a matched game after countdown."""
from game import GamePhase, GameOptions
await asyncio.sleep(countdown)
if room.game.phase != GamePhase.WAITING:
return # Game already started or room closed
if len(room.players) < 2:
return # Not enough players
# Standard rules for ranked games
options = GameOptions()
options.flip_mode = "never"
options.initial_flips = 2
try:
async with room.game_lock:
room.game.start_game(1, 9, options) # 1 deck, 9 rounds, standard rules
# Send game started to all players
for pid, rp in room.players.items():
if rp.websocket and not rp.is_cpu:
try:
state = room.game.get_state(pid)
await rp.websocket.send_json({
"type": "game_started",
"game_state": state,
})
except Exception:
pass
logger.info(f"Auto-started matched game in room {room.code}")
except Exception as e:
logger.error(f"Failed to auto-start matched game: {e}")
def _get_rating_window(self, wait_time: float) -> int:
"""Calculate the current rating window based on wait time."""
expansions = int(wait_time / self.config.expand_interval)
window = self.config.initial_rating_window + (expansions * self.config.expand_amount)
return min(window, self.config.max_rating_window)
def _get_position(self, user_id: str) -> int:
"""Get a player's position in the queue (1-indexed)."""
sorted_ids = sorted(
self._queue.keys(),
key=lambda uid: self._queue[uid].queued_at,
)
try:
return sorted_ids.index(user_id) + 1
except ValueError:
return 0
async def start(self, room_manager, broadcast_fn):
"""Start the matchmaking background task."""
if self._running:
return
self._running = True
self._task = asyncio.create_task(
self._matchmaking_loop(room_manager, broadcast_fn)
)
logger.info("Matchmaking service started")
async def stop(self):
"""Stop the matchmaking background task."""
self._running = False
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
logger.info("Matchmaking service stopped")
async def _matchmaking_loop(self, room_manager, broadcast_fn):
"""Background task that periodically checks for matches."""
while self._running:
try:
matches = await self.find_matches(room_manager, broadcast_fn)
if matches:
logger.info(f"Created {len(matches)} match(es)")
# Send queue status updates to all queued players
for user_id in list(self._queue.keys()):
ws = self._websockets.get(user_id)
if ws:
try:
status = await self.get_queue_status(user_id)
await ws.send_json({
"type": "queue_status",
**status,
})
except Exception:
# Player disconnected, remove from queue
await self.leave_queue(user_id)
except Exception as e:
logger.error(f"Matchmaking error: {e}")
await asyncio.sleep(self.config.match_check_interval)
async def cleanup(self):
"""Clean up Redis queue data on shutdown."""
if self.redis:
try:
await self.redis.delete("matchmaking:queue")
await self.redis.delete("matchmaking:players")
except Exception:
pass

View File

@@ -0,0 +1,322 @@
"""
Glicko-2 rating service for Golf game matchmaking.
Implements the Glicko-2 rating system adapted for multiplayer games.
Each game is treated as a set of pairwise comparisons between all players.
Reference: http://www.glicko.net/glicko/glicko2.pdf
"""
import logging
import math
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Optional
import asyncpg
logger = logging.getLogger(__name__)
# Glicko-2 constants
INITIAL_RATING = 1500.0
INITIAL_RD = 350.0
INITIAL_VOLATILITY = 0.06
TAU = 0.5 # System constant (constrains volatility change)
CONVERGENCE_TOLERANCE = 0.000001
GLICKO2_SCALE = 173.7178 # Factor to convert between Glicko and Glicko-2 scales
@dataclass
class PlayerRating:
"""A player's Glicko-2 rating."""
user_id: str
rating: float = INITIAL_RATING
rd: float = INITIAL_RD
volatility: float = INITIAL_VOLATILITY
updated_at: Optional[datetime] = None
@property
def mu(self) -> float:
"""Convert rating to Glicko-2 scale."""
return (self.rating - 1500) / GLICKO2_SCALE
@property
def phi(self) -> float:
"""Convert RD to Glicko-2 scale."""
return self.rd / GLICKO2_SCALE
def to_dict(self) -> dict:
return {
"rating": round(self.rating, 1),
"rd": round(self.rd, 1),
"volatility": round(self.volatility, 6),
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
def _g(phi: float) -> float:
"""Glicko-2 g function."""
return 1.0 / math.sqrt(1.0 + 3.0 * phi * phi / (math.pi * math.pi))
def _E(mu: float, mu_j: float, phi_j: float) -> float:
"""Glicko-2 expected score."""
return 1.0 / (1.0 + math.exp(-_g(phi_j) * (mu - mu_j)))
def _compute_variance(mu: float, opponents: list[tuple[float, float]]) -> float:
"""
Compute the estimated variance of the player's rating
based on game outcomes.
opponents: list of (mu_j, phi_j) tuples
"""
v_inv = 0.0
for mu_j, phi_j in opponents:
g_phi = _g(phi_j)
e = _E(mu, mu_j, phi_j)
v_inv += g_phi * g_phi * e * (1.0 - e)
if v_inv == 0:
return float('inf')
return 1.0 / v_inv
def _compute_delta(mu: float, opponents: list[tuple[float, float, float]], v: float) -> float:
"""
Compute the estimated improvement in rating.
opponents: list of (mu_j, phi_j, score) tuples
"""
total = 0.0
for mu_j, phi_j, score in opponents:
total += _g(phi_j) * (score - _E(mu, mu_j, phi_j))
return v * total
def _new_volatility(sigma: float, phi: float, v: float, delta: float) -> float:
"""Compute new volatility using the Illinois algorithm (Glicko-2 Step 5)."""
a = math.log(sigma * sigma)
delta_sq = delta * delta
phi_sq = phi * phi
def f(x):
ex = math.exp(x)
num1 = ex * (delta_sq - phi_sq - v - ex)
denom1 = 2.0 * (phi_sq + v + ex) ** 2
return num1 / denom1 - (x - a) / (TAU * TAU)
# Set initial bounds
A = a
if delta_sq > phi_sq + v:
B = math.log(delta_sq - phi_sq - v)
else:
k = 1
while f(a - k * TAU) < 0:
k += 1
B = a - k * TAU
# Illinois algorithm
f_A = f(A)
f_B = f(B)
for _ in range(100): # Safety limit
if abs(B - A) < CONVERGENCE_TOLERANCE:
break
C = A + (A - B) * f_A / (f_B - f_A)
f_C = f(C)
if f_C * f_B <= 0:
A = B
f_A = f_B
else:
f_A /= 2.0
B = C
f_B = f_C
return math.exp(A / 2.0)
def update_rating(player: PlayerRating, opponents: list[tuple[float, float, float]]) -> PlayerRating:
"""
Update a single player's rating based on game results.
Args:
player: Current player rating.
opponents: List of (mu_j, phi_j, score) where score is 1.0 (win), 0.5 (draw), 0.0 (loss).
Returns:
Updated PlayerRating.
"""
if not opponents:
# No opponents - just increase RD for inactivity
new_phi = math.sqrt(player.phi ** 2 + player.volatility ** 2)
return PlayerRating(
user_id=player.user_id,
rating=player.rating,
rd=min(new_phi * GLICKO2_SCALE, INITIAL_RD),
volatility=player.volatility,
updated_at=datetime.now(timezone.utc),
)
mu = player.mu
phi = player.phi
sigma = player.volatility
opp_pairs = [(mu_j, phi_j) for mu_j, phi_j, _ in opponents]
v = _compute_variance(mu, opp_pairs)
delta = _compute_delta(mu, opponents, v)
# New volatility
new_sigma = _new_volatility(sigma, phi, v, delta)
# Update phi (pre-rating)
phi_star = math.sqrt(phi ** 2 + new_sigma ** 2)
# New phi
new_phi = 1.0 / math.sqrt(1.0 / (phi_star ** 2) + 1.0 / v)
# New mu
improvement = 0.0
for mu_j, phi_j, score in opponents:
improvement += _g(phi_j) * (score - _E(mu, mu_j, phi_j))
new_mu = mu + new_phi ** 2 * improvement
# Convert back to Glicko scale
new_rating = new_mu * GLICKO2_SCALE + 1500
new_rd = new_phi * GLICKO2_SCALE
# Clamp RD to reasonable range
new_rd = max(30.0, min(new_rd, INITIAL_RD))
return PlayerRating(
user_id=player.user_id,
rating=max(100.0, new_rating), # Floor at 100
rd=new_rd,
volatility=new_sigma,
updated_at=datetime.now(timezone.utc),
)
class RatingService:
"""
Manages Glicko-2 ratings for players.
Ratings are only updated for standard-rules games.
Multiplayer games are decomposed into pairwise comparisons.
"""
def __init__(self, pool: asyncpg.Pool):
self.pool = pool
async def get_rating(self, user_id: str) -> PlayerRating:
"""Get a player's current rating."""
async with self.pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT rating, rating_deviation, rating_volatility, rating_updated_at
FROM player_stats
WHERE user_id = $1
""",
user_id,
)
if not row or row["rating"] is None:
return PlayerRating(user_id=user_id)
return PlayerRating(
user_id=user_id,
rating=float(row["rating"]),
rd=float(row["rating_deviation"]),
volatility=float(row["rating_volatility"]),
updated_at=row["rating_updated_at"],
)
async def get_ratings_batch(self, user_ids: list[str]) -> dict[str, PlayerRating]:
"""Get ratings for multiple players."""
ratings = {}
for uid in user_ids:
ratings[uid] = await self.get_rating(uid)
return ratings
async def update_ratings(
self,
player_results: list[tuple[str, int]],
is_standard_rules: bool,
) -> dict[str, PlayerRating]:
"""
Update ratings after a game.
Args:
player_results: List of (user_id, total_score) for each human player.
is_standard_rules: Whether the game used standard rules.
Returns:
Dict of user_id -> updated PlayerRating.
"""
if not is_standard_rules:
logger.debug("Skipping rating update for non-standard rules game")
return {}
if len(player_results) < 2:
logger.debug("Skipping rating update: fewer than 2 human players")
return {}
# Get current ratings
user_ids = [uid for uid, _ in player_results]
current_ratings = await self.get_ratings_batch(user_ids)
# Sort by score (lower is better in Golf)
sorted_results = sorted(player_results, key=lambda x: x[1])
# Build pairwise comparisons for each player
updated_ratings = {}
for uid, score in player_results:
player = current_ratings[uid]
opponents = []
for opp_uid, opp_score in player_results:
if opp_uid == uid:
continue
opp = current_ratings[opp_uid]
# Determine outcome (lower score wins in Golf)
if score < opp_score:
outcome = 1.0 # Win
elif score == opp_score:
outcome = 0.5 # Draw
else:
outcome = 0.0 # Loss
opponents.append((opp.mu, opp.phi, outcome))
updated = update_rating(player, opponents)
updated_ratings[uid] = updated
# Persist updated ratings
async with self.pool.acquire() as conn:
for uid, rating in updated_ratings.items():
await conn.execute(
"""
UPDATE player_stats
SET rating = $2,
rating_deviation = $3,
rating_volatility = $4,
rating_updated_at = $5
WHERE user_id = $1
""",
uid,
rating.rating,
rating.rd,
rating.volatility,
rating.updated_at,
)
logger.info(
f"Ratings updated for {len(updated_ratings)} players: "
+ ", ".join(f"{uid[:8]}={r.rating:.0f}" for uid, r in updated_ratings.items())
)
return updated_ratings

View File

@@ -37,6 +37,8 @@ class PlayerStats:
wolfpacks: int = 0 wolfpacks: int = 0
current_win_streak: int = 0 current_win_streak: int = 0
best_win_streak: int = 0 best_win_streak: int = 0
rating: float = 1500.0
rating_deviation: float = 350.0
first_game_at: Optional[datetime] = None first_game_at: Optional[datetime] = None
last_game_at: Optional[datetime] = None last_game_at: Optional[datetime] = None
achievements: List[str] = field(default_factory=list) achievements: List[str] = field(default_factory=list)
@@ -156,6 +158,8 @@ class StatsService:
wolfpacks=row["wolfpacks"] or 0, wolfpacks=row["wolfpacks"] or 0,
current_win_streak=row["current_win_streak"] or 0, current_win_streak=row["current_win_streak"] or 0,
best_win_streak=row["best_win_streak"] or 0, best_win_streak=row["best_win_streak"] or 0,
rating=float(row["rating"]) if row.get("rating") else 1500.0,
rating_deviation=float(row["rating_deviation"]) if row.get("rating_deviation") else 350.0,
first_game_at=row["first_game_at"].replace(tzinfo=timezone.utc) if row["first_game_at"] else None, first_game_at=row["first_game_at"].replace(tzinfo=timezone.utc) if row["first_game_at"] else None,
last_game_at=row["last_game_at"].replace(tzinfo=timezone.utc) if row["last_game_at"] else None, last_game_at=row["last_game_at"].replace(tzinfo=timezone.utc) if row["last_game_at"] else None,
achievements=[a["achievement_id"] for a in achievements], achievements=[a["achievement_id"] for a in achievements],
@@ -184,6 +188,7 @@ class StatsService:
"avg_score": ("avg_score", "ASC"), # Lower is better "avg_score": ("avg_score", "ASC"), # Lower is better
"knockouts": ("knockouts", "DESC"), "knockouts": ("knockouts", "DESC"),
"streak": ("best_win_streak", "DESC"), "streak": ("best_win_streak", "DESC"),
"rating": ("rating", "DESC"),
} }
if metric not in order_map: if metric not in order_map:
@@ -203,6 +208,7 @@ class StatsService:
SELECT SELECT
user_id, username, games_played, games_won, user_id, username, games_played, games_won,
win_rate, avg_score, knockouts, best_win_streak, win_rate, avg_score, knockouts, best_win_streak,
COALESCE(rating, 1500) as rating,
ROW_NUMBER() OVER (ORDER BY {column} {direction}) as rank ROW_NUMBER() OVER (ORDER BY {column} {direction}) as rank
FROM leaderboard_overall FROM leaderboard_overall
ORDER BY {column} {direction} ORDER BY {column} {direction}
@@ -216,6 +222,7 @@ class StatsService:
ROUND(s.games_won::numeric / NULLIF(s.games_played, 0) * 100, 1) as win_rate, ROUND(s.games_won::numeric / NULLIF(s.games_played, 0) * 100, 1) as win_rate,
ROUND(s.total_points::numeric / NULLIF(s.total_rounds, 0), 1) as avg_score, ROUND(s.total_points::numeric / NULLIF(s.total_rounds, 0), 1) as avg_score,
s.knockouts, s.best_win_streak, s.knockouts, s.best_win_streak,
COALESCE(s.rating, 1500) as rating,
ROW_NUMBER() OVER (ORDER BY {column} {direction}) as rank ROW_NUMBER() OVER (ORDER BY {column} {direction}) as rank
FROM player_stats s FROM player_stats s
JOIN users_v2 u ON s.user_id = u.id JOIN users_v2 u ON s.user_id = u.id

View File

@@ -204,6 +204,22 @@ BEGIN
WHERE table_name = 'player_stats' AND column_name = 'games_won_vs_humans') THEN WHERE table_name = 'player_stats' AND column_name = 'games_won_vs_humans') THEN
ALTER TABLE player_stats ADD COLUMN games_won_vs_humans INT DEFAULT 0; ALTER TABLE player_stats ADD COLUMN games_won_vs_humans INT DEFAULT 0;
END IF; END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'player_stats' AND column_name = 'rating') THEN
ALTER TABLE player_stats ADD COLUMN rating DECIMAL(7,2) DEFAULT 1500.0;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'player_stats' AND column_name = 'rating_deviation') THEN
ALTER TABLE player_stats ADD COLUMN rating_deviation DECIMAL(7,2) DEFAULT 350.0;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'player_stats' AND column_name = 'rating_volatility') THEN
ALTER TABLE player_stats ADD COLUMN rating_volatility DECIMAL(8,6) DEFAULT 0.06;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'player_stats' AND column_name = 'rating_updated_at') THEN
ALTER TABLE player_stats ADD COLUMN rating_updated_at TIMESTAMPTZ;
END IF;
END $$; END $$;
-- Stats processing queue (for async stats processing) -- Stats processing queue (for async stats processing)
@@ -265,9 +281,19 @@ CREATE TABLE IF NOT EXISTS system_metrics (
); );
-- Leaderboard materialized view (refreshed periodically) -- Leaderboard materialized view (refreshed periodically)
-- Note: Using DO block to handle case where view already exists -- Drop and recreate if missing rating column (v3.1.0 migration)
DO $$ DO $$
BEGIN BEGIN
IF EXISTS (SELECT 1 FROM pg_matviews WHERE matviewname = 'leaderboard_overall') THEN
-- Check if rating column exists in the view
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'leaderboard_overall' AND column_name = 'rating'
) THEN
DROP MATERIALIZED VIEW leaderboard_overall;
END IF;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_matviews WHERE matviewname = 'leaderboard_overall') THEN IF NOT EXISTS (SELECT 1 FROM pg_matviews WHERE matviewname = 'leaderboard_overall') THEN
EXECUTE ' EXECUTE '
CREATE MATERIALIZED VIEW leaderboard_overall AS CREATE MATERIALIZED VIEW leaderboard_overall AS
@@ -282,6 +308,7 @@ BEGIN
s.best_score as best_round_score, s.best_score as best_round_score,
s.knockouts, s.knockouts,
s.best_win_streak, s.best_win_streak,
COALESCE(s.rating, 1500) as rating,
s.last_game_at s.last_game_at
FROM player_stats s FROM player_stats s
JOIN users_v2 u ON s.user_id = u.id JOIN users_v2 u ON s.user_id = u.id
@@ -349,6 +376,9 @@ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_leaderboard_overall_score') THEN IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_leaderboard_overall_score') THEN
CREATE INDEX idx_leaderboard_overall_score ON leaderboard_overall(avg_score ASC); CREATE INDEX idx_leaderboard_overall_score ON leaderboard_overall(avg_score ASC);
END IF; END IF;
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_leaderboard_overall_rating') THEN
CREATE INDEX idx_leaderboard_overall_rating ON leaderboard_overall(rating DESC);
END IF;
END IF; END IF;
END $$; END $$;
""" """