Compare commits

...

311 Commits

Author SHA1 Message Date
semantic-release-bot
aa29379a9b chore(release): 2.16.7 [skip ci]
## [2.16.7](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.16.6...v2.16.7) (2025-10-10)

### Bug Fixes

* compile TypeScript server files to JavaScript for production ([83b9a4d](83b9a4d976))
* remove standalone output mode incompatible with custom server ([c8da5a8](c8da5a8340))
* update Dockerfile for non-standalone production builds ([14746c5](14746c568e))
2025-10-10 12:56:18 +00:00
Thomas Hallock
14746c568e fix: update Dockerfile for non-standalone production builds
- Remove standalone output references, copy .next directly
- Add compiled server files (server.js, socket-server.js, src/)
- Include drizzle migrations folder for database setup
- Create data directory for SQLite database
- Keep Python/g++/make in runtime for better-sqlite3
- Set correct working directory to /app/apps/web
- Add NODE_ENV=production environment variable

This enables proper production deployment with database migrations
running on container startup using pure Node.js.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-10 07:55:18 -05:00
Thomas Hallock
c8da5a8340 fix: remove standalone output mode incompatible with custom server
The standalone output mode in Next.js is incompatible with the custom
server.js implementation. Removing it resolves startup warnings and
ensures proper production builds with the custom server setup.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-10 07:55:18 -05:00
Thomas Hallock
83b9a4d976 fix: compile TypeScript server files to JavaScript for production
- Add tsconfig.server.json to compile server-side TypeScript
- Install tsc-alias to resolve path aliases (@/*) in compiled JS
- Update build script to run tsc + tsc-alias before Next.js build
- Update dev script to compile server files before starting
- Remove tsx runtime dependencies from server.js
- Add compiled JS files for socket-server, db, and arcade modules

This enables production builds to run with pure Node.js without
requiring tsx or ts-node at runtime, as required for Docker deployment.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-10 07:55:18 -05:00
semantic-release-bot
815f90e916 chore(release): 2.16.6 [skip ci]
## [2.16.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.16.5...v2.16.6) (2025-10-10)

### Bug Fixes

* correct static files and public path in Docker image ([c287b19](c287b19a39))
2025-10-10 00:09:14 +00:00
Thomas Hallock
c287b19a39 fix: correct static files and public path in Docker image
Next.js expects static files at /.next/static and public at /public
when running from /app, not at /apps/web/.next/static.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 19:08:22 -05:00
semantic-release-bot
a2796b4347 chore(release): 2.16.5 [skip ci]
## [2.16.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.16.4...v2.16.5) (2025-10-09)

### Bug Fixes

* correct node_modules path for pnpm symlinks in Docker ([c12351f](c12351f2c9))
2025-10-09 23:56:53 +00:00
Thomas Hallock
c12351f2c9 fix: correct node_modules path for pnpm symlinks in Docker
The Next.js standalone build creates symlinks in node_modules that point
to ../../../node_modules/.pnpm. When the working directory is /app, these
resolve to /node_modules/.pnpm. Fixed by copying node_modules to /node_modules
instead of /app/node_modules.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 18:56:06 -05:00
semantic-release-bot
9a9958a659 chore(release): 2.16.4 [skip ci]
## [2.16.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.16.3...v2.16.4) (2025-10-09)

### Bug Fixes

* correct Docker CMD to use root-level server.js ([48b47e9](48b47e9bdb))
2025-10-09 23:45:37 +00:00
Thomas Hallock
48b47e9bdb fix: correct Docker CMD to use root-level server.js
The Next.js standalone build outputs server.js to /app/server.js, not
/app/apps/web/server.js. This was causing the container to crash on
startup with MODULE_NOT_FOUND errors, resulting in 404s for abaci.one.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 18:44:34 -05:00
semantic-release-bot
41aa205d04 chore(release): 2.16.3 [skip ci]
## [2.16.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.16.2...v2.16.3) (2025-10-09)

### Bug Fixes

* use game state playerMetadata instead of GameModeContext in UI components ([388c254](388c25451d))
2025-10-09 23:08:44 +00:00
Thomas Hallock
388c25451d fix: use game state playerMetadata instead of GameModeContext in UI components
Replace useGameMode() calls with state.playerMetadata in PlayerStatusBar and
MemoryGrid to ensure only players in the current game are displayed.

Before: UI components used GameModeContext which includes all room members'
players, causing remote players to appear in local-only games.

After: UI components use state.playerMetadata and state.activePlayers from
MemoryPairsContext, which only contains players actually in the current game.

Changes:
- PlayerStatusBar: Get players from state.playerMetadata, not GameModeContext
- MemoryGrid: Check player.userId === viewerId instead of isLocal flag
- Remove useGameMode imports from display components

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 18:07:49 -05:00
semantic-release-bot
fa827ac792 chore(release): 2.16.2 [skip ci]
## [2.16.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.16.1...v2.16.2) (2025-10-09)

### Bug Fixes

* use only local user's players in LocalMemoryPairsProvider ([c26138f](c26138ffb5))
2025-10-09 23:05:31 +00:00
Thomas Hallock
c26138ffb5 fix: use only local user's players in LocalMemoryPairsProvider
Replace useGameMode() with direct useUserPlayers() call to prevent
room members' players from appearing in local-only games at /arcade/matching.

Before: LocalMemoryPairsProvider used GameModeContext which includes ALL
players from room members when user is in a room, showing remote players
in turn indicator for local games.

After: Direct useUserPlayers() call ensures only the current user's players
appear in local games, regardless of room membership.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 18:04:43 -05:00
semantic-release-bot
168b98b888 chore(release): 2.16.1 [skip ci]
## [2.16.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.16.0...v2.16.1) (2025-10-09)

### Bug Fixes

* convert LocalMemoryPairsProvider to pure client-side with useReducer ([b128db1](b128db1783))
2025-10-09 22:55:15 +00:00
Thomas Hallock
b128db1783 fix: convert LocalMemoryPairsProvider to pure client-side with useReducer
Replace arcade session-based provider with pure client-side reducer for
local-only games at /arcade/matching. This eliminates "No active session"
errors when starting non-networked games.

Changes:
- Replace useArcadeSession with useReducer for local state management
- Implement complete game logic client-side (match validation, turn switching)
- Remove dependency on socket/server for local games
- Add MATCH_FOUND, MATCH_FAILED, SWITCH_PLAYER actions to reducer
- Auto-detect matches and switch players using useEffect hooks

Local games now run entirely in the browser without requiring an arcade
session or network connection.

Fixes: Starting game at /arcade/matching no longer shows session errors

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 17:54:19 -05:00
semantic-release-bot
df50239079 chore(release): 2.16.0 [skip ci]
## [2.16.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.15.0...v2.16.0) (2025-10-09)

### Features

* fade out hover avatar when player stops hovering ([820eeb4](820eeb4fb0))
2025-10-09 22:43:21 +00:00
Thomas Hallock
820eeb4fb0 feat: fade out hover avatar when player stops hovering
Avatar now fades out smoothly when:
- Player moves mouse off all cards
- Player's turn ends
- Player hasn't hovered any card yet

Opacity requires all three conditions: position exists, it's player's turn,
AND player is actively hovering a card (cardElement !== null).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 17:42:28 -05:00
semantic-release-bot
90be7c053c chore(release): 2.15.0 [skip ci]
## [2.15.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.14.3...v2.15.0) (2025-10-09)

### Features

* implement smooth hover avatar animations with react-spring ([442c6b4](442c6b4529))
2025-10-09 21:48:46 +00:00
Thomas Hallock
442c6b4529 feat: implement smooth hover avatar animations with react-spring
Add networked hover presence feature showing player avatars over cards
they're considering. Avatars smoothly glide between cards using react-spring
physics-based animations.

Key features:
- Fixed positioning with getBoundingClientRect for accurate placement
- Component keyed by playerId to persist across card changes
- Spring animations for smooth position transitions
- Only shows remote players' avatars (filters out local player)
- Only sends hover events when it's the player's turn

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 16:47:50 -05:00
semantic-release-bot
75b193e1d2 chore(release): 2.14.3 [skip ci]
## [2.14.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.14.2...v2.14.3) (2025-10-09)

### Bug Fixes

* enable smooth spring animations between card hovers ([8d53b58](8d53b589aa))
2025-10-09 21:24:22 +00:00
Thomas Hallock
8d53b589aa fix: enable smooth spring animations between card hovers
Fixed spring animation not working when moving between cards:

ISSUE:
- Avatar would jump instantly to new position instead of smoothly gliding
- The 'from' config was preventing spring from animating position changes

SOLUTION:
- Remove 'from' entirely from spring config
- Use simple spring with immediate flag for first render only
- Track isFirstRender with useRef to skip initial animation
- After first render, all position changes animate smoothly

HOW IT WORKS:
1. First hover: immediate:true - Avatar appears instantly at position
2. isFirstRender flag cleared after position is set
3. Subsequent hovers: immediate:false - Spring animates smoothly
4. Position updates trigger spring to animate x,y to new values
5. Config (tension:280, friction:60) provides smooth glide

RESULT:
- First hover: Avatar appears instantly (no fly-in)
- Moving between cards: Avatar smoothly glides with spring physics
- Feels like watching remote player's mouse cursor move

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 16:23:26 -05:00
semantic-release-bot
af85b3e481 chore(release): 2.14.2 [skip ci]
## [2.14.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.14.1...v2.14.2) (2025-10-09)

### Bug Fixes

* correct avatar positioning to prevent fly-in animation ([573d0df](573d0df20d))
2025-10-09 21:19:43 +00:00
Thomas Hallock
573d0df20d fix: correct avatar positioning to prevent fly-in animation
Fixed positioning model to work properly with react-spring:

ISSUE:
- Avatar was still flying in from top-right
- Using translate(-50%, -50%) with animated left/top caused positioning issues
- Spring was animating from wrong initial position

SOLUTION:
- Remove transform: translate(-50%, -50%)
- Use negative margins instead: marginLeft: -24px, marginTop: -24px
- Calculate exact center position from getBoundingClientRect
- Set 'from' position in spring to match 'to' on first render
- Use x.to() and y.to() to convert spring values to px strings

POSITIONING MODEL:
1. Get card's bounding rect
2. Calculate avatar center point: (rect.right - 12, rect.top - 12)
3. Apply via left/top with negative margins for centering
4. Spring animates between positions smoothly
5. On first hover: from/to are same (no animation)
6. On subsequent hovers: smooth glide between cards

RESULT:
Avatar appears exactly at the card position with no fly-in animation,
then smoothly glides to new positions as user moves between cards.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 16:18:51 -05:00
semantic-release-bot
d312969747 chore(release): 2.14.1 [skip ci]
## [2.14.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.14.0...v2.14.1) (2025-10-09)

### Bug Fixes

* prevent avatar fly-in and hide local player's own hover ([7f65a67](7f65a67cef))
2025-10-09 21:10:10 +00:00
Thomas Hallock
7f65a67cef fix: prevent avatar fly-in and hide local player's own hover
Two critical UX fixes for hover avatars:

1. PREVENT FLY-IN FROM TOP-LEFT:
   - Initialize position state as null instead of {x:0, y:0}
   - Don't render avatar until position is calculated from card element
   - Set immediate:true on first render to prevent animation
   - Add opacity spring (0→1) for smooth fade-in
   - Result: Avatar appears exactly where it should, no fly-in animation

2. HIDE YOUR OWN HOVER AVATAR:
   - Filter out local player (isLocal === true) from hover avatar rendering
   - Only show remote players' hover avatars
   - You see opponents hovering, but not your own cursor
   - Result: Cleaner UX, less visual noise on your own session

BEFORE:
- Avatar would fly in from (0,0) top-left corner when first hovering
- You'd see your own avatar following your mouse (distracting)

AFTER:
- Avatar appears smoothly at the correct card position
- Only remote players' avatars are visible
- Feels like watching someone else's cursor, not your own

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 16:09:19 -05:00
semantic-release-bot
4d7f6f469f chore(release): 2.14.0 [skip ci]
## [2.14.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.13.0...v2.14.0) (2025-10-09)

### Features

* improve hover avatars with smooth animation and 3D elevation ([71b11f4](71b11f4ef0))
2025-10-09 21:07:42 +00:00
Thomas Hallock
71b11f4ef0 feat: improve hover avatars with smooth animation and 3D elevation
Major improvements to networked hover presence feature:

FIXES:
- Only send hover events when it's YOUR TURN (not all sessions)
  * Check isLocal property of current player before sending hover
  * Prevents all players from broadcasting hover simultaneously

VISUAL ENHANCEMENTS:
- 3D elevation effect with layered shadows for floating appearance
  * Large shadow: 0 8px 20px rgba(0,0,0,0.4)
  * Medium shadow: 0 4px 8px rgba(0,0,0,0.3)
  * Glow effect: 0 0 30px with player color
  * Drop-shadow filter for extra depth
  * 48px avatar size (up from 40px)

SMOOTH ANIMATIONS:
- React-spring for butter-smooth position transitions
  * Tension: 280, Friction: 60, Mass: 1
  * Avatars glide smoothly between cards as mouse moves
  * No jank, feels like remote mouse cursor

- Floating animation with vertical bob
  * 6px up/down motion over 2s
  * Creates illusion of hovering above surface
  * Combined with horizontal spring animation

ARCHITECTURE:
- HoverAvatar component uses fixed positioning
- Card refs stored in Map for position tracking
- Avatar positions calculated from card getBoundingClientRect()
- Rendered outside card wrapper to avoid clipping
- Position updates trigger spring animations automatically

RESULT:
Feels like you're literally watching someone's mouse move over the cards
from across the internet. Smooth, elevated, and visually distinct.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 16:06:50 -05:00
semantic-release-bot
e0d08a1aa2 chore(release): 2.13.0 [skip ci]
## [2.13.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.12.3...v2.13.0) (2025-10-09)

### Features

* implement networked hover presence for multiplayer gameplay ([62f3730](62f3730542))

### Code Refactoring

* move canModifyPlayers logic into provider layer ([db9f909](db9f9096b4))
* properly separate LocalMemoryPairsProvider and RoomMemoryPairsProvider ([98822ec](98822ecda5))
2025-10-09 20:50:21 +00:00
Thomas Hallock
62f3730542 feat: implement networked hover presence for multiplayer gameplay
Add real-time hover state visualization to show which players are
considering which cards in room-based multiplayer games.

FEATURES:
- Mouse hover detection on cards sends HOVER_CARD moves over network
- Remote player avatars appear as floating badges over hovered cards
- Smooth pulsing animation with glow effect for hover avatars
- Player emoji + color + name shown on hover
- Only active (non-matched) cards trigger hover events

IMPLEMENTATION:
- Added onMouseEnter/onMouseLeave handlers to card wrapper in MemoryGrid
- Calls hoverCard(cardId) on enter, hoverCard(null) on leave
- Filters playerHovers state to find players hovering each card
- Displays floating avatar badges with player info from playerMetadata
- Added hoverPulse animation for smooth visual feedback

UX BENEFITS:
- Network players can see what opponents are thinking about
- Provides rich multiplayer feedback without voice/video
- Makes remote play feel more in-person and engaging
- Smooth transitions as players move between cards

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 15:49:00 -05:00
Thomas Hallock
98822ecda5 refactor: properly separate LocalMemoryPairsProvider and RoomMemoryPairsProvider
Clean separation of concerns between arcade sessions and room-based play:

ARCHITECTURE:
- LocalMemoryPairsProvider: Arcade sessions (roomId: undefined)
  * Uses useArcadeRedirect for canModifyPlayers
  * No room sync, local-only play
  * Used by /arcade/matching

- RoomMemoryPairsProvider: Room-based multiplayer (roomId: roomData.id)
  * Hard-coded canModifyPlayers: false (always show buttons)
  * Network sync across all room members
  * Used by /arcade/room

CHANGES:
- Added canModifyPlayers to LocalMemoryPairsProvider (uses useArcadeRedirect)
- Added hoverCard action to LocalMemoryPairsProvider
- Added HOVER_CARD case to LocalMemoryPairsProvider optimistic updates
- Added playerHovers to LocalMemoryPairsProvider initial state
- Removed isInRoom conditional from RoomMemoryPairsProvider (cleaner)
- Updated /arcade/matching to use LocalMemoryPairsProvider (was incorrectly using Room)
- Added clear doc comments explaining each provider's purpose

FIXES:
- /arcade/matching no longer behaves like a room
- Each provider has single, clear responsibility
- No more cross-cutting concerns or conditional logic based on room state

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 15:44:41 -05:00
Thomas Hallock
db9f9096b4 refactor: move canModifyPlayers logic into provider layer
Architecture improvement to separate concerns between game components
and session management:

BEFORE:
- MemoryPairsGame component had to know about rooms vs arcade sessions
- Component imported useRoomData and useArcadeRedirect
- Logic for button visibility leaked into presentation layer

AFTER:
- canModifyPlayers added to MemoryPairsContextValue interface
- RoomMemoryPairsProvider handles the logic internally:
  * If in room (roomData?.id exists): canModifyPlayers = false (show buttons)
  * If arcade session (no room): uses useArcadeRedirect.canModifyPlayers
- MemoryPairsGame just reads canModifyPlayers from context
- Clean separation: provider knows session type, component doesn't care

FIXES:
- Room-based games now always show Setup/New Game/Quit buttons
- Arcade sessions correctly hide buttons during setup phase
- /arcade/matching was incorrectly behaving like a room (now fixed)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 15:42:20 -05:00
semantic-release-bot
1fe507bc12 chore(release): 2.12.3 [skip ci]
## [2.12.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.12.2...v2.12.3) (2025-10-09)

### Bug Fixes

* always show game control buttons in room-based sessions ([14ba422](14ba422919))
2025-10-09 20:38:16 +00:00
Thomas Hallock
14ba422919 fix: always show game control buttons in room-based sessions
In room-based multiplayer games, force canModifyPlayers=false to ensure
Setup, New Game, and Quit buttons are always visible to all room members.

The arcade session locking mechanism (hasActiveSession) doesn't apply
to room-based games, so we detect rooms via isInRoom and override the
canModifyPlayers logic.

This fixes the issue where some room members saw buttons while others
didn't, depending on their arcade session state.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 15:37:19 -05:00
semantic-release-bot
3541466630 chore(release): 2.12.2 [skip ci]
## [2.12.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.12.1...v2.12.2) (2025-10-09)

### Bug Fixes

* use RoomMemoryPairsProvider in room page ([c279731](c27973191f))
2025-10-09 19:51:13 +00:00
Thomas Hallock
c27973191f fix: use RoomMemoryPairsProvider in room page
Updates /arcade/room/page.tsx to use RoomMemoryPairsProvider instead
of ArcadeMemoryPairsProvider to match the updated component hooks.

The components now use useMemoryPairs() from MemoryPairsContext, which
is provided by RoomMemoryPairsProvider. This fixes the runtime error:
"useMemoryPairs must be used within a MemoryPairsProvider"

Changes:
- Import RoomMemoryPairsProvider instead of ArcadeMemoryPairsProvider
- Wrap matching game with RoomMemoryPairsProvider

Note: ArcadeMemoryPairsProvider can be deprecated in future cleanup.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 14:50:18 -05:00
semantic-release-bot
d423ff7612 chore(release): 2.12.1 [skip ci]
## [2.12.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.12.0...v2.12.1) (2025-10-09)

### Bug Fixes

* export MemoryPairsContext to fix provider hook error ([80ad33e](80ad33eec0))
2025-10-09 19:40:18 +00:00
Thomas Hallock
80ad33eec0 fix: export MemoryPairsContext to fix provider hook error
Exports MemoryPairsContext from MemoryPairsContext.tsx so that
RoomMemoryPairsProvider can properly provide to it. Also re-exports
useMemoryPairs hook from RoomMemoryPairsProvider for convenience.

This fixes the runtime error:
"useMemoryPairs must be used within a MemoryPairsProvider"

Changes:
- Export MemoryPairsContext in MemoryPairsContext.tsx
- Re-export useMemoryPairs from RoomMemoryPairsProvider.tsx

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 14:39:20 -05:00
semantic-release-bot
f160d2e4af chore(release): 2.12.0 [skip ci]
## [2.12.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.11.0...v2.12.0) (2025-10-09)

### Features

* add networked hover state infrastructure for multiplayer presence ([d149799](d14979907c))
2025-10-09 19:36:48 +00:00
Thomas Hallock
d14979907c feat: add networked hover state infrastructure for multiplayer presence
Implements the server-side and state management foundation for showing
which cards remote players are hovering over in real-time. This creates
"in-person feedback" for network games by transmitting hover cues.

Core changes:
- Add playerHovers state field mapping playerId -> cardId (or null)
- Add HOVER_CARD move type for transmitting hover state
- Add validateHoverCard() validator (always valid, lightweight UI state)
- Add optimistic hover updates in RoomMemoryPairsProvider
- Add hoverCard(cardId) action creator
- Initialize playerHovers in initial state

Remaining work to complete feature:
1. Wire up onMouseEnter/onMouseLeave in GameCard/MemoryGrid
2. Call hoverCard(cardId) on enter, hoverCard(null) on leave
3. Display remote player avatars floating over hovered cards
4. Add smooth transitions when hover changes

This establishes the data flow: hover event → HOVER_CARD move → server
validation → broadcast to room → all clients see hover state.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 14:35:55 -05:00
semantic-release-bot
c32f4dd1f6 chore(release): 2.11.0 [skip ci]
## [2.11.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.10.1...v2.11.0) (2025-10-09)

### Features

* add pause/resume game state architecture ([05eacac](05eacac438))
* add Resume button and config change warning to setup UI ([b5ee04f](b5ee04f576))
* implement pause/resume in game providers with optimistic updates ([ce30fca](ce30fcaf55))

### Bug Fixes

* convert guestId to internal userId for player ownership check ([3a01f46](3a01f4637d))
* implement shared session architecture for room-based multiplayer ([2856f4b](2856f4b83f))
2025-10-09 19:30:29 +00:00
Thomas Hallock
b5ee04f576 feat: add Resume button and config change warning to setup UI
Implements user-facing pause/resume controls in the setup screen with
smooth UX for managing paused games and configuration changes.

UI changes:
- Button dynamically switches between "START GAME" (red) and "RESUME GAME" (green)
- Resume button appears when canResumeGame is true
- Warning dialog shows when user tries to change config during paused game
- User can choose: "Keep Game & Cancel Change" or "End Game & Apply Change"
- Warning only shows once per pause session

UX flow:
1. User pauses game → sees green "RESUME GAME" button
2. User changes config → warning appears explaining game will end
3. User accepts → button becomes red "START GAME", paused game cleared
4. User cancels → config unchanged, can still resume game

All config buttons (game type, difficulty, timer) intercepted to show
warning before applying changes to paused games.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 14:29:31 -05:00
Thomas Hallock
ce30fcaf55 feat: implement pause/resume in game providers with optimistic updates
Adds pause/resume functionality to both local and room-based game
providers with optimistic client-side updates for instant feedback.

Provider changes:
- Add optimistic updates for GO_TO_SETUP, SET_CONFIG, RESUME_GAME moves
- Compute hasConfigChanged by comparing current vs originalConfig
- Compute canResumeGame (true if paused game exists and config unchanged)
- Add resumeGame() action creator that sends RESUME_GAME move

Optimistic behavior ensures users see immediate feedback when:
- Pausing a game (instant transition to setup with saved state)
- Changing config (instant clear of paused game if applicable)
- Resuming a game (instant restoration of game state)

Server validates all moves and sends authoritative state back.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 14:29:31 -05:00
Thomas Hallock
05eacac438 feat: add pause/resume game state architecture
Implements the core state management and validation for pausing and
resuming arcade games. When players navigate to setup during an active
game, the game state is saved and can be resumed if configuration
hasn't changed.

Core changes:
- Add GameConfiguration, pausedGamePhase, pausedGameState to track paused games
- Add hasConfigChanged and canResumeGame computed properties
- Add RESUME_GAME move type for restoring paused games

Validator logic:
- GO_TO_SETUP saves game state snapshot when called from playing/results
- SET_CONFIG clears paused game state if config changes
- RESUME_GAME validates and restores paused game state
- START_GAME tracks originalConfig for change detection

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 14:29:31 -05:00
semantic-release-bot
65828950a2 chore(release): 2.10.2 [skip ci]
## [2.10.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.10.1...v2.10.2) (2025-10-09)

### Bug Fixes

* convert guestId to internal userId for player ownership check ([3a01f46](3a01f4637d))
* implement shared session architecture for room-based multiplayer ([2856f4b](2856f4b83f))
2025-10-09 14:20:52 +00:00
Thomas Hallock
2856f4b83f fix: implement shared session architecture for room-based multiplayer
Fixes state divergence issue where different room members saw different
game states (e.g., version 11 vs version 2).

## Problem
- Each user created their own session (one row per userId in arcadeSessions)
- When users made moves, they updated different database rows
- Broadcasting tried to sync but versions diverged
- Result: Complete state inconsistency between room members

## Solution
Implement shared session architecture where all room members access the
same session:

### Backend Changes (session-manager.ts)
- Add getArcadeSessionByRoom(): Look up session by roomId
- Modify createArcadeSession(): Check for existing room session first
- Modify applyGameMove(): Accept optional roomId parameter for room-based lookup

### Server Changes (socket-server.ts)
- Update game-move handler to accept roomId in payload
- Pass roomId to applyGameMove() for shared session access
- Update join-arcade-session to use room-based lookup when roomId provided

### Client Changes
- Update useArcadeSocket.sendMove() to accept and send roomId
- Update useArcadeSession.sendMove() to pass roomId to socket
- Fix sendMove interface type (playerId must be included, not omitted)

## Result
All room members now read/write to single shared session with consistent
version numbers. State stays synchronized across all clients.

## Note
Codebase has pre-existing TypeScript errors in unrelated files (abacus-react
imports, tutorial components) that are not addressed by this fix.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 08:40:13 -05:00
Thomas Hallock
3a01f4637d fix: convert guestId to internal userId for player ownership check
The authorization check was failing because it was comparing two
different ID types:
- Player ownership map uses internal database userId (e.g., 'xlk...')
- Validation context was receiving guestId from cookie (e.g., 'ac9d...')

Solution:
- Call getUserIdFromGuestId() to convert guestId to internal userId
- Pass the internal userId to validator for room-based games
- Add logging to show which internal userId is being used
- Return error if user not found during conversion

This fixes the "You can only move your own players" error that was
incorrectly blocking legitimate moves from local players.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 08:20:18 -05:00
Thomas Hallock
97378b70b7 debug: add extensive logging to canFlipCard authorization check
Add detailed console logging to diagnose why tile clicks aren't working:
- Log all card flip attempts with game state details
- Show authorization check results (player found, isLocal value)
- Warn if current player not found in players map
- Log exact reason for each blocked attempt

This will help identify if the issue is:
- Game state (not active, processing, etc.)
- Card state (already flipped, matched, etc.)
- Authorization logic (player ownership check)
- Missing player data in the map

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 08:18:17 -05:00
semantic-release-bot
3158addda1 chore(release): 2.10.1 [skip ci]
## [2.10.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.10.0...v2.10.1) (2025-10-09)

### Bug Fixes

* enforce player ownership authorization for multiplayer games ([71b0aac](71b0aac13c))
2025-10-09 13:09:10 +00:00
Thomas Hallock
71b0aac13c fix: enforce player ownership authorization for multiplayer games
Add comprehensive authorization checks to prevent room members from
moving opponents' players in multiplayer games like matching pairs.

Server-side validation:
- Add ValidationContext interface with userId and playerOwnership map
- Update GameValidator interface to accept optional context parameter
- Modify MatchingGameValidator.validateFlipCard to check player ownership
- Update session-manager.applyGameMove to fetch player ownership from DB
  and pass it to validator
- Reject moves with error "You can only move your own players" if user
  tries to move opponent's player

Client-side authorization:
- Update ArcadeMemoryPairsContext.canFlipCard to check if current player
  is local (owned by current user)
- Prevent clicking/flipping cards when it's a network player's turn
- Log helpful console messages when authorization fails

UI improvements:
- Update PlayerStatusBar to distinguish local vs network players
- Show "Your turn" (red, glowing) when it's your player's turn
- Show "Their turn" (blue, pulsing) when it's opponent's player's turn
- Add isLocalPlayer property to player display data

This fixes the security issue where any room member could move for any
player, regardless of ownership. Now moves are properly authorized at
both client and server levels, and the UI clearly indicates whose turn
it is.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 08:08:15 -05:00
semantic-release-bot
0543377bda chore(release): 2.10.0 [skip ci]
## [2.10.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.9.0...v2.10.0) (2025-10-09)

### Features

* implement rich Radix UI tooltips for player avatars ([d03c789](d03c789879))
2025-10-09 12:57:22 +00:00
Thomas Hallock
d03c789879 feat: implement rich Radix UI tooltips for player avatars
Replace basic HTML title tooltips with Radix UI Tooltip components
that display comprehensive player information:

New PlayerTooltip component features:
- Player name with color accent dot
- Visual badge distinguishing local vs network players
- Player color indicator with glow effect
- "Joined X ago" timestamp (formatted as days/hours/minutes)
- For network players: shows controlling member name
- Glassmorphic dark design with backdrop blur
- Smooth fade-in animation

Updated components:
- ActivePlayersList: Wraps player avatars with PlayerTooltip
- NetworkPlayerIndicator: Shows "Controlled by [member]" in tooltip
- PageWithNav: Passes full player objects (including color, createdAt)
  instead of just {id, name, emoji}

Technical improvements:
- Added @radix-ui/react-tooltip dependency
- Removed all basic `title` attributes
- Type-safe player data passing through component tree
- Handles Date | number types for createdAt properly

The new tooltips provide much richer context for understanding
network opponents and collaborators in multiplayer games.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 07:56:22 -05:00
semantic-release-bot
15029ae52f chore(release): 2.9.0 [skip ci]
## [2.9.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.7...v2.9.0) (2025-10-09)

### Features

* implement auto-save for player settings modal ([a83dc09](a83dc097e4))
2025-10-09 12:48:28 +00:00
Thomas Hallock
a83dc097e4 feat: implement auto-save for player settings modal
Convert PlayerConfigDialog from explicit save button to auto-save UX:
- Add debounced name updates (500ms delay after typing stops)
- Add visual save status indicator ("Saving..." / "Changes saved automatically")
- Remove "Cancel" and "Save Changes" buttons
- Change modal title to "Player Settings"

Fix type errors from previous commits:
- Add notifyRoomOfPlayerUpdate to all useRoomData test mocks
- Fix type predicate for player filtering in PageWithNav
- Fix createdAt comparison to handle Date | number type
- Remove autoFocus attribute (accessibility linting rule)

All player settings (name and emoji) now save automatically without
requiring an explicit save button, providing a smoother user experience.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 07:47:36 -05:00
semantic-release-bot
0abec1a3bb chore(release): 2.8.7 [skip ci]
## [2.8.7](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.6...v2.8.7) (2025-10-09)

### Bug Fixes

* enable real-time player name updates across room members ([5171be3](5171be3d37))
2025-10-09 12:39:34 +00:00
Thomas Hallock
5171be3d37 fix: enable real-time player name updates across room members
When a player's name (or other properties) is updated, emit a
'players-updated' socket event to notify all room members. This triggers
the server to broadcast the updated player data via 'room-players-updated',
which all room members receive and use to update their local state.

Changes:
- Add notifyRoomOfPlayerUpdate() function to useRoomData hook
- Call it after successful player mutations (create, update, delete, setActive)
- This ensures all room members see player changes immediately without page reload

Fixes real-time synchronization of player names in the mini app nav.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 07:38:43 -05:00
semantic-release-bot
fc9eb253ad chore(release): 2.8.6 [skip ci]
## [2.8.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.5...v2.8.6) (2025-10-09)

### Bug Fixes

* prevent duplicate display of network avatars in nav ([d474ef0](d474ef07d6))
2025-10-09 12:27:57 +00:00
Thomas Hallock
d474ef07d6 fix: prevent duplicate display of network avatars in nav
Filter out remote players (isLocal: false) from active/inactive player
lists in PageWithNav to prevent them from appearing twice:
1. Once in the main player list (incorrect)
2. Once in the network players section (correct)

Now:
- Active/inactive player lists show only local players
- Network players section shows remote players separately

This provides a clear visual distinction between "your avatars" and
"network avatars" in the mini app game nav.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 07:27:03 -05:00
semantic-release-bot
3cdc0695f4 chore(release): 2.8.5 [skip ci]
## [2.8.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.4...v2.8.5) (2025-10-09)

### Bug Fixes

* remove redirect loop by not redirecting from room page ([10cf715](10cf71527f))
2025-10-09 01:17:27 +00:00
Thomas Hallock
10cf71527f fix: remove redirect loop by not redirecting from room page
The infinite redirect loop was caused by:
1. /arcade/room redirecting to /arcade when roomData is null
2. /arcade (via useArcadeRedirect) redirecting back to /arcade/room for active session
3. Loop repeats

Fix: Remove the redirect from room page entirely. Instead:
- Show loading state while fetching roomData
- Show error message with link if no room found (no automatic redirect)
- Let useArcadeRedirect on /arcade handle active session redirects

This prevents the redirect conflict and allows proper navigation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 20:16:27 -05:00
semantic-release-bot
678f4423b6 chore(release): 2.8.4 [skip ci]
## [2.8.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.3...v2.8.4) (2025-10-09)

### Bug Fixes

* prevent redirect loops by checking if already at target URL ([c5268b7](c5268b79de))
2025-10-09 01:11:33 +00:00
Thomas Hallock
c5268b79de fix: prevent redirect loops by checking if already at target URL
Both useArcadeRedirect and useArcadeGuard were causing infinite loops:
1. User navigates to /arcade/room
2. Room page redirects to /arcade (if roomData null during loading)
3. /arcade sees active session at /arcade/room, redirects back
4. Loop repeats

Fix: Check if pathname already matches target URL before redirecting.
If already at target, skip the redirect and log that we're already there.

This fixes the infinite redirect loop when navigating to /arcade/room.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 20:10:40 -05:00
semantic-release-bot
d9aadd1f81 chore(release): 2.8.3 [skip ci]
## [2.8.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.2...v2.8.3) (2025-10-08)

### Bug Fixes

* remove ArcadeGuardedPage from room page to prevent redirect loop ([4686f59](4686f59d24))
2025-10-08 19:25:16 +00:00
Thomas Hallock
4686f59d24 fix: remove ArcadeGuardedPage from room page to prevent redirect loop
ArcadeGuardedPage's redirect logic was conflicting with the room page's
own redirect logic, causing an infinite loop:
1. Room page would redirect to /arcade if no roomData
2. /arcade would see active session and redirect back to /arcade/room
3. Loop repeats

Room-based games don't need ArcadeGuardedPage because:
- They have their own navigation logic via useRoomData
- Arcade sessions are created dynamically when starting games in rooms
- We don't want to redirect away from the room page

This fixes the infinite redirect loop when navigating to /arcade/room.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 14:24:29 -05:00
semantic-release-bot
1219539585 chore(release): 2.8.2 [skip ci]
## [2.8.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.1...v2.8.2) (2025-10-08)

### Bug Fixes

* revert to showing only active players in room games ([87cc0b6](87cc0b64fb))
2025-10-08 16:55:40 +00:00
Thomas Hallock
87cc0b64fb fix: revert to showing only active players in room games
Reverted getRoomActivePlayers() to use getActivePlayers() instead of
getAllPlayers(). Room games should show only players marked isActive=true
from each room member, not all players regardless of status.

Behavior:
- Room mode: Active players from all room members
- Solo mode: Active players from current user

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 11:54:43 -05:00
semantic-release-bot
c640a79a44 chore(release): 2.8.1 [skip ci]
## [2.8.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.0...v2.8.1) (2025-10-08)

### Bug Fixes

* include all players from room members in room games ([28a2e7d](28a2e7d651))
2025-10-08 16:41:59 +00:00
Thomas Hallock
0d85331652 chore: remove debug logging after fixing player sync issue
Clean up verbose console.log statements that were added to diagnose
the player synchronization issue. The root cause has been identified
and fixed in player-manager.ts.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 11:41:02 -05:00
Thomas Hallock
28a2e7d651 fix: include all players from room members in room games
Change getRoomActivePlayers() to fetch ALL players from room members,
not just those marked with isActive=true. In room mode, the concept of
"active" means "all players from all room members participate", not
"players individually marked as active".

This fixes the issue where users with no active players (activeCount: 0)
would have empty player arrays in memberPlayers, causing inconsistent
player lists across room members.

Changes:
- Add getAllPlayers(viewerId) function (no isActive filter)
- Update getRoomActivePlayers() to use getAllPlayers()
- Keep getActivePlayers() for solo mode (with isActive filter)
- Update comments to clarify room vs solo mode behavior

Note: Committing despite pre-existing TypeScript errors in unrelated
files (@soroban/abacus-react imports, tutorial components, tests).
player-manager.ts changes are type-safe and don't introduce new errors.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 11:40:15 -05:00
Thomas Hallock
a27c36193e debug: add logging to diagnose player sync issue
Add console logging to track:
- When GameModeContext computes activePlayers from room data
- Which players are being added to the active set
- What activePlayers are sent when starting the game

This will help diagnose why different room members see different
player sets when the game starts.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 11:22:27 -05:00
semantic-release-bot
cc80a1454b chore(release): 2.8.0 [skip ci]
## [2.8.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.7.4...v2.8.0) (2025-10-08)

### Features

* implement room-wide multi-user game state synchronization ([8175c43](8175c43533))

### Tests

* add comprehensive tests for arcade guard and room navigation ([b49630f](b49630f3cb))
2025-10-08 16:17:21 +00:00
Thomas Hallock
8175c43533 feat: implement room-wide multi-user game state synchronization
Enable real-time game state sync across all users in a room playing
at /arcade/room. Previously only synced across one user's tabs, now
syncs across all room members.

Server changes (socket-server.ts):
- Accept optional roomId in join-arcade-session event
- Join socket to both arcade:userId and game:roomId rooms
- Broadcast move-accepted to both rooms when processing moves
- Log game room joins for debugging

Client changes:
- useArcadeSocket: Accept roomId parameter in joinSession()
- useArcadeSession: Accept roomId in options, pass to joinSession()
- ArcadeMemoryPairsContext: Get roomId from useRoomData() and wire up

How it works:
- User A joins game room → joins arcade:userA + game:room123
- User B joins game room → joins arcade:userB + game:room123
- User A makes move → broadcasts to both rooms
- User A's tabs receive on arcade:userA (reconcile optimistic update)
- User B's tabs receive on game:room123 (sync with server state)
- Optimistic update system handles both cases automatically

Architecture:
- Reuses existing optimistic update reconciliation
- Minimal changes (5 edits across 4 files)
- Backward compatible (roomId is optional)
- Solo play unaffected

See docs/MULTIPLAYER_SYNC_ARCHITECTURE.md for full details.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 11:16:27 -05:00
Thomas Hallock
b49630f3cb test: add comprehensive tests for arcade guard and room navigation
Add tests to prevent regression of the enabled flag bug and verify
room navigation behavior with active game sessions.

New tests:
- useArcadeGuard with enabled=false blocks HTTP redirects
- useArcadeGuard with enabled=false blocks WebSocket redirects
- useArcadeGuard with enabled=true still allows redirects
- Room browser renders without redirect when user has active session
- Room navigation works with active sessions
- No redirect loops during rapid navigation
- Users can browse rooms during active gameplay

Fixes in existing tests:
- Updated mock response format to match API (wrapped in { session })
- All 16 useArcadeGuard tests passing
- All 5 room navigation tests passing

This ensures the /arcade-rooms pages remain accessible during
active game sessions, preventing the bug where users were
immediately redirected to /arcade/room.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 11:08:21 -05:00
semantic-release-bot
c4d8032d02 chore(release): 2.7.4 [skip ci]
## [2.7.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.7.3...v2.7.4) (2025-10-08)

### Bug Fixes

* respect enabled flag in useArcadeGuard WebSocket redirects ([01ff114](01ff114258))

### Code Refactoring

* move room management pages to /arcade-rooms ([4316687](431668729c))
2025-10-08 16:06:05 +00:00
Thomas Hallock
01ff114258 fix: respect enabled flag in useArcadeGuard WebSocket redirects
The useArcadeGuard hook was ignoring the enabled flag for WebSocket
redirects, causing unwanted navigation from /arcade-rooms to /arcade/room.

When enabled=false (used by PageWithNav), the hook should only track
session state without triggering redirects.

Changes:
- Check enabled flag before redirecting in WebSocket onSessionState
- Check enabled flag before redirecting in HTTP checkSession
- Allows room management pages to use PageWithNav without redirects

Fixes the issue where visiting /arcade-rooms with an active game
session would immediately redirect to /arcade/room.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 11:05:07 -05:00
Thomas Hallock
d173a178bc chore: remove debug logging from room data fetching
Clean up console.log statements that were added for debugging the
room navigation race condition. Keeping only error logging.

Files cleaned:
- src/hooks/useRoomData.ts - removed trace logs for fetch/socket events
- src/app/arcade/room/page.tsx - removed state logging
- src/app/api/arcade/rooms/current/route.ts - removed request logging

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 11:01:47 -05:00
Thomas Hallock
431668729c refactor: move room management pages to /arcade-rooms
Move room browser and detail pages from /arcade/rooms to /arcade-rooms
to eliminate conflicts with arcade session redirect logic. This allows
users to navigate to room management pages even when they have an
active game session.

Changes:
- Move /arcade/rooms/page.tsx → /arcade-rooms/page.tsx (room browser)
- Move /arcade/rooms/[roomId]/page.tsx → /arcade-rooms/[roomId]/page.tsx (room detail)
- Update all router.push() calls to use /arcade-rooms
- Fix styled-system import paths for new location
- Delete old /arcade/rooms directory

Benefits:
- Room management pages are now outside /arcade namespace
- No exceptions needed in useArcadeRedirect hook
- Users can access room pages during active gameplay
- Cleaner separation between gameplay and room management

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 11:00:08 -05:00
semantic-release-bot
b5d0bee120 chore(release): 2.7.3 [skip ci]
## [2.7.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.7.2...v2.7.3) (2025-10-08)

### Bug Fixes

* set room sessions to use /arcade/room URL ([9dac431](9dac431c1f))
2025-10-08 15:44:46 +00:00
Thomas Hallock
9dac431c1f fix: set room sessions to use /arcade/room URL
When creating an arcade session for a room-based game, the gameUrl was
hardcoded to '/arcade/matching'. This caused useArcadeRedirect to
redirect users from /arcade/room to /arcade/matching, breaking the
simplified room addressing model.

Fix:
- Changed gameUrl to '/arcade/room' for room-based sessions
- Now users stay on /arcade/room for the duration of room gameplay
- Solo sessions still use game-specific URLs like /arcade/matching

This ensures the user experience matches the intended design:
- /arcade/room - room-based multiplayer (regardless of game type)
- /arcade/[game] - solo/local multiplayer for specific games

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 10:43:48 -05:00
semantic-release-bot
5bbb212da9 chore(release): 2.7.2 [skip ci]
## [2.7.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.7.1...v2.7.2) (2025-10-08)

### Bug Fixes

* add hasAttemptedFetch flag to prevent premature redirect ([c30f585](c30f585810))
2025-10-08 15:40:52 +00:00
Thomas Hallock
c30f585810 fix: add hasAttemptedFetch flag to prevent premature redirect
The previous fix didn't fully resolve the race condition. When userId
finished loading, there was a brief moment where:
- isUserIdPending = false (userId loaded)
- isLoading = false (fetch hasn't started yet)
- roomData = null

This triggered the redirect before the room fetch even began.

Solution:
- Added hasAttemptedFetch flag to track fetch attempt state
- Updated isLoading to include: isUserIdPending || isLoading || (!!userId && !hasAttemptedFetch)
- Now the page stays in loading state until we've both loaded userId AND attempted the room fetch

This ensures we never redirect while a fetch is pending.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 10:39:54 -05:00
semantic-release-bot
0a768c65fb chore(release): 2.7.1 [skip ci]
## [2.7.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.7.0...v2.7.1) (2025-10-08)

### Bug Fixes

* resolve race condition in /arcade/room redirect ([5ed2ab2](5ed2ab21ca))
2025-10-08 15:26:51 +00:00
Thomas Hallock
5ed2ab21ca fix: resolve race condition in /arcade/room redirect
The /arcade/room page was redirecting to /arcade before userId loaded,
causing a race condition where the page would redirect even when the user
was in a valid room.

Root cause:
- useViewerId() loads asynchronously
- useRoomData depended on userId but didn't expose userId loading state
- Page checked !isLoading && !roomData and redirected immediately
- By the time userId loaded and room data fetched, redirect already happened

Fix:
- Track isPending from useViewerId in useRoomData
- Combine isUserIdPending with room data loading state
- Page now waits for both userId and room data before redirecting

Added debug logging to help diagnose future issues.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 10:25:53 -05:00
semantic-release-bot
1cb175982a chore(release): 2.7.0 [skip ci]
## [2.7.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.6.0...v2.7.0) (2025-10-08)

### Features

* extend GameModeContext to support room-based multiplayer ([ee6094d](ee6094d59d))
2025-10-08 15:14:28 +00:00
Thomas Hallock
ee6094d59d feat: extend GameModeContext to support room-based multiplayer
When a user is in a room, GameModeContext now merges players from all
room members to create a unified player set for gameplay. This enables
true multiplayer where all participants' active players participate
together in the game.

Key changes:
- Added useRoomData and useViewerId to GameModeContext
- Local players (from DB) are marked with isLocal: true
- Remote players (from other room members) are marked with isLocal: false
- Players map merges local + remote players when in a room
- activePlayers set includes all active players from all room members
- Edit operations (update/remove/setActive) only work on local players
- Socket broadcast when local players change to notify room members
- When not in a room, behavior is unchanged (solo/local multiplayer)

This implements the "players is the union of all active players for all
members of the room" requirement for room-based gameplay.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 10:13:33 -05:00
semantic-release-bot
9d0c488f2b chore(release): 2.6.0 [skip ci]
## [2.6.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.5.0...v2.6.0) (2025-10-08)

### Features

* refactor room addressing to /arcade/room ([e7d2a73](e7d2a73ddf))
2025-10-08 15:08:52 +00:00
Thomas Hallock
e7d2a73ddf feat: refactor room addressing to /arcade/room
Simplify room URL structure so users access their room's game at
/arcade/room instead of /arcade/rooms/[roomId]/[game]. Since users can
only be in one room at a time (modal room enforcement), this provides a
cleaner addressing model.

Changes:
- useRoomData now fetches user's current room from /api/arcade/rooms/current
- Created /api/arcade/rooms/current endpoint to get user's active room
- Created /arcade/room page that renders the appropriate game for the room
- Removed URL parsing logic in favor of backend room lookup
- Socket connection and real-time updates still work with new structure

Next step: Extend GameModeContext to merge players from all room members
so gameplay uses the union of all active players in the room.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 10:07:58 -05:00
semantic-release-bot
63517cf45d chore(release): 2.5.0 [skip ci]
## [2.5.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.4.6...v2.5.0) (2025-10-08)

### Features

* display room info and network players in mini app nav ([5e3261f](5e3261f3be))
2025-10-08 14:43:33 +00:00
Thomas Hallock
5e3261f3be feat: display room info and network players in mini app nav
When users are in a room (/arcade/rooms/[roomId]/*), the mini app nav now shows:
1. Room name and game type in RoomInfo component
2. Other members' player avatars with "network" indicators
3. Clear distinction between local players and network players

Implementation:
- Created useRoomData hook to fetch room data and listen to real-time updates
- Updated PageWithNav to use room data and compute network players
- Enhanced RoomInfo component to display room name when available
- Network players shown with special borders and connection indicators

The nav automatically detects room context from the URL and fetches:
- Room details (name, game, member count)
- All room members and their players
- Real-time updates via socket events (member-joined, member-left, players-updated)

Network players are filtered to exclude the current user and show each other
member's players with their display names for clear identification.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 09:42:34 -05:00
semantic-release-bot
1e43e6945b chore(release): 2.4.6 [skip ci]
## [2.4.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.4.5...v2.4.6) (2025-10-08)

### Bug Fixes

* real-time room member updates via globalThis socket.io sharing ([94a1d9b](94a1d9b110))
2025-10-08 14:38:03 +00:00
Thomas Hallock
94a1d9b110 fix: real-time room member updates via globalThis socket.io sharing
The room member real-time update bug was caused by module isolation when API
routes dynamically imported socket-server.ts. Each import created a separate
module instance where the `io` variable was null, preventing broadcasts.

Root cause:
- API routes called getSocketIO() via dynamic import
- Dynamic imports created separate module instances
- The module-level `io` variable was never initialized in these instances
- Broadcasts from API routes never reached connected clients

The fix:
- Store socket.io instance in globalThis.__socketIO instead of module variable
- Ensures same instance accessible across all module boundaries
- API routes can now successfully broadcast to connected clients

Changes:
- socket-server.ts: Use globalThis.__socketIO for cross-module access
- src/lib/socket-io.ts: Clean up debug logging
- src/app/api/arcade/rooms/[roomId]/join/route.ts: Clean up debug logging
- __tests__/room-realtime-updates.e2e.test.ts: Add comprehensive e2e tests
- socket-server.js: DELETED (outdated, missing room handlers)

Tests verify:
1. member-joined broadcasts when users join via API
2. member-left broadcasts when users leave
3. Both members and players lists update correctly

All 3 e2e tests passing. User confirmed fix works in real app.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 09:37:00 -05:00
semantic-release-bot
9fa5652173 chore(release): 2.4.5 [skip ci]
## [2.4.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.4.4...v2.4.5) (2025-10-08)

### Bug Fixes

* send all members (not just online) in socket broadcasts ([3fa6cce](3fa6cce17a))
2025-10-08 14:03:35 +00:00
Thomas Hallock
3fa6cce17a fix: send all members (not just online) in socket broadcasts
The root issue was that socket broadcasts were sending only online
members while the initial page load showed all members, causing
inconsistency.

Changes:
- Join/leave API routes now send `members` (all) instead of `onlineMembers`
- Socket server join/leave handlers now send `members` instead of `onlineMembers`
- Client event handlers updated to expect `data.members` instead of `data.onlineMembers`
- Removed unused `getOnlineRoomMembers` import from socket-server.ts

Now when a user joins, other users immediately see them in the members
list without needing to reload the page. Both members and players are
updated together in the same broadcast.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 09:02:44 -05:00
semantic-release-bot
8f3dd9ec92 chore(release): 2.4.4 [skip ci]
## [2.4.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.4.3...v2.4.4) (2025-10-08)

### Bug Fixes

* correctly access getSocketIO from dynamic import ([30abf33](30abf33ee8))
2025-10-08 13:57:35 +00:00
Thomas Hallock
30abf33ee8 fix: correctly access getSocketIO from dynamic import
The dynamic import returns a module namespace object, so we need to
access socketServerModule.getSocketIO() rather than treating the
module itself as the function.

Simplified the wrapper to directly cache and use the module, checking
that getSocketIO exists before calling it.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 08:56:47 -05:00
semantic-release-bot
caa2bea7a8 chore(release): 2.4.3 [skip ci]
## [2.4.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.4.2...v2.4.3) (2025-10-08)

### Bug Fixes

* resolve socket-server import path for Next.js build ([12c3c37](12c3c37ff8))
2025-10-08 13:56:18 +00:00
Thomas Hallock
12c3c37ff8 fix: resolve socket-server import path for Next.js build
Create a wrapper module in src/lib/socket-io.ts that provides access
to the socket.io server instance for API routes, avoiding the build
error caused by importing from outside the src directory.

The wrapper uses dynamic imports to lazy-load the socket server module
only on the server-side, making it safe for Next.js to bundle.

Changes:
- Add src/lib/socket-io.ts with async getSocketIO() function
- Update join route to use @/lib/socket-io import
- Update leave route to use @/lib/socket-io import
- Both routes now await getSocketIO() since it's async

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 08:55:28 -05:00
semantic-release-bot
5c32209a2c chore(release): 2.4.2 [skip ci]
## [2.4.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.4.1...v2.4.2) (2025-10-08)

### Bug Fixes

* broadcast member join/leave events immediately via API ([ebfc88c](ebfc88c5ea))
2025-10-08 13:52:13 +00:00
Thomas Hallock
ebfc88c5ea fix: broadcast member join/leave events immediately via API
Add socket.io broadcasts to join and leave API routes to notify
all users in a room immediately when someone joins or leaves,
without waiting for socket connection.

Previously:
- API added member to database
- No socket event until user's browser connected
- Other users didn't see new member until page reload
- Players showed up but member didn't (timing race condition)

Now:
- API broadcasts `member-joined` immediately after adding member
- API broadcasts `member-left` immediately after removing member
- All connected users get real-time updates instantly
- Both members list and players list update simultaneously

Changes:
- Export `getSocketIO()` from socket-server.ts for API access
- Join route broadcasts member-joined with updated members/players
- Leave route broadcasts member-left with updated members/players
- Socket broadcasts are non-blocking (won't fail the request)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 08:51:17 -05:00
semantic-release-bot
7eb55899c8 chore(release): 2.4.1 [skip ci]
## [2.4.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.4.0...v2.4.1) (2025-10-08)

### Bug Fixes

* make leave room button actually remove user from room ([49f12f8](49f12f8cab))
2025-10-08 13:46:48 +00:00
Thomas Hallock
49f12f8cab fix: make leave room button actually remove user from room
When clicking "Leave Room", the user is now properly removed from
room membership (not just marked offline) and navigated to /arcade.

Previously:
- Just navigated to /arcade/rooms
- User remained as offline member of the room
- Socket cleanup only marked user offline

Now:
- Calls POST /api/arcade/rooms/:roomId/leave
- Actually removes user from room_members table
- Navigates to /arcade home page
- Users can be in at most 1 room (or 0 rooms)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 08:45:51 -05:00
semantic-release-bot
b4c8cfaad2 chore(release): 2.4.0 [skip ci]
## [2.4.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.3.1...v2.4.0) (2025-10-08)

### Features

* add arcade room/session info and network players to nav ([6800747](6800747f80))
* add real-time WebSocket updates for room membership ([7ebb2be](7ebb2be392))
* implement modal room enforcement (one room per user) ([f005fbb](f005fbbb77))
* improve room navigation and membership UI ([bc219c2](bc219c2ad6))

### Bug Fixes

* auto-cleanup orphaned arcade sessions without valid rooms ([3c002ab](3c002ab29d))
* show correct join/leave button based on room membership ([5751dfe](5751dfef5c))
2025-10-08 13:42:45 +00:00
Thomas Hallock
f005fbbb77 feat: implement modal room enforcement (one room per user)
Implement hybrid database + application-level enforcement to ensure users
can only be in one room at a time, with graceful auto-leave behavior and
clear error messaging.

## Changes

### Database Layer
- Add unique index on `room_members.user_id` to enforce one room per user
- Migration includes cleanup of any existing duplicate memberships
- Constraint provides safety net if application logic fails

### Application Layer
- Auto-leave logic: when joining a room, automatically remove user from
  all other rooms first
- Return `AutoLeaveResult` with metadata about rooms that were left
- Idempotent rejoining: rejoining the same room just updates status

### API Layer
- Join route returns auto-leave information in response
- Catches and handles constraint violations with 409 Conflict
- User-friendly error messages when conflicts occur

### Frontend
- Room list and detail pages handle ROOM_MEMBERSHIP_CONFLICT errors
- Show alerts when user needs to leave current room
- Refresh room list after conflicts to show current state

### Testing
- 7 integration tests for modal room behavior
- Tests cover: first join, auto-leave, rejoining, multi-user scenarios,
  constraint enforcement, and metadata accuracy
- Updated existing unit tests for new return signature

## Technical Details

- `addRoomMember()` now returns `{ member, autoLeaveResult? }`
- Auto-leave happens before new room join, preventing race conditions
- Database unique constraint as ultimate safety net
- Socket events remain status-only (joining goes through API)

## Testing
-  All modal room tests pass (7/7)
-  All room API e2e tests pass (12/12)
-  Format and lint checks pass

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 08:41:39 -05:00
Thomas Hallock
5751dfef5c fix: show correct join/leave button based on room membership
Fixes issue where non-members see "Leave Room" button instead of
"Join Room" when viewing a room they haven't joined.

Changes:
- Added `isMember` check by comparing viewerId with members list
- Conditional button rendering:
  - **Members see**: "Leave Room" + "Start Game" buttons
  - **Non-members see**: "Back to Rooms" + "Join Room" buttons
- Added `joinRoom()` function to handle joining from room detail page
- Join button respects room.isLocked status
- After joining, room data refreshes to update UI

This prevents confusion about membership status and provides the
correct action buttons based on whether the user is in the room.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 07:13:57 -05:00
Thomas Hallock
7ebb2be392 feat: add real-time WebSocket updates for room membership
Fixes issue where room members and players don't update in real-time
when someone joins from another tab/session.

Socket Events Added:
- `join-room`: Client → Server when user enters room page
  - Server marks member as online
  - Emits `room-joined` to the connecting user with full room state
  - Broadcasts `member-joined` to all other room members

- `leave-room`: Client → Server when user leaves room
  - Server marks member as offline
  - Broadcasts `member-left` to remaining members

- `players-updated`: Client → Server when user's active players change
  - Broadcasts `room-players-updated` to all members

Implementation:
- Added socket.join(`room:${roomId}`) for room-based broadcasting
- Integrated with room-membership manager (setMemberOnline)
- Fetches members, onlineMembers, and memberPlayers for each update
- Converts Map to Object for JSON serialization
- Uses io.to() for broadcasting to room members

Client Impact:
- Existing room detail page already listens for these events
- Real-time updates now work across tabs and sessions
- Members list and active players update automatically

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 07:12:23 -05:00
Thomas Hallock
bc219c2ad6 feat: improve room navigation and membership UI
Fixes two UX issues in the arcade rooms list:

1. **Show membership status**: Rooms now display whether the user is
   already a member
   - API returns `isMember` flag for each room
   - UI shows "✓ Joined" badge for joined rooms
   - Shows "Join Room" button for non-joined rooms

2. **Allow navigation without joining**: Users can now view rooms
   without automatically joining
   - Entire room card is clickable to navigate to room details
   - "Join Room" button specifically handles membership (with stopPropagation)
   - Users can browse room details before deciding to join

Changes:
- API (src/app/api/arcade/rooms/route.ts):
  - Added `isMember` check using viewerId
  - Enriched room response with membership status

- Frontend (src/app/arcade/rooms/page.tsx):
  - Added `isMember` to Room interface
  - Made room cards clickable for navigation
  - Show "✓ Joined" badge when user is a member
  - Show "Join Room" button when user is not a member
  - Button click stops propagation to prevent double navigation

This improves discoverability and prevents confusion about membership
status.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 07:07:24 -05:00
Thomas Hallock
3c002ab29d fix: auto-cleanup orphaned arcade sessions without valid rooms
Fixes critical bug where users were redirected to non-existent games
after room TTL deletion. This occurred because:

1. User creates arcade session in a room
2. Room expires via TTL cleanup
3. Session persists as orphan (roomId = null or points to deleted room)
4. useArcadeRedirect finds orphaned session
5. User redirected to /arcade/matching with no valid game state

Changes:

**Session validation (session-manager.ts)**
- getArcadeSession() now validates room association
- Auto-deletes sessions with no roomId
- Auto-deletes sessions pointing to non-existent rooms
- Returns undefined for orphaned sessions

**Session creation (session-manager.ts, route.ts, socket-server.ts)**
- createArcadeSession() now requires roomId parameter
- Socket server checks for existing user rooms before creating new ones
- Socket server auto-creates rooms when needed for backward compatibility
- API route requires roomId in request body

**Tests**
- Added orphaned-session-cleanup.test.ts: Unit/integration tests
- Added orphaned-session.e2e.test.ts: E2E regression tests
- Updated existing tests to provide roomId
- Tests cover TTL deletion, null roomId, and race conditions

This ensures sessions are always tied to valid rooms and prevents
orphaned session redirect loops.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 07:03:36 -05:00
Thomas Hallock
6800747f80 feat: add arcade room/session info and network players to nav
Add visual indicators for arcade sessions and other players:

Components added:
- NetworkPlayerIndicator: Shows network players with special
  "network" frame border (animated pulse, network icon badge)
- RoomInfo: Displays current arcade session info (game name,
  player count)

Modified:
- GameContextNav: Accept and render networkPlayers and roomInfo
- PageWithNav: Fetch arcade session info via useArcadeGuard and
  pass to GameContextNav

Visual features:
- Network players have gradient border and pulsing connection indicator
- Room info shows game name and player count in styled container
- Network players are visually distinct from local players
- Only shown when in active arcade session

This provides visibility into multiplayer state and prepares
for full room system implementation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 06:51:38 -05:00
Thomas Hallock
99906ae53d format 2025-10-07 15:45:57 -05:00
semantic-release-bot
caebefdce8 chore(release): 2.3.1 [skip ci]
## [2.3.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.3.0...v2.3.1) (2025-10-07)

### Bug Fixes

* add missing DOMPoint properties to getPointAtLength mock ([1e17278](1e17278f94))
* add missing name property to Passenger test mocks ([f8ca248](f8ca248844))
* add non-null assertions to skillConfiguration utilities ([9c71092](9c71092278))
* add optional chaining to stepBeadHighlights access ([a5fac5c](a5fac5c75c))
* add showAsAbacus property to ComplementQuestion type ([4adcc09](4adcc09643))
* add userId to optimistic player in useCreatePlayer ([5310463](5310463bec))
* change TypeScript moduleResolution from bundler to node ([327aee0](327aee0b4b))
* convert Jest mocks to Vitest in useSteamJourney tests ([e067271](e06727160c))
* convert player IDs from number to string in arcade tests ([72db1f4](72db1f4a2c))
* rewrite layout.nav.test to match actual RootLayout props ([a085de8](a085de816f))
* update useArcadeGuard tests with proper useViewerId mock ([4eb49d1](4eb49d1d44))
* use Object.defineProperty for NODE_ENV in middleware tests ([e73191a](e73191a729))
* wrap Buffer in Uint8Array for Next.js Response API ([98384d2](98384d264e))

### Documentation

* add explicit package.json script references to regime docs ([3353bca](3353bcadc2))
* establish mandatory code quality regime for Claude Code ([dd11043](dd1104310f))
* expand quality regime to define "done" for all work ([f92f7b5](f92f7b592a))
2025-10-07 20:45:07 +00:00
Thomas Hallock
a5fac5c75c fix: add optional chaining to stepBeadHighlights access
Add optional chaining (?.) when accessing stepBeadHighlights
to handle cases where it may be undefined.

Provides fallback to empty array when stepBeadHighlights is
not present, preventing potential runtime errors.

Fixes potential TS18048 error in progressive-test-suite.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 15:39:12 -05:00
Thomas Hallock
9c71092278 fix: add non-null assertions to skillConfiguration utilities
Add ! non-null assertion operator to target.basic,
target.advanced, and target.expert property accesses.

These objects are conditionally created earlier in the
function and are guaranteed to exist when accessed. The
assertions inform TypeScript of this runtime guarantee.

Fixes 9 TS18046 errors in skillConfiguration.ts.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 15:38:59 -05:00
Thomas Hallock
5310463bec fix: add userId to optimistic player in useCreatePlayer
Add temporary userId property to optimistic player object
to satisfy Player type requirements.

The Player type requires userId, but createPlayer only
accepts name/emoji/color. The optimistic update now includes
a temporary userId that gets replaced by the server response.

Fixes 1 TS2741 error in useUserPlayers.ts:108.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 15:38:53 -05:00
Thomas Hallock
4eb49d1d44 fix: update useArcadeGuard tests with proper useViewerId mock
Replace invalid userId parameter with proper useViewerId mock
that returns a UseQueryResult object.

The useArcadeGuard hook uses useViewerId() which returns a
React Query result object, not a plain string. Updated mocks
return {data, isLoading, error} to match the actual hook.

Also fixed all renderHook call syntax errors from previous
automated replacements.

Fixes ~15 TypeScript errors in useArcadeGuard.test.ts.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 15:38:46 -05:00
Thomas Hallock
a085de816f fix: rewrite layout.nav.test to match actual RootLayout props
Remove tests for non-existent 'nav' slot prop and rewrite
tests to match the actual RootLayout implementation.

The RootLayout component only accepts children, not a nav
prop. Updated tests verify the actual component behavior
with ClientProviders wrapper.

Fixes 2 TS2322 errors in layout.nav.test.tsx.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 15:38:38 -05:00
Thomas Hallock
72db1f4a2c fix: convert player IDs from number to string in arcade tests
Change all player ID values from numeric (1) to string ("1")
to match the arcade session schema.

The arcade session system uses string IDs for players, not
numbers. This aligns test data with production types.

Fixes 8 TS2322 errors in arcade session tests.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 15:38:28 -05:00
Thomas Hallock
1e17278f94 fix: add missing DOMPoint properties to getPointAtLength mock
Add w, z, matrixTransform, and toJSON properties to mock
getPointAtLength return value to satisfy DOMPoint interface.

The SVGPathElement.getPointAtLength() method returns a full
DOMPoint object, not just {x, y}. This fix ensures the mock
matches the real interface.

Fixes 2 TS2322 errors in useTrackManagement tests.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 15:38:13 -05:00
Thomas Hallock
f8ca248844 fix: add missing name property to Passenger test mocks
Add required name property to Passenger mock objects in
usePassengerAnimations and useTrackManagement tests.

The Passenger interface requires a name property. Adding it
to test mocks ensures type correctness.

Fixes 5 TS2741 errors in passenger-related tests.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 15:16:29 -05:00
Thomas Hallock
4adcc09643 fix: add showAsAbacus property to ComplementQuestion type
Import ComplementQuestion type from gameTypes and add
showAsAbacus property to test mocks and component interfaces.

The ComplementQuestion interface requires showAsAbacus as a
required property. Using the imported type ensures consistency
and fixes missing property errors.

Fixes ~34 TS2741 errors in complement-race components/tests.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 15:16:22 -05:00
Thomas Hallock
e06727160c fix: convert Jest mocks to Vitest in useSteamJourney tests
Replace jest.mock() and jest.* calls with vi.mock() and vi.*
for Vitest compatibility.

This project uses Vitest, not Jest. The Jest namespace was
causing TS2694 "namespace cannot be used as a value" errors.

Fixes ~20 TypeScript errors in passenger test files.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 15:16:14 -05:00
Thomas Hallock
98384d264e fix: wrap Buffer in Uint8Array for Next.js Response API
Wrap Buffer objects with new Uint8Array() to satisfy Next.js
Response BodyInit type requirements.

Next.js 13+ requires BodyInit types (not Buffer) for Response
constructors. This change maintains binary compatibility while
satisfying the type checker.

Fixes 3 TS2345 errors in API routes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 15:16:06 -05:00
Thomas Hallock
e73191a729 fix: use Object.defineProperty for NODE_ENV in middleware tests
Replace direct NODE_ENV assignments with Object.defineProperty
to avoid "Cannot assign to read-only property" TypeScript errors.

This allows tests to safely override the readonly NODE_ENV
environment variable for testing different environments.

Fixes 4 TS2540 errors.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 15:15:58 -05:00
Thomas Hallock
327aee0b4b fix: change TypeScript moduleResolution from bundler to node
Change moduleResolution from "bundler" to "node" for better
compatibility with pnpm workspace package resolution.

This helps TypeScript better resolve workspace dependencies
while maintaining compatibility with Next.js.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 15:15:49 -05:00
Thomas Hallock
3353bcadc2 docs: add explicit package.json script references to regime docs
Update .claude documentation to reference the actual npm scripts
from package.json, making it crystal clear what commands to run
and what they do under the hood.

**Added:**
- Exact script definitions from package.json
- What each script does (tsc, Biome, ESLint)
- Tool descriptions (TypeScript, Biome, ESLint)
- Quick reference sections for fast lookup

**Why:**
Makes it easier for Claude Code sessions to know exactly which
commands to run without ambiguity.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 12:54:25 -05:00
Thomas Hallock
f92f7b592a docs: expand quality regime to define "done" for all work
Update regime documentation to clarify that quality checks must pass
not just before commits, but before declaring ANY work complete.

**"Done" means:**
- npm run pre-commit passes (0 errors, 0 warnings)
- All TypeScript errors fixed
- All code formatted
- All linting passed

**Quality checks required before:**
- Committing code
- Saying work is "done" or "complete"
- Marking tasks as finished
- Telling the user something is "working" or "fixed"

This ensures quality standards are maintained throughout development,
not just at commit time.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 12:53:31 -05:00
Thomas Hallock
dd1104310f docs: establish mandatory code quality regime for Claude Code
Add comprehensive documentation and tooling to enforce code quality
checks before every commit. This regime persists across all Claude
Code sessions via `.claude/` directory files.

**The Regime** (mandatory before every commit):
1. TypeScript type checking (0 errors)
2. Biome formatting (auto-applied)
3. Linting with auto-fix (0 errors, 0 warnings)
4. Final verification

**Implementation:**
- `.claude/CLAUDE.md`: Quick reference for Claude Code sessions
- `.claude/CODE_QUALITY_REGIME.md`: Detailed regime documentation
- `npm run pre-commit`: Single command to run all checks

**Why no pre-commit hooks:**
Avoided for religious reasons. Claude Code is responsible for
enforcing quality checks through session-persistent documentation.

**Usage:**
```bash
# Before every commit
npm run pre-commit

# Or run steps individually
npm run type-check
npm run format
npm run lint:fix
npm run lint
```

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 12:52:29 -05:00
semantic-release-bot
ddbaf55aa2 chore(release): 2.3.0 [skip ci]
## [2.3.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.2.1...v2.3.0) (2025-10-07)

### Features

* add Biome + ESLint linting setup ([fc1838f](fc1838f4f5))

### Styles

* apply Biome formatting to entire codebase ([60d70cd](60d70cd2f2))
2025-10-07 17:49:19 +00:00
Thomas Hallock
60d70cd2f2 style: apply Biome formatting to entire codebase
Run Biome formatter on all files to ensure consistent code style:
- Single quotes for JS/TS
- Double quotes for JSX
- 2-space indentation
- 100 character line width
- Semicolons as needed
- ES5 trailing commas

This is the result of running: npx @biomejs/biome format . --write

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 12:48:26 -05:00
Thomas Hallock
fc1838f4f5 feat: add Biome + ESLint linting setup
Add Biome for formatting and general linting, with minimal ESLint
configuration for React Hooks rules only. This provides:

- Fast formatting via Biome (10-100x faster than Prettier)
- General JS/TS linting via Biome
- React Hooks validation via ESLint (rules-of-hooks)
- Import organization via Biome

Configuration files:
- biome.jsonc: Biome config with custom rule overrides
- eslint.config.js: Minimal flat config for React Hooks only
- .gitignore: Added Biome cache exclusion
- LINTING.md: Documentation for the setup

Scripts added to package.json:
- npm run lint: Check all files
- npm run lint:fix: Auto-fix issues
- npm run format: Format all files
- npm run check: Full Biome check

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 12:48:26 -05:00
semantic-release-bot
3c245d29fa chore(release): 2.2.1 [skip ci]
## [2.2.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.2.0...v2.2.1) (2025-10-07)

### Bug Fixes

* remove remaining typst-dependent files ([d1b9b72](d1b9b72cfc))
2025-10-07 15:47:29 +00:00
Thomas Hallock
d1b9b72cfc fix: remove remaining typst-dependent files
Remove preview API route and template-demo page that still
referenced the deleted typst-soroban library.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 10:46:46 -05:00
semantic-release-bot
3c00ebfe2f chore(release): 2.2.0 [skip ci]
## [2.2.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.1.3...v2.2.0) (2025-10-07)

### Features

* remove typst-related code and routes ([be6fb1a](be6fb1a881))
2025-10-07 15:42:43 +00:00
Thomas Hallock
be6fb1a881 feat: remove typst-related code and routes
Remove all typst-related files, API routes, and components.
This completes the typst dependency removal.

Removed:
- apps/web/src/app/api/typst-svg/route.ts
- apps/web/src/app/api/typst-template/route.ts
- apps/web/src/lib/typst-soroban.ts
- apps/web/src/components/TypstSoroban.tsx
- apps/web/src/app/test-typst/
- apps/web/src/app/typst-gallery/
- apps/web/src/app/typst-playground/

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 10:41:44 -05:00
semantic-release-bot
e157bbff43 chore(release): 2.1.3 [skip ci]
## [2.1.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.1.2...v2.1.3) (2025-10-07)

### Bug Fixes

* remove .npmrc from Dockerfile COPY ([e71c2b4](e71c2b4da8))
2025-10-07 15:37:01 +00:00
Thomas Hallock
e71c2b4da8 fix: remove .npmrc from Dockerfile COPY
.npmrc no longer exists after reverting to default pnpm mode.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 10:36:10 -05:00
semantic-release-bot
40cbe96385 chore(release): 2.1.2 [skip ci]
## [2.1.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.1.1...v2.1.2) (2025-10-07)

### Bug Fixes

* revert to default pnpm mode for Docker compatibility ([bd0092e](bd0092e69a))
2025-10-07 15:34:15 +00:00
Thomas Hallock
bd0092e69a fix: revert to default pnpm mode for Docker compatibility
Hoisted mode is incompatible with Docker's overlay filesystem.
Remove .npmrc and regenerate lockfile with default isolated mode.

This maintains semantic-release functionality while allowing
Docker builds to succeed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 10:33:26 -05:00
semantic-release-bot
f9262a2c83 chore(release): 2.1.1 [skip ci]
## [2.1.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.1.0...v2.1.1) (2025-10-07)

### Bug Fixes

* ignore all node_modules in Docker ([4792dde](4792dde1be))
2025-10-07 15:28:57 +00:00
Thomas Hallock
4792dde1be fix: ignore all node_modules in Docker
Docker overlay filesystem conflicts with local node_modules structure,
regardless of whether it's hoisted mode or not. Ignore all node_modules
and rely on the base stage installation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 10:28:09 -05:00
semantic-release-bot
f91248b0bb chore(release): 2.1.0 [skip ci]
## [2.1.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.0.7...v2.1.0) (2025-10-07)

### Features

* remove typst dependencies ([eedce28](eedce28572))
2025-10-07 15:23:47 +00:00
Thomas Hallock
eedce28572 feat: remove typst dependencies
Remove @myriaddreamin/typst-* packages that are no longer needed.
This eliminates Docker overlay conflicts with hoisted node_modules.

Removed packages (-365):
- @myriaddreamin/typst-all-in-one.ts
- @myriaddreamin/typst-ts-renderer
- @myriaddreamin/typst-ts-web-compiler
- @myriaddreamin/typst.ts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 10:22:48 -05:00
semantic-release-bot
d84bf9c845 chore(release): 2.0.7 [skip ci]
## [2.0.7](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.0.6...v2.0.7) (2025-10-07)

### Bug Fixes

* preserve workspace node_modules in Docker for hoisted mode ([4f8aaf0](4f8aaf04aa))
2025-10-07 15:15:43 +00:00
Thomas Hallock
4f8aaf04aa fix: preserve workspace node_modules in Docker for hoisted mode
With hoisted mode, each workspace needs its own node_modules folder
(containing symlinks). Only ignore root /node_modules.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 10:14:47 -05:00
semantic-release-bot
a43c8654e1 chore(release): 2.0.6 [skip ci]
## [2.0.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.0.5...v2.0.6) (2025-10-07)

### Bug Fixes

* ignore nested node_modules in Docker ([f554592](f554592272))
2025-10-07 15:11:09 +00:00
Thomas Hallock
f554592272 fix: ignore nested node_modules in Docker
Add **/node_modules pattern to prevent Docker overlay conflicts
when hoisted mode creates nested symlink structures.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 10:10:23 -05:00
semantic-release-bot
b073b9e1ec chore(release): 2.0.5 [skip ci]
## [2.0.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.0.4...v2.0.5) (2025-10-07)

### Bug Fixes

* use .npmrc in Docker for hoisted mode consistency ([2df8cdc](2df8cdc88e))
2025-10-07 15:06:45 +00:00
Thomas Hallock
2df8cdc88e fix: use .npmrc in Docker for hoisted mode consistency
The pnpm lockfile was generated with hoisted mode, so Docker must
also use hoisted mode to match the module resolution paths.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 10:05:56 -05:00
semantic-release-bot
e73afdb913 chore(release): 2.0.4 [skip ci]
## [2.0.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.0.3...v2.0.4) (2025-10-07)

### Bug Fixes

* remove .npmrc in Docker to avoid hoisted mode issues ([2a77d75](2a77d755b7))
2025-10-07 15:02:36 +00:00
Thomas Hallock
2a77d755b7 fix: remove .npmrc in Docker to avoid hoisted mode issues
Docker builds should use default pnpm isolated mode, not hoisted mode
which causes tsup module resolution failures.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 10:01:39 -05:00
semantic-release-bot
f4ab0ff9ba chore(release): 2.0.3 [skip ci]
## [2.0.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.0.2...v2.0.3) (2025-10-07)

### Bug Fixes

* remove duplicate PlayerStatusBar story file from arcade ([4e721f7](4e721f765a))
2025-10-07 14:58:20 +00:00
Thomas Hallock
4e721f765a fix: remove duplicate PlayerStatusBar story file from arcade
Remove apps/web/src/app/arcade/matching/components/PlayerStatusBar.stories.tsx
to fix Storybook build error about duplicate story IDs.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 09:57:17 -05:00
semantic-release-bot
8bd6d6d8b7 chore(release): 2.0.2 [skip ci]
## [2.0.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.0.1...v2.0.2) (2025-10-07)

### Bug Fixes

* update Dockerfile pnpm version and fix TypeScript config ([43077a8](43077a80e2))
2025-10-07 14:54:44 +00:00
Thomas Hallock
43077a80e2 fix: update Dockerfile pnpm version and fix TypeScript config
- Upgrade Dockerfile from pnpm 8.0.0 to 9.15.4 for lockfile compatibility
- Add "types": [] to abacus-react tsconfig to prevent implicit @types includes
- Fixes Docker build lockfile incompatibility
- Fixes TypeScript error looking for @types/minimatch

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 09:54:04 -05:00
semantic-release-bot
1f527581b8 chore(release): 2.0.1 [skip ci]
## [2.0.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.0.0...v2.0.1) (2025-10-07)

### Bug Fixes

* add @types/minimatch to abacus-react devDependencies ([fa45475](fa4547543d))
2025-10-07 14:47:53 +00:00
Thomas Hallock
fa4547543d fix: add @types/minimatch to abacus-react devDependencies
- TypeScript was looking for minimatch type definitions
- Hoisted mode made this implicit dependency explicit
- Fixes abacus-react build failure in CI

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 09:47:04 -05:00
semantic-release-bot
81f50323ad chore(release): 2.0.0 [skip ci]
## [2.0.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v1.2.1...v2.0.0) (2025-10-07)

### ⚠ BREAKING CHANGES

* abacus-react package now has independent versioning from monorepo

### Features

* **abacus-react:** add dual publishing to npm and GitHub Packages ([242ee52](242ee523ed))
* **abacus-react:** comprehensive README overhaul with current capabilities ([0ce351e](0ce351e572))
* **abacus-react:** configure GitHub Packages-only publishing workflow ([5eeedd9](5eeedd9a59))
* **abacus-react:** enable dual publishing to npm and GitHub Packages ([176a196](176a1961d0))
* **abacus-react:** enhance package description with semantic versioning details ([af037b5](af037b5e0a))
* **abacus-react:** implement GitHub Packages-only publishing workflow ([b194599](b194599f60))
* **abacus-react:** implement GitHub-only semantic release with manual package publishing ([33b0567](33b0567698))
* **abacus-react:** simplify to GitHub Packages-only publishing ([acc126b](acc126bd5a))
* **abacus-react:** update description to mention GitHub Packages support ([af77256](af7725622e))
* **abacus-react:** use environment variables to override npm registry ([ad444e1](ad444e108f))
* add API routes for players and user stats ([6f940e2](6f940e24d6))
* add arcade matching game components and utilities ([ff16303](ff16303a7c))
* add arcade room system database schema and managers (Phase 1) ([a9175a0](a9175a050c))
* add build info API endpoint ([571664e](571664e725))
* add build info generation script ([416dc89](416dc897e2))
* add category browsing and scrolling to emoji picker ([616a50e](616a50e234))
* add complement display options and unify equation display ([2ed7b2c](2ed7b2cbf8))
* add Complement Race game with three unique game modes ([582bce4](582bce411f))
* add comprehensive E2E testing with Playwright ([d58053f](d58053fad3))
* add comprehensive Storybook stories for PlayerStatusBar ([8973241](8973241297))
* add configuration access to active player emojis in prominent nav ([6049a7f](6049a7f6b7))
* add configuration access to fullscreen player selection ([b85968b](b85968bcb6))
* add consecutive match tracking system for escalating celebrations ([111c0ce](111c0ced71))
* add CSS animations and visual feedback system ([80e33e2](80e33e25b3))
* add deployment info modal with keyboard shortcut ([43be7ac](43be7ac83a))
* add direct URL routes for each game mode ([a08f053](a08f0535bf))
* add exitSession to MemoryPairsContextValue interface ([abc2ea5](abc2ea50d0))
* add GameControlButtons component with unit tests ([1f45c17](1f45c17e0a))
* add guest session system with JWT tokens ([10d8aaf](10d8aaf814))
* add initialStyle prop to ComplementRaceProvider ([f3bc2f6](f3bc2f6d92))
* add intelligent on-screen number pad for devices without keyboards ([d4740ff](d4740ff997))
* add interactive remove buttons for players in mini nav ([fa1cf96](fa1cf96789))
* add magnifying glass preview on emoji hover ([2c88b6b](2c88b6b5f3))
* add middleware for pathname header support in [@nav](https://github.com/nav) fallback ([b7e7c4b](b7e7c4beff))
* add mini app nav to arcade page ([a854fe3](a854fe3dc9))
* add optimistic updates and remove dead code ([b62cf26](b62cf26fb6))
* add passenger boarding system with station-based pickup ([23a9016](23a9016245))
* add player types and migration utilities ([79f44b2](79f44b25d6))
* add PlayerStatusBar with escalating celebration animations ([7f8c90a](7f8c90acea))
* add prominent game context display to mini nav with smooth transitions ([8792393](8792393956))
* add React Query setup with api helper ([a3878a8](a3878a8537))
* add realistic mountains with peaks and ground terrain ([99cdfa8](99cdfa8a0b))
* add security tests and userId injection protection ([aa1ad31](aa1ad315ef))
* add server-side validation for player modifications during active arcade sessions ([3b3cad4](3b3cad4b76))
* add Setup button to exit arcade sessions ([ae1318e](ae1318e8bf))
* add smooth spring animations to pressure gauge ([863a2e1](863a2e1319))
* add sound settings support to AbacusReact component ([90b9ffa](90b9ffa0d8))
* add train car system with smooth boarding/disembarking animations ([1613912](1613912740))
* add Web Audio API sound effects system with 16 sound types ([90ba866](90ba86640c))
* **complement-race:** add abacus displays to pressure gauge ([c5ebc63](c5ebc635af))
* complete themed navigation system with game-specific chrome ([0a4bf17](0a4bf1765c))
* create mode selection landing page for Complement Race ([1ff9695](1ff9695f69))
* create PlayerConfigDialog component for player customization ([4f2a661](4f2a661494))
* create StandardGameLayout for perfect viewport sizing ([728a920](728a92076a))
* display passengers visually on train and at stations ([1599904](159990489f))
* dynamic player card rendering on games page ([81d17f2](81d17f2397))
* dynamically calculate train cars based on max concurrent passengers ([9ea1553](9ea15535d1))
* emit session-state after creating arcade session ([70d6f43](70d6f43d6d))
* enable prominent nav and fix layout on arcade page ([5c8c18c](5c8c18cbb8))
* enhance emoji picker with super powered magnifying glass and hide empty categories ([d8b4e42](d8b4e425bf))
* enhance passenger card UI with boarding status indicators ([4bbdabc](4bbdabc3b5))
* extend ground terrain to cover entire track area ([ee48417](ee48417abf))
* extend player customization to support all 4 players ([72f8dee](72f8dee183))
* extend railroad track to viewport edges ([eadd7da](eadd7da6db))
* extend track and tunnels to absolute viewport edges ([f7419bc](f7419bc6a0))
* implement [@nav](https://github.com/nav) parallel routes for game name display in mini navigation ([885fc72](885fc725dc))
* implement cozy sound effects for abacus with variable intensity ([c95be1d](c95be1df6d))
* implement cozy sound effects for abacus with variable intensity ([cea5fad](cea5fadbe4))
* implement game control callbacks in MemoryPairsGame ([4758ad2](4758ad2f26))
* implement game theming system with context-based navigation chrome ([3fa11c4](3fa11c4fbc))
* implement innovative dynamic two-panel layout for on-screen keyboard ([4bb8f6d](4bb8f6daf1))
* implement mobile-first responsive design for speed memory quiz ([13efc4d](13efc4d070))
* implement simple fixed bottom keyboard bar ([9ef72d7](9ef72d7e88))
* implement smooth train exit with fade-out through right tunnel ([0176694](01766944f0))
* implement toggleable on-screen keyboard to prevent UI overlap ([701d23c](701d23c369))
* improve game availability logic and messaging ([9a3fa93](9a3fa93e53))
* increase landmark emoji size for better visibility ([0bcd7a3](0bcd7a30d4))
* integrate GameControlButtons into navigation ([fbd8cd4](fbd8cd4a6b))
* integrate remaining game sound effects ([600bc35](600bc35bc3))
* integrate sound effects into game flow (countdown, answers, performance) ([8c3a855](8c3a855239))
* integrate user profiles with PlayerStatusBar and game results ([beff646](beff64652c))
* make Steam Sprint infinite mode ([32c3a35](32c3a35eab))
* make SVG span full viewport width for sprint mode ([7488bb3](7488bb3803))
* migrate abacus display settings to database ([92ef136](92ef1360a4))
* migrate contexts to React Query (remove localStorage) ([fe01a1f](fe01a1fe18))
* migrate contexts to UUID-based player system ([2b94cad](2b94cad11b))
* preserve track and passengers during route transitions ([f2e7165](f2e71657dc))
* redesign passenger cards with vintage train station aesthetic ([651bc21](651bc21583))
* set up automated npm publishing for @soroban/abacus-react package ([dd80d29](dd80d29c97))
* set up Drizzle ORM with SQLite database ([5d5afd4](5d5afd4e68))
* skip countdown for train mode (sprint) ([65dafc9](65dafc9215))
* skip intro screen and start directly at game setup ([4b6888a](4b6888af05))
* sync URL with selected game mode ([3920bba](3920bbad33))
* UI polish for Sprint mode (viewport, backgrounds, data attributes) ([90ad789](90ad789ff1))
* update nav components for UUID players ([e85d041](e85d0415f2))
* use CSS transitions for smooth fullscreen player selection collapse ([3189832](31898328a3))
* wire player configuration through nav component hierarchy ([edfdd81](edfdd81227))

### Bug Fixes

* **abacus-react:** add debugging and explicit authentication for npm publish ([b82e9bb](b82e9bb9d6))
* **abacus-react:** add packages: write permission for GitHub Packages publishing ([8e16487](8e1648737d))
* **abacus-react:** apply global columnPosts styling and fix reckoning bar width ([bb9959f](bb9959f7fb))
* **abacus-react:** force npm to use GitHub Packages registry ([5e6c901](5e6c901f73))
* **abacus-react:** improve publishing workflow with better version sync ([7a4ecd2](7a4ecd2b59))
* **abacus-react:** improve workspace dependency cleanup and add validation ([11fd6f9](11fd6f9b3d))
* **abacus-react:** resolve workspace dependencies before npm publish ([834b062](834b062b2d))
* **abacus-react:** simplify semantic-release config to resolve dependency issues ([88cab38](88cab380ef))
* **abacus-react:** temporarily allow test failures during setup phase ([e3db7f4](e3db7f4daf))
* add CLEAR_MISMATCH move to allow mismatch feedback to auto-dismiss ([158f527](158f52773d))
* add missing GameThemeContext file for themed navigation ([d4fbdd1](d4fbdd1463))
* add npmrc for hoisting and fix template paths ([5c65ac5](5c65ac5caa))
* add Python setuptools and build tools for better-sqlite3 compilation ([a216a3d](a216a3d343))
* add testing mode for on-screen keyboard and fix toggle functionality ([904074c](904074ca82))
* align all bottom UI elements to same 20px baseline ([076c97a](076c97abac))
* align bottom-positioned UI elements ([227cfab](227cfabf11))
* allow navigation to game setup pages without active session ([c7ad3c0](c7ad3c0695))
* change pressure gauge to fixed positioning to stay above terrain ([1b11031](1b11031598))
* change question display to fixed positioning with higher z-index ([4ac8758](4ac8758957))
* **complement-race:** improve abacus display in equations ([491b299](491b299e28))
* **complement-race:** prevent passengers being left behind at delivery stations ([e6ebecb](e6ebecb09b))
* correct emoji category group IDs to match Unicode CLDR ([b2a21b7](b2a21b79ad))
* defer URL update until game starts ([12c54b2](12c54b27b7))
* delay passenger display update until train resets ([e06a750](e06a750454))
* disable turn validation in arcade mode matching game ([7c0e6b1](7c0e6b142b))
* eliminate rail jaggies on sharp curves by increasing sampling density ([46d4af2](46d4af2bda))
* enable shamefully-hoist for semantic-release dependencies ([6168c29](6168c292d5))
* enforce playerId must be explicitly provided in arcade moves ([d5a8a2a](d5a8a2a14c))
* ensure consistent r×c grid layout for memory matching game ([f1a0633](f1a0633596))
* ensure game names persist in navigation on page reload ([9191b12](9191b12493))
* ensure passengers only travel forward on train route ([8ad3144](8ad3144d2d))
* export missing hooks and types from @soroban/abacus-react package ([423ba55](423ba55350))
* implement route-based theme detection for page reload persistence ([3dcff2f](3dcff2ff88))
* improve navigation chrome background color extraction from gradients ([00bfcbc](00bfcbcdee))
* increase question display zIndex to stay above terrain ([8c8b8e0](8c8b8e08b4))
* lazy-load database connection to prevent build-time access ([af8d993](af8d993628))
* make results screen compact to fit viewport without scrolling ([9d4cba0](9d4cba05be))
* migrate viewport from metadata to separate viewport export ([1fe12c4](1fe12c4837))
* move auth.ts to src/ to match @/ path alias ([7829d8a](7829d8a0fb))
* move fontWeight to style object for station names ([05a3ddb](05a3ddb086))
* only show configuration gear icon for players 1 and 2 ([d0a3bc7](d0a3bc7dc1))
* pass player IDs (not user IDs) in all arcade game moves ([d00abd2](d00abd25e7))
* passengers now board/disembark based on their car position, not locomotive ([96782b0](96782b0e7a))
* position tunnels at absolute viewBox edges ([1a5fa28](1a5fa2873b))
* prevent layout shift when selecting Steam Sprint mode ([73a5974](73a59745a5))
* prevent multiple passengers from boarding same car in single frame ([63b0b55](63b0b552a8))
* prevent premature passenger display during route transitions ([fe9ea67](fe9ea67f56))
* prevent random passenger repopulation during route transitions ([db56ce8](db56ce89ee))
* prevent route celebration from immediately reappearing ([1a80934](1a8093416e))
* redesign matching game setup page for StandardGameLayout ([cc1f27f](cc1f27f0f8))
* reduce landmark size from 4.0x to 2.0x multiplier ([c928e90](c928e90785))
* regenerate lockfile with correct dependency order ([51bf448](51bf448c9f))
* regenerate lockfile with node-linker=hoisted from scratch ([480960c](480960c2c8))
* regenerate pnpm lockfile for pnpm 9 compatibility ([4ab1aef](4ab1aef9b8))
* remove double PageWithNav wrapper on matching page ([b58bcd9](b58bcd92ee))
* remove duplicate CAR_SPACING and MAX_CARS declarations ([e704a28](e704a28524))
* remove duplicate previousPassengersRef declaration ([fad8636](fad8636763))
* remove frozen lockfile flag from publishing workflow to resolve dependency installation issues ([18af973](18af9730ff))
* remove hard-coded car count from game loop ([6c90a68](6c90a68c49))
* remove unnecessary zIndex from question display ([db52e14](db52e14dfe))
* reposition on-screen keyboard to avoid covering abacus tiles ([6e5b4ec](6e5b4ec7bf))
* require activePlayers in START_GAME, never fallback to userId ([ea1b1a2](ea1b1a2f69))
* reset momentum and pressure when starting new route ([3ea88d7](3ea88d7a5a))
* resolve circular dependency errors in memory quiz on-screen keyboard ([d25e2c4](d25e2c4c00))
* resolve JSX parsing error with emoji in guide page ([bf046c9](bf046c999b))
* resolve mini navigation game name persistence across all routes ([3fa314a](3fa314aaa5))
* resolve runtime error - calculateOptimalGrid not defined ([fbc84fe](fbc84febda))
* resolve SSR/client hydration mismatch for themed navigation ([301e65d](301e65dfa6))
* resolve TypeScript errors in PlayerStatusBar component ([a935e5a](a935e5aed8))
* restore navigation to all pages using PageWithNav ([183706d](183706dade))
* show "Return to Arcade" button only during active game ([4153929](4153929a2a))
* smooth rail curves and deterministic track generation ([4f79c08](4f79c08d73))
* stabilize route completion threshold to prevent stuck trains ([b7233f9](b7233f9e4a))
* update lockfile and fix Makefile paths ([7ba746b](7ba746b6bd))
* update matching game for UUID player system ([2e041dd](2e041ddc44))
* update memory pairs game to use StandardGameLayout ([8df76c0](8df76c08fd))
* update memory quiz to use StandardGameLayout ([3f86163](3f86163c14))
* update pnpm version to 8.15.6 to resolve ERR_INVALID_THIS error in workflow ([0b9bfed](0b9bfed12d))
* update race track components for new player system ([ae4e8fc](ae4e8fcb5a))
* update tutorial tests to use consolidated AbacusDisplayProvider ([899fc69](899fc6975f))
* update workflow to support Personal Access Token for GitHub Packages publishing auth ([ae4b71b](ae4b71b986))
* upgrade CI dependencies and fix deprecated actions ([6a51c1e](6a51c1e9bd))
* use displayPassengers for station rendering in RailroadTrackPath ([a9e0d19](a9e0d19734))
* use node-linker=hoisted for full dependency hoisting ([d3b2e0b](d3b2e0b2e1))
* use player IDs instead of array indices in matching game ([ccd0d6d](ccd0d6d94c))
* use style fontSize instead of attribute for landmarks ([ebc6894](ebc6894746))
* use UUID player IDs in session creation fallback ([22541df](22541df99f))
* wrap animated pressure value in animated.span to prevent React error ([5c5954b](5c5954be74))

### Performance Improvements

* optimize React rendering with memoization and consolidated effects ([93cb070](93cb070ca5))

### Code Refactoring

* completely remove [@nav](https://github.com/nav) parallel routes and simplify navigation ([54ff20c](54ff20c755))
* consolidate abacus display context management ([a387b03](a387b030fa))
* extract ActivePlayersList component from PageWithNav ([2849576](28495767a9))
* extract AddPlayerButton component from PageWithNav ([57a72e3](57a72e34a5))
* extract FullscreenPlayerSelection component from PageWithNav ([66f5223](66f52234e1))
* extract GameContextNav orchestration component ([e3f552d](e3f552d8f5))
* extract GameHUD component from SteamTrainJourney ([78d5234](78d5234a79))
* extract GameModeIndicator component from PageWithNav ([d67315f](d67315f771))
* extract guide components to fix syntax error in large file ([c77e880](c77e880be3))
* extract RailroadTrackPath component from SteamTrainJourney ([d9acc0e](d9acc0efea))
* extract TrainAndCars component from SteamTrainJourney ([5ae22e4](5ae22e4645))
* extract TrainTerrainBackground component from SteamTrainJourney ([05bb035](05bb035db5))
* extract usePassengerAnimations hook from SteamTrainJourney ([32abde1](32abde107c))
* extract useTrackManagement hook from SteamTrainJourney ([a1f2b97](a1f2b9736a))
* extract useTrainTransforms hook from SteamTrainJourney ([a2512d5](a2512d5738))
* make game mode a computed property from active player count ([386c88a](386c88a3c0))
* remove drag-and-drop UI from EnhancedChampionArena ([982fa45](982fa45c08))
* remove duplicate game control buttons from game phases ([9165014](9165014997))
* remove redundant game titles from game screens ([402724c](402724c80e))
* replace bulky MemoryGrid stats with compact progress display ([c4d6691](c4d6691715))
* simplify navigation flow and enhance GameControls UI ([920aaa6](920aaa6398))
* simplify PageWithNav by extracting nav components ([98cfa56](98cfa5645b))
* split deployment info into server/client components ([5e7b273](5e7b273b33))
* streamline GamePhase header and integrate PlayerStatusBar ([dcefa74](dcefa74902))
* streamline UI and remove duplicate information displays ([7a3e34b](7a3e34b4fa))

### Documentation

* add comprehensive workflow documentation for automated npm publishing ([f923b53](f923b53a44))
* add server persistence migration plan ([dd0df8c](dd0df8c274))

### Tests

* add comprehensive unit tests for refactored hooks and components ([5d20839](5d2083903e))
* add E2E tests for arcade modal session behavior ([619be98](619be9859c))
2025-10-07 14:34:57 +00:00
Thomas Hallock
480960c2c8 fix: regenerate lockfile with node-linker=hoisted from scratch
- Delete and regenerate pnpm-lock.yaml to ensure clean state
- All deps now properly hoisted to root node_modules
- conventional-changelog-conventionalcommits now accessible

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 09:32:21 -05:00
Thomas Hallock
d3b2e0b2e1 fix: use node-linker=hoisted for full dependency hoisting
- Change from shamefully-hoist to node-linker=hoisted
- This creates a flat node_modules structure like npm
- Should fix semantic-release module resolution in CI

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 09:27:51 -05:00
Thomas Hallock
6168c292d5 fix: enable shamefully-hoist for semantic-release dependencies
- Switch .npmrc to shamefully-hoist=true for better compatibility
- This ensures all dependencies are hoisted to root node_modules
- Fixes module resolution issues in CI for semantic-release plugins

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 09:23:25 -05:00
Thomas Hallock
5c65ac5caa fix: add npmrc for hoisting and fix template paths
- Add .npmrc with public-hoist-pattern for semantic-release deps
- Fix project_root path in generate.py for monorepo structure
- Templates are at packages/templates/ not packages/core/templates/
- Fonts are at monorepo root fonts/ directory

Fixes:
- semantic-release conventional-changelog-conventionalcommits not found
- FileNotFoundError for single-card.typ and flashcards.typ

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 09:19:45 -05:00
Thomas Hallock
51bf448c9f fix: regenerate lockfile with correct dependency order
- Remove and regenerate pnpm-lock.yaml to match exact package.json state
- Fixes ERR_PNPM_OUTDATED_LOCKFILE errors in CI

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 09:10:25 -05:00
Thomas Hallock
4ab1aef9b8 fix: regenerate pnpm lockfile for pnpm 9 compatibility
- Completely regenerate pnpm-lock.yaml with pnpm 9.15.4
- Fixes ERR_PNPM_OUTDATED_LOCKFILE for dependency order changes
- pnpm 9 is stricter about matching dependency specs order

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 09:07:51 -05:00
Thomas Hallock
7ba746b6bd fix: update lockfile and fix Makefile paths
- Update pnpm-lock.yaml with new semantic-release dependencies
- Fix Makefile paths to use packages/core/src/ instead of src/
- All Python scripts now reference correct monorepo structure

Fixes:
- ERR_PNPM_OUTDATED_LOCKFILE in CI workflows
- Missing generate_examples.py in verify-examples workflow

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 09:05:41 -05:00
Thomas Hallock
6a51c1e9bd fix: upgrade CI dependencies and fix deprecated actions
- Upgrade pnpm from 8.0.0 to 9.15.4 to fix ERR_INVALID_THIS registry errors
- Upgrade actions/upload-artifact from v3 to v4 (v3 deprecated)
- Add missing semantic-release peer dependencies:
  - @semantic-release/commit-analyzer@^11.0.0
  - @semantic-release/release-notes-generator@^12.0.0

Fixes GitHub Actions failures in:
- Deploy Storybooks workflow
- Release workflow
- Verify Examples workflow

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 09:02:22 -05:00
Thomas Hallock
af8d993628 fix: lazy-load database connection to prevent build-time access
Refactor db/index.ts to use lazy initialization via Proxy pattern.
This prevents the database from being accessed at module import time,
which was causing Next.js build failures in CI/CD environments where
no database file exists.

The database connection is now created only when first accessed at
runtime, allowing static site generation to complete successfully.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 08:41:06 -05:00
Thomas Hallock
a9175a050c feat: add arcade room system database schema and managers (Phase 1)
Implement foundational infrastructure for multi-room arcade system:

Database:
- Add arcade_rooms table for room metadata and lifecycle
- Add room_members table for membership tracking
- Add nullable roomId field to arcade_sessions for room association
- Create migration 0003_naive_reptil.sql

Managers:
- Implement room-manager.ts with full CRUD operations
- Implement room-membership.ts for member management
- Add room-code.ts utility for unique room code generation
- Include TTL-based room cleanup functionality

Documentation:
- Add arcade-rooms-technical-plan.md with complete system design
- Add arcade-rooms-implementation-tasks.md with 62-task breakdown

This establishes the foundation for public multiplayer rooms with:
- URL-addressable rooms with unique codes
- Guest user support
- Configurable TTL for automatic cleanup
- Room creator moderation controls

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 15:11:24 -05:00
Thomas Hallock
9165014997 refactor: remove duplicate game control buttons from game phases
Remove duplicate New Game button from GamePhase and update ResultsPhase
to use proper arcade navigation now that controls are in the nav bar.

- Remove New Game button from GamePhase (now in nav)
- Change ResultsPhase "Back to Games" to "Back to Arcade"
- Add proper session exit in ResultsPhase arcade navigation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 14:25:01 -05:00
Thomas Hallock
4758ad2f26 feat: implement game control callbacks in MemoryPairsGame
Connect game control buttons to actual game actions using context
functions for Setup, New Game, and Quit functionality.

- Setup: exit session and navigate to /arcade/matching (returns to setup)
- New Game: call resetGame() from context
- Quit: exit session and navigate to /arcade

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 14:24:51 -05:00
Thomas Hallock
fbd8cd4a6b feat: integrate GameControlButtons into navigation
Wire up GameControlButtons through PageWithNav and GameContextNav to
enable game control buttons in the navigation bar during gameplay.

- Add onSetup, onNewGame props to PageWithNav and GameContextNav
- Show GameControlButtons when !showFullscreenSelection && !canModifyPlayers
- Pass callbacks through component hierarchy

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 14:24:40 -05:00
Thomas Hallock
1f45c17e0a feat: add GameControlButtons component with unit tests
Add reusable GameControlButtons component with Setup, New Game, and Quit
buttons for arcade game navigation. Includes comprehensive unit tests.

- Create GameControlButtons component with optional callbacks
- Add flexWrap: nowrap and whiteSpace: nowrap to prevent wrapping
- Write 10 unit tests covering all button behaviors
- All tests passing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 14:24:31 -05:00
Thomas Hallock
2248c34215 chore: improve logging in arcade session management
Added detailed logging to help debug player ID vs user ID flow:
- useArcadeSocket: Log full move payload with JSON stringification
- session-manager: Log player ID, game state players, and phase info

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 13:27:19 -05:00
Thomas Hallock
ea1b1a2f69 fix: require activePlayers in START_GAME, never fallback to userId
START_GAME moves must explicitly provide activePlayers array containing
database player IDs. Removed fallback to [data.userId] which incorrectly
used guest ID as player ID. Server now rejects START_GAME moves that are
missing activePlayers.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 13:27:10 -05:00
Thomas Hallock
d00abd25e7 fix: pass player IDs (not user IDs) in all arcade game moves
All game moves now explicitly include the database player ID (avatar ID)
in the playerId field. This fixes multiplayer turn validation which was
failing because it was comparing database player IDs with guest IDs.

- FLIP_CARD: Use state.currentPlayer (database player ID)
- START_GAME/resetGame: Use first active player ID, with validation
- CLEAR_MISMATCH: Use state.currentPlayer

Removed fallback to viewerId which incorrectly conflated user/guest IDs
with player IDs.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 13:27:00 -05:00
Thomas Hallock
d5a8a2a14c fix: enforce playerId must be explicitly provided in arcade moves
Player IDs (database avatar IDs) must never be conflated with or fall back
to user/guest IDs. This commit makes playerId a required field in all game
moves and throws an error if missing.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 13:26:49 -05:00
Thomas Hallock
22541df99f fix: use UUID player IDs in session creation fallback
- Changed CreateSessionOptions.activePlayers from number[] to string[]
- Updated socket-server.ts fallback from [1] to [data.userId]
- Added debug logging to validateFlipCard to diagnose turn validation issues

This ensures that when a session is created without explicit activePlayers,
it uses the actual UUID of the requesting player instead of the numeric value 1.
2025-10-06 13:04:33 -05:00
Thomas Hallock
ccd0d6d94c fix: use player IDs instead of array indices in matching game
Changed Player type from number to string (UUID) throughout the
matching game to properly identify players by their unique IDs
rather than array positions. This fixes the "Not your turn"
validation errors that were occurring because server-side
validation was comparing UUIDs (move.playerId) with numeric
indices (state.currentPlayer).

Changes:
- Updated Player type from number to string in both arcade and
  games matching context types
- Changed all player tracking to use UUID strings instead of
  numeric indices (1, 2, 3)
- Updated turn validation in MatchingGameValidator to compare
  string IDs correctly
- Fixed all UI components (GameCard, PlayerStatusBar, etc.) to
  use player.findIndex() for array positions when needed
- Updated MatchingStartGameMove type to expect string[] for
  activePlayers
- Re-enabled turn validation (previously disabled as workaround)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 12:49:10 -05:00
Thomas Hallock
619be9859c test: add E2E tests for arcade modal session behavior
Added comprehensive Playwright tests for arcade modal session system:
- Session redirects and persistence
- Player modification blocking during games
- "Return to Arcade" button functionality
- Session lifecycle from creation to end

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 12:21:42 -05:00
Thomas Hallock
7c0e6b142b fix: disable turn validation in arcade mode matching game
Removed turn validation check that was causing "Not your turn" errors.
The validator was comparing player numbers (1, 2, 3) with viewer UUIDs,
which never matched. In arcade mode, a single user controls all players,
so turn validation is not applicable.

- Removed comparison of state.currentPlayer (number) vs playerId (UUID)
- Allows single user to flip cards for any player in arcade mode
- Fixes card flipping functionality in matching game

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 12:21:34 -05:00
Thomas Hallock
70d6f43d6d feat: emit session-state after creating arcade session
Added session-state event emission after creating new arcade session
during START_GAME. This ensures connected clients (like useArcadeRedirect)
are immediately notified of the new session, triggering proper UI updates.

- Fetches newly created session after START_GAME
- Emits session-state to all clients in user's arcade room
- Enables "Return to Arcade" button to appear immediately

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 12:21:24 -05:00
Thomas Hallock
4153929a2a fix: show "Return to Arcade" button only during active game
Modified GameContextNav to only display "Return to Arcade" button when
user cannot modify players (i.e., during active game session). This
prevents the button from appearing during game setup phase.

- Button now conditional on !canModifyPlayers
- Removed incorrect "Setup" button display during setup

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 12:21:15 -05:00
Thomas Hallock
c7ad3c0695 fix: allow navigation to game setup pages without active session
Modified useArcadeRedirect to not redirect users away from game pages
when they have no active session. Users can now navigate to game setup
pages to start new sessions.

- Removed redirect logic from onNoActiveSession callback
- Updated canModifyPlayers to allow modification whenever no active session
- Only redirect when user has active session for DIFFERENT game

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 12:21:07 -05:00
Thomas Hallock
3b3cad4b76 feat: add server-side validation for player modifications during active arcade sessions
Prevents users from changing isActive status of players while they have
an active arcade session in progress. Returns 403 error with game info
when blocked.

- Added arcade session check in PATCH /api/players/[id] endpoint
- Enhanced error handling to surface server validation errors to users
- Added comprehensive E2E tests for validation behavior

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 12:20:58 -05:00
Thomas Hallock
ff16303a7c feat: add arcade matching game components and utilities
- Add game components (GameCard, GamePhase, SetupPhase, MemoryGrid)
- Add player status bar with multiplayer support
- Add emoji picker for player customization
- Add card generation and validation utilities
- Add game scoring system with combo multipliers
- Add page route for arcade matching game

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 11:04:41 -05:00
Thomas Hallock
9d4cba05be fix: make results screen compact to fit viewport without scrolling
- Use flexbox with space-between for non-scrollable layout
- Reduce all spacing and font sizes for compactness
- Remove performance analysis section
- Add responsive breakpoints for mobile/desktop
- Ensures Play Again button is always visible

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 11:04:29 -05:00
Thomas Hallock
ae1318e8bf feat: add Setup button to exit arcade sessions
- Add onExitSession prop to PageWithNav and GameContextNav
- Display Setup button (⚙️) in nav bar during games
- Call exitSession() and reload page to return to setup
- Provides consistent exit UI across all arcade games

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 11:04:22 -05:00
Thomas Hallock
abc2ea50d0 feat: add exitSession to MemoryPairsContextValue interface
- Add exitSession method to context type definition
- Enables arcade session cleanup and return to setup

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 11:04:15 -05:00
Thomas Hallock
158f52773d fix: add CLEAR_MISMATCH move to allow mismatch feedback to auto-dismiss
- Add MatchingClearMismatchMove type to arcade validation types
- Implement CLEAR_MISMATCH validation in MatchingGameValidator
- Keep mismatched cards visible briefly (1.5s) so player can see them
- Auto-dismiss mismatch feedback toast after timeout
- Essential for memory gameplay where seeing wrong cards builds memory

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 11:04:03 -05:00
Thomas Hallock
7829d8a0fb fix: move auth.ts to src/ to match @/ path alias
- Moved apps/web/auth.ts to apps/web/src/auth.ts
- Updated import in src/lib/viewer.ts to use @/auth alias
- Fixes 'Module not found: Can't resolve @/auth' error
- This error was not caught in dev due to typescript.ignoreBuildErrors
  but caused production build failures and 500 errors on /api/auth/* endpoints

The @/* path alias resolves to src/*, so auth.ts must be in src/ directory.
Previously, /api/auth/csrf and other NextAuth endpoints returned 500 errors.
2025-10-06 06:46:18 -05:00
Thomas Hallock
a216a3d343 fix: add Python setuptools and build tools for better-sqlite3 compilation
- Add python3, py3-setuptools, make, g++ to Alpine base image
- Required for better-sqlite3 native module compilation in Docker build
- Fixes 'ModuleNotFoundError: No module named distutils' error
2025-10-06 06:46:04 -05:00
Thomas Hallock
aa1ad315ef feat: add security tests and userId injection protection
Security improvements:
- Add comprehensive e2e tests for userId injection attacks
- Explicitly strip userId from abacus-settings PATCH request body
- Add security comments to player update routes
- Tests verify foreign key and unique constraints prevent attacks
- Document that API layer security is critical (DB constraints insufficient)

Test coverage:
- 12 tests for abacus-settings API (including 3 security tests)
- 11 tests for players API (including 3 security tests)
- All 23 tests passing

Key findings documented in tests:
- Database foreign keys prevent invalid userId references
- Primary key constraints prevent duplicate userIds (abacus_settings)
- For players, userId CAN be changed to another valid userId at DB level
- API layer MUST filter userId from request body and use session-derived userId
- WHERE clauses scope all queries to current user's data

Defense in depth:
1. Session-derived userId (JWT cookie)
2. Explicit userId filtering from request body
3. WHERE clauses limiting scope to user's own data
4. Foreign key constraints (fallback)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-05 19:57:51 -05:00
Thomas Hallock
92ef1360a4 feat: migrate abacus display settings to database
- Add abacus_settings table with all display configuration fields
- Create API routes (GET/PATCH) for abacus settings
- Add React Query hooks with optimistic updates
- Create AbacusSettingsSync component to bridge localStorage and API
- Settings now persist server-side per guest/user session
- Maintains backward compatibility with existing localStorage pattern

Migration includes:
- Database schema for 12 abacus display settings
- Automatic migration generation and application
- API-driven persistence with guest session support
- Sync component loads from API on mount and saves changes automatically

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-05 19:34:33 -05:00
Thomas Hallock
b62cf26fb6 feat: add optimistic updates and remove dead code
Phase 4 & 5: Cleanup + Optimistic Updates
- Removed dead localStorage types from player.ts
- Added optimistic updates to all player mutations
- Added optimistic updates to stats mutations
- Instant UI feedback with automatic rollback on error

Breaking changes: None

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-05 18:21:33 -05:00
Thomas Hallock
fe01a1fe18 feat: migrate contexts to React Query (remove localStorage)
Phase 3: React Query Integration
- Created useUserPlayers hook with CRUD mutations
- Created useUserStats hook with update mutations
- Rewrote GameModeContext to use API instead of localStorage
- Rewrote UserProfileContext to use API instead of localStorage
- Removed playerMigration.ts (localStorage utilities)
- Maintained backward-compatible interfaces

Technical details:
- All data now persists to SQLite via API
- React Query handles caching, invalidation, and optimistic updates
- Contexts still provide same interface for existing components
- No localStorage dependencies remaining (except sound settings)

Breaking changes:
- None - interfaces remain compatible

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-05 18:13:32 -05:00
Thomas Hallock
6f940e24d6 feat: add API routes for players and user stats
Phase 2.2: API Routes
- POST /api/players - Create player
- GET /api/players - List user's players
- PATCH /api/players/[id] - Update player
- DELETE /api/players/[id] - Delete player
- GET /api/user-stats - Get user statistics
- PATCH /api/user-stats - Update user statistics

Technical details:
- Middleware passes guest ID via x-guest-id header for same-request access
- API routes use getViewerId() to identify guest/user sessions
- Automatic user record creation on first API access
- Full test coverage (16 tests passing)
- Manual API testing verified with curl

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-05 18:03:36 -05:00
Thomas Hallock
10d8aaf814 feat: add guest session system with JWT tokens
Phase 1.2: Guest Session System
- Guest token utilities with JWT signing/verification (jose)
- Middleware for automatic guest cookie generation
- NextAuth v5 configuration with guest provider support
- Viewer helper utility for unified session access
- API route handlers for NextAuth
- Comprehensive test coverage (22 tests passing)

Technical details:
- Uses HttpOnly cookies for security
- Conditional cookie naming (__Host- in prod, plain in dev)
- 30-day token expiration with automatic rotation
- No localStorage dependency (fully server-side)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-05 17:35:18 -05:00
Thomas Hallock
5d5afd4e68 feat: set up Drizzle ORM with SQLite database
Phase 1.1 Complete: Database & Auth Infrastructure

- Configure Drizzle with SQLite and better-sqlite3
- Create schema for users, players, and user_stats tables
- Set up database client with foreign keys and WAL mode enabled
- Add migration runner and package.json scripts
- Generate initial migration (0000_third_carnage.sql)

Database Features:
- Users table with guestId for guest sessions
- Players table with userId FK (cascade delete)
- UserStats table with userId FK (cascade delete)
- Indexes on foreign keys for performance
- Type-safe schema with Drizzle ORM

Testing:
- 20 unit + e2e tests all passing
- Schema validation tests
- Migration idempotency tests
- Foreign key constraint tests
- Cascade delete tests

Scripts added:
- pnpm db:generate - Generate migration from schema
- pnpm db:migrate - Run pending migrations
- pnpm db:push - Push schema directly (dev)
- pnpm db:studio - Visual DB browser
- pnpm db:drop - Drop migration (dev)

User tests verified:
 Migration runs successfully
 Database tables created with correct schema
 Migration is idempotent (can run multiple times)
2025-10-05 17:01:27 -05:00
Thomas Hallock
dd0df8c274 docs: add server persistence migration plan
Add comprehensive plan for migrating from localStorage to server-side
database with NextAuth guest sessions.

Key features:
- SQLite + Drizzle ORM with type-safe schema definitions
- NextAuth v5 with JWT strategy for stateless guest sessions
- React Query for client-side data fetching and caching
- Comprehensive testing strategy (unit, e2e, manual)
- Fast-failure approach with no backwards compatibility
- Detailed Drizzle migration setup and workflow
- 5 phases with 10 checkpoints, each with specific tests

Strategy: greenfield approach with hard cutover at each checkpoint,
no localStorage fallbacks, no gradual migration.
2025-10-05 16:55:11 -05:00
Thomas Hallock
a3878a8537 feat: add React Query setup with api helper
- Install @tanstack/react-query
- Create QueryClientProvider in ClientProviders with stable client instance
- Add queryClient.ts with createQueryClient() and api() helper
- Add api() helper that wraps fetch with automatic /api prefix
- Add example.ts with complete CRUD hook examples
- Configure sensible defaults (5min staleTime, retry once)

All API routes are now prefixed with /api automatically via api() helper.
2025-10-05 16:55:03 -05:00
Thomas Hallock
1234e6ce60 chore: clean up working tree and update gitignore
Remove unused multiplayer/player code and abandoned e2e tests.
Add gitignore patterns for playwright reports and database files.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-05 06:59:26 -05:00
Thomas Hallock
5e7b273b33 refactor: split deployment info into server/client components
Refactor to use server component composition pattern where DeploymentInfoContent (server component) imports build info JSON directly and is rendered as a child of DeploymentInfoModal (client component). Eliminates unnecessary API fetch.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-05 06:43:34 -05:00
Thomas Hallock
43be7ac83a feat: add deployment info modal with keyboard shortcut
Add modal component to view deployment information (version, git commit, build time, etc.) accessible via Cmd/Ctrl+Shift+I keyboard shortcut throughout the app.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-05 06:37:12 -05:00
Thomas Hallock
571664e725 feat: add build info API endpoint
Add API route to serve deployment information and TypeScript definitions for type safety.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-05 06:37:05 -05:00
Thomas Hallock
416dc897e2 feat: add build info generation script
Add script to capture deployment metadata (git commit, branch, timestamp, version) at build time and integrate it into the build process.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-05 06:37:00 -05:00
Thomas Hallock
2e041ddc44 fix: update matching game for UUID player system
- GamePhase: Convert Map to array, map numeric IDs to players
- GameCard: Use active players array for emoji lookup
- PlayerStatusBar: Fix Map.filter, fix duplicate className, fix type comparison
- ResultsPhase: Convert to array-based player lookup, add numericId mapping
- MemoryPairsContext: Create compatibility layer for numeric player IDs
- EmojiPicker: Update import path for PLAYER_EMOJIS
- EmojiPicker test: Update import path

Maintains backward compatibility with internal numeric player tracking
while using UUID-based players from GameModeContext
2025-10-04 17:06:55 -05:00
Thomas Hallock
ae4e8fcb5a fix: update race track components for new player system
CircularTrack, LinearTrack, SteamTrainJourney:
- Convert Map to array for active player lookup
- Use player.emoji directly instead of profile.player1Emoji
- Remove hardcoded player ID switch statements
2025-10-04 17:06:55 -05:00
Thomas Hallock
81d17f2397 feat: dynamic player card rendering on games page
- Render player cards from Map instead of hardcoded 1-4
- Support arbitrary number of players
- Use player.color for theming
- Add React import for Fragment usage
2025-10-04 17:06:54 -05:00
Thomas Hallock
e85d0415f2 feat: update nav components for UUID players
- Update all player ID types from number to string
- Remove switch statements for player lookups
- Use Map/Set operations instead of array methods
- Support arbitrary number of players
- PlayerConfigDialog now accepts string IDs
2025-10-04 17:06:54 -05:00
Thomas Hallock
2b94cad11b feat: migrate contexts to UUID-based player system
GameModeContext:
- Use Map<string, Player> instead of array
- Use Set<string> for active player tracking
- Add migration support on initialization
- Remove hardcoded 1-4 player limit

UserProfileContext:
- Remove player1-4 fields (moved to GameModeContext)
- Keep only game statistics
- Add stats migration from V1
2025-10-04 17:06:53 -05:00
Thomas Hallock
79f44b25d6 feat: add player types and migration utilities
- Add Player interface with UUID-based id field
- Add PlayerStorageV2 format for new storage schema
- Add migration utilities to convert from V1 (indexed) to V2 (UUID)
- Add validation and rollback support
- Move PLAYER_EMOJIS to shared constants
2025-10-04 17:06:53 -05:00
Thomas Hallock
42b73cf8ee chore: add nanoid dependency
Add nanoid for generating universally unique player IDs
2025-10-04 17:06:53 -05:00
Thomas Hallock
2960eef2b5 debug: add comprehensive passenger boarding debug logging
Adds detailed console logging to capture all state during passenger
boarding and delivery in Steam Sprint mode. When passengers are left
behind, the entire log can be copied and pasted into a new Claude Code
session for immediate debugging.

Debug log includes:
- Train position, speed, momentum, and configuration
- All station positions and details
- Complete passenger states (waiting, boarded, delivered)
- Car positions and occupancy
- Passengers scheduled for delivery in current frame
- Detailed boarding attempt analysis for each waiting passenger
- Distance calculations and eligibility checks per car
- Actual boarding and delivery events

Enable by setting DEBUG_PASSENGER_BOARDING = true in useSteamJourney.ts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 17:56:21 -05:00
Thomas Hallock
e6ebecb09b fix(complement-race): prevent passengers being left behind at delivery stations
The bug occurred when a car reached a station where both:
- A passenger needed to be delivered
- A new passenger was waiting to board

The car appeared occupied during boarding check, then the passenger was delivered,
leaving the new passenger behind.

Fix: Identify passengers to be delivered BEFORE building the occupiedCars map,
and exclude them from the map. This makes cars that are about to become empty
immediately available for new passengers.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 17:48:40 -05:00
semantic-release-bot
93f4cb0b11 chore(abacus-react): release v1.7.0 [skip ci]
# [1.7.0](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v1.6.0...abacus-react-v1.7.0) (2025-10-01)

### Bug Fixes

* **abacus-react:** apply global columnPosts styling and fix reckoning bar width ([bb9959f](bb9959f7fb))
* **complement-race:** improve abacus display in equations ([491b299](491b299e28))

### Features

* **complement-race:** add abacus displays to pressure gauge ([c5ebc63](c5ebc635af))
2025-10-01 22:44:24 +00:00
Thomas Hallock
c5ebc635af feat(complement-race): add abacus displays to pressure gauge
- Replace digital PSI readout with 3-column abacus display
- Replace tick mark labels (0, 50, 100, 150) with mini abacuses
- Use invisible column posts and proper sizing for readability
- Expand gauge dimensions to accommodate abacus labels without clipping
- Show inactive beads on dial indicators for place value clarity

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 17:43:54 -05:00
Thomas Hallock
491b299e28 fix(complement-race): improve abacus display in equations
- Add hideInactiveBeads to AbacusTarget for cleaner minimal display
- Improve vertical centering with translateY adjustment in equations
- Apply changes consistently across practice, survival, and sprint modes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 17:43:54 -05:00
Thomas Hallock
bb9959f7fb fix(abacus-react): apply global columnPosts styling and fix reckoning bar width
- Apply global columnPosts property as fallback when rendering column rods
- Fix reckoning bar to span only across actual columns instead of full SVG width
- Column-specific columnPost styling still takes precedence over global

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 17:43:54 -05:00
semantic-release-bot
88ac0b9bcb chore(abacus-react): release v1.6.0 [skip ci]
# [1.6.0](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v1.5.1...abacus-react-v1.6.0) (2025-10-01)

### Bug Fixes

* align all bottom UI elements to same 20px baseline ([076c97a](076c97abac))
* align bottom-positioned UI elements ([227cfab](227cfabf11))
* change pressure gauge to fixed positioning to stay above terrain ([1b11031](1b11031598))
* change question display to fixed positioning with higher z-index ([4ac8758](4ac8758957))
* correct emoji category group IDs to match Unicode CLDR ([b2a21b7](b2a21b79ad))
* defer URL update until game starts ([12c54b2](12c54b27b7))
* delay passenger display update until train resets ([e06a750](e06a750454))
* eliminate rail jaggies on sharp curves by increasing sampling density ([46d4af2](46d4af2bda))
* ensure passengers only travel forward on train route ([8ad3144](8ad3144d2d))
* increase question display zIndex to stay above terrain ([8c8b8e0](8c8b8e08b4))
* move fontWeight to style object for station names ([05a3ddb](05a3ddb086))
* only show configuration gear icon for players 1 and 2 ([d0a3bc7](d0a3bc7dc1))
* passengers now board/disembark based on their car position, not locomotive ([96782b0](96782b0e7a))
* position tunnels at absolute viewBox edges ([1a5fa28](1a5fa2873b))
* prevent layout shift when selecting Steam Sprint mode ([73a5974](73a59745a5))
* prevent multiple passengers from boarding same car in single frame ([63b0b55](63b0b552a8))
* prevent premature passenger display during route transitions ([fe9ea67](fe9ea67f56))
* prevent random passenger repopulation during route transitions ([db56ce8](db56ce89ee))
* prevent route celebration from immediately reappearing ([1a80934](1a8093416e))
* reduce landmark size from 4.0x to 2.0x multiplier ([c928e90](c928e90785))
* remove double PageWithNav wrapper on matching page ([b58bcd9](b58bcd92ee))
* remove duplicate CAR_SPACING and MAX_CARS declarations ([e704a28](e704a28524))
* remove duplicate previousPassengersRef declaration ([fad8636](fad8636763))
* remove hard-coded car count from game loop ([6c90a68](6c90a68c49))
* remove unnecessary zIndex from question display ([db52e14](db52e14dfe))
* reset momentum and pressure when starting new route ([3ea88d7](3ea88d7a5a))
* smooth rail curves and deterministic track generation ([4f79c08](4f79c08d73))
* stabilize route completion threshold to prevent stuck trains ([b7233f9](b7233f9e4a))
* use displayPassengers for station rendering in RailroadTrackPath ([a9e0d19](a9e0d19734))
* use style fontSize instead of attribute for landmarks ([ebc6894](ebc6894746))
* wrap animated pressure value in animated.span to prevent React error ([5c5954b](5c5954be74))

### Features

* add category browsing and scrolling to emoji picker ([616a50e](616a50e234))
* add complement display options and unify equation display ([2ed7b2c](2ed7b2cbf8))
* add Complement Race game with three unique game modes ([582bce4](582bce411f))
* add configuration access to active player emojis in prominent nav ([6049a7f](6049a7f6b7))
* add configuration access to fullscreen player selection ([b85968b](b85968bcb6))
* add CSS animations and visual feedback system ([80e33e2](80e33e25b3))
* add direct URL routes for each game mode ([a08f053](a08f0535bf))
* add initialStyle prop to ComplementRaceProvider ([f3bc2f6](f3bc2f6d92))
* add interactive remove buttons for players in mini nav ([fa1cf96](fa1cf96789))
* add magnifying glass preview on emoji hover ([2c88b6b](2c88b6b5f3))
* add mini app nav to arcade page ([a854fe3](a854fe3dc9))
* add passenger boarding system with station-based pickup ([23a9016](23a9016245))
* add prominent game context display to mini nav with smooth transitions ([8792393](8792393956))
* add realistic mountains with peaks and ground terrain ([99cdfa8](99cdfa8a0b))
* add smooth spring animations to pressure gauge ([863a2e1](863a2e1319))
* add train car system with smooth boarding/disembarking animations ([1613912](1613912740))
* add Web Audio API sound effects system with 16 sound types ([90ba866](90ba86640c))
* create mode selection landing page for Complement Race ([1ff9695](1ff9695f69))
* create PlayerConfigDialog component for player customization ([4f2a661](4f2a661494))
* display passengers visually on train and at stations ([1599904](159990489f))
* dynamically calculate train cars based on max concurrent passengers ([9ea1553](9ea15535d1))
* enable prominent nav and fix layout on arcade page ([5c8c18c](5c8c18cbb8))
* enhance emoji picker with super powered magnifying glass and hide empty categories ([d8b4e42](d8b4e425bf))
* enhance passenger card UI with boarding status indicators ([4bbdabc](4bbdabc3b5))
* extend ground terrain to cover entire track area ([ee48417](ee48417abf))
* extend player customization to support all 4 players ([72f8dee](72f8dee183))
* extend railroad track to viewport edges ([eadd7da](eadd7da6db))
* extend track and tunnels to absolute viewport edges ([f7419bc](f7419bc6a0))
* implement smooth train exit with fade-out through right tunnel ([0176694](01766944f0))
* improve game availability logic and messaging ([9a3fa93](9a3fa93e53))
* increase landmark emoji size for better visibility ([0bcd7a3](0bcd7a30d4))
* integrate remaining game sound effects ([600bc35](600bc35bc3))
* integrate sound effects into game flow (countdown, answers, performance) ([8c3a855](8c3a855239))
* make Steam Sprint infinite mode ([32c3a35](32c3a35eab))
* make SVG span full viewport width for sprint mode ([7488bb3](7488bb3803))
* preserve track and passengers during route transitions ([f2e7165](f2e71657dc))
* redesign passenger cards with vintage train station aesthetic ([651bc21](651bc21583))
* skip countdown for train mode (sprint) ([65dafc9](65dafc9215))
* skip intro screen and start directly at game setup ([4b6888a](4b6888af05))
* sync URL with selected game mode ([3920bba](3920bbad33))
* UI polish for Sprint mode (viewport, backgrounds, data attributes) ([90ad789](90ad789ff1))
* use CSS transitions for smooth fullscreen player selection collapse ([3189832](31898328a3))
* wire player configuration through nav component hierarchy ([edfdd81](edfdd81227))

### Performance Improvements

* optimize React rendering with memoization and consolidated effects ([93cb070](93cb070ca5))
2025-10-01 22:14:42 +00:00
Thomas Hallock
2ed7b2cbf8 feat: add complement display options and unify equation display
- Add complement display modes (number/abacus/random) in game setup
- Create AbacusTarget component for inline abacus display in equations
- Unify equation display across practice, survival, and sprint modes
- Fix abacus vertical centering using inline-flex alignment
- Add Storybook examples demonstrating invisible column posts
- Fix passenger boarding bug using proper car occupancy tracking
- Redesign game setup UI with compact pill-based controls

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 17:13:54 -05:00
Thomas Hallock
63b0b552a8 fix: prevent multiple passengers from boarding same car in single frame
Problem: When multiple passengers were waiting at the same station and a car
arrived, they could all try to board the same car in the same game loop
iteration. This happened because currentBoardedPassengers was calculated once
at the start of each frame and didn't reflect passengers who boarded during
that same iteration.

Solution: Track which cars are assigned within each frame using a Set. Before
assigning a passenger to a car, check both:
1. currentBoardedPassengers (passengers from previous frames)
2. carsAssignedThisFrame (passengers assigned this frame)

This ensures only one passenger boards per car per frame, preventing the bug
where passengers get left behind at stations because the game thinks they've
already boarded when they haven't.

Tests: Added 6 comprehensive boarding logic tests that successfully reproduce
and verify the fix for various edge cases including:
- Single passenger boarding
- Multiple passengers with enough cars
- Fast-moving train scenarios
- Passenger left behind scenarios
- Single car with multiple passengers
- All passengers boarding before train passes

All tests pass (6/6).
2025-10-01 12:44:45 -05:00
Thomas Hallock
b7233f9e4a fix: stabilize route completion threshold to prevent stuck trains
Problem: Route completion threshold was recalculated every frame based on
current passengers. When passengers were auto-regenerated mid-route (after
all delivered), the maxCars value could change, shifting the threshold and
potentially causing the train to never cross it properly.

Solution:
- Store route exit threshold in a ref when route starts
- Only update threshold at route completion for next route
- Threshold remains stable throughout entire route even when passengers regenerate

This ensures trains always complete routes when reaching the exit threshold,
preventing situations where players get stuck answering problems after train
has disappeared.
2025-10-01 12:36:55 -05:00
Thomas Hallock
db56ce89ee fix: prevent random passenger repopulation during route transitions
- Track displayRouteRef to identify which route's passengers are shown
- Only update passengers when train resets (< 0) OR same route and in middle of track (10-90%)
- Exclude start/end transition zones to prevent premature passenger updates
- Add comprehensive unit tests (11 tests) covering edge cases:
  - New passengers at position 0 with old route
  - Passengers regenerated at 5% position
  - Rapid route increment with position oscillation
  - Multiple rapid gameplay updates during same route
- All 44 hook tests pass
2025-10-01 12:22:57 -05:00
Thomas Hallock
4f79c08d73 fix: smooth rail curves and deterministic track generation
- Use bezier curve paths instead of polylines for rails to eliminate jaggies on sharp turns
- Sample rail waypoints densely (every 2px) with gentle control points (0.33)
- Use longer lookahead distance (8px) for smoother perpendicular angle calculation
- Implement seeded random number generator for consistent tracks per route
- Each route generates a unique but deterministic track layout
2025-10-01 12:22:47 -05:00
Thomas Hallock
46d4af2bda fix: eliminate rail jaggies on sharp curves by increasing sampling density
The rails were rendered as polylines with points sampled at each tie position
(every 12 pixels). On sharp turns, the inside rail (smaller radius) didn't
have enough points to maintain smoothness, creating visible jaggies.

Solution: Separate tie generation from rail point generation. Ties still use
12px spacing (looks good), but rails now sample at 3px intervals (4x denser).
This provides enough points for smooth curves even on the inside rail of
sharp turns, while keeping tie density reasonable.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 11:39:26 -05:00
Thomas Hallock
6c90a68c49 fix: remove hard-coded car count from game loop
The useSteamJourney hook was still using hard-coded MAX_CARS = 5 for:
- Checking which cars should board passengers
- Checking which cars should deliver passengers
- Calculating when entire train exits (route completion)

This caused the game to wait for a 5-car train to exit even when there were
fewer passengers, making players answer problems well after the last visible
car had exited.

Now dynamically calculates maxCars in the game loop using the same
calculateMaxConcurrentPassengers() utility, ensuring route completion
happens as soon as the actual last car exits.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 11:34:44 -05:00
Thomas Hallock
9ea15535d1 feat: dynamically calculate train cars based on max concurrent passengers
Instead of hard-coding 5 train cars, now calculate the number of cars needed
based on the maximum number of passengers that will be on the train at any
given moment during the route.

For example: if 8 total passengers exist, but only 4 board at the start, all
get off halfway, then the remaining 4 board and ride to the end, we only need
4 cars (not 8).

Added calculateMaxConcurrentPassengers() utility that:
- Tracks boarding/delivery events by station position
- Sorts events to handle same-station boarding/delivery correctly
- Returns the peak concurrent passenger count

Updated SteamTrainJourney to calculate maxCars dynamically using this utility.
Updated all hooks and tests to use required maxCars/carSpacing parameters.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 11:26:59 -05:00
Thomas Hallock
65dafc9215 feat: skip countdown for train mode (sprint)
Train mode now starts immediately when clicking "Start Your Race!" button,
bypassing the 3-2-1-GO countdown. Other game modes (practice, survival) still
show the countdown as expected.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 11:20:31 -05:00
Thomas Hallock
e06a750454 fix: delay passenger display update until train resets
Previously, passengers would update as soon as all cars exited (at 97%+),
causing new passengers to briefly appear on the old track before it changed.

Now passengers only update when:
1. Train resets to start position (< 0) - track has changed, OR
2. Same passengers (same route gameplay updates like boarding/delivering)

This eliminates the ephemeral passengers showing up after the last car
fades out but before the new track is displayed.

Updated test to verify passengers don't change until train resets.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 11:16:20 -05:00
Thomas Hallock
5d2083903e test: add comprehensive unit tests for refactored hooks and components
Added 56 unit tests covering:
- usePassengerAnimations hook (7 tests) - boarding/disembarking animations
- useTrainTransforms hook (14 tests) - train positioning and opacity
- useTrackManagement hook (12 tests) - track generation and route transitions
- TrainTerrainBackground component (10 tests) - terrain rendering
- GameHUD component (13 tests) - HUD overlay elements

Also configured vitest to properly inject React for JSX transforms, eliminating
the need for explicit React imports in test files and components.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 11:13:32 -05:00
Thomas Hallock
a9e0d19734 fix: use displayPassengers for station rendering in RailroadTrackPath
Fix the actual bug: passengers at stations and in passenger list were
switching to the new route once the locomotive exited, but before the
last train car exited.

Root cause: RailroadTrackPath was using state.passengers directly instead
of displayPassengers, which has transition logic to delay the switch until
all cars have exited.

The previous commit added the transition logic to useTrackManagement, but
RailroadTrackPath wasn't using the displayPassengers output - it was still
reading state.passengers directly.

Changes:
- SteamTrainJourney: Pass displayPassengers to RailroadTrackPath instead
  of state.passengers

Note: usePassengerAnimations correctly continues to use state.passengers
for immediate animation triggering. boardedPassengers and
nonDeliveredPassengers already use displayPassengers correctly.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 10:49:07 -05:00
Thomas Hallock
fe9ea67f56 fix: prevent premature passenger display during route transitions
Fix bug where new route passengers would appear on train cars before
all cars from the previous route had exited through the tunnel.

The issue occurred because passengers were switched when trainPosition < 0
(locomotive exits), but train cars trail behind by up to 35% (5 cars × 7%
spacing). This caused new passengers to briefly appear on the old cars that
were still fading out (positions 65-97%).

Solution: Calculate the last car's position and only switch passengers when:
1. Train has reset to start position (trainPosition < 0), OR
2. All cars have fully faded out (lastCarPosition >= 97%)

Changes:
- useTrackManagement: Added maxCars and carSpacing parameters
- Updated passenger display logic to check lastCarPosition >= fadeOutEnd
- SteamTrainJourney: Pass maxCars and carSpacing from useTrainTransforms

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 10:15:43 -05:00
Thomas Hallock
78d5234a79 refactor: extract GameHUD component from SteamTrainJourney
Extract game HUD elements (route info, time, pressure gauge, passenger list,
question display) into a separate GameHUD component for better maintainability.

- Created GameHUD.tsx (190 lines)
- Reduced SteamTrainJourney.tsx from 412 to 283 lines (-129 lines, 31.3%)
- Preserved all HUD elements and functionality exactly

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 09:59:39 -05:00
Thomas Hallock
5ae22e4645 refactor: extract TrainAndCars component from SteamTrainJourney
Extract train rendering (locomotive, cars, passenger animations) into
a separate TrainAndCars component for better maintainability.

- Created TrainAndCars.tsx (217 lines)
- Reduced SteamTrainJourney.tsx from 546 to 412 lines (-134 lines, 24.5%)
- Preserved all train elements and animations exactly

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 09:57:32 -05:00
Thomas Hallock
d9acc0efea refactor: extract RailroadTrackPath component from SteamTrainJourney
Extract track rendering (ties, rails, path, landmarks, stations) into
a separate RailroadTrackPath component for better maintainability.

- Created RailroadTrackPath.tsx (192 lines)
- Reduced SteamTrainJourney.tsx from 687 to 546 lines (-141 lines, 20.5%)
- Preserved all track elements and passenger rendering exactly

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 09:55:46 -05:00
Thomas Hallock
05bb035db5 refactor: extract TrainTerrainBackground component from SteamTrainJourney
Extract terrain background rendering (ground, mountains, tunnels) into
a separate TrainTerrainBackground component for better maintainability.

- Created TrainTerrainBackground.tsx (203 lines)
- Reduced SteamTrainJourney.tsx from 857 to 687 lines (-170 lines, 19.8%)
- Preserved all visual elements and functionality exactly

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 09:53:34 -05:00
Thomas Hallock
a1f2b9736a refactor: extract useTrackManagement hook from SteamTrainJourney
- Create dedicated hook for track generation and positioning logic
- Move 115+ lines of track/landmark/passenger display management to useTrackManagement.ts
- Consolidates track generation, ties/rails, station positions, landmarks, and passenger display transitions
- Reduce SteamTrainJourney.tsx from 959 to 857 lines (102 lines removed)
- Preserve all functionality exactly - no behavioral changes

Benefits:
- Centralizes all track-related state management
- Handles route transition logic in one place
- Makes track generation logic easier to test
- Improves overall code organization

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 09:43:07 -05:00
Thomas Hallock
a2512d5738 refactor: extract useTrainTransforms hook from SteamTrainJourney
- Create dedicated hook for train position and car transform calculations
- Move 71 lines of transform logic to useTrainTransforms.ts
- Extract train position useEffect, trainCars useMemo, and locomotiveOpacity useMemo
- Reduce SteamTrainJourney.tsx from 1023 to 959 lines (64 lines removed)
- Preserve all functionality exactly - no behavioral changes

Benefits:
- Separates transform calculations from presentation logic
- Makes train physics calculations easier to test
- Improves code organization and readability

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 09:34:37 -05:00
Thomas Hallock
32abde107c refactor: extract usePassengerAnimations hook from SteamTrainJourney
- Create dedicated hook for passenger boarding/disembarking animations
- Move 112 lines of animation logic to usePassengerAnimations.ts
- Export BoardingAnimation and DisembarkingAnimation types
- Reduce SteamTrainJourney.tsx from 1147 to 1023 lines (124 lines removed)
- Preserve all functionality exactly - no behavioral changes

Benefits:
- Better separation of concerns
- Easier to test animation logic independently
- Improved maintainability

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 09:30:58 -05:00
Thomas Hallock
fad8636763 fix: remove duplicate previousPassengersRef declaration
- Remove duplicate ref declaration that was causing compilation error
- Use existing previousPassengersRef initialized with state.passengers

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 09:21:52 -05:00
Thomas Hallock
f2e71657dc feat: preserve track and passengers during route transitions
- Store pending track data and only apply when train resets to beginning
- Preserve passenger list display until entire train exits
- Prevent visual jumps by keeping old route data visible during fade-out
- Track transitions now seamless: old track/passengers persist until trainPosition < 0

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 09:19:58 -05:00
Thomas Hallock
e704a28524 fix: remove duplicate CAR_SPACING and MAX_CARS declarations 2025-10-01 09:09:34 -05:00
Thomas Hallock
96782b0e7a fix: passengers now board/disembark based on their car position, not locomotive
Previously, passengers would board/disembark when the locomotive reached their
station, which was incorrect. Now:

**Boarding:**
- Passengers board when an EMPTY train car reaches their origin station
- Each passenger is assigned to the first available empty car (0-4)
- Car position calculated as: trainPosition - (carIndex + 1) * 7%

**Disembarking:**
- Passengers disembark when THEIR specific car reaches their destination
- Uses the passenger's position in the boardedPassengers array to find car index
- Each passenger's car position is independently checked against their destination

This creates more realistic train behavior where passengers interact with
individual train cars rather than just the locomotive.

Removed unused imports: findBoardablePassengers, findDeliverablePassengers

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 09:07:31 -05:00
Thomas Hallock
01766944f0 feat: implement smooth train exit with fade-out through right tunnel
- Allow train position to exceed 100% so entire train can exit before route change
- Change route completion threshold from 100% to 135% (locomotive + 5 cars at 7% spacing)
- Add fade-out effect for locomotive and train cars between 92-97% position
- Symmetric with fade-in effect (3-8%) used for left tunnel entrance
- Prevents jarring snap-back when route changes mid-train

The train now smoothly fades out as it enters the right tunnel, with the route
only changing after the entire train (including all passenger cars) has fully
exited through the tunnel.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 09:02:41 -05:00
Thomas Hallock
93cb070ca5 perf: optimize React rendering with memoization and consolidated effects
- Memoize BoardingPassengerAnimation and DisembarkingPassengerAnimation with React.memo
- Memoize expensive calculations: train cars, filtered passengers, ground texture
- Consolidate boarding/disembarking useEffect hooks to reduce passenger array processing
- Wrap PassengerCard in React.memo to prevent unnecessary re-renders
- Add useMemo for boardedPassengers and nonDeliveredPassengers lists

These optimizations reduce unnecessary re-renders and recalculations during gameplay,
improving performance especially when multiple passengers are in transit.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 09:00:19 -05:00
Thomas Hallock
1613912740 feat: add train car system with smooth boarding/disembarking animations
Implement a complete passenger train car system with animated boarding
and disembarking using react-spring.

Train Car System:
- Each passenger gets their own train car (🚃) following the track
- Cars follow curved track with proper rotation and spacing (7%)
- Cars fade in as they emerge from tunnel to avoid visual pile-up
- Compact car sizing (65px) and passenger sizing (42px)
- Maximum 5 cars per train for performance

Boarding Animations:
- Smooth spring-animated transitions from station to train car
- Passengers fly from station platform to assigned car (800ms)
- Passengers hidden from station and car during animation
- Animated passenger tracked separately to avoid flickering

Disembarking Animations:
- Reverse animation from train car to destination station
- Green glow effect for delivered passengers
- Celebration animation plays after landing at station
- Smooth transition prevents "ghost" effect

Station Passenger Display:
- Positioned directly above station circle (30px offset)
- Compact 55px size for better visual balance
- Tight horizontal spacing (28px) when multiple waiting
- Passengers properly excluded during boarding/disembarking

Visual Improvements:
- Removed decorative rock/bush elements from mountains
- Cleaner mountain tunnel appearance
- Better layering of animations between stations and train

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 08:52:44 -05:00
Thomas Hallock
651bc21583 feat: redesign passenger cards with vintage train station aesthetic
Redesign passenger information cards to match classic Grand Central
Terminal style with compact, elegant vintage design.

- Vintage color scheme: sepia, gold accents, dark backgrounds
- Courier New monospace font for mechanical board feel
- Compact layout with FROM/TO route information
- Shows origin station (was missing before)
- Status badges: WAIT, BOARD, DLVRD
- Color-coded by state (waiting/aboard/urgent/delivered)
- Art Deco gold borders and styling
- Much smaller footprint while showing more info

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 08:52:25 -05:00
Thomas Hallock
8ad3144d2d fix: ensure passengers only travel forward on train route
Update passenger generation to only create passengers whose destination
stations are ahead of their origin stations (higher track position).
This prevents passengers from being stranded waiting for backwards travel.

- Exclude final station as possible origin (no stations ahead)
- Filter destination stations to only those with position > origin
- Maintains 40% depot start probability for variety

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 08:52:15 -05:00
Thomas Hallock
076c97abac fix: align all bottom UI elements to same 20px baseline 2025-10-01 08:23:44 -05:00
Thomas Hallock
227cfabf11 fix: align bottom-positioned UI elements
Standardize positioning for all bottom-aligned elements:
- Pressure gauge: bottom 10px (fixed, z-index 1000)
- Question display: bottom 40px (fixed, z-index 1000)
- Passenger list: bottom 220px (fixed, z-index 1000)

Changed passenger list from absolute to fixed positioning and adjusted
bottom spacing to sit above the question display without overlapping.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 08:21:54 -05:00
Thomas Hallock
1b11031598 fix: change pressure gauge to fixed positioning to stay above terrain 2025-10-01 08:20:36 -05:00
Thomas Hallock
4ac8758957 fix: change question display to fixed positioning with higher z-index 2025-10-01 08:19:10 -05:00
Thomas Hallock
db52e14dfe fix: remove unnecessary zIndex from question display 2025-09-30 16:48:00 -05:00
Thomas Hallock
8c8b8e08b4 fix: increase question display zIndex to stay above terrain 2025-09-30 16:46:22 -05:00
Thomas Hallock
ee48417abf feat: extend ground terrain to cover entire track area
Expand ground layer to span full viewport width and extend from
the top of the track (y=120) to the bottom of the viewport (y=650).

Changes:
- Ground now starts at y=120 (above highest track point at ~160)
- Height increased from 200 to 530 pixels
- Added scattered rock/pebble texture across ground surface
- Enhanced surface gradient from 40 to 60 pixels for better depth

The entire railroad track now sits on realistic earth terrain.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 16:42:35 -05:00
Thomas Hallock
99cdfa8a0b feat: add realistic mountains with peaks and ground terrain
Enhanced tunnel entrances with proper mountain scenery:

Mountains:
- Added triangular mountain peaks rising above tunnel entrances
- Gradient shading for depth and dimension
- Rocky texture with vertical crack details
- Green vegetation/bushes at mountain base

Tunnels:
- Stone brick archways with brown/tan coloring
- Brick texture pattern on arch rim
- Dark interior for depth

Ground:
- Brown earth terrain extending full viewport width
- Gradient shading for ground surface depth
- Positioned below tunnel entrances

Train now emerges from/enters proper mountain tunnels set into
realistic terrain.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 16:41:41 -05:00
Thomas Hallock
7488bb3803 feat: make SVG span full viewport width for sprint mode
Remove constraints preventing full-width rendering:
- Remove 8px horizontal padding from track container in sprint mode
- Remove maxWidth: 1400px constraint from SVG
- Change justifyContent to 'stretch' for sprint mode
- Remove borderRadius from steam-train-journey container

Now the railroad track SVG truly spans the entire browser viewport
width, with tunnel entrances at the absolute edges.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 16:39:27 -05:00
Thomas Hallock
1a5fa2873b fix: position tunnels at absolute viewBox edges
Corrected tunnel positioning based on actual viewBox coordinates:
- viewBox is "-50 -50 900 700" (x: -50 to 850, y: -50 to 650)
- Left mountain face now starts at x=-50 (absolute left edge)
- Right mountain face now ends at x=850 (absolute right edge)
- Both mountain faces extend full viewport height
- Track waypoints adjusted to span from left tunnel (x=20) to right tunnel (x=780)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 16:36:53 -05:00
Thomas Hallock
f7419bc6a0 feat: extend track and tunnels to absolute viewport edges
- Extend track waypoints from x: -120 to x: 920 (beyond viewport)
- Add mountain face rectangles at absolute left and right edges
- Redesign tunnel entrances as proper mountain tunnels with:
  - Gray stone mountain faces extending to viewport edges
  - Dark tunnel interiors
  - Arched stone openings
  - Stone rim detail around arch

Train now truly emerges from and enters into mountain tunnels at
the viewport boundaries.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 16:34:52 -05:00
Thomas Hallock
eadd7da6db feat: extend railroad track to viewport edges
Modify track waypoints to span from left edge (x: -50) to right edge
(x: 850) of the viewport, creating a more expansive journey across
the full screen width.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 16:33:05 -05:00
Thomas Hallock
05a3ddb086 fix: move fontWeight to style object for station names 2025-09-30 16:28:51 -05:00
Thomas Hallock
c928e90785 fix: reduce landmark size from 4.0x to 2.0x multiplier 2025-09-30 16:27:28 -05:00
Thomas Hallock
ebc6894746 fix: use style fontSize instead of attribute for landmarks 2025-09-30 16:24:29 -05:00
Thomas Hallock
0bcd7a30d4 feat: increase landmark emoji size for better visibility
Increase landmark size multiplier from 2.5x to 4.0x, making scenery
emojis more prominent and visually similar in scale to the player
avatar (96px default vs 70px player).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 16:21:15 -05:00
Thomas Hallock
5c5954be74 fix: wrap animated pressure value in animated.span to prevent React error 2025-09-30 15:54:22 -05:00
Thomas Hallock
863a2e1319 feat: add smooth spring animations to pressure gauge
Use React Spring to animate pressure changes with natural physics-based
motion. The gauge needle, digital readout, and color all smoothly
interpolate to new values instead of jumping instantly.

Configuration:
- tension: 120 (responsive spring)
- friction: 14 (smooth damping)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 15:53:06 -05:00
Thomas Hallock
4b6888af05 feat: skip intro screen and start directly at game setup
Remove "How to Play" screen in favor of self-explanatory UX.
Users now land directly on the game configuration page where they
can immediately see and select their preferred game mode, style,
and difficulty.

The game mechanics are intuitive enough that they can be learned
through play rather than reading instructions.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 15:49:25 -05:00
Thomas Hallock
32c3a35eab feat: make Steam Sprint infinite mode
- Remove 60-second time limit for infinite gameplay
- Auto-advance to next route upon completion instead of showing modal
- Time-of-day cycle now repeats every 60 seconds indefinitely
- Update documentation to reflect infinite mode behavior

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 15:46:29 -05:00
Thomas Hallock
3ea88d7a5a fix: reset momentum and pressure when starting new route
Added momentum and pressure reset to START_NEW_ROUTE action.
Each new route now starts fresh with zero momentum/pressure,
preventing carryover from the previous route.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 15:12:13 -05:00
Thomas Hallock
1a8093416e fix: prevent route celebration from immediately reappearing
Fixed race condition where route celebration modal would immediately
reappear after clicking 'Continue Journey'.

Root cause: trainPosition stayed >= 100 while showRouteCelebration
was toggled, causing the interval check to re-trigger COMPLETE_ROUTE.

Solution:
1. START_NEW_ROUTE now resets showRouteCelebration to false
2. Removed setTimeout delay and HIDE_ROUTE_CELEBRATION action
3. Single atomic state update prevents race condition

Now continuing to next route works smoothly without modal flickering.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 15:09:53 -05:00
Thomas Hallock
12c54b27b7 fix: defer URL update until game starts
Move router.push() from mode selection to game start button.
This prevents the game from rebooting when user is exploring
different race types in the setup screen.

Now the flow is:
1. User selects race type → background preview changes
2. User clicks 'Start Your Race!' → URL updates + game begins

This keeps the setup experience smooth while still providing
shareable URLs once the game starts.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 15:06:56 -05:00
Thomas Hallock
73a59745a5 fix: prevent layout shift when selecting Steam Sprint mode
Remove conditional padding that caused 12px upward shift when
selecting Steam Sprint. Now uses consistent 20px 8px padding
across all game modes for stable layout.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 15:04:06 -05:00
Thomas Hallock
3920bbad33 feat: sync URL with selected game mode
Add Next.js router integration to update URL when user selects
a game mode in GameControls. Now when selecting:
- Practice Mode → URL becomes /games/complement-race/practice
- Steam Sprint → URL becomes /games/complement-race/sprint
- Survival Mode → URL becomes /games/complement-race/survival

This makes URLs shareable at any point in the setup flow.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 15:02:05 -05:00
Thomas Hallock
80e33e25b3 feat: add CSS animations and visual feedback system
Added complete animation system with Panda CSS:

1. Panda CSS Keyframe Animations (panda.config.ts):
   - shake: Horizontal oscillation for errors (-5px to 5px)
   - successPulse: Gentle scale for correct answers (1 to 1.05)
   - errorShake: Stronger shake for errors (-10px to 10px)
   - pulse: Continuous breathing effect (1 to 1.05)
   - bounce: Vertical bounce (0 to -10px)
   - bounceIn: Entry animation with scale and rotate
   - glow: Expanding box shadow effect

   All animations match exact timing and easing from original
   Python implementation (web_generator.py lines 1996-6274)

2. Visual Feedback Integration (GameDisplay.tsx):
   - Trigger successPulse animation on correct answers
   - Trigger errorShake animation on incorrect answers
   - Auto-clear animations after 500ms (matching duration)
   - Applied to answer input element for clear visual feedback
   - Synchronized with sound effects for multi-sensory feedback

Animations enhance user experience by providing immediate
visual confirmation of answer correctness alongside audio cues.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 14:25:10 -05:00
Thomas Hallock
600bc35bc3 feat: integrate remaining game sound effects
Added 5 remaining sound effects across all game modes:

1. ai_turbo (0.12 volume) - Plays when AI enters desperate_catchup
   mode (>10 units behind), matches line 11941

2. lap_celebration (0.6 volume) - Plays when player completes a lap
   in survival mode circular track, matches line 12801

3. celebration (default volume) - Plays when:
   - Player wins practice mode (reaches goal first)
   - Route completes in sprint mode (with train_whistle)
   Matches lines 14182, 13543

4. gameOver (default volume) - Plays when AI wins in practice mode
   (AI reaches goal before player), matches line 14193

5. train_whistle (0.25-0.6 volume) - Plays in sprint mode when:
   - Streak milestone: streak >= 5 and streak % 3 === 0 (0.4 volume)
   - High momentum: momentum >= 90 (30% chance, 0.25 volume)
   - Route completion: train reaches 100% (0.6 volume)
   Matches lines 13222-13235, 13541

All sound integrations preserve exact timing and volume levels from
original Python implementation (web_generator.py).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 14:22:16 -05:00
Thomas Hallock
920aaa6398 refactor: simplify navigation flow and enhance GameControls UI
Simplified flow:
- /games/complement-race goes directly to setup page (no redundant landing)
- Direct URLs (/practice, /sprint, /survival) pre-select game type
- Users configure all options on single fancy setup page

Enhanced GameControls with:
- Gradient background and hero title
- 3px borders with gradient fills on selected options
- Larger icons (24-28px) and better spacing
- Detailed descriptions for each race type explaining gameplay
- Color-coded sections (blue for number mode, green/amber/purple for race types, pink for difficulty)
- Prominent start button with gradient and emoji
- Professional shadows and hover effects throughout

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 14:18:07 -05:00
Thomas Hallock
1ff9695f69 feat: create mode selection landing page for Complement Race
Transform /games/complement-race into a beautiful mode selection page
with three interactive cards:
- Practice Mode (🏁) - Race to 20 correct answers
- Steam Sprint (🚂) - 60-second momentum challenge
- Survival Mode (🔄) - Endless circular race

Features gradient title, responsive grid layout, and smooth hover
effects (translateY + box-shadow).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 14:15:32 -05:00
Thomas Hallock
a08f0535bf feat: add direct URL routes for each game mode
Create dedicated routes for each Complement Race game mode:
- /games/complement-race/practice - Practice Mode (race to 20)
- /games/complement-race/sprint - Steam Sprint (60 second momentum)
- /games/complement-race/survival - Survival Mode (endless laps)

Each route uses ComplementRaceProvider with initialStyle prop to set
the game mode, allowing users to share direct links to specific modes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 14:15:31 -05:00
Thomas Hallock
f3bc2f6d92 feat: add initialStyle prop to ComplementRaceProvider
- Add ComplementRaceProviderProps interface with optional initialStyle
- Accept initialStyle param ('practice' | 'sprint' | 'survival')
- Initialize game state with provided style or default to 'practice'
- Enables URL-based routing for different game modes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 14:13:37 -05:00
Thomas Hallock
8c3a855239 feat: integrate sound effects into game flow (countdown, answers, performance)
Wire up sound effects to game events:

Countdown & Start:
- Play countdown beep (0.4 volume) for 3-2-1
- Play race_start fanfare (0.6 volume) on GO!

Answer Feedback (matching original logic from web_generator.py):
- Streak sound: every 5th correct answer when streak > 0
- Whoosh sound: responses under 800ms (very fast!)
- Combo sound: responses under 1200ms when streak >= 3
- Correct sound: regular correct answers
- Incorrect sound: wrong answers

Sound triggering follows exact original logic (lines 11530-11542, 11589):
- Check streak % 5 first
- Then check response time < 800ms
- Then check response time < 1200ms AND streak >= 3
- Default to regular correct sound

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 14:10:47 -05:00
Thomas Hallock
90ba86640c feat: add Web Audio API sound effects system with 16 sound types
Implement useSoundEffects hook with programmatically generated retro 90s arcade sounds:
- correct, incorrect, timeout - answer feedback
- countdown, race_start - game flow
- celebration, lap_celebration, gameOver - major events
- ai_turbo, milestone, streak, combo - performance feedback
- whoosh - fast responses
- train_chuff, train_whistle, coal_spill, steam_hiss - Sprint mode train sounds

Features:
- play90sSound helper for multi-note sequences with waveform control
- Custom oscillators for complex sounds (whoosh, train effects)
- Classic 90s arcade envelope (quick attack, moderate decay)
- Vibrato effects for sawtooth/square waves
- Low-pass filtering for retro sound quality
- Audio context tracking for proper cleanup
- stopAllSounds() for immediate silence when needed

Based on original Python implementation (lines 14218-14490)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 12:15:07 -05:00
Thomas Hallock
90ad789ff1 feat: UI polish for Sprint mode (viewport, backgrounds, data attributes)
Previous session improvements:
- Add data-component attributes to all major components for debugging
- Fix viewport issues: set overflow:hidden, maxHeight:100vh to prevent scrolling
- Make backgrounds transparent for sprint mode (sky gradient at root level)
- Adjust padding for sprint mode (8px vs 20px)
- Redesign race configuration screen for responsive mobile-first layout
- Rebuild pressure gauge with proper 180° semicircular arc
- Make complement equation more prominent (96px font)
- Enlarge station names and icons for better visibility
- Fix viewport clipping issues with pressure gauge and question display

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 12:13:48 -05:00
Thomas Hallock
159990489f feat: display passengers visually on train and at stations
- Show boarded passengers riding on the train behind the engineer
  - Position passengers at x=90+ with 35px spacing
  - Size 28px, with urgent passengers having orange glow
- Display waiting passengers at their origin stations
  - Large avatars (fontSize 80) above station platforms
  - Urgent passengers have orange glow effect
  - Positioned at y=-100 with 90px horizontal spacing
- Show delivered passengers celebrating at destination stations
  - Same size as waiting passengers (fontSize 80)
  - Green glow effect with celebrateDelivery animation
  - 2-second celebration before fading away
- Add celebrateDelivery CSS animation with scale and translateY effects
- Update passenger list to show all non-delivered passengers (waiting + aboard)
- Add maxHeight and overflowY to passenger list container

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 12:06:17 -05:00
Thomas Hallock
4bbdabc3b5 feat: enhance passenger card UI with boarding status indicators
- Add visual status badges showing WAITING () or ABOARD (🚂) state
- Color code cards by passenger state:
  - Gray gradient for waiting passengers
  - Blue/orange gradient for boarded passengers
  - Green gradient for delivered passengers
- Adjust opacity: 0.8 for waiting, 1.0 for aboard, 0.6 for delivered
- Show urgent warning (⚠️) only for boarded urgent passengers
- Update animations to only pulse when passenger is urgent and aboard
- Improve card layout with flex container for status badge positioning

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 12:06:02 -05:00
Thomas Hallock
23a9016245 feat: add passenger boarding system with station-based pickup
- Add BOARD_PASSENGER action to GameAction types
- Implement BOARD_PASSENGER reducer to mark passengers as boarded
- Add findBoardablePassengers helper to identify passengers ready to board
- Update useSteamJourney hook to automatically board passengers when train reaches their origin station
- Modify findDeliverablePassengers to only check boarded passengers for delivery
- Passengers now transition through complete lifecycle: waiting → boarding → aboard → delivery

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 12:05:45 -05:00
Thomas Hallock
582bce411f feat: add Complement Race game with three unique game modes
Implemented a new complement arithmetic game with three distinct modes:

Practice Mode (Linear Track):
- Race against AI opponents on a straight track
- Fixed race goal with finish line
- Traditional racing format with visual progress indicators

Endless Circuit (Circular Track):
- Infinite laps around an oval track
- Continuous gameplay with lap tracking
- AI racers with personality-driven commentary system

Steam Sprint (Train Journey):
- 60-second timed challenge with momentum-based gameplay
- SVG railroad track with dynamic curved paths that vary by route
- Passenger delivery system with station stops
- Momentum decay mechanic balanced by skill level
- Animated sky gradient with time-of-day progression (dawn to night)
- Route progression system with 10 themed routes
- Enhanced pressure gauge and visual effects (steam, coal particles)
- Geographic landmarks themed to each route

Core Features:
- Adaptive difficulty system that learns user performance patterns
- Real-time feedback based on speed and accuracy
- Comprehensive state management with React Context
- Multiple track visualization systems (linear, circular, SVG-based)
- AI personality system with dynamic commentary

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 10:49:22 -05:00
Thomas Hallock
d8b4e425bf feat: enhance emoji picker with super powered magnifying glass and hide empty categories
Improve emoji selection UX by making hover preview more dramatic and removing empty category tabs.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 18:56:40 -05:00
Thomas Hallock
2c88b6b5f3 feat: add magnifying glass preview on emoji hover
Add large preview tooltip when hovering over emojis in picker:
- 72px magnified emoji preview in white card with blue glow
- Appears above hovered emoji with smooth scale-in animation
- Arrow indicator pointing to source emoji
- Fixed positioning follows cursor between emojis
- Helps users make informed decisions with small emoji buttons

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 18:12:07 -05:00
Thomas Hallock
b2a21b79ad fix: correct emoji category group IDs to match Unicode CLDR
Fix category filtering by using correct emojibase group IDs:
- Groups are 0-9, not 0-7
- Skip group 2 (Component - skin tone modifiers)
- Match Unicode CLDR standard group ordering
- Group 0: Smileys & Emotion (not Smileys & People)
- Group 1: People & Body (separate from smileys)
- Groups 3-9: Animals, Food, Travel, Activities, Objects, Symbols, Flags

Categories now correctly filter the right emojis.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 18:10:46 -05:00
Thomas Hallock
616a50e234 feat: add category browsing and scrolling to emoji picker
Enhance emoji picker with better navigation and usability:
- Add 8 emoji category tabs with icons (Smileys, Animals, Food, etc.)
- Enable vertical scrolling in emoji grid with custom scrollbar styling
- Category pills with blue highlight for selected category
- Dynamic header showing category name and emoji count
- Horizontal scrolling for category tabs on smaller screens
- Fix overflow clipping issue by adding scrollable container

Users can now easily browse thousands of emojis by category.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 18:07:57 -05:00
Thomas Hallock
72f8dee183 feat: extend player customization to support all 4 players
Extend profile system and configuration UI to support all players:
- Add player3Emoji, player4Emoji, player3Name, player4Name to UserProfile
- Update PlayerConfigDialog to support players 1-4 with unique gradients
  - Player 1: Blue, Player 2: Pink, Player 3: Purple, Player 4: Yellow
- Update EmojiPicker color schemes for all 4 players
- Revert gear icon restrictions - show for all players
- Update PageWithNav to use profile data for all players

All players now fully customizable with persistent storage.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 18:04:09 -05:00
Thomas Hallock
d0a3bc7dc1 fix: only show configuration gear icon for players 1 and 2
Hide gear icons for players 3+ since only players 1 & 2 have customizable profiles in UserProfileContext:
- ActivePlayersList: conditional render gear button, update cursor style
- FullscreenPlayerSelection: conditional render gear button
- Prevents confusing UI where clicking gear had no effect

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 17:58:45 -05:00
Thomas Hallock
9a3fa93e53 feat: improve game availability logic and messaging
Update game card availability checks and empty state handling:
- Show games as faded when 0 players selected instead of hiding
- Display "⚠️ Select X players" when no players active
- Fix isGameAvailable to require activePlayerCount > 0
- Remove empty state check that hid games with no players
- Always show available games for better discoverability

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 17:41:22 -05:00
Thomas Hallock
982fa45c08 refactor: remove drag-and-drop UI from EnhancedChampionArena
Simplify component by removing all drag-and-drop functionality:
- Remove @dnd-kit and @react-spring dependencies usage
- Remove ChampionCard, DroppableZone, and drag handlers
- Reduce from ~690 lines to ~36 lines
- Now only renders GameSelector component
- Prominent mini nav handles player management instead

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 17:41:13 -05:00
Thomas Hallock
b58bcd92ee fix: remove double PageWithNav wrapper on matching page
Remove redundant PageWithNav wrapper since MemoryPairsGame component already includes it internally with game phase-aware navigation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 17:41:06 -05:00
Thomas Hallock
5c8c18cbb8 feat: enable prominent nav and fix layout on arcade page
Add emphasizeGameContext to enable player selection UI and adjust layout:
- Set emphasizeGameContext={true} to show fullscreen player selection
- Change height from 100vh to calc(100vh - 80px) for nav clearance
- Add vertical padding for better spacing
- Remove redundant bottom padding from inner container

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 17:41:00 -05:00
Thomas Hallock
edfdd81227 feat: wire player configuration through nav component hierarchy
Connect configuration dialog to all nav components:
- Add onConfigurePlayer callback prop through GameContextNav
- Manage dialog state in PageWithNav component
- Pass configuration handler to all child nav components
- Support only Players 1 & 2 (customizable players)
- Dialog renders above all content with proper z-index

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 17:40:45 -05:00
Thomas Hallock
6049a7f6b7 feat: add configuration access to active player emojis in prominent nav
Make player emojis clickable and add hover controls:
- Click emoji to open configuration dialog
- Hover reveals two buttons:
  - Small gear icon (bottom-left) for configuration
  - Red × (top-right) to remove player
- Only active when emphasizeGameContext is true
- Clean during gameplay with no distracting controls

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 17:40:38 -05:00
Thomas Hallock
b85968bcb6 feat: add configuration access to fullscreen player selection
Add subtle gear icon to each player card in fullscreen selection view:
- 32px circular button in top-right corner
- Gray by default, blue on hover with glow effect
- Only visible when choosing initial players
- Non-intrusive design that doesn't distract from selection

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 17:40:32 -05:00
Thomas Hallock
4f2a661494 feat: create PlayerConfigDialog component for player customization
Add modal dialog for configuring player name and emoji. Features:
- Beautiful gradient-styled modal (blue for P1, pink for P2)
- Integrates with EmojiPicker component
- Character name input with 20-char limit
- Smooth transitions and hover effects

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 17:40:26 -05:00
Thomas Hallock
31898328a3 feat: use CSS transitions for smooth fullscreen player selection collapse
Replace conditional rendering with always-mounted component that uses CSS transitions for height, width, and opacity. Prevents height from snapping when transitioning between fullscreen and prominent nav modes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 16:57:36 -05:00
Thomas Hallock
98cfa5645b refactor: simplify PageWithNav by extracting nav components
Reduce PageWithNav from 471 lines to 81 lines by extracting 5 nav components into dedicated files. Fixes Next.js parser errors while maintaining identical functionality.

Total: 608 lines distributed across 6 manageable files, all under parser limits.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 16:51:35 -05:00
Thomas Hallock
e3f552d8f5 refactor: extract GameContextNav orchestration component
Create orchestration layer that composes GameModeIndicator, ActivePlayersList, AddPlayerButton, and FullscreenPlayerSelection. Handles layout switching between row/column and smooth CSS transitions.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 16:51:34 -05:00
Thomas Hallock
66f52234e1 refactor: extract FullscreenPlayerSelection component from PageWithNav
Extract fullscreen player grid shown when no players selected. Features responsive grid layout, large emoji cards with hover animations, and multiplayer guidance.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 16:51:34 -05:00
Thomas Hallock
57a72e34a5 refactor: extract AddPlayerButton component from PageWithNav
Extract green + button with popover dropdown into dedicated component. Includes click-outside detection and scales with emphasis mode (24px→48px).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 16:51:33 -05:00
Thomas Hallock
28495767a9 refactor: extract ActivePlayersList component from PageWithNav
Extract active player display with hover-triggered remove buttons into dedicated component. Handles emoji scaling and red × button on hover in emphasized mode.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 16:51:33 -05:00
Thomas Hallock
d67315f771 refactor: extract GameModeIndicator component from PageWithNav
Extract mode badge display logic into dedicated component to reduce PageWithNav complexity. Includes config for none/single/battle/tournament modes with proper colors and emojis.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 16:51:33 -05:00
Thomas Hallock
fa1cf96789 feat: add interactive remove buttons for players in mini nav
- Hover over player emoji to reveal red × button
- Click × to remove player from active game
- Smooth animations on hover (scale 1.1, darker red)
- Button positioned absolutely in top-right corner
- Works on all pages with game context (arcade, matching setup, etc)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 16:25:15 -05:00
Thomas Hallock
8792393956 feat: add prominent game context display to mini nav with smooth transitions
- Display active player emojis and game mode badge in PageWithNav
- Add emphasizeGameContext prop for conditional prominence
- Player emojis: 20px normal, 48px emphasized with drop-shadow
- Mode badge: gradient backgrounds, 3px borders, scale transforms
- Smooth 0.4s cubic-bezier transitions with mount animation
- Memory Pairs setup phase shows emphasized context, gameplay shows normal
- Use profile emojis for players 1 & 2 for consistency

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 16:25:15 -05:00
Thomas Hallock
386c88a3c0 refactor: make game mode a computed property from active player count
- Remove setGameMode and setGameModeWithPlayers methods from GameModeContext
- Compute game mode: 1 player = single, 2 = battle, 3+ = tournament
- Update localStorage key to 'soroban-game-mode-players'
- Remove setGameMode calls from ChampionArena and EnhancedChampionArena
- Simplify state management by deriving mode instead of storing it

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 16:25:15 -05:00
Thomas Hallock
a854fe3dc9 feat: add mini app nav to arcade page
- Wrap arcade content with PageWithNav component
- Add "Champion Arena" title with 🏟️ emoji
- Remove redundant embedded header
- Consistent navigation across all pages

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 16:25:15 -05:00
Thomas Hallock
402724c80e refactor: remove redundant game titles from game screens
- Remove title/description from Memory Quiz page
- Remove "Game Setup" header from Memory Pairs SetupPhase
- Mini app nav now displays titles inline, making page headers redundant

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 16:25:15 -05:00
Thomas Hallock
c77e880be3 refactor: extract guide components to fix syntax error in large file
- Create ReadingNumbersGuide.tsx (694 lines)
- Create ArithmeticOperationsGuide.tsx (476 lines)
- Simplify guide/page.tsx from 1290 to 113 lines
- Add tab navigation between guide sections
- Resolve Next.js parser error by breaking up massive file

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 16:25:15 -05:00
462 changed files with 88693 additions and 26734 deletions

View File

@@ -168,9 +168,22 @@
"Bash(open http://localhost:3001/arcade)",
"Bash(open http://localhost:6006)",
"Bash(open http://localhost:3002/games/matching)",
"Bash(open http://localhost:3002/create)"
"Bash(open http://localhost:3002/create)",
"Bash(open http://localhost:3002/games/complement-race/practice)",
"Bash(open http://localhost:3002/games/complement-race)",
"Bash(npx vitest run:*)",
"Bash(sqlite3:*)",
"Bash(NODE_ENV=development node server.js)",
"Bash(lsof:*)",
"Bash(xargs kill:*)",
"Bash(./test-arcade-api.sh:*)",
"Bash(pkill:*)",
"Bash(shasum:*)",
"Bash(open http://localhost:3000/arcade/matching)",
"Bash(echo:*)",
"Bash(npm run type-check:*)"
],
"deny": [],
"ask": []
}
}
}

57
.claude/terminology.md Normal file
View File

@@ -0,0 +1,57 @@
# Soroban Abacus Flashcards - Terminology Reference
## User vs Player vs Room Member
**CRITICAL**: Do not confuse these three concepts!
### Quick Reference
- **USER** = Identity/account (one per person, identified by `guestId` cookie)
- **PLAYER** = Game avatar/profile (multiple per user, from `players` table)
- **ROOM MEMBER** = USER's participation in a multiplayer room
### Key Rule
**When a USER joins a room, their ACTIVE PLAYERS join the game.**
Example:
- USER "Jane" has 3 players: Alice, Bob, Charlie
- Alice and Bob are active (`isActive: true`)
- When Jane joins a room, Alice and Bob participate in the game
- The `arcade_sessions.activePlayers` array contains `[alice_id, bob_id]`
### Database Schema
```
users (identity)
├─ players (avatars/profiles) - where isActive = true
└─ room_members (room participation)
arcade_sessions
├─ userId: references users.id
├─ activePlayers: Array<player.id> ← PLAYER IDs, not USER IDs!
└─ roomId: references arcade_rooms.id
```
### Common Mistakes to Avoid
❌ Using USER ID in `activePlayers` - should be PLAYER IDs
❌ Assuming one USER = one PLAYER - users can have multiple players
❌ Tracking game moves/scores by USER - should track by PLAYER
❌ Confusing room_members.displayName with players.name - different concepts
### Full Documentation
See: `docs/terminology-user-player-room.md` for complete explanation with examples.
## Other Project-Specific Terms
### Arcade vs Games
- **`/games/*`** - Single player or local multiplayer (same device)
- **`/arcade/*`** - Online multiplayer with sessions and rooms
### Session Types
- **Solo Session**: `arcade_sessions.roomId = null`, user playing alone
- **Room Session**: `arcade_sessions.roomId = room_xyz`, shared game state across room members

View File

@@ -1,5 +1,7 @@
# Ignore development files
# Ignore all node_modules to prevent Docker overlay conflicts
node_modules
**/node_modules
.next
.git
.github

View File

@@ -36,7 +36,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: 8.0.0
version: 9.15.4
- name: Get pnpm store directory
shell: bash

View File

@@ -18,7 +18,7 @@ jobs:
- uses: pnpm/action-setup@v2
with:
version: 8.0.0
version: 9.15.4
- uses: actions/setup-node@v4
with:

View File

@@ -32,7 +32,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: 8.0.0
version: 9.15.4
- name: Get pnpm store directory
shell: bash

View File

@@ -46,7 +46,7 @@ jobs:
- name: Upload example images if changed
if: failure()
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: updated-examples
path: docs/images/

11
.gitignore vendored
View File

@@ -10,6 +10,9 @@ dist/
# Generated CSS (Panda CSS / styled-system)
**/styled-system/
# Generated build info
**/generated/build-info.json
# Environment
.env*
@@ -40,6 +43,14 @@ Thumbs.db
# Test coverage
coverage/
# Test reports
playwright-report/
# Database files
*.db
*.db-shm
*.db-wal
# Temporary files
tmp/
temp/

View File

@@ -1,3 +1,728 @@
## [2.16.7](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.16.6...v2.16.7) (2025-10-10)
### Bug Fixes
* compile TypeScript server files to JavaScript for production ([83b9a4d](https://github.com/antialias/soroban-abacus-flashcards/commit/83b9a4d976fa540782826afa13a35c92e706bf1e))
* remove standalone output mode incompatible with custom server ([c8da5a8](https://github.com/antialias/soroban-abacus-flashcards/commit/c8da5a8340c8798bba452b43244bc0e04ce8b0c5))
* update Dockerfile for non-standalone production builds ([14746c5](https://github.com/antialias/soroban-abacus-flashcards/commit/14746c568e58f4a847e0da2d866dbaeabf5a0e8b))
## [2.16.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.16.5...v2.16.6) (2025-10-10)
### Bug Fixes
* correct static files and public path in Docker image ([c287b19](https://github.com/antialias/soroban-abacus-flashcards/commit/c287b19a39e1506033db6de39aa4d3761cb65d62))
## [2.16.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.16.4...v2.16.5) (2025-10-09)
### Bug Fixes
* correct node_modules path for pnpm symlinks in Docker ([c12351f](https://github.com/antialias/soroban-abacus-flashcards/commit/c12351f2c99daaed710a1136eb13f6ccc54cbcff))
## [2.16.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.16.3...v2.16.4) (2025-10-09)
### Bug Fixes
* correct Docker CMD to use root-level server.js ([48b47e9](https://github.com/antialias/soroban-abacus-flashcards/commit/48b47e9bdb0da44746282cd7cf7599a69bf5130d))
## [2.16.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.16.2...v2.16.3) (2025-10-09)
### Bug Fixes
* use game state playerMetadata instead of GameModeContext in UI components ([388c254](https://github.com/antialias/soroban-abacus-flashcards/commit/388c25451d11b85236c1f7682fe2f7a62a15d5eb))
## [2.16.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.16.1...v2.16.2) (2025-10-09)
### Bug Fixes
* use only local user's players in LocalMemoryPairsProvider ([c26138f](https://github.com/antialias/soroban-abacus-flashcards/commit/c26138ffb55a237a99cb6ff399c8a2ac54a22b51))
## [2.16.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.16.0...v2.16.1) (2025-10-09)
### Bug Fixes
* convert LocalMemoryPairsProvider to pure client-side with useReducer ([b128db1](https://github.com/antialias/soroban-abacus-flashcards/commit/b128db1783a8dcffe7879745c3342add2f9ffe29))
## [2.16.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.15.0...v2.16.0) (2025-10-09)
### Features
* fade out hover avatar when player stops hovering ([820eeb4](https://github.com/antialias/soroban-abacus-flashcards/commit/820eeb4fb03ad8be6a86dd0a26e089052224f427))
## [2.15.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.14.3...v2.15.0) (2025-10-09)
### Features
* implement smooth hover avatar animations with react-spring ([442c6b4](https://github.com/antialias/soroban-abacus-flashcards/commit/442c6b4529ba5c820b1fe8a64805a3d85489a8ea))
## [2.14.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.14.2...v2.14.3) (2025-10-09)
### Bug Fixes
* enable smooth spring animations between card hovers ([8d53b58](https://github.com/antialias/soroban-abacus-flashcards/commit/8d53b589aa17ebc6d0a9251b3006fd8a90f90a61))
## [2.14.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.14.1...v2.14.2) (2025-10-09)
### Bug Fixes
* correct avatar positioning to prevent fly-in animation ([573d0df](https://github.com/antialias/soroban-abacus-flashcards/commit/573d0df20dcdac41021c46feb423dbf3782728f6))
## [2.14.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.14.0...v2.14.1) (2025-10-09)
### Bug Fixes
* prevent avatar fly-in and hide local player's own hover ([7f65a67](https://github.com/antialias/soroban-abacus-flashcards/commit/7f65a67cef3d7f0ebce1bd7417972a6138acfc46))
## [2.14.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.13.0...v2.14.0) (2025-10-09)
### Features
* improve hover avatars with smooth animation and 3D elevation ([71b11f4](https://github.com/antialias/soroban-abacus-flashcards/commit/71b11f4ef08a5f9c3f1c1aaabca21ef023d5c0ce))
## [2.13.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.12.3...v2.13.0) (2025-10-09)
### Features
* implement networked hover presence for multiplayer gameplay ([62f3730](https://github.com/antialias/soroban-abacus-flashcards/commit/62f3730542334a0580f5dad1c73adc333614ee58))
### Code Refactoring
* move canModifyPlayers logic into provider layer ([db9f909](https://github.com/antialias/soroban-abacus-flashcards/commit/db9f9096b446b078e1b4dfe970723bef54a6f4ae))
* properly separate LocalMemoryPairsProvider and RoomMemoryPairsProvider ([98822ec](https://github.com/antialias/soroban-abacus-flashcards/commit/98822ecda52bf004d9950e3f4c92c834fd820e49))
## [2.12.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.12.2...v2.12.3) (2025-10-09)
### Bug Fixes
* always show game control buttons in room-based sessions ([14ba422](https://github.com/antialias/soroban-abacus-flashcards/commit/14ba422919abd648e2a134ce167a5e6fd9f84e73))
## [2.12.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.12.1...v2.12.2) (2025-10-09)
### Bug Fixes
* use RoomMemoryPairsProvider in room page ([c279731](https://github.com/antialias/soroban-abacus-flashcards/commit/c27973191f0144604e17a8a14adf0a88df476e27))
## [2.12.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.12.0...v2.12.1) (2025-10-09)
### Bug Fixes
* export MemoryPairsContext to fix provider hook error ([80ad33e](https://github.com/antialias/soroban-abacus-flashcards/commit/80ad33eec0b6946702eaa9cf1b1c246852864b00))
## [2.12.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.11.0...v2.12.0) (2025-10-09)
### Features
* add networked hover state infrastructure for multiplayer presence ([d149799](https://github.com/antialias/soroban-abacus-flashcards/commit/d14979907c5df9b793a1c110028fc5b54457f507))
## [2.11.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.10.1...v2.11.0) (2025-10-09)
### Features
* add pause/resume game state architecture ([05eacac](https://github.com/antialias/soroban-abacus-flashcards/commit/05eacac438dbaf405ce91e188c53dbbe2e9f9507))
* add Resume button and config change warning to setup UI ([b5ee04f](https://github.com/antialias/soroban-abacus-flashcards/commit/b5ee04f57651f53517468fcc4c456f0ccb65a8e2))
* implement pause/resume in game providers with optimistic updates ([ce30fca](https://github.com/antialias/soroban-abacus-flashcards/commit/ce30fcaf55270f9089249bd13ba73a25fbfa5ab4))
### Bug Fixes
* convert guestId to internal userId for player ownership check ([3a01f46](https://github.com/antialias/soroban-abacus-flashcards/commit/3a01f4637d2081c66fe37c7f8cfee229442ec744))
* implement shared session architecture for room-based multiplayer ([2856f4b](https://github.com/antialias/soroban-abacus-flashcards/commit/2856f4b83fbcc6483d96cc6e7da2fe5bc911625d))
## [2.10.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.10.1...v2.10.2) (2025-10-09)
### Bug Fixes
* convert guestId to internal userId for player ownership check ([3a01f46](https://github.com/antialias/soroban-abacus-flashcards/commit/3a01f4637d2081c66fe37c7f8cfee229442ec744))
* implement shared session architecture for room-based multiplayer ([2856f4b](https://github.com/antialias/soroban-abacus-flashcards/commit/2856f4b83fbcc6483d96cc6e7da2fe5bc911625d))
## [2.10.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.10.0...v2.10.1) (2025-10-09)
### Bug Fixes
* enforce player ownership authorization for multiplayer games ([71b0aac](https://github.com/antialias/soroban-abacus-flashcards/commit/71b0aac13c970c03fe8d296d41e9472ad72a00fa))
## [2.10.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.9.0...v2.10.0) (2025-10-09)
### Features
* implement rich Radix UI tooltips for player avatars ([d03c789](https://github.com/antialias/soroban-abacus-flashcards/commit/d03c7898799b378f912f47d7267a00bc7ce3d580))
## [2.9.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.7...v2.9.0) (2025-10-09)
### Features
* implement auto-save for player settings modal ([a83dc09](https://github.com/antialias/soroban-abacus-flashcards/commit/a83dc097e43c265a297281da54754f58ac831754))
## [2.8.7](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.6...v2.8.7) (2025-10-09)
### Bug Fixes
* enable real-time player name updates across room members ([5171be3](https://github.com/antialias/soroban-abacus-flashcards/commit/5171be3d37980eb1c98aa0d1e1d6e06f589763d1))
## [2.8.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.5...v2.8.6) (2025-10-09)
### Bug Fixes
* prevent duplicate display of network avatars in nav ([d474ef0](https://github.com/antialias/soroban-abacus-flashcards/commit/d474ef07d69cf0b4f5dedd404616e3bbee7289fe))
## [2.8.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.4...v2.8.5) (2025-10-09)
### Bug Fixes
* remove redirect loop by not redirecting from room page ([10cf715](https://github.com/antialias/soroban-abacus-flashcards/commit/10cf71527f7cede7fd93e502dbfc59df99b5a524))
## [2.8.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.3...v2.8.4) (2025-10-09)
### Bug Fixes
* prevent redirect loops by checking if already at target URL ([c5268b7](https://github.com/antialias/soroban-abacus-flashcards/commit/c5268b79dee66aa02e14e2024fe1c6242a172ed3))
## [2.8.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.2...v2.8.3) (2025-10-08)
### Bug Fixes
* remove ArcadeGuardedPage from room page to prevent redirect loop ([4686f59](https://github.com/antialias/soroban-abacus-flashcards/commit/4686f59d245b2b502dc0764c223a5ce84bf1af44))
## [2.8.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.1...v2.8.2) (2025-10-08)
### Bug Fixes
* revert to showing only active players in room games ([87cc0b6](https://github.com/antialias/soroban-abacus-flashcards/commit/87cc0b64fb5f3debaf1d2f122aecfefc62922fed))
## [2.8.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.0...v2.8.1) (2025-10-08)
### Bug Fixes
* include all players from room members in room games ([28a2e7d](https://github.com/antialias/soroban-abacus-flashcards/commit/28a2e7d6511e70b83adf7d0465789a91026bc1f7))
## [2.8.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.7.4...v2.8.0) (2025-10-08)
### Features
* implement room-wide multi-user game state synchronization ([8175c43](https://github.com/antialias/soroban-abacus-flashcards/commit/8175c43533c474fff48eb128c97747033bfb434a))
### Tests
* add comprehensive tests for arcade guard and room navigation ([b49630f](https://github.com/antialias/soroban-abacus-flashcards/commit/b49630f3cb02ebbac75b4680948bbface314dccb))
## [2.7.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.7.3...v2.7.4) (2025-10-08)
### Bug Fixes
* respect enabled flag in useArcadeGuard WebSocket redirects ([01ff114](https://github.com/antialias/soroban-abacus-flashcards/commit/01ff114258ff7ab43ef2bd79b41c7035fe02ac70))
### Code Refactoring
* move room management pages to /arcade-rooms ([4316687](https://github.com/antialias/soroban-abacus-flashcards/commit/431668729cfb145d6e0c13947de2a82f27fa400d))
## [2.7.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.7.2...v2.7.3) (2025-10-08)
### Bug Fixes
* set room sessions to use /arcade/room URL ([9dac431](https://github.com/antialias/soroban-abacus-flashcards/commit/9dac431c1f91c246f67a059cda3cff6cbef40a43))
## [2.7.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.7.1...v2.7.2) (2025-10-08)
### Bug Fixes
* add hasAttemptedFetch flag to prevent premature redirect ([c30f585](https://github.com/antialias/soroban-abacus-flashcards/commit/c30f58581028878350282cad5231d614590d9f2b))
## [2.7.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.7.0...v2.7.1) (2025-10-08)
### Bug Fixes
* resolve race condition in /arcade/room redirect ([5ed2ab2](https://github.com/antialias/soroban-abacus-flashcards/commit/5ed2ab21cab408147081a493c8dd6b1de48b2d01))
## [2.7.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.6.0...v2.7.0) (2025-10-08)
### Features
* extend GameModeContext to support room-based multiplayer ([ee6094d](https://github.com/antialias/soroban-abacus-flashcards/commit/ee6094d59d26a9e80ba5d023ca6dc13143bea308))
## [2.6.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.5.0...v2.6.0) (2025-10-08)
### Features
* refactor room addressing to /arcade/room ([e7d2a73](https://github.com/antialias/soroban-abacus-flashcards/commit/e7d2a73ddf2048691325a18e3d71a7ece444c131))
## [2.5.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.4.6...v2.5.0) (2025-10-08)
### Features
* display room info and network players in mini app nav ([5e3261f](https://github.com/antialias/soroban-abacus-flashcards/commit/5e3261f3bec8c19ec88c9a35a7e6ef8eda88a55e))
## [2.4.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.4.5...v2.4.6) (2025-10-08)
### Bug Fixes
* real-time room member updates via globalThis socket.io sharing ([94a1d9b](https://github.com/antialias/soroban-abacus-flashcards/commit/94a1d9b11058bfb4b54a4753e143cf85f215e913))
## [2.4.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.4.4...v2.4.5) (2025-10-08)
### Bug Fixes
* send all members (not just online) in socket broadcasts ([3fa6cce](https://github.com/antialias/soroban-abacus-flashcards/commit/3fa6cce17a7acd940cf5a9e6433bf6c4b497540c))
## [2.4.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.4.3...v2.4.4) (2025-10-08)
### Bug Fixes
* correctly access getSocketIO from dynamic import ([30abf33](https://github.com/antialias/soroban-abacus-flashcards/commit/30abf33ee86b36f2a98014e5b017fa8e466a2107))
## [2.4.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.4.2...v2.4.3) (2025-10-08)
### Bug Fixes
* resolve socket-server import path for Next.js build ([12c3c37](https://github.com/antialias/soroban-abacus-flashcards/commit/12c3c37ff8e1d3df71d72e527c08fa975043c504))
## [2.4.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.4.1...v2.4.2) (2025-10-08)
### Bug Fixes
* broadcast member join/leave events immediately via API ([ebfc88c](https://github.com/antialias/soroban-abacus-flashcards/commit/ebfc88c5ea0a8a0fdda039fa129e1054b9c42e65))
## [2.4.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.4.0...v2.4.1) (2025-10-08)
### Bug Fixes
* make leave room button actually remove user from room ([49f12f8](https://github.com/antialias/soroban-abacus-flashcards/commit/49f12f8cab631fedd33f1bc09febfdc95e444625))
## [2.4.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.3.1...v2.4.0) (2025-10-08)
### Features
* add arcade room/session info and network players to nav ([6800747](https://github.com/antialias/soroban-abacus-flashcards/commit/6800747f80a29c91ba0311a8330d594c1074097d))
* add real-time WebSocket updates for room membership ([7ebb2be](https://github.com/antialias/soroban-abacus-flashcards/commit/7ebb2be3927762a5fe9b6fb7fb15d6b88abb7b6a))
* implement modal room enforcement (one room per user) ([f005fbb](https://github.com/antialias/soroban-abacus-flashcards/commit/f005fbbb773f4d250b80d71593490976af82d5a5))
* improve room navigation and membership UI ([bc219c2](https://github.com/antialias/soroban-abacus-flashcards/commit/bc219c2ad66707f03e7a6cf587b9d190c736e26d))
### Bug Fixes
* auto-cleanup orphaned arcade sessions without valid rooms ([3c002ab](https://github.com/antialias/soroban-abacus-flashcards/commit/3c002ab29d1b72a0e1ffb70bb0744dc560e7bdc2))
* show correct join/leave button based on room membership ([5751dfe](https://github.com/antialias/soroban-abacus-flashcards/commit/5751dfef5c81981937cd5300c4256e5b74bb7488))
## [2.3.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.3.0...v2.3.1) (2025-10-07)
### Bug Fixes
* add missing DOMPoint properties to getPointAtLength mock ([1e17278](https://github.com/antialias/soroban-abacus-flashcards/commit/1e17278f942b3fbcc5d05be746178f2e780f0bd9))
* add missing name property to Passenger test mocks ([f8ca248](https://github.com/antialias/soroban-abacus-flashcards/commit/f8ca2488447e89151085942f708f6acf350a2747))
* add non-null assertions to skillConfiguration utilities ([9c71092](https://github.com/antialias/soroban-abacus-flashcards/commit/9c7109227822884d25f8546739c80c6e7491e28d))
* add optional chaining to stepBeadHighlights access ([a5fac5c](https://github.com/antialias/soroban-abacus-flashcards/commit/a5fac5c75c8cd67b218a5fd5ad98818dad74ab67))
* add showAsAbacus property to ComplementQuestion type ([4adcc09](https://github.com/antialias/soroban-abacus-flashcards/commit/4adcc096430fbb03f0a8b2f0aef4be239aff9cd0))
* add userId to optimistic player in useCreatePlayer ([5310463](https://github.com/antialias/soroban-abacus-flashcards/commit/5310463becd0974291cff49522ae5669a575410d))
* change TypeScript moduleResolution from bundler to node ([327aee0](https://github.com/antialias/soroban-abacus-flashcards/commit/327aee0b4b5c0b0b2bf3eeb48d861bb3068f6127))
* convert Jest mocks to Vitest in useSteamJourney tests ([e067271](https://github.com/antialias/soroban-abacus-flashcards/commit/e06727160c70a1ab38a003104d1fef8fb83ff92d))
* convert player IDs from number to string in arcade tests ([72db1f4](https://github.com/antialias/soroban-abacus-flashcards/commit/72db1f4a2c3f930025cd5ced3fcf7c810dcc569d))
* rewrite layout.nav.test to match actual RootLayout props ([a085de8](https://github.com/antialias/soroban-abacus-flashcards/commit/a085de816fcdeb055addabb8aec391b111cb5f94))
* update useArcadeGuard tests with proper useViewerId mock ([4eb49d1](https://github.com/antialias/soroban-abacus-flashcards/commit/4eb49d1d44e1d85526ef6564f88a8fbcebffb4d2))
* use Object.defineProperty for NODE_ENV in middleware tests ([e73191a](https://github.com/antialias/soroban-abacus-flashcards/commit/e73191a7298dbb6dd15da594267ea6221062c36b))
* wrap Buffer in Uint8Array for Next.js Response API ([98384d2](https://github.com/antialias/soroban-abacus-flashcards/commit/98384d264e4a10d1836aa9f2e69151b122ffa7b0))
### Documentation
* add explicit package.json script references to regime docs ([3353bca](https://github.com/antialias/soroban-abacus-flashcards/commit/3353bcadc2849104248c624973274ed90b86722a))
* establish mandatory code quality regime for Claude Code ([dd11043](https://github.com/antialias/soroban-abacus-flashcards/commit/dd1104310f4e0e85640730ea0e96e4adda4bc505))
* expand quality regime to define "done" for all work ([f92f7b5](https://github.com/antialias/soroban-abacus-flashcards/commit/f92f7b592af38ba9d0f5b1db3a061d63d92a5093))
## [2.3.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.2.1...v2.3.0) (2025-10-07)
### Features
* add Biome + ESLint linting setup ([fc1838f](https://github.com/antialias/soroban-abacus-flashcards/commit/fc1838f4f53a4f8d8f1c5303de3a63f12d9c9303))
### Styles
* apply Biome formatting to entire codebase ([60d70cd](https://github.com/antialias/soroban-abacus-flashcards/commit/60d70cd2f2f2b1d250c4c645889af4334968cb7e))
## [2.2.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.2.0...v2.2.1) (2025-10-07)
### Bug Fixes
* remove remaining typst-dependent files ([d1b9b72](https://github.com/antialias/soroban-abacus-flashcards/commit/d1b9b72cfc2f2ba36c40d7ae54bc6fdfcc5f34da))
## [2.2.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.1.3...v2.2.0) (2025-10-07)
### Features
* remove typst-related code and routes ([be6fb1a](https://github.com/antialias/soroban-abacus-flashcards/commit/be6fb1a881b983f9830d36c079b7b41f35153b8a))
## [2.1.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.1.2...v2.1.3) (2025-10-07)
### Bug Fixes
* remove .npmrc from Dockerfile COPY ([e71c2b4](https://github.com/antialias/soroban-abacus-flashcards/commit/e71c2b4da85076dfc97401fc170cd88cb0aa4375))
## [2.1.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.1.1...v2.1.2) (2025-10-07)
### Bug Fixes
* revert to default pnpm mode for Docker compatibility ([bd0092e](https://github.com/antialias/soroban-abacus-flashcards/commit/bd0092e69ac4f74ea89b8d31399cf72f57484cbb))
## [2.1.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.1.0...v2.1.1) (2025-10-07)
### Bug Fixes
* ignore all node_modules in Docker ([4792dde](https://github.com/antialias/soroban-abacus-flashcards/commit/4792dde1beef9c6cb84a27bc6bb6acfa43919a72))
## [2.1.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.0.7...v2.1.0) (2025-10-07)
### Features
* remove typst dependencies ([eedce28](https://github.com/antialias/soroban-abacus-flashcards/commit/eedce28572035897001f6b8a08f79beaa2360d44))
## [2.0.7](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.0.6...v2.0.7) (2025-10-07)
### Bug Fixes
* preserve workspace node_modules in Docker for hoisted mode ([4f8aaf0](https://github.com/antialias/soroban-abacus-flashcards/commit/4f8aaf04aadda11ce9ec470dec44f78062929e77))
## [2.0.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.0.5...v2.0.6) (2025-10-07)
### Bug Fixes
* ignore nested node_modules in Docker ([f554592](https://github.com/antialias/soroban-abacus-flashcards/commit/f554592272c2e92d7f1ec6550211518de9c3242f))
## [2.0.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.0.4...v2.0.5) (2025-10-07)
### Bug Fixes
* use .npmrc in Docker for hoisted mode consistency ([2df8cdc](https://github.com/antialias/soroban-abacus-flashcards/commit/2df8cdc88ed03b6b04642a3441e17c6fda11d2a5))
## [2.0.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.0.3...v2.0.4) (2025-10-07)
### Bug Fixes
* remove .npmrc in Docker to avoid hoisted mode issues ([2a77d75](https://github.com/antialias/soroban-abacus-flashcards/commit/2a77d755b7820b5b6b52ea99db418e6d071d726e))
## [2.0.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.0.2...v2.0.3) (2025-10-07)
### Bug Fixes
* remove duplicate PlayerStatusBar story file from arcade ([4e721f7](https://github.com/antialias/soroban-abacus-flashcards/commit/4e721f765a29fe8628d4e34ef94cdf5728eea3dc))
## [2.0.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.0.1...v2.0.2) (2025-10-07)
### Bug Fixes
* update Dockerfile pnpm version and fix TypeScript config ([43077a8](https://github.com/antialias/soroban-abacus-flashcards/commit/43077a80e271a793c88f100874914ae6f3c515b5))
## [2.0.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.0.0...v2.0.1) (2025-10-07)
### Bug Fixes
* add @types/minimatch to abacus-react devDependencies ([fa45475](https://github.com/antialias/soroban-abacus-flashcards/commit/fa4547543dcd0cddc7cc9ff9da62f60a4717fb1f))
## [2.0.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v1.2.1...v2.0.0) (2025-10-07)
### ⚠ BREAKING CHANGES
* abacus-react package now has independent versioning from monorepo
### Features
* **abacus-react:** add dual publishing to npm and GitHub Packages ([242ee52](https://github.com/antialias/soroban-abacus-flashcards/commit/242ee523edebe2cfc5db27cc72fba0315072bec2))
* **abacus-react:** comprehensive README overhaul with current capabilities ([0ce351e](https://github.com/antialias/soroban-abacus-flashcards/commit/0ce351e572ac34fa816ee7533a26403c843d93f3))
* **abacus-react:** configure GitHub Packages-only publishing workflow ([5eeedd9](https://github.com/antialias/soroban-abacus-flashcards/commit/5eeedd9a59a6b3898cadb30c413daa791a9561ee))
* **abacus-react:** enable dual publishing to npm and GitHub Packages ([176a196](https://github.com/antialias/soroban-abacus-flashcards/commit/176a1961d05f99908a72837cf4e8ec93c0d33145))
* **abacus-react:** enhance package description with semantic versioning details ([af037b5](https://github.com/antialias/soroban-abacus-flashcards/commit/af037b5e0a1ded5460f95498eb1fb5ac19c2e3fa))
* **abacus-react:** implement GitHub Packages-only publishing workflow ([b194599](https://github.com/antialias/soroban-abacus-flashcards/commit/b194599f6029015b1aba0e57eb5fe9f83b89d403))
* **abacus-react:** implement GitHub-only semantic release with manual package publishing ([33b0567](https://github.com/antialias/soroban-abacus-flashcards/commit/33b056769811d1cf1c41dee9e65f6e12188e6f5f))
* **abacus-react:** simplify to GitHub Packages-only publishing ([acc126b](https://github.com/antialias/soroban-abacus-flashcards/commit/acc126bd5a0f0b2017263593ac2e3a180606f17b))
* **abacus-react:** update description to mention GitHub Packages support ([af77256](https://github.com/antialias/soroban-abacus-flashcards/commit/af7725622e15801f9e56af12930c4e14c5e67c53))
* **abacus-react:** use environment variables to override npm registry ([ad444e1](https://github.com/antialias/soroban-abacus-flashcards/commit/ad444e108f76d3014e492ddc94de0e52c61743ea))
* add API routes for players and user stats ([6f940e2](https://github.com/antialias/soroban-abacus-flashcards/commit/6f940e24d663cc06084a943df4743c2a1c1b3c33))
* add arcade matching game components and utilities ([ff16303](https://github.com/antialias/soroban-abacus-flashcards/commit/ff16303a7cd2880fcdfd51ef8a744e245905d87d))
* add arcade room system database schema and managers (Phase 1) ([a9175a0](https://github.com/antialias/soroban-abacus-flashcards/commit/a9175a050c1668a6ba066078e0bdbd944b4eb960))
* add build info API endpoint ([571664e](https://github.com/antialias/soroban-abacus-flashcards/commit/571664e725b63f22fa9f0bca8a1c518a54441dec))
* add build info generation script ([416dc89](https://github.com/antialias/soroban-abacus-flashcards/commit/416dc897e26ab93930b52faf77da3a6ffd4a0fb9))
* add category browsing and scrolling to emoji picker ([616a50e](https://github.com/antialias/soroban-abacus-flashcards/commit/616a50e234f79e271cb0bd9c959866d2d2e5ac82))
* add complement display options and unify equation display ([2ed7b2c](https://github.com/antialias/soroban-abacus-flashcards/commit/2ed7b2cbf8ad7c18b14c0e86b04a3ba96cc4de0b))
* add Complement Race game with three unique game modes ([582bce4](https://github.com/antialias/soroban-abacus-flashcards/commit/582bce411f5e89fe1ee677321d06ca7d0fd78701))
* add comprehensive E2E testing with Playwright ([d58053f](https://github.com/antialias/soroban-abacus-flashcards/commit/d58053fad3ab06b9884b46dbb6807e938426dbb5))
* add comprehensive Storybook stories for PlayerStatusBar ([8973241](https://github.com/antialias/soroban-abacus-flashcards/commit/8973241297d50604028bde95b9ebbf033688db89))
* add configuration access to active player emojis in prominent nav ([6049a7f](https://github.com/antialias/soroban-abacus-flashcards/commit/6049a7f6b7481ca42a7907d11d93676549bf6629))
* add configuration access to fullscreen player selection ([b85968b](https://github.com/antialias/soroban-abacus-flashcards/commit/b85968bcb6afa379d50242185e7743f6fe4ba982))
* add consecutive match tracking system for escalating celebrations ([111c0ce](https://github.com/antialias/soroban-abacus-flashcards/commit/111c0ced715be7cade006387d01f4e2f52c59be9))
* add CSS animations and visual feedback system ([80e33e2](https://github.com/antialias/soroban-abacus-flashcards/commit/80e33e25b3d30a44a1a5294997d56949e2aeef8b))
* add deployment info modal with keyboard shortcut ([43be7ac](https://github.com/antialias/soroban-abacus-flashcards/commit/43be7ac83a9ba3e0ad970f4588729ba2ad394702))
* add direct URL routes for each game mode ([a08f053](https://github.com/antialias/soroban-abacus-flashcards/commit/a08f0535bf6dfb424e8f9764b37ce6912db6021c))
* add exitSession to MemoryPairsContextValue interface ([abc2ea5](https://github.com/antialias/soroban-abacus-flashcards/commit/abc2ea50d07e87537ced649bcc9276ef95a3bc4e))
* add GameControlButtons component with unit tests ([1f45c17](https://github.com/antialias/soroban-abacus-flashcards/commit/1f45c17e0a68db2a452844f87217671223cf7bb0))
* add guest session system with JWT tokens ([10d8aaf](https://github.com/antialias/soroban-abacus-flashcards/commit/10d8aaf814275a9c3f08e0f1b39970c3ab1a8427))
* add initialStyle prop to ComplementRaceProvider ([f3bc2f6](https://github.com/antialias/soroban-abacus-flashcards/commit/f3bc2f6d926b9c8d9229636e3ed688bf9ea3baf3))
* add intelligent on-screen number pad for devices without keyboards ([d4740ff](https://github.com/antialias/soroban-abacus-flashcards/commit/d4740ff99709be915c41f51d973706f6ff2774b3))
* add interactive remove buttons for players in mini nav ([fa1cf96](https://github.com/antialias/soroban-abacus-flashcards/commit/fa1cf967898bdc396b0bfdcbfe2147a06e189190))
* add magnifying glass preview on emoji hover ([2c88b6b](https://github.com/antialias/soroban-abacus-flashcards/commit/2c88b6b5f3ba3a47007aa832c2e204bf2ebcc90b))
* add middleware for pathname header support in [@nav](https://github.com/nav) fallback ([b7e7c4b](https://github.com/antialias/soroban-abacus-flashcards/commit/b7e7c4beff1e37e90e9e20a890c5af7a134a7fca))
* add mini app nav to arcade page ([a854fe3](https://github.com/antialias/soroban-abacus-flashcards/commit/a854fe3dc935b10db8dc71569c0c8abd81938e4c))
* add optimistic updates and remove dead code ([b62cf26](https://github.com/antialias/soroban-abacus-flashcards/commit/b62cf26fb6597bae9a590f8b8d630fd31a8dd321))
* add passenger boarding system with station-based pickup ([23a9016](https://github.com/antialias/soroban-abacus-flashcards/commit/23a9016245e061b65151735db31aebdc9d36ed1d))
* add player types and migration utilities ([79f44b2](https://github.com/antialias/soroban-abacus-flashcards/commit/79f44b25d6c17f119c3ae225fb449be27c77c56d))
* add PlayerStatusBar with escalating celebration animations ([7f8c90a](https://github.com/antialias/soroban-abacus-flashcards/commit/7f8c90acea84b208df0e3e23e80a02cf425c0950))
* add prominent game context display to mini nav with smooth transitions ([8792393](https://github.com/antialias/soroban-abacus-flashcards/commit/8792393956b01a5a8ca67d78209e6defb6a11903))
* add React Query setup with api helper ([a3878a8](https://github.com/antialias/soroban-abacus-flashcards/commit/a3878a8537fe123fa345d2d2990b3cd76132ba1e))
* add realistic mountains with peaks and ground terrain ([99cdfa8](https://github.com/antialias/soroban-abacus-flashcards/commit/99cdfa8a0ba63e7b523466d8b2108bca05b0310a))
* add security tests and userId injection protection ([aa1ad31](https://github.com/antialias/soroban-abacus-flashcards/commit/aa1ad315ef75af5b6833a3a3628a9bbceb80c03c))
* add server-side validation for player modifications during active arcade sessions ([3b3cad4](https://github.com/antialias/soroban-abacus-flashcards/commit/3b3cad4b769b0ed9ed8e6dc2363bcaf13cc8e08a))
* add Setup button to exit arcade sessions ([ae1318e](https://github.com/antialias/soroban-abacus-flashcards/commit/ae1318e8bf2c584853ceeb38336d871110f13a39))
* add smooth spring animations to pressure gauge ([863a2e1](https://github.com/antialias/soroban-abacus-flashcards/commit/863a2e1319448381c853540301886fb4a169e112))
* add sound settings support to AbacusReact component ([90b9ffa](https://github.com/antialias/soroban-abacus-flashcards/commit/90b9ffa0d8659891bfe8062217e45245bbff5d5a))
* add train car system with smooth boarding/disembarking animations ([1613912](https://github.com/antialias/soroban-abacus-flashcards/commit/1613912740756d984205e3625791c1d8a2a6fa51))
* add Web Audio API sound effects system with 16 sound types ([90ba866](https://github.com/antialias/soroban-abacus-flashcards/commit/90ba86640c7062a00d2c7553827a61524ec17da1))
* **complement-race:** add abacus displays to pressure gauge ([c5ebc63](https://github.com/antialias/soroban-abacus-flashcards/commit/c5ebc635afb6e78f9f4b1192ff39dcec53879a60))
* complete themed navigation system with game-specific chrome ([0a4bf17](https://github.com/antialias/soroban-abacus-flashcards/commit/0a4bf1765cbd86bf6f67fb3b99c577cfe3cce075))
* create mode selection landing page for Complement Race ([1ff9695](https://github.com/antialias/soroban-abacus-flashcards/commit/1ff9695f6930f5232b2ad80ddcbd32bbc182d4e7))
* create PlayerConfigDialog component for player customization ([4f2a661](https://github.com/antialias/soroban-abacus-flashcards/commit/4f2a661494add3f61b714d0bead07b0e0bc3f92d))
* create StandardGameLayout for perfect viewport sizing ([728a920](https://github.com/antialias/soroban-abacus-flashcards/commit/728a92076a6ac9ef71f0c75d2e9503575881130a))
* display passengers visually on train and at stations ([1599904](https://github.com/antialias/soroban-abacus-flashcards/commit/159990489fec9162d9ed9ecf77c7592b776bbb23))
* dynamic player card rendering on games page ([81d17f2](https://github.com/antialias/soroban-abacus-flashcards/commit/81d17f23976cc340e23c63f8e27f1a15afd1a4d0))
* dynamically calculate train cars based on max concurrent passengers ([9ea1553](https://github.com/antialias/soroban-abacus-flashcards/commit/9ea15535d18efc25739342b0945c6d7ec7896c5d))
* emit session-state after creating arcade session ([70d6f43](https://github.com/antialias/soroban-abacus-flashcards/commit/70d6f43d6d7ff918ab15edb6e27d4eab8c7a3de6))
* enable prominent nav and fix layout on arcade page ([5c8c18c](https://github.com/antialias/soroban-abacus-flashcards/commit/5c8c18cbb89da38ccaab3c2ad7081e1a6d45a73e))
* enhance emoji picker with super powered magnifying glass and hide empty categories ([d8b4e42](https://github.com/antialias/soroban-abacus-flashcards/commit/d8b4e425bf019c593abdcb7693a04e4780b18f06))
* enhance passenger card UI with boarding status indicators ([4bbdabc](https://github.com/antialias/soroban-abacus-flashcards/commit/4bbdabc3b576ba8cdda5a053878b3f2e9004afca))
* extend ground terrain to cover entire track area ([ee48417](https://github.com/antialias/soroban-abacus-flashcards/commit/ee48417abfe9f5a2788d6de8ff522f60c13b6066))
* extend player customization to support all 4 players ([72f8dee](https://github.com/antialias/soroban-abacus-flashcards/commit/72f8dee183b17c88d51748b5131b5a51906a24b3))
* extend railroad track to viewport edges ([eadd7da](https://github.com/antialias/soroban-abacus-flashcards/commit/eadd7da6dbc4103342dd673f03f97850cdc20f23))
* extend track and tunnels to absolute viewport edges ([f7419bc](https://github.com/antialias/soroban-abacus-flashcards/commit/f7419bc6a0c03cbe2dbbc095e47891ee67d10b51))
* implement [@nav](https://github.com/nav) parallel routes for game name display in mini navigation ([885fc72](https://github.com/antialias/soroban-abacus-flashcards/commit/885fc725dc0bb41bbb5e500c2c907c6182192854))
* implement cozy sound effects for abacus with variable intensity ([c95be1d](https://github.com/antialias/soroban-abacus-flashcards/commit/c95be1df6dbe74aad08b9a1feb1f33688212be0b))
* implement cozy sound effects for abacus with variable intensity ([cea5fad](https://github.com/antialias/soroban-abacus-flashcards/commit/cea5fadbe4b4d5ae9e0ee988e9b1c4db09f21ba6))
* implement game control callbacks in MemoryPairsGame ([4758ad2](https://github.com/antialias/soroban-abacus-flashcards/commit/4758ad2f266ef3f3f67c22533fbb5f475dd8bd5b))
* implement game theming system with context-based navigation chrome ([3fa11c4](https://github.com/antialias/soroban-abacus-flashcards/commit/3fa11c4fbcbeabeb3bdd0db38374fb9a13cbb754))
* implement innovative dynamic two-panel layout for on-screen keyboard ([4bb8f6d](https://github.com/antialias/soroban-abacus-flashcards/commit/4bb8f6daf1f3eecb5cbaf31bf4057f43e43aeb07))
* implement mobile-first responsive design for speed memory quiz ([13efc4d](https://github.com/antialias/soroban-abacus-flashcards/commit/13efc4d0705bb9e71a2002689a4ebac109caacc2))
* implement simple fixed bottom keyboard bar ([9ef72d7](https://github.com/antialias/soroban-abacus-flashcards/commit/9ef72d7e88a6a9b30cfd7a7d3944197cc1e0037a))
* implement smooth train exit with fade-out through right tunnel ([0176694](https://github.com/antialias/soroban-abacus-flashcards/commit/01766944f0267b1f2adeb6a30c9f89d48038a7f8))
* implement toggleable on-screen keyboard to prevent UI overlap ([701d23c](https://github.com/antialias/soroban-abacus-flashcards/commit/701d23c36992b09c075e1a394f8a72edffb919f9))
* improve game availability logic and messaging ([9a3fa93](https://github.com/antialias/soroban-abacus-flashcards/commit/9a3fa93e53d05475844b54052acbc838d7487d23))
* increase landmark emoji size for better visibility ([0bcd7a3](https://github.com/antialias/soroban-abacus-flashcards/commit/0bcd7a30d42a0a0d5bdfcf5abd8eb3eb9a8b6a73))
* integrate GameControlButtons into navigation ([fbd8cd4](https://github.com/antialias/soroban-abacus-flashcards/commit/fbd8cd4a6bca44bbc0f7c4e8153900558805a84a))
* integrate remaining game sound effects ([600bc35](https://github.com/antialias/soroban-abacus-flashcards/commit/600bc35bc3111a455290638e7be31d0032ff656c))
* integrate sound effects into game flow (countdown, answers, performance) ([8c3a855](https://github.com/antialias/soroban-abacus-flashcards/commit/8c3a85523930fca7f2dcc53c79454fb9be523d55))
* integrate user profiles with PlayerStatusBar and game results ([beff646](https://github.com/antialias/soroban-abacus-flashcards/commit/beff64652c72a5cd0c008891b6dc2f5167e28b62))
* make Steam Sprint infinite mode ([32c3a35](https://github.com/antialias/soroban-abacus-flashcards/commit/32c3a35eabd10f8c9b50a55cfb525a76ea050914))
* make SVG span full viewport width for sprint mode ([7488bb3](https://github.com/antialias/soroban-abacus-flashcards/commit/7488bb38033b2d3d3fc18b9f09373506d69e25a5))
* migrate abacus display settings to database ([92ef136](https://github.com/antialias/soroban-abacus-flashcards/commit/92ef1360a4792d0b36f3a35e100bd9f3c7451656))
* migrate contexts to React Query (remove localStorage) ([fe01a1f](https://github.com/antialias/soroban-abacus-flashcards/commit/fe01a1fe182293aeadd5cbfd73f0a54a858ae38e))
* migrate contexts to UUID-based player system ([2b94cad](https://github.com/antialias/soroban-abacus-flashcards/commit/2b94cad11bd05b1a324e360c56be686c3c6a4b64))
* preserve track and passengers during route transitions ([f2e7165](https://github.com/antialias/soroban-abacus-flashcards/commit/f2e71657dc1587c2b6df1f4227160b8a261c6084))
* redesign passenger cards with vintage train station aesthetic ([651bc21](https://github.com/antialias/soroban-abacus-flashcards/commit/651bc2158361fbaafb0b011ab90006b21d3a7c85))
* set up automated npm publishing for @soroban/abacus-react package ([dd80d29](https://github.com/antialias/soroban-abacus-flashcards/commit/dd80d29c979e20b4d3624cf66be79ec51d5f53a9))
* set up Drizzle ORM with SQLite database ([5d5afd4](https://github.com/antialias/soroban-abacus-flashcards/commit/5d5afd4e6860241ff45c7173d4aad2b7156a41b1))
* skip countdown for train mode (sprint) ([65dafc9](https://github.com/antialias/soroban-abacus-flashcards/commit/65dafc92153399336f200a566bc91f869fdfcbb1))
* skip intro screen and start directly at game setup ([4b6888a](https://github.com/antialias/soroban-abacus-flashcards/commit/4b6888af05c6be9616cf20b9d2b8b66ac13cf253))
* sync URL with selected game mode ([3920bba](https://github.com/antialias/soroban-abacus-flashcards/commit/3920bbad33ef5dd6323d2baea498943f5115dbec))
* UI polish for Sprint mode (viewport, backgrounds, data attributes) ([90ad789](https://github.com/antialias/soroban-abacus-flashcards/commit/90ad789ff1f94f52b98de9fd934a623eab452387))
* update nav components for UUID players ([e85d041](https://github.com/antialias/soroban-abacus-flashcards/commit/e85d0415f23049da861533bbec2a65e1d84adfe1))
* use CSS transitions for smooth fullscreen player selection collapse ([3189832](https://github.com/antialias/soroban-abacus-flashcards/commit/31898328a391614a0fe8d24ec9d2881bfb6e6984))
* wire player configuration through nav component hierarchy ([edfdd81](https://github.com/antialias/soroban-abacus-flashcards/commit/edfdd8122774e36dbda9acea741a5e248be95676))
### Bug Fixes
* **abacus-react:** add debugging and explicit authentication for npm publish ([b82e9bb](https://github.com/antialias/soroban-abacus-flashcards/commit/b82e9bb9d6adf3793065067f96c6fbbfd1a78bca))
* **abacus-react:** add packages: write permission for GitHub Packages publishing ([8e16487](https://github.com/antialias/soroban-abacus-flashcards/commit/8e1648737de9305f82872cb9b86b98b5045f77a7))
* **abacus-react:** apply global columnPosts styling and fix reckoning bar width ([bb9959f](https://github.com/antialias/soroban-abacus-flashcards/commit/bb9959f7fb8985e0c6496247306838d97e7121f7))
* **abacus-react:** force npm to use GitHub Packages registry ([5e6c901](https://github.com/antialias/soroban-abacus-flashcards/commit/5e6c901f73a68b60ec05f19c4a991ca8affc1589))
* **abacus-react:** improve publishing workflow with better version sync ([7a4ecd2](https://github.com/antialias/soroban-abacus-flashcards/commit/7a4ecd2b5970ed8b6bfde8938b36917f8e7a7176))
* **abacus-react:** improve workspace dependency cleanup and add validation ([11fd6f9](https://github.com/antialias/soroban-abacus-flashcards/commit/11fd6f9b3deb1122d3788a7e0698de891eeb0f3a))
* **abacus-react:** resolve workspace dependencies before npm publish ([834b062](https://github.com/antialias/soroban-abacus-flashcards/commit/834b062b2d22356b9d96bb9c3c444eccaa51d793))
* **abacus-react:** simplify semantic-release config to resolve dependency issues ([88cab38](https://github.com/antialias/soroban-abacus-flashcards/commit/88cab380ef383c941b41671d58d3e35fcaefb2d3))
* **abacus-react:** temporarily allow test failures during setup phase ([e3db7f4](https://github.com/antialias/soroban-abacus-flashcards/commit/e3db7f4daf16fce82bccfe47dcaa90d7f4896a79))
* add CLEAR_MISMATCH move to allow mismatch feedback to auto-dismiss ([158f527](https://github.com/antialias/soroban-abacus-flashcards/commit/158f52773d20dfab7dc55575d9999f32b4c589a2))
* add missing GameThemeContext file for themed navigation ([d4fbdd1](https://github.com/antialias/soroban-abacus-flashcards/commit/d4fbdd14630e2f2fcdbc0de23ccc4ccd9eb74b48))
* add npmrc for hoisting and fix template paths ([5c65ac5](https://github.com/antialias/soroban-abacus-flashcards/commit/5c65ac5caabb7197f069344d0ed29d02c3de2b9a))
* add Python setuptools and build tools for better-sqlite3 compilation ([a216a3d](https://github.com/antialias/soroban-abacus-flashcards/commit/a216a3d3435a132c8add0a7c711b021bf4b1555f))
* add testing mode for on-screen keyboard and fix toggle functionality ([904074c](https://github.com/antialias/soroban-abacus-flashcards/commit/904074ca821b62cd6b1e129354eb36c5dd4b5e7f))
* align all bottom UI elements to same 20px baseline ([076c97a](https://github.com/antialias/soroban-abacus-flashcards/commit/076c97abac7a33f600b80083d8990a8c4a51be99))
* align bottom-positioned UI elements ([227cfab](https://github.com/antialias/soroban-abacus-flashcards/commit/227cfabf113bc875ea3a61f0de41a9093ad1dd30))
* allow navigation to game setup pages without active session ([c7ad3c0](https://github.com/antialias/soroban-abacus-flashcards/commit/c7ad3c069502580d1e72e7cc01e7b1f793ba9357))
* change pressure gauge to fixed positioning to stay above terrain ([1b11031](https://github.com/antialias/soroban-abacus-flashcards/commit/1b110315982f631dadca96a37fc88db98a7f9cca))
* change question display to fixed positioning with higher z-index ([4ac8758](https://github.com/antialias/soroban-abacus-flashcards/commit/4ac875895781dba5e115eeb3336ef76744b782bb))
* **complement-race:** improve abacus display in equations ([491b299](https://github.com/antialias/soroban-abacus-flashcards/commit/491b299e28ee82c49069cf892609b1b2b3c0aee3))
* **complement-race:** prevent passengers being left behind at delivery stations ([e6ebecb](https://github.com/antialias/soroban-abacus-flashcards/commit/e6ebecb09b1e5dd78c2dc11e125399082fb420ab))
* correct emoji category group IDs to match Unicode CLDR ([b2a21b7](https://github.com/antialias/soroban-abacus-flashcards/commit/b2a21b79ad705a5b52767317af00e4d666d33907))
* defer URL update until game starts ([12c54b2](https://github.com/antialias/soroban-abacus-flashcards/commit/12c54b27b717e852b96585eacdf6e9d964e32c50))
* delay passenger display update until train resets ([e06a750](https://github.com/antialias/soroban-abacus-flashcards/commit/e06a7504549bb4e0fcc38bd03249b9c0386c3079))
* disable turn validation in arcade mode matching game ([7c0e6b1](https://github.com/antialias/soroban-abacus-flashcards/commit/7c0e6b142b90f0fc3d444b3dcc1fff1512a0a3b2))
* eliminate rail jaggies on sharp curves by increasing sampling density ([46d4af2](https://github.com/antialias/soroban-abacus-flashcards/commit/46d4af2bdad761366890171cc666ba24d5309257))
* enable shamefully-hoist for semantic-release dependencies ([6168c29](https://github.com/antialias/soroban-abacus-flashcards/commit/6168c292d5f15748e80610103a6a787c0cf29d0f))
* enforce playerId must be explicitly provided in arcade moves ([d5a8a2a](https://github.com/antialias/soroban-abacus-flashcards/commit/d5a8a2a14cb14ecd00827ddc96873f3db79573fd))
* ensure consistent r×c grid layout for memory matching game ([f1a0633](https://github.com/antialias/soroban-abacus-flashcards/commit/f1a0633596fd1bb53418e56e28f3f27d3fce8b54))
* ensure game names persist in navigation on page reload ([9191b12](https://github.com/antialias/soroban-abacus-flashcards/commit/9191b124934b9a5577a91f67e8fb6f83b173cc4f))
* ensure passengers only travel forward on train route ([8ad3144](https://github.com/antialias/soroban-abacus-flashcards/commit/8ad3144d2da9b4ceedd62a9f379f664fa9381afe))
* export missing hooks and types from @soroban/abacus-react package ([423ba55](https://github.com/antialias/soroban-abacus-flashcards/commit/423ba5535023928f1e0198b2bd01c3c6cf7ee848))
* implement route-based theme detection for page reload persistence ([3dcff2f](https://github.com/antialias/soroban-abacus-flashcards/commit/3dcff2ff888558d7b746a732cfd53a1897c2b1df))
* improve navigation chrome background color extraction from gradients ([00bfcbc](https://github.com/antialias/soroban-abacus-flashcards/commit/00bfcbcdee28d63094c09a4ae0359789ebcf4a22))
* increase question display zIndex to stay above terrain ([8c8b8e0](https://github.com/antialias/soroban-abacus-flashcards/commit/8c8b8e08b4d51e6462d4b7dd258a25e64bf16dba))
* lazy-load database connection to prevent build-time access ([af8d993](https://github.com/antialias/soroban-abacus-flashcards/commit/af8d9936285c697ff45700115eba83b5debdf9ad))
* make results screen compact to fit viewport without scrolling ([9d4cba0](https://github.com/antialias/soroban-abacus-flashcards/commit/9d4cba05be84e7c162d706c8697eede23314b1a4))
* migrate viewport from metadata to separate viewport export ([1fe12c4](https://github.com/antialias/soroban-abacus-flashcards/commit/1fe12c4837b1229d0f0ab93c55d0ffb504eb8721))
* move auth.ts to src/ to match @/ path alias ([7829d8a](https://github.com/antialias/soroban-abacus-flashcards/commit/7829d8a0fb86dac07aa1b2fb0b68908e7e8381b8))
* move fontWeight to style object for station names ([05a3ddb](https://github.com/antialias/soroban-abacus-flashcards/commit/05a3ddb086e28529efb321943f4e423dbe5ed6a6))
* only show configuration gear icon for players 1 and 2 ([d0a3bc7](https://github.com/antialias/soroban-abacus-flashcards/commit/d0a3bc7dc1efc00c1db63fb50bc2ab3c6aabdd59))
* pass player IDs (not user IDs) in all arcade game moves ([d00abd2](https://github.com/antialias/soroban-abacus-flashcards/commit/d00abd25e755c0304517a7953cb78022a073b7c3))
* passengers now board/disembark based on their car position, not locomotive ([96782b0](https://github.com/antialias/soroban-abacus-flashcards/commit/96782b0e7a6d0db5a4435ca303b1d819947ce460))
* position tunnels at absolute viewBox edges ([1a5fa28](https://github.com/antialias/soroban-abacus-flashcards/commit/1a5fa2873bcda9a24cc578a7ecea43632077a0a1))
* prevent layout shift when selecting Steam Sprint mode ([73a5974](https://github.com/antialias/soroban-abacus-flashcards/commit/73a59745a5145b734b0893a489c5d018e3c9475c))
* prevent multiple passengers from boarding same car in single frame ([63b0b55](https://github.com/antialias/soroban-abacus-flashcards/commit/63b0b552a89a1165137de125bf57246a7cf6ac73))
* prevent premature passenger display during route transitions ([fe9ea67](https://github.com/antialias/soroban-abacus-flashcards/commit/fe9ea67f56847859b4fb4fa4f747022f0a2e5a70))
* prevent random passenger repopulation during route transitions ([db56ce8](https://github.com/antialias/soroban-abacus-flashcards/commit/db56ce89ee1d4e7583b60cde4e1d2610ee31123a))
* prevent route celebration from immediately reappearing ([1a80934](https://github.com/antialias/soroban-abacus-flashcards/commit/1a8093416e0feff774b6cdc6dfafdbafbb8baf7f))
* redesign matching game setup page for StandardGameLayout ([cc1f27f](https://github.com/antialias/soroban-abacus-flashcards/commit/cc1f27f0f82256f9344531814e8b965fa547d555))
* reduce landmark size from 4.0x to 2.0x multiplier ([c928e90](https://github.com/antialias/soroban-abacus-flashcards/commit/c928e907854e18264266958d813d4e1d4c03e760))
* regenerate lockfile with correct dependency order ([51bf448](https://github.com/antialias/soroban-abacus-flashcards/commit/51bf448c9f159152e89296d9014dde688fcf3a97))
* regenerate lockfile with node-linker=hoisted from scratch ([480960c](https://github.com/antialias/soroban-abacus-flashcards/commit/480960c2c8e0c50fe2b6ec69a34b772751a8bf41))
* regenerate pnpm lockfile for pnpm 9 compatibility ([4ab1aef](https://github.com/antialias/soroban-abacus-flashcards/commit/4ab1aef9b8fcf15cc03e86c829ca9885e7201b77))
* remove double PageWithNav wrapper on matching page ([b58bcd9](https://github.com/antialias/soroban-abacus-flashcards/commit/b58bcd92ee0521a6413f4d6e9656c9ccb1c72851))
* remove duplicate CAR_SPACING and MAX_CARS declarations ([e704a28](https://github.com/antialias/soroban-abacus-flashcards/commit/e704a28524e1217b6f56ca5a51784db73f5eadce))
* remove duplicate previousPassengersRef declaration ([fad8636](https://github.com/antialias/soroban-abacus-flashcards/commit/fad86367638b1a48b7e1544976148f43b061c832))
* remove frozen lockfile flag from publishing workflow to resolve dependency installation issues ([18af973](https://github.com/antialias/soroban-abacus-flashcards/commit/18af9730ffbcd822da292161815ffd09ad97f66c))
* remove hard-coded car count from game loop ([6c90a68](https://github.com/antialias/soroban-abacus-flashcards/commit/6c90a68c49b5f7fbc262c38f9a8828f5c725cb6a))
* remove unnecessary zIndex from question display ([db52e14](https://github.com/antialias/soroban-abacus-flashcards/commit/db52e14dfe9aceaaa1f98fc79100c04adc84611a))
* reposition on-screen keyboard to avoid covering abacus tiles ([6e5b4ec](https://github.com/antialias/soroban-abacus-flashcards/commit/6e5b4ec7bf7e2af5f724628693d3c4ee8c5b3968))
* require activePlayers in START_GAME, never fallback to userId ([ea1b1a2](https://github.com/antialias/soroban-abacus-flashcards/commit/ea1b1a2f69a35a6a27f7f952971509b2bb2e6f8d))
* reset momentum and pressure when starting new route ([3ea88d7](https://github.com/antialias/soroban-abacus-flashcards/commit/3ea88d7a5a6ed427b17cf408d993918870b75f7f))
* resolve circular dependency errors in memory quiz on-screen keyboard ([d25e2c4](https://github.com/antialias/soroban-abacus-flashcards/commit/d25e2c4c006b54a51eaa3a93fa8462e3a06221b7))
* resolve JSX parsing error with emoji in guide page ([bf046c9](https://github.com/antialias/soroban-abacus-flashcards/commit/bf046c999b51ba422284a139ebadde2c35187ac7))
* resolve mini navigation game name persistence across all routes ([3fa314a](https://github.com/antialias/soroban-abacus-flashcards/commit/3fa314aaa5de7b9c26a5390a52996c7d5ef9ea51))
* resolve runtime error - calculateOptimalGrid not defined ([fbc84fe](https://github.com/antialias/soroban-abacus-flashcards/commit/fbc84febda5507d434cf60aa0fce32350e01ec96))
* resolve SSR/client hydration mismatch for themed navigation ([301e65d](https://github.com/antialias/soroban-abacus-flashcards/commit/301e65dfa66d0de6b6efbbfbd09b717308ab57f1))
* resolve TypeScript errors in PlayerStatusBar component ([a935e5a](https://github.com/antialias/soroban-abacus-flashcards/commit/a935e5aed8c4584d21c8fc4359453b7dec494464))
* restore navigation to all pages using PageWithNav ([183706d](https://github.com/antialias/soroban-abacus-flashcards/commit/183706dade12080a748b0c074d0bd71fb0471d7e))
* show "Return to Arcade" button only during active game ([4153929](https://github.com/antialias/soroban-abacus-flashcards/commit/4153929a2ab199836249d53d92c1be4979782b73))
* smooth rail curves and deterministic track generation ([4f79c08](https://github.com/antialias/soroban-abacus-flashcards/commit/4f79c08d73c090165a1c419e7aa8ef543bc23e7e))
* stabilize route completion threshold to prevent stuck trains ([b7233f9](https://github.com/antialias/soroban-abacus-flashcards/commit/b7233f9e4afe1dadfe29ecc4430c74074a2674fc))
* update lockfile and fix Makefile paths ([7ba746b](https://github.com/antialias/soroban-abacus-flashcards/commit/7ba746b6bdcc4c06172e1dbacd856da9416e010a))
* update matching game for UUID player system ([2e041dd](https://github.com/antialias/soroban-abacus-flashcards/commit/2e041ddc4443be1d032139ad9850bbc28db5c171))
* update memory pairs game to use StandardGameLayout ([8df76c0](https://github.com/antialias/soroban-abacus-flashcards/commit/8df76c08fdf4108b88ce95de252cb8bd559fc5e4))
* update memory quiz to use StandardGameLayout ([3f86163](https://github.com/antialias/soroban-abacus-flashcards/commit/3f86163c142e577a64adfb3bf262656d2e100ced))
* update pnpm version to 8.15.6 to resolve ERR_INVALID_THIS error in workflow ([0b9bfed](https://github.com/antialias/soroban-abacus-flashcards/commit/0b9bfed12dfd48d9eacae69b378e28e188d3f2b1))
* update race track components for new player system ([ae4e8fc](https://github.com/antialias/soroban-abacus-flashcards/commit/ae4e8fcb5a62c07fb9ffa9a70c07e45ca8be88c8))
* update tutorial tests to use consolidated AbacusDisplayProvider ([899fc69](https://github.com/antialias/soroban-abacus-flashcards/commit/899fc6975f1fa14ddb42b2ead03524c9389e7c38))
* update workflow to support Personal Access Token for GitHub Packages publishing auth ([ae4b71b](https://github.com/antialias/soroban-abacus-flashcards/commit/ae4b71b98655364887a729ef9d2b67b6a753d6e9))
* upgrade CI dependencies and fix deprecated actions ([6a51c1e](https://github.com/antialias/soroban-abacus-flashcards/commit/6a51c1e9bdc299d86b8001eba35f930fe16cd60c))
* use displayPassengers for station rendering in RailroadTrackPath ([a9e0d19](https://github.com/antialias/soroban-abacus-flashcards/commit/a9e0d197348377cb66581150df47d2d1127ad09a))
* use node-linker=hoisted for full dependency hoisting ([d3b2e0b](https://github.com/antialias/soroban-abacus-flashcards/commit/d3b2e0b2e152150886110edd80dfe43f70df63d9))
* use player IDs instead of array indices in matching game ([ccd0d6d](https://github.com/antialias/soroban-abacus-flashcards/commit/ccd0d6d94ccd1cc25eed602d32a9cf884bda2ee6))
* use style fontSize instead of attribute for landmarks ([ebc6894](https://github.com/antialias/soroban-abacus-flashcards/commit/ebc6894746d1d490c3a5cae19c38bb86fe8fdc65))
* use UUID player IDs in session creation fallback ([22541df](https://github.com/antialias/soroban-abacus-flashcards/commit/22541df99f049ce99e020e12c2d28b33434de51d))
* wrap animated pressure value in animated.span to prevent React error ([5c5954b](https://github.com/antialias/soroban-abacus-flashcards/commit/5c5954be74708fb7019802a6dd80b10e9b2c1d6a))
### Performance Improvements
* optimize React rendering with memoization and consolidated effects ([93cb070](https://github.com/antialias/soroban-abacus-flashcards/commit/93cb070ca503effa05541f7ed217bb4260359581))
### Code Refactoring
* completely remove [@nav](https://github.com/nav) parallel routes and simplify navigation ([54ff20c](https://github.com/antialias/soroban-abacus-flashcards/commit/54ff20c7555e028b50471ac83a7030921c76f43b))
* consolidate abacus display context management ([a387b03](https://github.com/antialias/soroban-abacus-flashcards/commit/a387b030fa9fe36f2c8c0e65e34ec9ee872a7afa))
* extract ActivePlayersList component from PageWithNav ([2849576](https://github.com/antialias/soroban-abacus-flashcards/commit/28495767a9b792e6f8b9cc9f4df85e1b27a15c35))
* extract AddPlayerButton component from PageWithNav ([57a72e3](https://github.com/antialias/soroban-abacus-flashcards/commit/57a72e34a5df198ac795ff83cb6bfc6fb8a2f27e))
* extract FullscreenPlayerSelection component from PageWithNav ([66f5223](https://github.com/antialias/soroban-abacus-flashcards/commit/66f52234e12594947069c4ffed3648ce034c3e79))
* extract GameContextNav orchestration component ([e3f552d](https://github.com/antialias/soroban-abacus-flashcards/commit/e3f552d8f588b25d13eca20fbcd4f43b30de917f))
* extract GameHUD component from SteamTrainJourney ([78d5234](https://github.com/antialias/soroban-abacus-flashcards/commit/78d5234a79eb5293ae8eb279fb46b5aa2bbb6ae7))
* extract GameModeIndicator component from PageWithNav ([d67315f](https://github.com/antialias/soroban-abacus-flashcards/commit/d67315f771a3f660be58495c1236f74b49001d23))
* extract guide components to fix syntax error in large file ([c77e880](https://github.com/antialias/soroban-abacus-flashcards/commit/c77e880be32456c1e91d37358d85c445b2f707df))
* extract RailroadTrackPath component from SteamTrainJourney ([d9acc0e](https://github.com/antialias/soroban-abacus-flashcards/commit/d9acc0efea397ce58013160efbb2ed4fbee244b2))
* extract TrainAndCars component from SteamTrainJourney ([5ae22e4](https://github.com/antialias/soroban-abacus-flashcards/commit/5ae22e4645614bc67e7dece509d92bdd85250e28))
* extract TrainTerrainBackground component from SteamTrainJourney ([05bb035](https://github.com/antialias/soroban-abacus-flashcards/commit/05bb035db5483892f101cef0971bf08575cb041b))
* extract usePassengerAnimations hook from SteamTrainJourney ([32abde1](https://github.com/antialias/soroban-abacus-flashcards/commit/32abde107ca050bfc191a325d0bf074d53df33fc))
* extract useTrackManagement hook from SteamTrainJourney ([a1f2b97](https://github.com/antialias/soroban-abacus-flashcards/commit/a1f2b9736a0ff087dae44110708254e6da966b79))
* extract useTrainTransforms hook from SteamTrainJourney ([a2512d5](https://github.com/antialias/soroban-abacus-flashcards/commit/a2512d573823e187aad08f3fed365c4211023bb3))
* make game mode a computed property from active player count ([386c88a](https://github.com/antialias/soroban-abacus-flashcards/commit/386c88a3c03eb6de7814f65a6556a2d0ab50386b))
* remove drag-and-drop UI from EnhancedChampionArena ([982fa45](https://github.com/antialias/soroban-abacus-flashcards/commit/982fa45c08b34fdb64ec0835bc591b850e1c1373))
* remove duplicate game control buttons from game phases ([9165014](https://github.com/antialias/soroban-abacus-flashcards/commit/9165014997ccf6f4859646709d7e800933b4868e))
* remove redundant game titles from game screens ([402724c](https://github.com/antialias/soroban-abacus-flashcards/commit/402724c80e1776b90be1fc02186813389560f380))
* replace bulky MemoryGrid stats with compact progress display ([c4d6691](https://github.com/antialias/soroban-abacus-flashcards/commit/c4d6691715d09066661be4a0af7e917c6217ed8c))
* simplify navigation flow and enhance GameControls UI ([920aaa6](https://github.com/antialias/soroban-abacus-flashcards/commit/920aaa639887b42dcb225cb42ce146e67d29a98e))
* simplify PageWithNav by extracting nav components ([98cfa56](https://github.com/antialias/soroban-abacus-flashcards/commit/98cfa5645bc21faaeffa676d205bdbdf604eb488))
* split deployment info into server/client components ([5e7b273](https://github.com/antialias/soroban-abacus-flashcards/commit/5e7b273b339cd11d7b4b55dd50a1bf6c823b41d5))
* streamline GamePhase header and integrate PlayerStatusBar ([dcefa74](https://github.com/antialias/soroban-abacus-flashcards/commit/dcefa74902c65618f79eda32c94a5b8736b15b55))
* streamline UI and remove duplicate information displays ([7a3e34b](https://github.com/antialias/soroban-abacus-flashcards/commit/7a3e34b4faab62c069e6698a935ad66ee80037d2))
### Documentation
* add comprehensive workflow documentation for automated npm publishing ([f923b53](https://github.com/antialias/soroban-abacus-flashcards/commit/f923b53a44c2875fe152c9bd326d6a427d07a71e))
* add server persistence migration plan ([dd0df8c](https://github.com/antialias/soroban-abacus-flashcards/commit/dd0df8c274f513947e43f014b9086f77077f0196))
### Tests
* add comprehensive unit tests for refactored hooks and components ([5d20839](https://github.com/antialias/soroban-abacus-flashcards/commit/5d2083903e9eb751d810921e06dc51b7137f726f))
* add E2E tests for arcade modal session behavior ([619be98](https://github.com/antialias/soroban-abacus-flashcards/commit/619be9859c548b6c3f0af45379a61f690bcb8e13))
## [1.2.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v1.2.0...v1.2.1) (2025-09-28)

View File

@@ -1,8 +1,11 @@
# Multi-stage build for Soroban Abacus Flashcards
FROM node:18-alpine AS base
# Install Python and build tools for better-sqlite3
RUN apk add --no-cache python3 py3-setuptools make g++
# Install pnpm and turbo
RUN npm install -g pnpm@8.0.0 turbo@1.10.0
RUN npm install -g pnpm@9.15.4 turbo@1.10.0
WORKDIR /app
@@ -14,7 +17,7 @@ COPY packages/core/client/typescript/package.json ./packages/core/client/typescr
COPY packages/abacus-react/package.json ./packages/abacus-react/
COPY packages/templates/package.json ./packages/templates/
# Install dependencies
# Install dependencies (will use .npmrc with hoisted mode)
RUN pnpm install --frozen-lockfile
# Builder stage
@@ -31,20 +34,44 @@ RUN turbo build --filter=@soroban/web
FROM node:18-alpine AS runner
WORKDIR /app
# Install Python and build tools for better-sqlite3 (needed at runtime)
RUN apk add --no-cache python3 py3-setuptools make g++
# Create non-root user
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy built application
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
# Copy built Next.js application
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next ./apps/web/.next
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public
# Copy server files (compiled from TypeScript)
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/server.js ./apps/web/
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/socket-server.js ./apps/web/
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/src ./apps/web/src
# Copy database migrations
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/drizzle ./apps/web/drizzle
# Copy node_modules (for dependencies)
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/node_modules ./apps/web/node_modules
# Copy package.json files for module resolution
COPY --from=builder --chown=nextjs:nodejs /app/package.json ./
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/package.json ./apps/web/
# Set up environment
WORKDIR /app/apps/web
# Create data directory for SQLite database
RUN mkdir -p data && chown nextjs:nodejs data
USER nextjs
EXPOSE 3000
ENV PORT 3000
ENV HOSTNAME "0.0.0.0"
ENV NODE_ENV production
# Start the application
CMD ["node", "apps/web/server.js"]
CMD ["node", "server.js"]

View File

@@ -19,7 +19,7 @@ install:
# Generate default flashcards
out/flashcards.pdf: check-deps
@mkdir -p out
python3 src/generate.py --config config/default.yaml --output out/flashcards.pdf
python3 packages/core/src/generate.py --config config/default.yaml --output out/flashcards.pdf
# Generate linearized version
out/flashcards_linear.pdf: out/flashcards.pdf
@@ -29,23 +29,23 @@ out/flashcards_linear.pdf: out/flashcards.pdf
samples: check-deps
@echo "Generating sample outputs..."
@mkdir -p out/samples
python3 src/generate.py --config config/default.yaml --output out/samples/default.pdf
python3 src/generate.py --config config/0-99.yaml --output out/samples/0-99.pdf
python3 src/generate.py --config config/3-column-fixed.yaml --output out/samples/3-column-fixed.pdf
python3 src/generate.py --range "1,2,5,10,20,50,100" --cards-per-page 8 --output out/samples/custom-list.pdf
python3 packages/core/src/generate.py --config config/default.yaml --output out/samples/default.pdf
python3 packages/core/src/generate.py --config config/0-99.yaml --output out/samples/0-99.pdf
python3 packages/core/src/generate.py --config config/3-column-fixed.yaml --output out/samples/3-column-fixed.pdf
python3 packages/core/src/generate.py --range "1,2,5,10,20,50,100" --cards-per-page 8 --output out/samples/custom-list.pdf
@echo "Sample PDFs generated in out/samples/"
# Quick test with small range
test: check-deps
@echo "Running quick test..."
python3 src/generate.py --range "0-9" --output out/test.pdf
python3 packages/core/src/generate.py --range "0-9" --output out/test.pdf
@command -v qpdf >/dev/null 2>&1 && qpdf --check out/test.pdf || echo "PDF generated (validation skipped)"
@echo "Test completed successfully"
# Generate README example images
examples: check-deps
@echo "Generating example images for README..."
@python3 src/generate_examples.py
@python3 packages/core/src/generate_examples.py
@echo "✓ Example images generated in docs/images/"
# Verify examples are up to date (for CI)

View File

@@ -773,4 +773,16 @@ make verify-examples
MIT License - see LICENSE file for details.
This project uses DejaVu Sans font (included), which is released under a free license.
This project uses DejaVu Sans font (included), which is released under a free license.
---
## 🚀 Active Development Projects
### Speed Complement Race Port (In Progress)
**Status**: Planning Complete, Ready to Implement
**Plan Document**: [`apps/web/COMPLEMENT_RACE_PORT_PLAN.md`](./apps/web/COMPLEMENT_RACE_PORT_PLAN.md)
**Source**: `packages/core/src/web_generator.py` (lines 10956-15113)
**Target**: `apps/web/src/app/games/complement-race/`
A comprehensive port of the sophisticated Speed Complement Race game from standalone HTML to Next.js. Features 3 game modes, 2 AI personalities with 82 unique commentary messages, adaptive difficulty, and multiple visualization systems.

View File

@@ -0,0 +1,492 @@
# Arcade Game Architecture
## Overview
The arcade system supports two distinct game modes that must remain completely isolated from each other:
1. **Local Play** - Games without network synchronization (can be single-player OR local multiplayer)
2. **Room-Based Play** - Networked games with real-time synchronization across room members
## Core Terminology
Following `docs/terminology-user-player-room.md`:
- **USER** - Identity (guest or authenticated account), retrieved via `useViewerId()`, one per browser/account
- **PLAYER** - Game avatar/profile (e.g., "Alice 👧", "Bob 👦"), stored in `players` table
- **PLAYER ROSTER** - All PLAYERS belonging to a USER (can have many)
- **ACTIVE PLAYERS** - PLAYERS where `isActive = true` - these are the ones that actually participate in games
- **ROOM MEMBER** - A USER's participation in a multiplayer room (tracked in `room_members` table)
**Important:** A USER can have many PLAYERS in their roster, but only the ACTIVE PLAYERS (where `isActive = true`) participate in games. This enables "hot-potato" style local multiplayer where multiple people share the same device. This is LOCAL play (not networked), even though multiple PLAYERS participate.
In arcade sessions:
- `arcade_sessions.userId` - The USER who owns the session
- `arcade_sessions.activePlayers` - Array of PLAYER IDs (only active players with `isActive = true`)
- `arcade_sessions.roomId` - If present, the room ID for networked play (references `arcade_rooms.id`)
## Critical Architectural Requirements
### 1. Mode Isolation (MUST ENFORCE)
**Local Play** (`/arcade/[game-name]`)
- MUST NOT sync game state across the network
- MUST NOT use room data, even if the USER is currently a member of an active room
- MUST create isolated, per-USER game sessions
- Game state lives only in the current browser tab/session
- CAN have multiple ACTIVE PLAYERS from the same USER (local multiplayer / hot-potato)
- State is NOT shared across the network, only within the browser session
**Room-Based Play** (`/arcade/room`)
- MUST sync game state across all room members via network
- MUST use the USER's current active room
- MUST coordinate moves via server WebSocket
- Game state is shared across all ACTIVE PLAYERS from all USERS in the room
- When a PLAYER makes a move, all room members see it in real-time
- CAN ALSO have multiple ACTIVE PLAYERS per USER (networked + local multiplayer combined)
### 2. Room ID Usage Rules
```typescript
// ❌ WRONG: Always checking for room data
const { roomData } = useRoomData()
useArcadeSession({ roomId: roomData?.id }) // This causes the bug!
// ✅ CORRECT: Explicit mode control via separate providers
<LocalMemoryPairsProvider> {/* Never passes roomId */}
<RoomMemoryPairsProvider> {/* Always passes roomId */}
```
**Key principle:** The presence of a `roomId` parameter in `useArcadeSession` determines synchronization behavior:
- `roomId` present → room-wide network sync enabled (room-based play)
- `roomId` undefined → local play only (no network sync)
### 3. Composition Over Flags (PREFERRED APPROACH)
**✅ Option 1: Separate Providers (CLEAREST - USE THIS)**
Create two distinct provider components:
```typescript
// context/LocalMemoryPairsProvider.tsx
export function LocalMemoryPairsProvider({ children }) {
const { data: viewerId } = useViewerId()
const { activePlayers } = useGameMode() // Gets active players (isActive = true)
// NEVER fetch room data for local play
const { state, sendMove } = useArcadeSession<MemoryPairsState>({
userId: viewerId || '',
roomId: undefined, // Explicitly undefined - no network sync
initialState,
applyMove: applyMoveOptimistically,
})
// ... rest of provider logic
// Note: activePlayers contains only PLAYERS with isActive = true
}
// context/RoomMemoryPairsProvider.tsx
export function RoomMemoryPairsProvider({ children }) {
const { data: viewerId } = useViewerId()
const { roomData } = useRoomData() // OK to fetch for room-based play
const { activePlayers } = useGameMode() // Gets active players (isActive = true)
const { state, sendMove } = useArcadeSession<MemoryPairsState>({
userId: viewerId || '',
roomId: roomData?.id, // Pass roomId for network sync
initialState,
applyMove: applyMoveOptimistically,
})
// ... rest of provider logic
}
```
Then use them explicitly:
```typescript
// /arcade/matching/page.tsx (Local Play)
export default function MatchingPage() {
return (
<ArcadeGuardedPage>
<LocalMemoryPairsProvider>
<MemoryPairsGame />
</LocalMemoryPairsProvider>
</ArcadeGuardedPage>
)
}
// /arcade/room/page.tsx (Room-Based Play)
export default function RoomPage() {
// ... room validation logic
if (roomData.gameName === 'matching') {
return (
<RoomMemoryPairsProvider>
<MemoryPairsGame />
</RoomMemoryPairsProvider>
)
}
}
```
**Benefits of separate providers:**
- Compile-time safety - impossible to mix modes
- Clear intent - any developer can see which mode at a glance
- No runtime conditionals needed
- Easier to test - each provider tests separately
**❌ Avoid:** Runtime flag checking scattered throughout code
```typescript
// Anti-pattern: Too many conditionals
if (isRoomBased) { ... } else { ... }
```
### 4. How Synchronization Works
#### Local Play Flow
```
USER Action → useArcadeSession (roomId: undefined)
→ WebSocket emit('join-arcade-session', { userId })
→ Server creates isolated session for userId
→ Session key = userId
→ session.activePlayers = USER's active player IDs (isActive = true)
→ State changes only affect this USER's browser tabs
Note: Multiple ACTIVE PLAYERS from same USER can participate (local multiplayer),
but state is NEVER synced across network
```
#### Room-Based Play Flow
```
USER Action (on behalf of PLAYER)
→ useArcadeSession (roomId: 'room_xyz')
→ WebSocket emit('join-arcade-session', { userId, roomId })
→ Server creates/joins shared session for roomId
→ session.activePlayers = ALL active players from ALL room members
→ Socket joins TWO rooms: `arcade:${userId}` AND `game:${roomId}`
→ PLAYER makes move
→ Server validates PLAYER ownership (is this PLAYER owned by this USER?)
→ State changes broadcast to:
- arcade:${userId} - All tabs of this USER (for optimistic reconciliation)
- game:${roomId} - All USERS in the room (for network sync)
Note: Each USER can still have multiple ACTIVE PLAYERS (local + networked multiplayer)
```
The server-side logic uses `roomId` to determine session scope:
- No `roomId`: Session key = `userId` (isolated to USER's browser)
- With `roomId`: Session key = `roomId` (shared across all room members)
See `docs/MULTIPLAYER_SYNC_ARCHITECTURE.md` for detailed socket room mechanics.
### 5. USER vs PLAYER in Game Logic
**Important distinction:**
- **Session ownership**: Tracked by USER ID (`useViewerId()`)
- **Player roster**: All PLAYERS for a USER (can be many)
- **Active players**: PLAYERS with `isActive = true` (these join the game)
- **Game actions**: Performed by PLAYER ID (from `players` table)
- **Move validation**: Server checks that PLAYER ID belongs to the requesting USER
- **Local multiplayer**: One USER with multiple ACTIVE PLAYERS (hot-potato style, same device)
- **Networked multiplayer**: Multiple USERS, each with their own ACTIVE PLAYERS, in a room
```typescript
// ✅ Correct: USER owns session, ACTIVE PLAYERS participate
const { data: viewerId } = useViewerId() // USER ID
const { activePlayers } = useGameMode() // ACTIVE PLAYER IDs (isActive = true)
// activePlayers might be [player_001, player_002]
// even though USER has 5 total PLAYERS in their roster
const { state, sendMove } = useArcadeSession({
userId: viewerId, // Session owned by USER
roomId: undefined, // Local play (or roomData?.id for room-based)
// ...
})
// When PLAYER flips card:
sendMove({
type: 'FLIP_CARD',
playerId: currentPlayerId, // PLAYER ID from activePlayers
data: { cardId: '...' }
})
```
**Example Scenarios:**
1. **Single-player local game:**
- USER: "guest_abc"
- Player roster: ["player_001" (active), "player_002" (inactive), "player_003" (inactive)]
- Active PLAYERS in game: ["player_001"]
- Mode: Local play (no roomId)
2. **Local multiplayer (hot-potato):**
- USER: "guest_abc"
- Player roster: ["player_001" (active), "player_002" (active), "player_003" (active), "player_004" (inactive)]
- Active PLAYERS in game: ["player_001", "player_002", "player_003"] (3 kids sharing device)
- Mode: Local play (no roomId)
- Game rotates turns between the 3 active PLAYERS, but NO network sync
3. **Room-based networked play:**
- USER A: "guest_abc"
- Player roster: 5 total PLAYERS
- Active PLAYERS: ["player_001", "player_002"]
- USER B: "guest_def"
- Player roster: 3 total PLAYERS
- Active PLAYERS: ["player_003"]
- Mode: Room-based play (roomId: "room_xyz")
- Total PLAYERS in game: 3 (player_001, player_002, player_003)
- All 3 synced across network
4. **Room-based + local multiplayer combined:**
- USER A: "guest_abc" with 3 active PLAYERS (3 kids at Device A)
- USER B: "guest_def" with 2 active PLAYERS (2 kids at Device B)
- Mode: Room-based play (roomId: "room_xyz")
- 5 total active PLAYERS across 2 devices, all synced over network
## Common Mistakes to Avoid
### Mistake 1: Conditional Room Usage
```typescript
// ❌ BAD: Room sync leaks into local play
const { roomData } = useRoomData()
useArcadeSession({
roomId: roomData?.id // Local play will sync if USER is in a room!
})
```
### Mistake 2: Shared Components Without Mode Context
```typescript
// ❌ BAD: Same provider used for both modes
export default function LocalGamePage() {
return <GameProvider><Game /></GameProvider> // Which mode?
}
```
### Mistake 3: Confusing "multiplayer" with "networked"
```typescript
// ❌ BAD: Thinking multiple PLAYERS means room-based
if (activePlayers.length > 1) {
// Must be room-based! WRONG!
// Could be local multiplayer (hot-potato style)
}
// ✅ CORRECT: Check for roomId to determine network sync
const isNetworked = !!roomId
const isLocalMultiplayer = activePlayers.length > 1 && !roomId
```
### Mistake 4: Using all PLAYERS instead of only active ones
```typescript
// ❌ BAD: Including inactive players
const allPlayers = await db.query.players.findMany({
where: eq(players.userId, userId)
})
// ✅ CORRECT: Only active players join the game
const activePlayers = await db.query.players.findMany({
where: and(
eq(players.userId, userId),
eq(players.isActive, true)
)
})
```
### Mistake 5: Mixing USER ID and PLAYER ID
```typescript
// ❌ BAD: Using USER ID for game actions
sendMove({
type: 'FLIP_CARD',
playerId: viewerId, // WRONG! viewerId is USER ID, not PLAYER ID
data: { cardId: '...' }
})
// ✅ CORRECT: Use PLAYER ID from game state
sendMove({
type: 'FLIP_CARD',
playerId: state.currentPlayer, // PLAYER ID from activePlayers
data: { cardId: '...' }
})
```
### Mistake 6: Server-Side Ambiguity
```typescript
// ❌ BAD: Server can't distinguish intent
socket.on('join-arcade-session', ({ userId, roomId }) => {
// If roomId exists, did USER want local or room-based play?
// This happens when provider always passes roomData?.id
})
```
## Testing Requirements
Tests MUST verify mode isolation:
### Local Play Tests
```typescript
it('should NOT sync state when USER is in a room but playing locally', async () => {
// Setup: USER is a member of an active room
// Action: USER navigates to /arcade/matching
// Assert: Game state is NOT shared with other room members
// Assert: Other room members' actions do NOT affect this game
})
it('should create isolated sessions for concurrent local games', () => {
// Setup: Two USERS who are members of the same room
// Action: Both navigate to /arcade/matching separately
// Assert: Each has independent game state
// Assert: USER A's moves do NOT appear in USER B's game
})
it('should support local multiplayer without network sync', () => {
// Setup: USER with 3 active PLAYERS in roster (hot-potato style)
// Action: USER plays at /arcade/matching with the 3 active PLAYERS
// Assert: All 3 active PLAYERS participate in the same session
// Assert: Inactive PLAYERS do NOT participate
// Assert: State is NOT synced across network
// Assert: Game rotates turns between active PLAYERS locally
})
it('should only include active players in game', () => {
// Setup: USER has 5 PLAYERS in roster, but only 2 are active
// Action: USER starts a local game
// Assert: Only the 2 active PLAYERS are in activePlayers array
// Assert: Inactive PLAYERS are not included
})
it('should sync across USER tabs but not across network', () => {
// Setup: USER opens /arcade/matching in 2 browser tabs
// Action: PLAYER makes move in Tab 1
// Assert: Tab 2 sees the move (multi-tab sync)
// Assert: Other USERS do NOT see the move (no network sync)
})
```
### Room-Based Play Tests
```typescript
it('should sync state across all room members', async () => {
// Setup: Two USERS are members of the same room
// Action: USER A's PLAYER flips card at /arcade/room
// Assert: USER B sees the card flip in real-time
})
it('should sync across multiple active PLAYERS from multiple USERS', () => {
// Setup: USER A has 2 active PLAYERS, USER B has 1 active PLAYER in same room
// Action: USER A's PLAYER 1 makes move
// Assert: All 3 PLAYERS see the move (networked)
})
it('should only include active players in room games', () => {
// Setup: USER A (5 PLAYERS, 2 active), USER B (3 PLAYERS, 1 active) join room
// Action: Game starts
// Assert: session.activePlayers = [userA_player1, userA_player2, userB_player1]
// Assert: Inactive PLAYERS are NOT included
})
it('should handle combined local + networked multiplayer', () => {
// Setup: USER A (3 active PLAYERS), USER B (2 active PLAYERS) in same room
// Action: Any PLAYER makes a move
// Assert: All 5 active PLAYERS see the move across both devices
})
it('should fail gracefully when no room exists', () => {
// Setup: USER is not a member of any room
// Action: Navigate to /arcade/room
// Assert: Shows "No active room" message
// Assert: Does not create a session
})
it('should validate PLAYER ownership', async () => {
// Setup: USER A in room with active PLAYER 'alice'
// Action: USER A attempts move for PLAYER 'bob' (owned by USER B)
// Assert: Server rejects the move
// Assert: Error indicates unauthorized PLAYER
})
```
## Implementation Checklist
When adding a new game or modifying existing ones:
- [ ] Create separate `LocalGameProvider` and `RoomGameProvider` components
- [ ] Local provider never calls `useRoomData()`
- [ ] Local provider passes `roomId: undefined` to `useArcadeSession`
- [ ] Room provider calls `useRoomData()` and passes `roomId: roomData?.id`
- [ ] Both providers use `useGameMode()` to get active players
- [ ] Local play page uses `LocalGameProvider`
- [ ] `/arcade/room` page uses `RoomGameProvider`
- [ ] Game components correctly use PLAYER IDs (not USER IDs) for moves
- [ ] Game supports multiple active PLAYERS from same USER (local multiplayer)
- [ ] Inactive PLAYERS are never included in game sessions
- [ ] Tests verify mode isolation (local doesn't network sync, room-based does)
- [ ] Tests verify PLAYER ownership validation
- [ ] Tests verify only active PLAYERS participate
- [ ] Tests verify local multiplayer works (multiple active PLAYERS, one USER)
- [ ] Documentation updated if behavior changes
## File Organization
```
src/app/arcade/
├── [game-name]/ # Local play games
│ ├── page.tsx # Uses LocalGameProvider
│ └── context/
│ ├── LocalGameProvider.tsx # roomId: undefined
│ └── RoomGameProvider.tsx # roomId: roomData?.id
├── room/ # Room-based play
│ └── page.tsx # Uses RoomGameProvider
└── ...
```
## Architecture Decision Records
### Why separate providers instead of auto-detect from route?
While we could detect mode based on the route (`/arcade/room` vs `/arcade/matching`), separate providers are clearer and prevent accidental misuse. Future developers can immediately see the intent, and the type system can enforce correctness.
### Why being in a room doesn't mean all games sync?
A USER being a room member does NOT mean all their games should network sync. They should be able to play local games while remaining in a room for future room-based sessions. Mode is determined by the page they're on, not their room membership status.
### Why not use a single shared provider with mode props?
We tried that. It led to the current bug where local play accidentally synced with rooms. Separate providers make the distinction compile-time safe rather than runtime conditional, and eliminate the possibility of accidentally passing `roomId` when we shouldn't.
### Why do we track sessions by USER but moves by PLAYER?
- **Sessions** are per-USER because each USER can have their own game session
- **Moves** are per-PLAYER because PLAYERS are the game avatars that score points
- **Only active PLAYERS** (isActive = true) participate in games
- This allows:
- One USER with multiple active PLAYERS (local multiplayer / hot-potato)
- Multiple USERS in one room (networked play)
- Combined: Multiple USERS each with multiple active PLAYERS (local + networked)
- Proper ownership validation (server checks USER owns PLAYER)
- PLAYERS can be toggled active/inactive without deleting them
### Why use "local" vs "room-based" instead of "solo" vs "multiplayer"?
- **"Solo"** is misleading - a USER can have multiple active PLAYERS in local play (hot-potato style)
- **"Multiplayer"** is ambiguous - it could mean local multiplayer OR networked multiplayer
- **"Local play"** clearly means: no network sync (but can have multiple active PLAYERS)
- **"Room-based play"** clearly means: network sync across room members
## Related Files
- `src/hooks/useArcadeSession.ts` - Session management with optional roomId
- `src/hooks/useArcadeSocket.ts` - WebSocket connection with sync logic (socket rooms: `arcade:${userId}` and `game:${roomId}`)
- `src/hooks/useRoomData.ts` - Fetches USER's current room membership
- `src/hooks/useViewerId.ts` - Retrieves current USER ID
- `src/contexts/GameModeContext.tsx` - Provides active PLAYER information
- `src/app/arcade/matching/context/ArcadeMemoryPairsContext.tsx` - Game context (needs refactoring to separate providers)
- `src/app/arcade/matching/page.tsx` - Local play entry point
- `src/app/arcade/room/page.tsx` - Room-based play entry point
- `docs/terminology-user-player-room.md` - Terminology guide (USER/PLAYER/MEMBER)
- `docs/MULTIPLAYER_SYNC_ARCHITECTURE.md` - Technical details of room-based sync
## Version History
- **2025-10-09**: Initial documentation
- Issue identified: Local play was syncing with rooms over network
- Root cause: Same provider always fetched `roomData` and passed `roomId` to `useArcadeSession`
- Solution: Separate providers for local vs room-based play
- Terminology clarification: "local" vs "room-based" (not "solo" vs "multiplayer")
- Active players: Only PLAYERS with `isActive = true` participate in games

View File

@@ -0,0 +1,416 @@
# Arcade Setup Pattern
## Overview
This document describes the **standard synchronized setup pattern** for arcade games. Following this pattern ensures that:
1.**Setup is synchronized** - All room members see the same setup screen and config changes in real-time
2.**No local state hacks** - Configuration lives entirely in session state, no React state merging
3.**Optimistic updates** - Config changes feel instant with client-side prediction
4.**Consistent pattern** - All games follow the same architecture
**Reference Implementation**: `src/app/arcade/matching/*` (Matching game)
---
## Core Concept
Setup configuration is **game state**, not UI state. Configuration changes are **moves** that are validated, synchronized, and can be made by any room member.
### Key Principles
1. **Game state includes configuration** during ALL phases (setup, playing, results)
2. **No local React state** for configuration - use session state directly
3. **Standard move types** that all games should implement
4. **Setup phase is collaborative** - any room member can configure the game
---
## Required Move Types
Every arcade game must support these standard moves:
### 1. `GO_TO_SETUP`
Transitions game to setup phase, allowing reconfiguration.
```typescript
{
type: 'GO_TO_SETUP',
playerId: string,
data: {}
}
```
**Behavior:**
- Can be called from any phase (setup, playing, results)
- Sets `gamePhase: 'setup'`
- Resets game progression (scores, cards, etc.)
- Preserves configuration (players can modify it)
- Synchronized across all room members
### 2. `SET_CONFIG`
Updates a configuration field during setup phase.
```typescript
{
type: 'SET_CONFIG',
playerId: string,
data: {
field: string, // Config field name
value: any // New value
}
}
```
**Behavior:**
- Only allowed during setup phase
- Validates field name and value
- Updates immediately with optimistic update
- Synchronized across all room members
### 3. `START_GAME`
Starts the game with current configuration.
```typescript
{
type: 'START_GAME',
playerId: string,
data: {
activePlayers: string[],
playerMetadata: { [playerId: string]: PlayerMetadata },
// ... game-specific initial data
}
}
```
**Behavior:**
- Only allowed during setup phase
- Uses current session state configuration
- Initializes game-specific state
- Sets `gamePhase: 'playing'`
---
## Implementation Checklist
### 1. Update Validation Types
Add move types to your game's validation types:
```typescript
// src/lib/arcade/validation/types.ts
export interface YourGameGoToSetupMove extends GameMove {
type: 'GO_TO_SETUP'
data: Record<string, never>
}
export interface YourGameSetConfigMove extends GameMove {
type: 'SET_CONFIG'
data: {
field: 'configField1' | 'configField2' | 'configField3'
value: any
}
}
export type YourGameMove =
| YourGameStartGameMove
| YourGameGoToSetupMove
| YourGameSetConfigMove
| ... // other game-specific moves
```
### 2. Implement Validators
Add validators for setup moves in your game's validator class:
```typescript
// src/lib/arcade/validation/YourGameValidator.ts
export class YourGameValidator implements GameValidator<YourGameState, YourGameMove> {
validateMove(state, move, context) {
switch (move.type) {
case 'GO_TO_SETUP':
return this.validateGoToSetup(state)
case 'SET_CONFIG':
return this.validateSetConfig(state, move.data.field, move.data.value)
case 'START_GAME':
return this.validateStartGame(state, move.data)
// ... other moves
}
}
private validateGoToSetup(state: YourGameState): ValidationResult {
return {
valid: true,
newState: {
...state,
gamePhase: 'setup',
// Reset game progression, preserve configuration
// ... reset scores, game data, etc.
},
}
}
private validateSetConfig(
state: YourGameState,
field: string,
value: any
): ValidationResult {
// Only during setup
if (state.gamePhase !== 'setup') {
return { valid: false, error: 'Cannot change config outside setup' }
}
// Validate field-specific values
switch (field) {
case 'configField1':
if (!isValidValue(value)) {
return { valid: false, error: 'Invalid value' }
}
break
// ... validate other fields
}
return {
valid: true,
newState: { ...state, [field]: value },
}
}
private validateStartGame(state: YourGameState, data: any): ValidationResult {
if (state.gamePhase !== 'setup') {
return { valid: false, error: 'Can only start from setup' }
}
// Use current state configuration to initialize game
const initialGameData = initializeYourGame(state.configField1, state.configField2)
return {
valid: true,
newState: {
...state,
gamePhase: 'playing',
...initialGameData,
},
}
}
}
```
### 3. Add Optimistic Updates
Update `applyMoveOptimistically` in your providers:
```typescript
// src/app/arcade/your-game/context/YourGameProvider.tsx
function applyMoveOptimistically(state: YourGameState, move: GameMove): YourGameState {
switch (move.type) {
case 'GO_TO_SETUP':
return {
...state,
gamePhase: 'setup',
// Reset game state, preserve config
}
case 'SET_CONFIG':
const { field, value } = move.data
return {
...state,
[field]: value,
}
case 'START_GAME':
return {
...state,
gamePhase: 'playing',
// ... initialize game data from move
}
// ... other moves
}
}
```
### 4. Remove Local State from Providers
**❌ OLD PATTERN (Don't do this):**
```typescript
// DON'T: Local React state for configuration
const [localDifficulty, setLocalDifficulty] = useState(6)
// DON'T: Merge hack
const effectiveState = state.gamePhase === 'setup'
? { ...state, difficulty: localDifficulty }
: state
// DON'T: Direct setter
const setDifficulty = (value) => setLocalDifficulty(value)
```
**✅ NEW PATTERN (Do this):**
```typescript
// DO: Use session state directly
const { state, sendMove } = useArcadeSession(...)
// DO: Send move for config changes
const setDifficulty = useCallback((value) => {
const playerId = activePlayers[0] || ''
sendMove({
type: 'SET_CONFIG',
playerId,
data: { field: 'difficulty', value },
})
}, [activePlayers, sendMove])
// DO: Use state directly (no merging!)
const contextValue = { state: { ...state, gameMode }, ... }
```
### 5. Update Action Creators
All configuration actions should send moves:
```typescript
export function YourGameProvider({ children }) {
const { state, sendMove } = useArcadeSession(...)
const setConfigField = useCallback((value) => {
const playerId = activePlayers[0] || ''
sendMove({
type: 'SET_CONFIG',
playerId,
data: { field: 'configField', value },
})
}, [activePlayers, sendMove])
const goToSetup = useCallback(() => {
const playerId = activePlayers[0] || state.currentPlayer || ''
sendMove({
type: 'GO_TO_SETUP',
playerId,
data: {},
})
}, [activePlayers, state.currentPlayer, sendMove])
const startGame = useCallback(() => {
// Use current session state config (not local state!)
const initialData = initializeGame(state.config1, state.config2)
const playerId = activePlayers[0]
sendMove({
type: 'START_GAME',
playerId,
data: {
...initialData,
activePlayers,
playerMetadata: capturePlayerMetadata(players, activePlayers),
},
})
}, [state.config1, state.config2, activePlayers, sendMove])
return <YourGameContext.Provider value={...} />
}
```
---
## Benefits of This Pattern
### 1. **Synchronized Setup**
- User A clicks "Setup" → All room members see setup screen
- User B changes difficulty → All room members see the change
- User A clicks "Start" → All room members start playing
### 2. **No Special Cases**
- Setup works like gameplay (moves + validation)
- No conditional logic based on phase
- No React state merging hacks
### 3. **Easy to Extend**
- New games copy the same pattern
- Well-documented and tested
- Consistent developer experience
### 4. **Optimistic Updates**
- Config changes feel instant
- Client-side prediction + server validation
- Rollback on validation failure
---
## Testing Checklist
When implementing this pattern, test these scenarios:
### Local Mode
- [ ] Click setup button during game → returns to setup
- [ ] Change config fields → updates immediately
- [ ] Start game → uses current config
### Room Mode (Multi-User)
- [ ] User A clicks setup → User B sees setup screen
- [ ] User A changes difficulty → User B sees change in real-time
- [ ] User B changes game type → User A sees change in real-time
- [ ] User A starts game → Both users see game with same config
### Edge Cases
- [ ] Change config rapidly → no race conditions
- [ ] User with 0 players can see/modify setup
- [ ] Setup → Play → Setup preserves last config
- [ ] Invalid config values are rejected by validator
---
## Migration Guide
If you have an existing game using local state, follow these steps:
### Step 1: Add Move Types
Add `GO_TO_SETUP` and `SET_CONFIG` to your validation types.
### Step 2: Implement Validators
Add validators for the new moves in your game validator class.
### Step 3: Add Optimistic Updates
Update `applyMoveOptimistically` to handle the new moves.
### Step 4: Remove Local State
1. Delete all `useState` calls for configuration
2. Delete the `effectiveState` merging logic
3. Update action creators to send moves instead
### Step 5: Test
Run through the testing checklist above.
---
## Reference Implementation
See the Matching game for a complete reference implementation:
- **Types**: `src/lib/arcade/validation/types.ts`
- **Validator**: `src/lib/arcade/validation/MatchingGameValidator.ts`
- **Provider**: `src/app/arcade/matching/context/RoomMemoryPairsProvider.tsx`
- **Optimistic Updates**: `applyMoveOptimistically` function in provider
Look for comments marked with:
- `// STANDARD ARCADE PATTERN: GO_TO_SETUP`
- `// STANDARD ARCADE PATTERN: SET_CONFIG`
- `// NO LOCAL STATE`
- `// NO MORE effectiveState merging!`
---
## Questions?
If something is unclear or you encounter issues implementing this pattern, refer to the Matching game implementation or update this document with clarifications.

View File

@@ -0,0 +1,86 @@
# Claude Code Instructions for apps/web
## MANDATORY: Quality Checks for ALL Work
**BEFORE declaring ANY work complete, fixed, or working**, you MUST run and pass these checks:
### When This Applies
- Before every commit
- Before saying "it's done" or "it's fixed"
- Before marking a task as complete
- Before telling the user something is working
- After any code changes, no matter how small
```bash
npm run pre-commit
```
This single command runs all quality checks in the correct order:
1. `npm run type-check` - TypeScript type checking (must have 0 errors)
2. `npm run format` - Auto-format all code with Biome
3. `npm run lint:fix` - Auto-fix linting issues with Biome + ESLint
4. `npm run lint` - Verify 0 errors, 0 warnings
**DO NOT COMMIT** until all checks pass with zero errors and zero warnings.
## Available Scripts
```bash
npm run type-check # TypeScript: tsc --noEmit
npm run format # Biome: format all files
npm run format:check # Biome: check formatting without fixing
npm run lint # Biome + ESLint: check for errors/warnings
npm run lint:fix # Biome + ESLint: auto-fix issues
npm run check # Biome: full check (format + lint + imports)
npm run pre-commit # Run all checks (type + format + lint)
```
## Workflow
When asked to make ANY changes:
1. Make your code changes
2. Run `npm run pre-commit`
3. If it fails, fix the issues and run again
4. Only after all checks pass can you:
- Say the work is "done" or "complete"
- Mark tasks as finished
- Create commits
- Tell the user it's working
5. Push immediately after committing
**Nothing is complete until `npm run pre-commit` passes.**
## Details
See `.claude/CODE_QUALITY_REGIME.md` for complete documentation.
## No Pre-Commit Hooks
This project does not use git pre-commit hooks for religious reasons.
You (Claude Code) are responsible for enforcing code quality before commits.
## Quick Reference: package.json Scripts
**Primary workflow:**
```bash
npm run pre-commit # ← Use this before every commit
```
**Individual checks (if needed):**
```bash
npm run type-check # TypeScript: tsc --noEmit
npm run format # Biome: format code (--write)
npm run lint # Biome + ESLint: check only
npm run lint:fix # Biome + ESLint: auto-fix
```
**Additional tools:**
```bash
npm run format:check # Check formatting without changing files
npm run check # Biome check (format + lint + organize imports)
```
---
**Remember: Always run `npm run pre-commit` before creating commits.**

View File

@@ -0,0 +1,143 @@
# Code Quality Regime
**MANDATORY**: Before declaring ANY work complete, fixed, or working, Claude MUST run these checks and fix all issues.
## Definition of "Done"
Work is NOT complete until:
- ✅ All TypeScript errors are fixed (0 errors)
- ✅ All code is formatted with Biome
- ✅ All linting passes (0 errors, 0 warnings)
-`npm run pre-commit` exits successfully
**Until these checks pass, the work is considered incomplete.**
## Quality Check Checklist (Always Required)
Run these before:
- Committing code
- Saying work is "done" or "complete"
- Marking tasks as finished
- Telling the user something is "working" or "fixed"
Run these commands in order. All must pass with 0 errors and 0 warnings:
```bash
# 1. Type check
npm run type-check
# 2. Format code
npm run format
# 3. Lint and fix
npm run lint:fix
# 4. Verify clean state
npm run lint && npm run type-check
```
## Quick Command (Run All Checks)
```bash
npm run pre-commit
```
**What it does:**
```json
"pre-commit": "npm run type-check && npm run format && npm run lint:fix && npm run lint"
```
This single command runs:
1. `npm run type-check``tsc --noEmit` (TypeScript errors)
2. `npm run format``npx @biomejs/biome format . --write` (auto-format)
3. `npm run lint:fix``npx @biomejs/biome lint . --write && npx eslint . --fix` (auto-fix)
4. `npm run lint``npx @biomejs/biome lint . && npx eslint .` (verify clean)
Fails fast if any step fails.
## The Regime Rules
### 1. TypeScript Errors: ZERO TOLERANCE
- Run `npm run type-check` before every commit
- Fix ALL TypeScript errors
- No `@ts-ignore` or `@ts-expect-error` without explicit justification
### 2. Formatting: AUTOMATIC
- Run `npm run format` before every commit
- Biome handles all formatting automatically
- Never commit unformatted code
### 3. Linting: ZERO ERRORS, ZERO WARNINGS
- Run `npm run lint:fix` to auto-fix issues
- Then run `npm run lint` to verify 0 errors, 0 warnings
- Fix any remaining issues manually
### 4. Commit Order
1. Make code changes
2. Run `npm run pre-commit`
3. If any check fails, fix and repeat
4. Only commit when all checks pass
5. Push immediately after commit
## Why No Pre-Commit Hooks?
This project intentionally avoids pre-commit hooks due to religious constraints.
Instead, Claude Code is responsible for enforcing this regime through:
1. **This documentation** - Always visible and reference-able
2. **Package.json scripts** - Easy to run checks
3. **Session persistence** - This file lives in `.claude/` and is read by every session
## For Claude Code Sessions
**READ THIS FILE AT THE START OF EVERY SESSION WHERE YOU WILL COMMIT CODE**
When asked to commit:
1. Check if you've run `npm run pre-commit` (or all 4 steps individually)
2. If not, STOP and run the checks first
3. Fix all issues before proceeding with the commit
4. Only create commits when all checks pass
## Complete Scripts Reference
From `apps/web/package.json`:
```json
{
"scripts": {
"type-check": "tsc --noEmit",
"format": "npx @biomejs/biome format . --write",
"format:check": "npx @biomejs/biome format .",
"lint": "npx @biomejs/biome lint . && npx eslint .",
"lint:fix": "npx @biomejs/biome lint . --write && npx eslint . --fix",
"check": "npx @biomejs/biome check .",
"pre-commit": "npm run type-check && npm run format && npm run lint:fix && npm run lint"
}
}
```
**Tools used:**
- TypeScript: `tsc --noEmit` (type checking only, no output)
- Biome: Fast formatter + linter (Rust-based, 10-100x faster than Prettier)
- ESLint: React Hooks rules only (`rules-of-hooks` validation)
## Emergency Override
If you absolutely MUST commit with failing checks:
1. Document WHY in the commit message
2. Create a follow-up task to fix the issues
3. Only use for emergency hotfixes
## Verification
After following this regime, you should see:
```
✓ Type check passed (0 errors)
✓ Formatting applied
✓ Linting passed (0 errors, 0 warnings)
✓ Ready to commit
```
---
**This regime is non-negotiable. Every commit must pass these checks.**

View File

@@ -0,0 +1,19 @@
{
"permissions": {
"allow": [
"Bash(npm test:*)",
"Read(//Users/antialias/projects/**)",
"Bash(npm run lint:*)",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git push:*)",
"Bash(git pull:*)",
"Bash(git stash:*)",
"Bash(npm run format:*)",
"Bash(npm run pre-commit:*)",
"Bash(npm run type-check:*)"
],
"deny": [],
"ask": []
}
}

50
apps/web/.gitignore vendored Normal file
View File

@@ -0,0 +1,50 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# vitest
/.vitest
# storybook
storybook-static
# panda css
styled-system
# generated
src/generated/build-info.json
# biome
.biome

View File

@@ -1,36 +1,31 @@
import type { StorybookConfig } from '@storybook/nextjs';
import type { StorybookConfig } from '@storybook/nextjs'
import { join, dirname } from "path"
import { dirname, join } from 'path'
/**
* This function is used to resolve the absolute path of a package.
* It is needed in projects that use Yarn PnP or are set up within a monorepo.
*/
* This function is used to resolve the absolute path of a package.
* It is needed in projects that use Yarn PnP or are set up within a monorepo.
*/
function getAbsolutePath(value: string): any {
return dirname(require.resolve(join(value, 'package.json')))
}
const config: StorybookConfig = {
"stories": [
"../src/**/*.mdx",
"../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"
],
"addons": [
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
addons: [
getAbsolutePath('@storybook/addon-docs'),
getAbsolutePath('@storybook/addon-onboarding')
getAbsolutePath('@storybook/addon-onboarding'),
],
"framework": {
"name": getAbsolutePath('@storybook/nextjs'),
"options": {
"nextConfigPath": "../next.config.js"
}
framework: {
name: getAbsolutePath('@storybook/nextjs'),
options: {
nextConfigPath: '../next.config.js',
},
},
"staticDirs": [
"../public"
],
"typescript": {
"reactDocgen": "react-docgen-typescript"
staticDirs: ['../public'],
typescript: {
reactDocgen: 'react-docgen-typescript',
},
"webpackFinal": async (config) => {
webpackFinal: async (config) => {
// Handle PandaCSS styled-system imports
if (config.resolve) {
config.resolve.alias = {
@@ -39,10 +34,10 @@ const config: StorybookConfig = {
'../../styled-system/css': join(__dirname, '../styled-system/css/index.mjs'),
'../../styled-system/patterns': join(__dirname, '../styled-system/patterns/index.mjs'),
'../styled-system/css': join(__dirname, '../styled-system/css/index.mjs'),
'../styled-system/patterns': join(__dirname, '../styled-system/patterns/index.mjs')
'../styled-system/patterns': join(__dirname, '../styled-system/patterns/index.mjs'),
}
}
return config
}
};
export default config;
},
}
export default config

View File

@@ -5,11 +5,11 @@ const preview: Preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
};
}
export default preview;
export default preview

File diff suppressed because it is too large Load Diff

104
apps/web/LINTING.md Normal file
View File

@@ -0,0 +1,104 @@
# Linting & Formatting Setup
This project uses **Biome** for formatting and general linting, with **ESLint** handling React Hooks rules only.
## Tools
- **@biomejs/biome** - Fast formatter + linter + import organizer
- **eslint** + **eslint-plugin-react-hooks** - React Hooks validation only
## Scripts
```bash
# Check formatting and lint (non-destructive)
npm run check
# Lint all files
npm run lint
# Fix lint issues
npm run lint:fix
# Format all files
npm run format
# Check formatting (dry run)
npm run format:check
```
## Configuration Files
- `biome.jsonc` - Biome configuration (format + lint)
- `eslint.config.js` - Minimal ESLint flat config for React Hooks only
- `.gitignore` - Includes patterns for Biome cache
## What Each Tool Does
### Biome
- Code formatting (Prettier-compatible)
- General JavaScript/TypeScript linting
- Import organization (alphabetical, remove unused)
- Dead code detection
- Performance optimizations
### ESLint (React Hooks only)
- `react-hooks/rules-of-hooks` - Ensures hooks are called unconditionally
- `react-hooks/exhaustive-deps` - Warns about incomplete dependency arrays
## IDE Integration
### VS Code
Install the Biome extension:
```
code --install-extension biomejs.biome
```
Add to `.vscode/settings.json`:
```json
{
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true,
"[javascript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
}
}
```
## CI/CD
Add to your GitHub Actions workflow:
```yaml
- name: Lint
run: npm run lint
- name: Check formatting
run: npm run format:check
```
## Migration from ESLint + Prettier
This setup replaces most ESLint and Prettier functionality:
- ✅ Removed `eslint-config-next` inline config from `package.json`
- ✅ No `.eslintrc.js` or `.prettierrc` files needed
- ✅ ESLint now only runs React Hooks rules
- ✅ Biome handles all formatting and general linting
## Why This Setup?
1. **Speed** - Biome is 10-100x faster than ESLint + Prettier
2. **Simplicity** - Single tool for most concerns
3. **Accuracy** - ESLint still catches React-specific issues Biome can't yet handle
4. **Low Maintenance** - Minimal config overlap
## Customization
To add custom lint rules, edit:
- `biome.jsonc` for general rules
- `eslint.config.js` for React Hooks rules

View File

@@ -0,0 +1,344 @@
/**
* @vitest-environment node
*/
import { eq } from 'drizzle-orm'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { db, schema } from '../src/db'
/**
* API Abacus Settings E2E Tests
*
* These tests verify the abacus-settings API endpoints work correctly.
*/
describe('Abacus Settings API', () => {
let testUserId: string
let testGuestId: string
beforeEach(async () => {
// Create a test user with unique guest ID
testGuestId = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}`
const [user] = await db.insert(schema.users).values({ guestId: testGuestId }).returning()
testUserId = user.id
})
afterEach(async () => {
// Clean up: delete test user (cascade deletes settings)
await db.delete(schema.users).where(eq(schema.users.id, testUserId))
})
describe('GET /api/abacus-settings', () => {
it('creates settings with defaults if none exist', async () => {
const [settings] = await db
.insert(schema.abacusSettings)
.values({ userId: testUserId })
.returning()
expect(settings).toBeDefined()
expect(settings.colorScheme).toBe('place-value')
expect(settings.beadShape).toBe('diamond')
expect(settings.colorPalette).toBe('default')
expect(settings.hideInactiveBeads).toBe(false)
expect(settings.coloredNumerals).toBe(false)
expect(settings.scaleFactor).toBe(1.0)
expect(settings.showNumbers).toBe(true)
expect(settings.animated).toBe(true)
expect(settings.interactive).toBe(false)
expect(settings.gestures).toBe(false)
expect(settings.soundEnabled).toBe(true)
expect(settings.soundVolume).toBe(0.8)
})
it('returns existing settings', async () => {
// Create settings
await db.insert(schema.abacusSettings).values({
userId: testUserId,
colorScheme: 'monochrome',
beadShape: 'circle',
soundEnabled: false,
soundVolume: 0.5,
})
const settings = await db.query.abacusSettings.findFirst({
where: eq(schema.abacusSettings.userId, testUserId),
})
expect(settings).toBeDefined()
expect(settings?.colorScheme).toBe('monochrome')
expect(settings?.beadShape).toBe('circle')
expect(settings?.soundEnabled).toBe(false)
expect(settings?.soundVolume).toBe(0.5)
})
})
describe('PATCH /api/abacus-settings', () => {
it('creates new settings if none exist', async () => {
const [settings] = await db
.insert(schema.abacusSettings)
.values({
userId: testUserId,
soundEnabled: false,
})
.returning()
expect(settings).toBeDefined()
expect(settings.soundEnabled).toBe(false)
})
it('updates existing settings', async () => {
// Create initial settings
await db.insert(schema.abacusSettings).values({
userId: testUserId,
colorScheme: 'place-value',
beadShape: 'diamond',
})
// Update
const [updated] = await db
.update(schema.abacusSettings)
.set({
colorScheme: 'heaven-earth',
beadShape: 'square',
})
.where(eq(schema.abacusSettings.userId, testUserId))
.returning()
expect(updated.colorScheme).toBe('heaven-earth')
expect(updated.beadShape).toBe('square')
})
it('updates only provided fields', async () => {
// Create initial settings
await db.insert(schema.abacusSettings).values({
userId: testUserId,
colorScheme: 'place-value',
soundEnabled: true,
soundVolume: 0.8,
})
// Update only soundEnabled
const [updated] = await db
.update(schema.abacusSettings)
.set({ soundEnabled: false })
.where(eq(schema.abacusSettings.userId, testUserId))
.returning()
expect(updated.soundEnabled).toBe(false)
expect(updated.colorScheme).toBe('place-value') // unchanged
expect(updated.soundVolume).toBe(0.8) // unchanged
})
it('prevents setting invalid userId via foreign key constraint', async () => {
// Create initial settings
await db.insert(schema.abacusSettings).values({
userId: testUserId,
})
// Try to update with invalid userId - should fail
await expect(async () => {
await db
.update(schema.abacusSettings)
.set({
userId: 'HACKER_ID_INVALID',
soundEnabled: false,
})
.where(eq(schema.abacusSettings.userId, testUserId))
}).rejects.toThrow()
})
it('allows updating all display settings', async () => {
await db.insert(schema.abacusSettings).values({
userId: testUserId,
})
const [updated] = await db
.update(schema.abacusSettings)
.set({
colorScheme: 'alternating',
beadShape: 'circle',
colorPalette: 'colorblind',
hideInactiveBeads: true,
coloredNumerals: true,
scaleFactor: 1.5,
showNumbers: false,
animated: false,
interactive: true,
gestures: true,
soundEnabled: false,
soundVolume: 0.3,
})
.where(eq(schema.abacusSettings.userId, testUserId))
.returning()
expect(updated.colorScheme).toBe('alternating')
expect(updated.beadShape).toBe('circle')
expect(updated.colorPalette).toBe('colorblind')
expect(updated.hideInactiveBeads).toBe(true)
expect(updated.coloredNumerals).toBe(true)
expect(updated.scaleFactor).toBe(1.5)
expect(updated.showNumbers).toBe(false)
expect(updated.animated).toBe(false)
expect(updated.interactive).toBe(true)
expect(updated.gestures).toBe(true)
expect(updated.soundEnabled).toBe(false)
expect(updated.soundVolume).toBe(0.3)
})
})
describe('Cascade delete behavior', () => {
it('deletes settings when user is deleted', async () => {
// Create settings
await db.insert(schema.abacusSettings).values({
userId: testUserId,
soundEnabled: false,
})
// Verify settings exist
let settings = await db.query.abacusSettings.findFirst({
where: eq(schema.abacusSettings.userId, testUserId),
})
expect(settings).toBeDefined()
// Delete user
await db.delete(schema.users).where(eq(schema.users.id, testUserId))
// Verify settings are gone
settings = await db.query.abacusSettings.findFirst({
where: eq(schema.abacusSettings.userId, testUserId),
})
expect(settings).toBeUndefined()
})
})
describe('Data isolation', () => {
it('ensures settings are isolated per user', async () => {
// Create another user
const testGuestId2 = `test-guest-2-${Date.now()}-${Math.random().toString(36).slice(2)}`
const [user2] = await db.insert(schema.users).values({ guestId: testGuestId2 }).returning()
try {
// Create settings for both users
await db.insert(schema.abacusSettings).values({
userId: testUserId,
colorScheme: 'monochrome',
})
await db.insert(schema.abacusSettings).values({
userId: user2.id,
colorScheme: 'place-value',
})
// Verify isolation
const settings1 = await db.query.abacusSettings.findFirst({
where: eq(schema.abacusSettings.userId, testUserId),
})
const settings2 = await db.query.abacusSettings.findFirst({
where: eq(schema.abacusSettings.userId, user2.id),
})
expect(settings1?.colorScheme).toBe('monochrome')
expect(settings2?.colorScheme).toBe('place-value')
} finally {
// Clean up second user
await db.delete(schema.users).where(eq(schema.users.id, user2.id))
}
})
})
describe('Security: userId injection prevention', () => {
it('rejects attempts to update settings with non-existent userId', async () => {
// Create initial settings
await db.insert(schema.abacusSettings).values({
userId: testUserId,
soundEnabled: true,
})
// Attempt to inject a fake userId
await expect(async () => {
await db
.update(schema.abacusSettings)
.set({
userId: 'HACKER_ID_NON_EXISTENT',
soundEnabled: false,
})
.where(eq(schema.abacusSettings.userId, testUserId))
}).rejects.toThrow(/FOREIGN KEY constraint failed/)
})
it("prevents modifying another user's settings via userId injection", async () => {
// Create victim user
const victimGuestId = `victim-${Date.now()}-${Math.random().toString(36).slice(2)}`
const [victimUser] = await db
.insert(schema.users)
.values({ guestId: victimGuestId })
.returning()
try {
// Create settings for both users
await db.insert(schema.abacusSettings).values({
userId: testUserId,
colorScheme: 'monochrome',
soundEnabled: true,
})
await db.insert(schema.abacusSettings).values({
userId: victimUser.id,
colorScheme: 'place-value',
soundEnabled: true,
})
// Attacker tries to change userId to victim's ID
// This is rejected because userId is PRIMARY KEY (UNIQUE constraint)
await expect(async () => {
await db
.update(schema.abacusSettings)
.set({
userId: victimUser.id, // Trying to inject victim's ID
soundEnabled: false,
})
.where(eq(schema.abacusSettings.userId, testUserId))
}).rejects.toThrow(/UNIQUE constraint failed/)
// Verify victim's settings are unchanged
const victimSettings = await db.query.abacusSettings.findFirst({
where: eq(schema.abacusSettings.userId, victimUser.id),
})
expect(victimSettings?.soundEnabled).toBe(true)
expect(victimSettings?.colorScheme).toBe('place-value')
} finally {
await db.delete(schema.users).where(eq(schema.users.id, victimUser.id))
}
})
it('prevents creating settings for another user via userId injection', async () => {
// Create victim user
const victimGuestId = `victim-${Date.now()}-${Math.random().toString(36).slice(2)}`
const [victimUser] = await db
.insert(schema.users)
.values({ guestId: victimGuestId })
.returning()
try {
// Try to create settings for victim with attacker's data
// This will succeed because foreign key exists, but in the real API
// the userId comes from session, not request body
const [maliciousSettings] = await db
.insert(schema.abacusSettings)
.values({
userId: victimUser.id,
colorScheme: 'alternating', // Attacker's preference
})
.returning()
// This test shows that at the DB level, we CAN insert for any valid userId
// The security comes from the API layer filtering userId from request body
// and deriving it from the session cookie instead
expect(maliciousSettings.userId).toBe(victimUser.id)
expect(maliciousSettings.colorScheme).toBe('alternating')
} finally {
await db.delete(schema.users).where(eq(schema.users.id, victimUser.id))
}
})
})
})

View File

@@ -0,0 +1,455 @@
/**
* @vitest-environment node
*/
import { eq } from 'drizzle-orm'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { db, schema } from '../src/db'
import { createRoom } from '../src/lib/arcade/room-manager'
import { addRoomMember } from '../src/lib/arcade/room-membership'
/**
* Arcade Rooms API E2E Tests
*
* Tests the full arcade room system:
* - Room CRUD operations
* - Member management
* - Access control
* - Room code lookups
*/
describe('Arcade Rooms API', () => {
let testUserId1: string
let testUserId2: string
let testGuestId1: string
let testGuestId2: string
let testRoomId: string
beforeEach(async () => {
// Create test users
testGuestId1 = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}`
testGuestId2 = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}`
const [user1] = await db.insert(schema.users).values({ guestId: testGuestId1 }).returning()
const [user2] = await db.insert(schema.users).values({ guestId: testGuestId2 }).returning()
testUserId1 = user1.id
testUserId2 = user2.id
})
afterEach(async () => {
// Clean up rooms (cascade deletes members)
if (testRoomId) {
await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, testRoomId))
}
// Clean up users
await db.delete(schema.users).where(eq(schema.users.id, testUserId1))
await db.delete(schema.users).where(eq(schema.users.id, testUserId2))
})
describe('Room Creation', () => {
it('creates a room with valid data', async () => {
const room = await createRoom({
name: 'Test Room',
createdBy: testGuestId1,
creatorName: 'Test User',
gameName: 'matching',
gameConfig: { difficulty: 6 },
})
testRoomId = room.id
expect(room).toBeDefined()
expect(room.name).toBe('Test Room')
expect(room.createdBy).toBe(testGuestId1)
expect(room.gameName).toBe('matching')
expect(room.status).toBe('lobby')
expect(room.isLocked).toBe(false)
expect(room.ttlMinutes).toBe(60)
expect(room.code).toMatch(/^[A-Z0-9]{6}$/)
})
it('creates room with custom TTL', async () => {
const room = await createRoom({
name: 'Custom TTL Room',
createdBy: testGuestId1,
creatorName: 'Test User',
gameName: 'matching',
gameConfig: {},
ttlMinutes: 120,
})
testRoomId = room.id
expect(room.ttlMinutes).toBe(120)
})
it('generates unique room codes', async () => {
const room1 = await createRoom({
name: 'Room 1',
createdBy: testGuestId1,
creatorName: 'User 1',
gameName: 'matching',
gameConfig: {},
})
const room2 = await createRoom({
name: 'Room 2',
createdBy: testGuestId2,
creatorName: 'User 2',
gameName: 'matching',
gameConfig: {},
})
// Clean up both rooms
testRoomId = room1.id
await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, room2.id))
expect(room1.code).not.toBe(room2.code)
})
})
describe('Room Retrieval', () => {
beforeEach(async () => {
// Create a test room
const room = await createRoom({
name: 'Retrieval Test Room',
createdBy: testGuestId1,
creatorName: 'Test User',
gameName: 'matching',
gameConfig: {},
})
testRoomId = room.id
})
it('retrieves room by ID', async () => {
const room = await db.query.arcadeRooms.findFirst({
where: eq(schema.arcadeRooms.id, testRoomId),
})
expect(room).toBeDefined()
expect(room?.id).toBe(testRoomId)
expect(room?.name).toBe('Retrieval Test Room')
})
it('retrieves room by code', async () => {
const createdRoom = await db.query.arcadeRooms.findFirst({
where: eq(schema.arcadeRooms.id, testRoomId),
})
const room = await db.query.arcadeRooms.findFirst({
where: eq(schema.arcadeRooms.code, createdRoom!.code),
})
expect(room).toBeDefined()
expect(room?.id).toBe(testRoomId)
})
it('returns undefined for non-existent room', async () => {
const room = await db.query.arcadeRooms.findFirst({
where: eq(schema.arcadeRooms.id, 'nonexistent-room-id'),
})
expect(room).toBeUndefined()
})
})
describe('Room Updates', () => {
beforeEach(async () => {
const room = await createRoom({
name: 'Update Test Room',
createdBy: testGuestId1,
creatorName: 'Test User',
gameName: 'matching',
gameConfig: {},
})
testRoomId = room.id
})
it('updates room name', async () => {
const [updated] = await db
.update(schema.arcadeRooms)
.set({ name: 'Updated Name' })
.where(eq(schema.arcadeRooms.id, testRoomId))
.returning()
expect(updated.name).toBe('Updated Name')
})
it('locks room', async () => {
const [updated] = await db
.update(schema.arcadeRooms)
.set({ isLocked: true })
.where(eq(schema.arcadeRooms.id, testRoomId))
.returning()
expect(updated.isLocked).toBe(true)
})
it('updates room status', async () => {
const [updated] = await db
.update(schema.arcadeRooms)
.set({ status: 'playing' })
.where(eq(schema.arcadeRooms.id, testRoomId))
.returning()
expect(updated.status).toBe('playing')
})
it('updates lastActivity on any change', async () => {
const originalRoom = await db.query.arcadeRooms.findFirst({
where: eq(schema.arcadeRooms.id, testRoomId),
})
// Wait a bit to ensure different timestamp (at least 1 second for SQLite timestamp resolution)
await new Promise((resolve) => setTimeout(resolve, 1100))
const [updated] = await db
.update(schema.arcadeRooms)
.set({ name: 'Activity Test', lastActivity: new Date() })
.where(eq(schema.arcadeRooms.id, testRoomId))
.returning()
expect(updated.lastActivity.getTime()).toBeGreaterThan(originalRoom!.lastActivity.getTime())
})
})
describe('Room Deletion', () => {
it('deletes room', async () => {
const room = await createRoom({
name: 'Delete Test Room',
createdBy: testGuestId1,
creatorName: 'Test User',
gameName: 'matching',
gameConfig: {},
})
await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, room.id))
const deleted = await db.query.arcadeRooms.findFirst({
where: eq(schema.arcadeRooms.id, room.id),
})
expect(deleted).toBeUndefined()
})
it('cascades delete to room members', async () => {
const room = await createRoom({
name: 'Cascade Test Room',
createdBy: testGuestId1,
creatorName: 'Test User',
gameName: 'matching',
gameConfig: {},
})
// Add member
await addRoomMember({
roomId: room.id,
userId: testGuestId1,
displayName: 'Test User',
})
// Verify member exists
const membersBefore = await db.query.roomMembers.findMany({
where: eq(schema.roomMembers.roomId, room.id),
})
expect(membersBefore).toHaveLength(1)
// Delete room
await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, room.id))
// Verify members deleted
const membersAfter = await db.query.roomMembers.findMany({
where: eq(schema.roomMembers.roomId, room.id),
})
expect(membersAfter).toHaveLength(0)
})
})
describe('Room Members', () => {
beforeEach(async () => {
const room = await createRoom({
name: 'Members Test Room',
createdBy: testGuestId1,
creatorName: 'Test User 1',
gameName: 'matching',
gameConfig: {},
})
testRoomId = room.id
})
it('adds member to room', async () => {
const result = await addRoomMember({
roomId: testRoomId,
userId: testGuestId1,
displayName: 'Test User 1',
isCreator: true,
})
expect(result.member).toBeDefined()
expect(result.member.roomId).toBe(testRoomId)
expect(result.member.userId).toBe(testGuestId1)
expect(result.member.displayName).toBe('Test User 1')
expect(result.member.isCreator).toBe(true)
expect(result.member.isOnline).toBe(true)
})
it('adds multiple members to room', async () => {
await addRoomMember({
roomId: testRoomId,
userId: testGuestId1,
displayName: 'User 1',
})
await addRoomMember({
roomId: testRoomId,
userId: testGuestId2,
displayName: 'User 2',
})
const members = await db.query.roomMembers.findMany({
where: eq(schema.roomMembers.roomId, testRoomId),
})
expect(members).toHaveLength(2)
})
it('updates existing member instead of creating duplicate', async () => {
// Add member first time
await addRoomMember({
roomId: testRoomId,
userId: testGuestId1,
displayName: 'First Time',
})
// Add same member again
await addRoomMember({
roomId: testRoomId,
userId: testGuestId1,
displayName: 'Second Time',
})
const members = await db.query.roomMembers.findMany({
where: eq(schema.roomMembers.roomId, testRoomId),
})
// Should still only have 1 member
expect(members).toHaveLength(1)
})
it('removes member from room', async () => {
const result = await addRoomMember({
roomId: testRoomId,
userId: testGuestId1,
displayName: 'Test User',
})
await db.delete(schema.roomMembers).where(eq(schema.roomMembers.id, result.member.id))
const members = await db.query.roomMembers.findMany({
where: eq(schema.roomMembers.roomId, testRoomId),
})
expect(members).toHaveLength(0)
})
it('tracks online status', async () => {
const result = await addRoomMember({
roomId: testRoomId,
userId: testGuestId1,
displayName: 'Test User',
})
expect(result.member.isOnline).toBe(true)
// Set offline
const [updated] = await db
.update(schema.roomMembers)
.set({ isOnline: false })
.where(eq(schema.roomMembers.id, result.member.id))
.returning()
expect(updated.isOnline).toBe(false)
})
})
describe('Access Control', () => {
beforeEach(async () => {
const room = await createRoom({
name: 'Access Test Room',
createdBy: testGuestId1,
creatorName: 'Creator',
gameName: 'matching',
gameConfig: {},
})
testRoomId = room.id
})
it('identifies room creator correctly', async () => {
const room = await db.query.arcadeRooms.findFirst({
where: eq(schema.arcadeRooms.id, testRoomId),
})
expect(room?.createdBy).toBe(testGuestId1)
})
it('distinguishes creator from other users', async () => {
const room = await db.query.arcadeRooms.findFirst({
where: eq(schema.arcadeRooms.id, testRoomId),
})
expect(room?.createdBy).not.toBe(testGuestId2)
})
})
describe('Room Listing', () => {
beforeEach(async () => {
// Create multiple test rooms
const room1 = await createRoom({
name: 'Matching Room',
createdBy: testGuestId1,
creatorName: 'User 1',
gameName: 'matching',
gameConfig: {},
})
const room2 = await createRoom({
name: 'Memory Quiz Room',
createdBy: testGuestId2,
creatorName: 'User 2',
gameName: 'memory-quiz',
gameConfig: {},
})
testRoomId = room1.id
// Clean up room2 after test
afterEach(async () => {
await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, room2.id))
})
})
it('lists all active rooms', async () => {
const rooms = await db.query.arcadeRooms.findMany({
where: eq(schema.arcadeRooms.status, 'lobby'),
})
expect(rooms.length).toBeGreaterThanOrEqual(2)
})
it('excludes locked rooms from listing', async () => {
// Lock one room
await db
.update(schema.arcadeRooms)
.set({ isLocked: true })
.where(eq(schema.arcadeRooms.id, testRoomId))
const unlockedRooms = await db.query.arcadeRooms.findMany({
where: eq(schema.arcadeRooms.isLocked, false),
})
expect(unlockedRooms.every((r) => !r.isLocked)).toBe(true)
})
})
})

View File

@@ -0,0 +1,500 @@
/**
* @vitest-environment node
*/
import { eq } from 'drizzle-orm'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { db, schema } from '../src/db'
/**
* API Players E2E Tests
*
* These tests verify the players API endpoints work correctly.
* They use the actual database and test the full request/response cycle.
*/
describe('Players API', () => {
let testUserId: string
let testGuestId: string
beforeEach(async () => {
// Create a test user with unique guest ID
testGuestId = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}`
const [user] = await db.insert(schema.users).values({ guestId: testGuestId }).returning()
testUserId = user.id
})
afterEach(async () => {
// Clean up: delete test user (cascade deletes players)
await db.delete(schema.users).where(eq(schema.users.id, testUserId))
})
describe('POST /api/players', () => {
it('creates a player with valid data', async () => {
const playerData = {
name: 'Test Player',
emoji: '😀',
color: '#3b82f6',
isActive: true,
}
// Simulate creating via DB (API would do this)
const [player] = await db
.insert(schema.players)
.values({
userId: testUserId,
...playerData,
})
.returning()
expect(player).toBeDefined()
expect(player.name).toBe(playerData.name)
expect(player.emoji).toBe(playerData.emoji)
expect(player.color).toBe(playerData.color)
expect(player.isActive).toBe(true)
expect(player.userId).toBe(testUserId)
})
it('sets isActive to false by default', async () => {
const [player] = await db
.insert(schema.players)
.values({
userId: testUserId,
name: 'Inactive Player',
emoji: '😴',
color: '#999999',
})
.returning()
expect(player.isActive).toBe(false)
})
})
describe('GET /api/players', () => {
it('returns all players for a user', async () => {
// Create multiple players
await db.insert(schema.players).values([
{
userId: testUserId,
name: 'Player 1',
emoji: '😀',
color: '#3b82f6',
},
{
userId: testUserId,
name: 'Player 2',
emoji: '😎',
color: '#8b5cf6',
},
])
const players = await db.query.players.findMany({
where: eq(schema.players.userId, testUserId),
})
expect(players).toHaveLength(2)
expect(players[0].name).toBe('Player 1')
expect(players[1].name).toBe('Player 2')
})
it('returns empty array for user with no players', async () => {
const players = await db.query.players.findMany({
where: eq(schema.players.userId, testUserId),
})
expect(players).toHaveLength(0)
})
})
describe('PATCH /api/players/[id]', () => {
it('updates player fields', async () => {
const [player] = await db
.insert(schema.players)
.values({
userId: testUserId,
name: 'Original Name',
emoji: '😀',
color: '#3b82f6',
})
.returning()
const [updated] = await db
.update(schema.players)
.set({
name: 'Updated Name',
emoji: '🎉',
})
.where(eq(schema.players.id, player.id))
.returning()
expect(updated.name).toBe('Updated Name')
expect(updated.emoji).toBe('🎉')
expect(updated.color).toBe('#3b82f6') // unchanged
})
it('toggles isActive status', async () => {
const [player] = await db
.insert(schema.players)
.values({
userId: testUserId,
name: 'Test Player',
emoji: '😀',
color: '#3b82f6',
isActive: false,
})
.returning()
const [updated] = await db
.update(schema.players)
.set({ isActive: true })
.where(eq(schema.players.id, player.id))
.returning()
expect(updated.isActive).toBe(true)
})
})
describe('DELETE /api/players/[id]', () => {
it('deletes a player', async () => {
const [player] = await db
.insert(schema.players)
.values({
userId: testUserId,
name: 'To Delete',
emoji: '👋',
color: '#ef4444',
})
.returning()
const [deleted] = await db
.delete(schema.players)
.where(eq(schema.players.id, player.id))
.returning()
expect(deleted).toBeDefined()
expect(deleted.id).toBe(player.id)
// Verify it's gone
const found = await db.query.players.findFirst({
where: eq(schema.players.id, player.id),
})
expect(found).toBeUndefined()
})
})
describe('Cascade delete behavior', () => {
it('deletes players when user is deleted', async () => {
// Create players
await db.insert(schema.players).values([
{
userId: testUserId,
name: 'Player 1',
emoji: '😀',
color: '#3b82f6',
},
{
userId: testUserId,
name: 'Player 2',
emoji: '😎',
color: '#8b5cf6',
},
])
// Verify players exist
let players = await db.query.players.findMany({
where: eq(schema.players.userId, testUserId),
})
expect(players).toHaveLength(2)
// Delete user
await db.delete(schema.users).where(eq(schema.users.id, testUserId))
// Verify players are gone
players = await db.query.players.findMany({
where: eq(schema.players.userId, testUserId),
})
expect(players).toHaveLength(0)
})
})
describe('Arcade Session: isActive Modification Restrictions', () => {
it('prevents isActive changes when user has an active arcade session', async () => {
// Create a player
const [player] = await db
.insert(schema.players)
.values({
userId: testUserId,
name: 'Test Player',
emoji: '😀',
color: '#3b82f6',
isActive: false,
})
.returning()
// Create an active arcade session
const now = new Date()
await db.insert(schema.arcadeSessions).values({
userId: testGuestId,
currentGame: 'matching',
gameUrl: '/arcade/matching',
gameState: JSON.stringify({}),
activePlayers: JSON.stringify([player.id]),
startedAt: now,
lastActivityAt: now,
expiresAt: new Date(now.getTime() + 3600000), // 1 hour from now
version: 1,
})
// Attempt to update isActive should be prevented at API level
// This test validates the logic that the API route implements
const activeSession = await db.query.arcadeSessions.findFirst({
where: eq(schema.arcadeSessions.userId, testGuestId),
})
expect(activeSession).toBeDefined()
expect(activeSession?.currentGame).toBe('matching')
// Clean up session
await db.delete(schema.arcadeSessions).where(eq(schema.arcadeSessions.userId, testGuestId))
})
it('allows isActive changes when user has no active arcade session', async () => {
// Create a player
const [player] = await db
.insert(schema.players)
.values({
userId: testUserId,
name: 'Test Player',
emoji: '😀',
color: '#3b82f6',
isActive: false,
})
.returning()
// Verify no active session
const activeSession = await db.query.arcadeSessions.findFirst({
where: eq(schema.arcadeSessions.userId, testGuestId),
})
expect(activeSession).toBeUndefined()
// Should be able to update isActive
const [updated] = await db
.update(schema.players)
.set({ isActive: true })
.where(eq(schema.players.id, player.id))
.returning()
expect(updated.isActive).toBe(true)
})
it('allows non-isActive changes even with active session', async () => {
// Create a player
const [player] = await db
.insert(schema.players)
.values({
userId: testUserId,
name: 'Test Player',
emoji: '😀',
color: '#3b82f6',
isActive: true,
})
.returning()
// Create an active arcade session
const now = new Date()
await db.insert(schema.arcadeSessions).values({
userId: testGuestId,
currentGame: 'matching',
gameUrl: '/arcade/matching',
gameState: JSON.stringify({}),
activePlayers: JSON.stringify([player.id]),
startedAt: now,
lastActivityAt: now,
expiresAt: new Date(now.getTime() + 3600000), // 1 hour from now
version: 1,
})
try {
// Should be able to update name, emoji, color (non-isActive fields)
const [updated] = await db
.update(schema.players)
.set({
name: 'Updated Name',
emoji: '🎉',
color: '#ff0000',
})
.where(eq(schema.players.id, player.id))
.returning()
expect(updated.name).toBe('Updated Name')
expect(updated.emoji).toBe('🎉')
expect(updated.color).toBe('#ff0000')
expect(updated.isActive).toBe(true) // Unchanged
} finally {
// Clean up session
await db.delete(schema.arcadeSessions).where(eq(schema.arcadeSessions.userId, testGuestId))
}
})
it('session ends, then isActive changes are allowed again', async () => {
// Create a player
const [player] = await db
.insert(schema.players)
.values({
userId: testUserId,
name: 'Test Player',
emoji: '😀',
color: '#3b82f6',
isActive: true,
})
.returning()
// Create an active arcade session
const now = new Date()
await db.insert(schema.arcadeSessions).values({
userId: testGuestId,
currentGame: 'matching',
gameUrl: '/arcade/matching',
gameState: JSON.stringify({}),
activePlayers: JSON.stringify([player.id]),
startedAt: now,
lastActivityAt: now,
expiresAt: new Date(now.getTime() + 3600000), // 1 hour from now
version: 1,
})
// Verify session exists
let activeSession = await db.query.arcadeSessions.findFirst({
where: eq(schema.arcadeSessions.userId, testGuestId),
})
expect(activeSession).toBeDefined()
// End the session
await db.delete(schema.arcadeSessions).where(eq(schema.arcadeSessions.userId, testGuestId))
// Verify session is gone
activeSession = await db.query.arcadeSessions.findFirst({
where: eq(schema.arcadeSessions.userId, testGuestId),
})
expect(activeSession).toBeUndefined()
// Now should be able to update isActive
const [updated] = await db
.update(schema.players)
.set({ isActive: false })
.where(eq(schema.players.id, player.id))
.returning()
expect(updated.isActive).toBe(false)
})
})
describe('Security: userId injection prevention', () => {
it('rejects creating player with non-existent userId', async () => {
// Attempt to create a player with a fake userId
await expect(async () => {
await db.insert(schema.players).values({
userId: 'HACKER_ID_NON_EXISTENT',
name: 'Hacker Player',
emoji: '🦹',
color: '#ff0000',
})
}).rejects.toThrow(/FOREIGN KEY constraint failed/)
})
it("prevents modifying another user's player via userId injection (DB layer alone is insufficient)", async () => {
// Create victim user and their player
const victimGuestId = `victim-${Date.now()}-${Math.random().toString(36).slice(2)}`
const [victimUser] = await db
.insert(schema.users)
.values({ guestId: victimGuestId })
.returning()
try {
// Create attacker's player
const [attackerPlayer] = await db
.insert(schema.players)
.values({
userId: testUserId,
name: 'Attacker Player',
emoji: '😈',
color: '#ff0000',
})
.returning()
const [_victimPlayer] = await db
.insert(schema.players)
.values({
userId: victimUser.id,
name: 'Victim Player',
emoji: '👤',
color: '#00ff00',
isActive: true,
})
.returning()
// IMPORTANT: At the DB level, changing userId to another valid userId SUCCEEDS
// This is why API layer MUST filter userId from request body!
const [updated] = await db
.update(schema.players)
.set({
userId: victimUser.id, // This WILL succeed at DB level!
name: 'Stolen Player',
})
.where(eq(schema.players.id, attackerPlayer.id))
.returning()
// The update succeeded - the player now belongs to victim!
expect(updated.userId).toBe(victimUser.id)
expect(updated.name).toBe('Stolen Player')
// This test demonstrates why the API route MUST:
// 1. Strip userId from request body
// 2. Derive userId from session cookie
// 3. Use WHERE clause to scope updates to current user's data only
} finally {
await db.delete(schema.users).where(eq(schema.users.id, victimUser.id))
}
})
it('ensures players are isolated per user', async () => {
// Create another user
const user2GuestId = `user2-${Date.now()}-${Math.random().toString(36).slice(2)}`
const [user2] = await db.insert(schema.users).values({ guestId: user2GuestId }).returning()
try {
// Create players for both users
await db.insert(schema.players).values({
userId: testUserId,
name: 'User 1 Player',
emoji: '🎮',
color: '#0000ff',
})
await db.insert(schema.players).values({
userId: user2.id,
name: 'User 2 Player',
emoji: '🎯',
color: '#ff00ff',
})
// Verify each user only sees their own players
const user1Players = await db.query.players.findMany({
where: eq(schema.players.userId, testUserId),
})
const user2Players = await db.query.players.findMany({
where: eq(schema.players.userId, user2.id),
})
expect(user1Players).toHaveLength(1)
expect(user1Players[0].name).toBe('User 1 Player')
expect(user2Players).toHaveLength(1)
expect(user2Players[0].name).toBe('User 2 Player')
} finally {
await db.delete(schema.users).where(eq(schema.users.id, user2.id))
}
})
})
})

View File

@@ -0,0 +1,186 @@
/**
* @vitest-environment node
*/
import { eq } from 'drizzle-orm'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { db, schema } from '../src/db'
/**
* API User Stats E2E Tests
*
* These tests verify the user-stats API endpoints work correctly.
*/
describe('User Stats API', () => {
let testUserId: string
let testGuestId: string
beforeEach(async () => {
// Create a test user with unique guest ID
testGuestId = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}`
const [user] = await db.insert(schema.users).values({ guestId: testGuestId }).returning()
testUserId = user.id
})
afterEach(async () => {
// Clean up: delete test user (cascade deletes stats)
await db.delete(schema.users).where(eq(schema.users.id, testUserId))
})
describe('GET /api/user-stats', () => {
it('creates stats with defaults if none exist', async () => {
const [stats] = await db.insert(schema.userStats).values({ userId: testUserId }).returning()
expect(stats).toBeDefined()
expect(stats.gamesPlayed).toBe(0)
expect(stats.totalWins).toBe(0)
expect(stats.favoriteGameType).toBeNull()
expect(stats.bestTime).toBeNull()
expect(stats.highestAccuracy).toBe(0)
})
it('returns existing stats', async () => {
// Create stats
await db.insert(schema.userStats).values({
userId: testUserId,
gamesPlayed: 10,
totalWins: 7,
favoriteGameType: 'abacus-numeral',
bestTime: 5000,
highestAccuracy: 0.95,
})
const stats = await db.query.userStats.findFirst({
where: eq(schema.userStats.userId, testUserId),
})
expect(stats).toBeDefined()
expect(stats?.gamesPlayed).toBe(10)
expect(stats?.totalWins).toBe(7)
expect(stats?.favoriteGameType).toBe('abacus-numeral')
expect(stats?.bestTime).toBe(5000)
expect(stats?.highestAccuracy).toBe(0.95)
})
})
describe('PATCH /api/user-stats', () => {
it('creates new stats if none exist', async () => {
const [stats] = await db
.insert(schema.userStats)
.values({
userId: testUserId,
gamesPlayed: 1,
totalWins: 1,
})
.returning()
expect(stats).toBeDefined()
expect(stats.gamesPlayed).toBe(1)
expect(stats.totalWins).toBe(1)
})
it('updates existing stats', async () => {
// Create initial stats
await db.insert(schema.userStats).values({
userId: testUserId,
gamesPlayed: 5,
totalWins: 3,
})
// Update
const [updated] = await db
.update(schema.userStats)
.set({
gamesPlayed: 6,
totalWins: 4,
favoriteGameType: 'complement-pairs',
})
.where(eq(schema.userStats.userId, testUserId))
.returning()
expect(updated.gamesPlayed).toBe(6)
expect(updated.totalWins).toBe(4)
expect(updated.favoriteGameType).toBe('complement-pairs')
})
it('updates only provided fields', async () => {
// Create initial stats
await db.insert(schema.userStats).values({
userId: testUserId,
gamesPlayed: 10,
totalWins: 5,
bestTime: 3000,
})
// Update only gamesPlayed
const [updated] = await db
.update(schema.userStats)
.set({ gamesPlayed: 11 })
.where(eq(schema.userStats.userId, testUserId))
.returning()
expect(updated.gamesPlayed).toBe(11)
expect(updated.totalWins).toBe(5) // unchanged
expect(updated.bestTime).toBe(3000) // unchanged
})
it('allows setting favoriteGameType', async () => {
await db.insert(schema.userStats).values({
userId: testUserId,
})
const [updated] = await db
.update(schema.userStats)
.set({ favoriteGameType: 'abacus-numeral' })
.where(eq(schema.userStats.userId, testUserId))
.returning()
expect(updated.favoriteGameType).toBe('abacus-numeral')
})
it('allows setting bestTime and highestAccuracy', async () => {
await db.insert(schema.userStats).values({
userId: testUserId,
})
const [updated] = await db
.update(schema.userStats)
.set({
bestTime: 2500,
highestAccuracy: 0.98,
})
.where(eq(schema.userStats.userId, testUserId))
.returning()
expect(updated.bestTime).toBe(2500)
expect(updated.highestAccuracy).toBe(0.98)
})
})
describe('Cascade delete behavior', () => {
it('deletes stats when user is deleted', async () => {
// Create stats
await db.insert(schema.userStats).values({
userId: testUserId,
gamesPlayed: 10,
totalWins: 5,
})
// Verify stats exist
let stats = await db.query.userStats.findFirst({
where: eq(schema.userStats.userId, testUserId),
})
expect(stats).toBeDefined()
// Delete user
await db.delete(schema.users).where(eq(schema.users.id, testUserId))
// Verify stats are gone
stats = await db.query.userStats.findFirst({
where: eq(schema.userStats.userId, testUserId),
})
expect(stats).toBeUndefined()
})
})
})

View File

@@ -0,0 +1,136 @@
/**
* @vitest-environment node
*/
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it } from 'vitest'
import { GUEST_COOKIE_NAME, verifyGuestToken } from '../src/lib/guest-token'
import { middleware } from '../src/middleware'
describe('Middleware E2E', () => {
beforeEach(() => {
process.env.AUTH_SECRET = 'test-secret-for-middleware'
})
it('sets guest cookie on first request', async () => {
const req = new NextRequest('http://localhost:3000/')
const res = await middleware(req)
const cookie = res.cookies.get(GUEST_COOKIE_NAME)
expect(cookie).toBeDefined()
expect(cookie?.value).toBeDefined()
expect(cookie?.httpOnly).toBe(true)
expect(cookie?.sameSite).toBe('lax')
expect(cookie?.path).toBe('/')
})
it('creates valid guest token', async () => {
const req = new NextRequest('http://localhost:3000/')
const res = await middleware(req)
const cookie = res.cookies.get(GUEST_COOKIE_NAME)
expect(cookie).toBeDefined()
// Verify the token is valid
const verified = await verifyGuestToken(cookie!.value)
expect(verified.sid).toBeDefined()
expect(typeof verified.sid).toBe('string')
})
it('preserves existing guest cookie', async () => {
// First request - creates cookie
const req1 = new NextRequest('http://localhost:3000/')
const res1 = await middleware(req1)
const cookie1 = res1.cookies.get(GUEST_COOKIE_NAME)
// Second request - with existing cookie
const req2 = new NextRequest('http://localhost:3000/')
req2.cookies.set(GUEST_COOKIE_NAME, cookie1!.value)
const res2 = await middleware(req2)
const cookie2 = res2.cookies.get(GUEST_COOKIE_NAME)
// Cookie should not be set again (preserves existing)
expect(cookie2).toBeUndefined()
})
it('sets different guest IDs for different visitors', async () => {
const req1 = new NextRequest('http://localhost:3000/')
const req2 = new NextRequest('http://localhost:3000/')
const res1 = await middleware(req1)
const res2 = await middleware(req2)
const cookie1 = res1.cookies.get(GUEST_COOKIE_NAME)
const cookie2 = res2.cookies.get(GUEST_COOKIE_NAME)
const verified1 = await verifyGuestToken(cookie1!.value)
const verified2 = await verifyGuestToken(cookie2!.value)
// Different visitors get different guest IDs
expect(verified1.sid).not.toBe(verified2.sid)
})
it('sets secure flag in production', async () => {
const originalEnv = process.env.NODE_ENV
Object.defineProperty(process.env, 'NODE_ENV', {
value: 'production',
configurable: true,
})
const req = new NextRequest('http://localhost:3000/')
const res = await middleware(req)
const cookie = res.cookies.get(GUEST_COOKIE_NAME)
expect(cookie?.secure).toBe(true)
Object.defineProperty(process.env, 'NODE_ENV', {
value: originalEnv,
configurable: true,
})
})
it('does not set secure flag in development', async () => {
const originalEnv = process.env.NODE_ENV
Object.defineProperty(process.env, 'NODE_ENV', {
value: 'development',
configurable: true,
})
const req = new NextRequest('http://localhost:3000/')
const res = await middleware(req)
const cookie = res.cookies.get(GUEST_COOKIE_NAME)
expect(cookie?.secure).toBe(false)
Object.defineProperty(process.env, 'NODE_ENV', {
value: originalEnv,
configurable: true,
})
})
it('sets maxAge correctly', async () => {
const req = new NextRequest('http://localhost:3000/')
const res = await middleware(req)
const cookie = res.cookies.get(GUEST_COOKIE_NAME)
expect(cookie?.maxAge).toBe(60 * 60 * 24 * 30) // 30 days
})
it('runs on valid paths', async () => {
const paths = [
'http://localhost:3000/',
'http://localhost:3000/games',
'http://localhost:3000/tutorial-editor',
'http://localhost:3000/some/deep/path',
]
for (const path of paths) {
const req = new NextRequest(path)
const res = await middleware(req)
const cookie = res.cookies.get(GUEST_COOKIE_NAME)
expect(cookie).toBeDefined()
}
})
})

View File

@@ -0,0 +1,203 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { eq } from 'drizzle-orm'
import { db, schema } from '../src/db'
import { createArcadeSession, getArcadeSession } from '../src/lib/arcade/session-manager'
import { cleanupExpiredRooms, createRoom } from '../src/lib/arcade/room-manager'
/**
* E2E Test: Orphaned Session After Room TTL Deletion
*
* This test simulates the exact scenario reported by the user:
* 1. User creates a game session in a room
* 2. Room expires via TTL cleanup
* 3. User navigates to /arcade
* 4. System should NOT redirect to the orphaned game
* 5. User should see the arcade lobby normally
*/
describe('E2E: Orphaned Session Cleanup on Navigation', () => {
const testUserId = 'e2e-user-id'
const testGuestId = 'e2e-guest-id'
let testRoomId: string
beforeEach(async () => {
// Create test user (simulating new or returning visitor)
await db
.insert(schema.users)
.values({
id: testUserId,
guestId: testGuestId,
createdAt: new Date(),
})
.onConflictDoNothing()
})
afterEach(async () => {
// Clean up test data
await db.delete(schema.arcadeSessions).where(eq(schema.arcadeSessions.userId, testUserId))
await db.delete(schema.users).where(eq(schema.users.id, testUserId))
if (testRoomId) {
try {
await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, testRoomId))
} catch {
// Room may already be deleted
}
}
})
it('should not redirect user to orphaned game after room TTL cleanup', async () => {
// === SETUP PHASE ===
// User creates or joins a room
const room = await createRoom({
name: 'My Game Room',
createdBy: testGuestId,
creatorName: 'Test Player',
gameName: 'matching',
gameConfig: { difficulty: 6, gameType: 'abacus-numeral', turnTimer: 30 },
ttlMinutes: 1, // Short TTL for testing
})
testRoomId = room.id
// User starts a game session
const session = await createArcadeSession({
userId: testGuestId,
gameName: 'matching',
gameUrl: '/arcade/matching',
initialState: {
gamePhase: 'playing',
cards: [],
gameCards: [],
flippedCards: [],
matchedPairs: 0,
totalPairs: 6,
currentPlayer: 'player-1',
difficulty: 6,
gameType: 'abacus-numeral',
turnTimer: 30,
},
activePlayers: ['player-1'],
roomId: room.id,
})
// Verify session was created
expect(session).toBeDefined()
expect(session.roomId).toBe(room.id)
// === TTL EXPIRATION PHASE ===
// Simulate time passing - room's TTL expires
// Set lastActivity to past so cleanup detects it
await db
.update(schema.arcadeRooms)
.set({
lastActivity: new Date(Date.now() - 2 * 60 * 1000), // 2 minutes ago
})
.where(eq(schema.arcadeRooms.id, room.id))
// Run cleanup (simulating background cleanup job)
const deletedCount = await cleanupExpiredRooms()
expect(deletedCount).toBeGreaterThan(0) // Room should be deleted
// === USER NAVIGATION PHASE ===
// User navigates to /arcade (arcade lobby)
// The useArcadeRedirect hook calls getArcadeSession to check for active session
const activeSession = await getArcadeSession(testGuestId)
// === ASSERTION PHASE ===
// Expected behavior: NO active session returned
// This prevents redirect to /arcade/matching which would be broken
expect(activeSession).toBeUndefined()
// Verify the orphaned session was cleaned up from database
const [orphanedSessionCheck] = await db
.select()
.from(schema.arcadeSessions)
.where(eq(schema.arcadeSessions.userId, testUserId))
.limit(1)
expect(orphanedSessionCheck).toBeUndefined()
})
it('should allow user to start new game after orphaned session cleanup', async () => {
// === SETUP: Create and orphan a session ===
const oldRoom = await createRoom({
name: 'Old Room',
createdBy: testGuestId,
creatorName: 'Test Player',
gameName: 'matching',
gameConfig: { difficulty: 6 },
ttlMinutes: 1,
})
await createArcadeSession({
userId: testGuestId,
gameName: 'matching',
gameUrl: '/arcade/matching',
initialState: { gamePhase: 'setup' },
activePlayers: ['player-1'],
roomId: oldRoom.id,
})
// Delete room (TTL cleanup)
await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, oldRoom.id))
// === ACTION: User tries to access arcade ===
const orphanedSession = await getArcadeSession(testGuestId)
expect(orphanedSession).toBeUndefined() // Orphan cleaned up
// === ACTION: User creates new room and session ===
const newRoom = await createRoom({
name: 'New Room',
createdBy: testGuestId,
creatorName: 'Test Player',
gameName: 'matching',
gameConfig: { difficulty: 8 },
ttlMinutes: 60,
})
testRoomId = newRoom.id
const newSession = await createArcadeSession({
userId: testGuestId,
gameName: 'matching',
gameUrl: '/arcade/matching',
initialState: { gamePhase: 'setup' },
activePlayers: ['player-1', 'player-2'],
roomId: newRoom.id,
})
// === ASSERTION: New session works correctly ===
expect(newSession).toBeDefined()
expect(newSession.roomId).toBe(newRoom.id)
const activeSession = await getArcadeSession(testGuestId)
expect(activeSession).toBeDefined()
expect(activeSession?.roomId).toBe(newRoom.id)
})
it('should handle race condition: getArcadeSession called while room is being deleted', async () => {
// Create room and session
const room = await createRoom({
name: 'Race Condition Room',
createdBy: testGuestId,
creatorName: 'Test Player',
gameName: 'matching',
gameConfig: { difficulty: 6 },
ttlMinutes: 60,
})
testRoomId = room.id
await createArcadeSession({
userId: testGuestId,
gameName: 'matching',
gameUrl: '/arcade/matching',
initialState: { gamePhase: 'setup' },
activePlayers: ['player-1'],
roomId: room.id,
})
// Simulate race: delete room while getArcadeSession is checking
await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, room.id))
// Should gracefully handle and return undefined
const result = await getArcadeSession(testGuestId)
expect(result).toBeUndefined()
})
})

View File

@@ -0,0 +1,371 @@
/**
* @vitest-environment node
*/
import { createServer } from 'http'
import { eq } from 'drizzle-orm'
import { io as ioClient, type Socket } from 'socket.io-client'
import { afterEach, beforeEach, describe, expect, it, afterAll, beforeAll } from 'vitest'
import { db, schema } from '../src/db'
import { createRoom } from '../src/lib/arcade/room-manager'
import { addRoomMember } from '../src/lib/arcade/room-membership'
import { initializeSocketServer } from '../socket-server'
import type { Server as SocketIOServerType } from 'socket.io'
/**
* Real-time Room Updates E2E Tests
*
* Tests that socket broadcasts work correctly when users join/leave rooms.
* Simulates multiple connected users and verifies they receive real-time updates.
*/
describe('Room Real-time Updates', () => {
let testUserId1: string
let testUserId2: string
let testGuestId1: string
let testGuestId2: string
let testRoomId: string
let socket1: Socket
let httpServer: any
let io: SocketIOServerType
let serverPort: number
beforeAll(async () => {
// Create HTTP server and initialize Socket.IO for testing
httpServer = createServer()
io = initializeSocketServer(httpServer)
// Find an available port
await new Promise<void>((resolve) => {
httpServer.listen(0, () => {
serverPort = (httpServer.address() as any).port
console.log(`Test socket server listening on port ${serverPort}`)
resolve()
})
})
})
afterAll(async () => {
// Close all socket connections
if (io) {
io.close()
}
if (httpServer) {
await new Promise<void>((resolve) => {
httpServer.close(() => resolve())
})
}
})
beforeEach(async () => {
// Create test users
testGuestId1 = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}`
testGuestId2 = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}`
const [user1] = await db.insert(schema.users).values({ guestId: testGuestId1 }).returning()
const [user2] = await db.insert(schema.users).values({ guestId: testGuestId2 }).returning()
testUserId1 = user1.id
testUserId2 = user2.id
// Create a test room
const room = await createRoom({
name: 'Realtime Test Room',
createdBy: testGuestId1,
creatorName: 'User 1',
gameName: 'matching',
gameConfig: { difficulty: 6 },
ttlMinutes: 60,
})
testRoomId = room.id
})
afterEach(async () => {
// Disconnect sockets
if (socket1?.connected) {
socket1.disconnect()
}
// Clean up room members
await db.delete(schema.roomMembers).where(eq(schema.roomMembers.roomId, testRoomId))
// Clean up rooms
if (testRoomId) {
await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, testRoomId))
}
// Clean up users
await db.delete(schema.users).where(eq(schema.users.id, testUserId1))
await db.delete(schema.users).where(eq(schema.users.id, testUserId2))
})
it('should broadcast member-joined when a user joins via API', async () => {
// User 1 joins the room via API first (this is what happens when they click "Join Room")
await addRoomMember({
roomId: testRoomId,
userId: testGuestId1,
displayName: 'User 1',
isCreator: false,
})
// User 1 connects to socket
socket1 = ioClient(`http://localhost:${serverPort}`, {
path: '/api/socket',
transports: ['websocket'],
})
// Wait for socket to connect
await new Promise<void>((resolve, reject) => {
socket1.on('connect', () => resolve())
socket1.on('connect_error', (err) => reject(err))
setTimeout(() => reject(new Error('Connection timeout')), 2000)
})
// Small delay to ensure event handlers are set up
await new Promise((resolve) => setTimeout(resolve, 50))
// Set up listener for room-joined BEFORE emitting
const roomJoinedPromise = new Promise<void>((resolve, reject) => {
socket1.on('room-joined', () => resolve())
socket1.on('room-error', (err) => reject(new Error(err.error)))
setTimeout(() => reject(new Error('Room-joined timeout')), 3000)
})
// Now emit the join-room event
socket1.emit('join-room', { roomId: testRoomId, userId: testGuestId1 })
// Wait for confirmation
await roomJoinedPromise
// Set up listener for member-joined event BEFORE User 2 joins
const memberJoinedPromise = new Promise<any>((resolve, reject) => {
socket1.on('member-joined', (data) => {
resolve(data)
})
setTimeout(() => reject(new Error('Timeout waiting for member-joined event')), 3000)
})
// User 2 joins the room via addRoomMember
const { member: newMember } = await addRoomMember({
roomId: testRoomId,
userId: testGuestId2,
displayName: 'User 2',
isCreator: false,
})
// Manually trigger the broadcast (this is what the API route SHOULD do)
const { getRoomMembers } = await import('../src/lib/arcade/room-membership')
const { getRoomActivePlayers } = await import('../src/lib/arcade/player-manager')
const members = await getRoomMembers(testRoomId)
const memberPlayers = await getRoomActivePlayers(testRoomId)
const memberPlayersObj: Record<string, any[]> = {}
for (const [uid, players] of memberPlayers.entries()) {
memberPlayersObj[uid] = players
}
io.to(`room:${testRoomId}`).emit('member-joined', {
roomId: testRoomId,
userId: testGuestId2,
members,
memberPlayers: memberPlayersObj,
})
// Wait for the socket broadcast with timeout
const data = await memberJoinedPromise
// Verify the broadcast data
expect(data).toBeDefined()
expect(data.roomId).toBe(testRoomId)
expect(data.userId).toBe(testGuestId2)
expect(data.members).toBeDefined()
expect(Array.isArray(data.members)).toBe(true)
// Verify both users are in the members list
const memberUserIds = data.members.map((m: any) => m.userId)
expect(memberUserIds).toContain(testGuestId1)
expect(memberUserIds).toContain(testGuestId2)
// Verify the new member details
const addedMember = data.members.find((m: any) => m.userId === testGuestId2)
expect(addedMember).toBeDefined()
expect(addedMember.displayName).toBe('User 2')
expect(addedMember.roomId).toBe(testRoomId)
})
it('should broadcast member-left when a user leaves via API', async () => {
// User 1 joins the room first
await addRoomMember({
roomId: testRoomId,
userId: testGuestId1,
displayName: 'User 1',
isCreator: false,
})
// User 2 joins the room
await addRoomMember({
roomId: testRoomId,
userId: testGuestId2,
displayName: 'User 2',
isCreator: false,
})
// User 1 connects to socket
socket1 = ioClient(`http://localhost:${serverPort}`, {
path: '/api/socket',
transports: ['websocket'],
})
await new Promise<void>((resolve) => {
socket1.on('connect', () => resolve())
})
socket1.emit('join-room', { roomId: testRoomId, userId: testGuestId1 })
await new Promise<void>((resolve) => {
socket1.on('room-joined', () => resolve())
})
// Set up listener for member-left event
const memberLeftPromise = new Promise<any>((resolve) => {
socket1.on('member-left', (data) => {
resolve(data)
})
})
// User 2 leaves the room via API
await db.delete(schema.roomMembers).where(eq(schema.roomMembers.userId, testGuestId2))
// Manually trigger the leave broadcast (simulating what the API does)
const { getSocketIO } = await import('../src/lib/socket-io')
const io = await getSocketIO()
if (io) {
const { getRoomMembers } = await import('../src/lib/arcade/room-membership')
const { getRoomActivePlayers } = await import('../src/lib/arcade/player-manager')
const members = await getRoomMembers(testRoomId)
const memberPlayers = await getRoomActivePlayers(testRoomId)
const memberPlayersObj: Record<string, any[]> = {}
for (const [uid, players] of memberPlayers.entries()) {
memberPlayersObj[uid] = players
}
io.to(`room:${testRoomId}`).emit('member-left', {
roomId: testRoomId,
userId: testGuestId2,
members,
memberPlayers: memberPlayersObj,
})
}
// Wait for the socket broadcast with timeout
const data = await Promise.race([
memberLeftPromise,
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout waiting for member-left event')), 2000)
),
])
// Verify the broadcast data
expect(data).toBeDefined()
expect(data.roomId).toBe(testRoomId)
expect(data.userId).toBe(testGuestId2)
expect(data.members).toBeDefined()
expect(Array.isArray(data.members)).toBe(true)
// Verify User 2 is no longer in the members list
const memberUserIds = data.members.map((m: any) => m.userId)
expect(memberUserIds).toContain(testGuestId1)
expect(memberUserIds).not.toContain(testGuestId2)
})
it('should update both members and players lists in member-joined broadcast', async () => {
// Create an active player for User 2
const [player2] = await db
.insert(schema.players)
.values({
userId: testUserId2,
name: 'Player 2',
emoji: '🎮',
color: '#3b82f6',
isActive: true,
})
.returning()
// User 1 connects and joins room
socket1 = ioClient(`http://localhost:${serverPort}`, {
path: '/api/socket',
transports: ['websocket'],
})
await new Promise<void>((resolve) => {
socket1.on('connect', () => resolve())
})
socket1.emit('join-room', { roomId: testRoomId, userId: testGuestId1 })
await new Promise<void>((resolve) => {
socket1.on('room-joined', () => resolve())
})
const memberJoinedPromise = new Promise<any>((resolve) => {
socket1.on('member-joined', (data) => {
resolve(data)
})
})
// User 2 joins via API
await addRoomMember({
roomId: testRoomId,
userId: testGuestId2,
displayName: 'User 2',
isCreator: false,
})
// Manually trigger the broadcast (simulating what the API does)
const { getRoomMembers: getRoomMembers3 } = await import('../src/lib/arcade/room-membership')
const { getRoomActivePlayers: getRoomActivePlayers3 } = await import(
'../src/lib/arcade/player-manager'
)
const members2 = await getRoomMembers3(testRoomId)
const memberPlayers2 = await getRoomActivePlayers3(testRoomId)
const memberPlayersObj2: Record<string, any[]> = {}
for (const [uid, players] of memberPlayers2.entries()) {
memberPlayersObj2[uid] = players
}
io.to(`room:${testRoomId}`).emit('member-joined', {
roomId: testRoomId,
userId: testGuestId2,
members: members2,
memberPlayers: memberPlayersObj2,
})
const data = await Promise.race([
memberJoinedPromise,
new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), 2000)),
])
// Verify members list is updated
expect(data.members).toBeDefined()
const memberUserIds = data.members.map((m: any) => m.userId)
expect(memberUserIds).toContain(testGuestId2)
// Verify players list is updated
expect(data.memberPlayers).toBeDefined()
expect(data.memberPlayers[testGuestId2]).toBeDefined()
expect(Array.isArray(data.memberPlayers[testGuestId2])).toBe(true)
// User 2's players should include the active player we created
const user2Players = data.memberPlayers[testGuestId2]
expect(user2Players.length).toBeGreaterThan(0)
expect(user2Players.some((p: any) => p.id === player2.id)).toBe(true)
// Clean up player
await db.delete(schema.players).where(eq(schema.players.id, player2.id))
})
})

69
apps/web/biome.jsonc Normal file
View File

@@ -0,0 +1,69 @@
{
"$schema": "https://biomejs.dev/schemas/2.2.5/schema.json",
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"a11y": {
"useButtonType": "off",
"noSvgWithoutTitle": "off",
"noLabelWithoutControl": "off",
"noStaticElementInteractions": "off",
"useKeyWithClickEvents": "off",
"useSemanticElements": "off"
},
"suspicious": {
"noExplicitAny": "off",
"noArrayIndexKey": "off",
"noImplicitAnyLet": "off",
"noAssignInExpressions": "off",
"useIterableCallbackReturn": "off"
},
"style": {
"useNodejsImportProtocol": "off",
"noNonNullAssertion": "off",
"noDescendingSpecificity": "off"
},
"correctness": {
"noUnusedVariables": "off",
"noUnusedFunctionParameters": "off",
"useUniqueElementIds": "off",
"noChildrenProp": "off",
"useExhaustiveDependencies": "off",
"noInvalidUseBeforeDeclaration": "off",
"useHookAtTopLevel": "off",
"noNestedComponentDefinitions": "off",
"noUnreachable": "off"
},
"security": {
"noDangerouslySetInnerHtml": "off"
},
"performance": {
"noAccumulatingSpread": "off"
}
}
},
"files": {
"ignoreUnknown": true
},
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true,
"defaultBranch": "main"
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"jsxQuoteStyle": "double",
"semicolons": "asNeeded",
"trailingCommas": "es5"
}
}
}

View File

@@ -0,0 +1,169 @@
# User/Player/Room Member Inconsistencies - FIXED ✅
All critical inconsistencies between users, players, and room members have been resolved.
## Summary of Fixes
### 1. ✅ Backend - Player Fetching
**Created**: `src/lib/arcade/player-manager.ts`
- `getActivePlayers(userId)` - Get a user's active players
- `getRoomActivePlayers(roomId)` - Get all active players for all members in a room
- `getRoomPlayerIds(roomId)` - Get flat list of all player IDs in a room
- `validatePlayerInRoom(playerId, roomId)` - Validate player belongs to room member
- `getPlayer(playerId)` - Get single player by ID
- `getPlayers(playerIds[])` - Get multiple players by IDs
### 2. ✅ API Endpoints Updated
**`/api/arcade/rooms/:roomId/join` (POST)**
```typescript
// Now returns:
{
member: RoomMember,
room: Room,
activePlayers: Player[], // USER's active players
alreadyMember: boolean
}
```
**`/api/arcade/rooms/:roomId` (GET)**
```typescript
// Now returns:
{
room: Room,
members: RoomMember[],
memberPlayers: Record<userId, Player[]>, // Map of all members' players
canModerate: boolean
}
```
**`/api/arcade/rooms` (GET)**
```typescript
// Now returns:
{
rooms: Array<{
...roomData,
memberCount: number, // Number of users in room
playerCount: number // Total players across all users
}>
}
```
### 3. ✅ Socket Events Updated
**`join-room` event**
```typescript
// Server emits:
socket.emit('room-joined', {
room,
members,
onlineMembers,
memberPlayers: Record<userId, Player[]>, // All members' players
activePlayers: Player[] // This user's active players
})
socket.to(`room:${roomId}`).emit('member-joined', {
member,
activePlayers: Player[], // New member's active players
onlineMembers,
memberPlayers: Record<userId, Player[]>
})
```
**`room-game-move` event**
```typescript
// Now validates:
1. User is a room member (userId check)
2. Player belongs to a room member (playerId validation)
// Rejects move if playerId doesn't belong to any room member
```
### 4. ✅ Frontend UI Updated
**Room Lobby (`/arcade/rooms/[roomId]/page.tsx`)**
Before:
```
Member: Jane
Status: Online
```
After:
```
Member: Jane
Status: Online
Players: 👧 Alice, 👦 Bob
```
**Room Browser (`/arcade/rooms/page.tsx`)**
Before:
```
Room: Math Masters
Host: Jane | Game: matching | Status: Waiting
```
After:
```
Room: Math Masters
Host: Jane | Game: matching | 👥 3 members | 🎯 7 players | Status: Waiting
```
## Key Changes Summary
| Component | Change |
|-----------|--------|
| **Helper Functions** | Created `player-manager.ts` with 6 new functions |
| **Join Endpoint** | Now fetches and returns user's active players |
| **Room Detail Endpoint** | Returns player map for all members |
| **Rooms List Endpoint** | Returns member and player counts |
| **Socket join-room** | Broadcasts active players to room |
| **Socket room-game-move** | Validates player IDs belong to members |
| **Room Lobby UI** | Shows each member's players |
| **Room Browser UI** | Shows total member and player counts |
## Validation Rules Enforced
1.**Room membership tracked by USER ID** - Correct
2.**Game participation tracked by PLAYER IDs** - Fixed
3.**When user joins room, their active players join game** - Implemented
4.**Socket moves validate player belongs to room** - Added validation
5.**UI shows both members and their players** - Updated
## TypeScript Validation
All changes pass TypeScript validation with 0 errors in modified files:
- `src/lib/arcade/player-manager.ts`
- `src/app/api/arcade/rooms/route.ts`
- `src/app/api/arcade/rooms/[roomId]/route.ts`
- `src/app/api/arcade/rooms/[roomId]/join/route.ts`
- `src/app/arcade/rooms/page.tsx`
- `src/app/arcade/rooms/[roomId]/page.tsx`
- `socket-server.ts`
## Testing Checklist
- [ ] Create a user with multiple active players
- [ ] Join a room and verify all active players are shown
- [ ] Have multiple users join the same room
- [ ] Verify each user's players are displayed correctly
- [ ] Verify room browser shows correct member/player counts
- [ ] Start a game and verify all player IDs are collected
- [ ] Test that invalid player IDs are rejected in game moves
## Documentation Created
1. `docs/terminology-user-player-room.md` - Complete explanation
2. `.claude/terminology.md` - Quick reference for AI
3. `docs/INCONSISTENCIES.md` - Analysis of issues (pre-fix)
4. `docs/FIXES-APPLIED.md` - This document
## Next Steps (Phase 4)
The system is now ready for full multiplayer game integration:
1. When room game starts, collect all player IDs from all members
2. Set `arcade_sessions.activePlayers` to all room player IDs
3. Game state tracks scores/moves by PLAYER ID
4. Broadcast game updates to all room members

View File

@@ -0,0 +1,189 @@
# Current Implementation vs Correct Design - Inconsistencies
## ❌ Inconsistency 1: Room Join Doesn't Fetch Active Players
**Current Code** (`/api/arcade/rooms/:roomId/join`):
```typescript
// Only creates room_member record with userId
const member = await addRoomMember({
roomId,
userId: viewerId, // ✅ Correct: USER ID
displayName,
isCreator: false,
})
// ❌ Missing: Does not fetch user's active players
```
**Should Be**:
```typescript
// 1. Create room member
const member = await addRoomMember({ ... })
// 2. Fetch user's active players
const activePlayers = await db.query.players.findMany({
where: and(
eq(players.userId, viewerId),
eq(players.isActive, true)
)
})
// 3. Return both member and their active players
return { member, activePlayers }
```
---
## ❌ Inconsistency 2: Socket Events Use USER ID Instead of PLAYER ID
**Current Code** (`socket-server.ts`):
```typescript
socket.on('join-room', ({ roomId, userId }) => {
// Uses USER ID for presence
await setMemberOnline(roomId, userId, true)
socket.emit('room-joined', { members })
})
socket.on('room-game-move', ({ roomId, userId, move }) => {
// ❌ Wrong: Uses USER ID for game moves
// Should use PLAYER ID
})
```
**Should Be**:
```typescript
socket.on('join-room', ({ roomId, userId }) => {
// ✅ Correct: Use USER ID for room presence
await setMemberOnline(roomId, userId, true)
// ❌ Missing: Should also fetch and broadcast active players
const activePlayers = await getActivePlayers(userId)
socket.emit('room-joined', { members, activePlayers })
})
socket.on('room-game-move', ({ roomId, playerId, move }) => {
// ✅ Correct: Use PLAYER ID for game actions
// Validate that playerId belongs to a member in this room
})
```
---
## ❌ Inconsistency 3: Room Member Interface Missing Player Association
**Current Code** (`room_members` table):
```typescript
interface RoomMember {
id: string
roomId: string
userId: string // ✅ Correct: USER ID
displayName: string
isCreator: boolean
// ❌ Missing: No link to user's players
}
```
**Need to Add** (runtime association, not DB schema):
```typescript
interface RoomMemberWithPlayers {
member: RoomMember
activePlayers: Player[] // The user's active players
}
```
---
## ❌ Inconsistency 4: Client UI Shows Room Members, Not Players
**Current Code** (`/arcade/rooms/[roomId]/page.tsx`):
```typescript
// Shows room members (users)
{members.map((member) => (
<div key={member.id}>
{member.displayName} {/* USER's display name */}
</div>
))}
// ❌ Missing: Should show the PLAYERS that will participate
```
**Should Show**:
```typescript
{members.map((member) => (
<div key={member.id}>
<div>{member.displayName} (Room Member)</div>
<div>Players:
{member.activePlayers.map(player => (
<span key={player.id}>{player.emoji} {player.name}</span>
))}
</div>
</div>
))}
```
---
## Summary of Required Changes
### Phase 1: Backend - Player Fetching
1.`room_members` table correctly uses USER ID (no change needed)
2.`/api/arcade/rooms/:roomId/join` - Fetch and return active players
3.`/api/arcade/rooms/:roomId` GET - Include active players in response
4. ❌ Create helper: `getActivePlayers(userId) => Player[]`
### Phase 2: Socket Layer - Player Association
1.`join-room` event - Broadcast active players to room
2.`room-game-move` event - Accept PLAYER ID, not USER ID
3. ❌ Validate PLAYER ID belongs to a room member
### Phase 3: Frontend - Player Display
1. ❌ Room lobby - Show each member's active players
2. ❌ Game setup - Use PLAYER IDs for `activePlayers` array
3. ❌ Move/action events - Send PLAYER ID
### Phase 4: Game Integration
1. ❌ When room game starts, collect all PLAYER IDs from all members
2. ❌ Arcade session `activePlayers` should contain all room PLAYER IDs
3. ❌ Game state tracks scores/moves by PLAYER ID, not USER ID
---
## Test Scenarios
### Scenario 1: Single Player Per User
```
USER Jane (guest_123)
└─ PLAYER Alice (active)
Joins room → Room shows "Jane: Alice 👧"
Game starts → activePlayers: ["alice_id"]
```
### Scenario 2: Multiple Players Per User
```
USER Jane (guest_123)
├─ PLAYER Alice (active)
└─ PLAYER Bob (active)
Joins room → Room shows "Jane: Alice 👧, Bob 👦"
Game starts → activePlayers: ["alice_id", "bob_id"]
```
### Scenario 3: Multi-User Room
```
USER Jane
└─ PLAYER Alice, Bob (active)
USER Mark
└─ PLAYER Mario (active)
USER Sara
└─ PLAYER Luna, Nova, Star (active)
Room shows:
- Jane: Alice 👧, Bob 👦
- Mark: Mario 🍄
- Sara: Luna 🌙, Nova ✨, Star ⭐
Game starts → activePlayers: [alice, bob, mario, luna, nova, star]
Total: 6 players across 3 users
```

View File

@@ -0,0 +1,490 @@
# Multiplayer Synchronization Architecture
## Current State: Single-User Multi-Tab Sync
### How it Works
**Client-Side Flow:**
1. User opens game in Tab A and Tab B
2. Both tabs create WebSocket connections via `useArcadeSocket()`
3. Both emit `join-arcade-session` with `userId`
4. Server adds both sockets to `arcade:${userId}` room
**When User Makes a Move (from Tab A):**
```typescript
// Client (Tab A)
sendMove({ type: 'FLIP_CARD', playerId: 'player-1', data: { cardId: 'card-5' } })
// Optimistic update applied locally
state = applyMoveOptimistically(state, move)
// Socket emits to server
socket.emit('game-move', { userId, move })
```
**Server Processing:**
```typescript
// socket-server.ts line 71
socket.on('game-move', async (data) => {
// Validate move
const result = await applyGameMove(data.userId, data.move)
if (result.success) {
// ✅ Broadcast to ALL tabs of this user
io.to(`arcade:${data.userId}`).emit('move-accepted', {
gameState: result.session.gameState,
version: result.session.version,
move: data.move
})
}
})
```
**Both Tabs Receive Update:**
```typescript
// Client (Tab A and Tab B)
socket.on('move-accepted', (data) => {
// Update server state
optimistic.handleMoveAccepted(data.gameState, data.version, data.move)
// Tab A: Remove from pending queue (was optimistic)
// Tab B: Just sync with server state (wasn't expecting it)
})
```
### Key Components
1. **`useOptimisticGameState`** - Manages optimistic updates
- Keeps `serverState` (last confirmed by server)
- Keeps `pendingMoves[]` (not yet confirmed)
- Current state = serverState + all pending moves applied
2. **`useArcadeSession`** - Combines socket + optimistic state
- Connects socket
- Applies moves optimistically
- Sends moves to server
- Handles server responses
3. **Socket Rooms** - Server-side broadcast channels
- `arcade:${userId}` - All tabs of one user
- Each socket can be in multiple rooms
- `io.to(room).emit()` broadcasts to all sockets in that room
4. **Session Storage** - Database
- One session per user (userId is unique key)
- Contains `gameState`, `version`, `roomId`
- Optimistic locking via version number
---
## Required: Room-Based Multi-User Sync
### The Goal
Multiple users in the same room at `/arcade/room` should all see synchronized game state:
- User A (2 tabs): Tab A1, Tab A2
- User B (1 tab): Tab B1
- User C (2 tabs): Tab C1, Tab C2
When User A makes a move in Tab A1:
- **All of User A's tabs** see the move (Tab A1, Tab A2)
- **All of User B's tabs** see the move (Tab B1)
- **All of User C's tabs** see the move (Tab C1, Tab C2)
### The Challenge
Current architecture only broadcasts within one user:
```typescript
// ❌ Only reaches User A's tabs
io.to(`arcade:${userA}`).emit('move-accepted', ...)
```
We need to broadcast to the entire room:
```typescript
// ✅ Reaches all users in the room
io.to(`game:${roomId}`).emit('move-accepted', ...)
```
### The Solution
#### 1. Add Room-Based Game Socket Room
When a user joins `/arcade/room`, they join TWO socket rooms:
```typescript
// socket-server.ts - extend join-arcade-session
socket.on('join-arcade-session', async ({ userId, roomId }) => {
// Join user's personal room (for multi-tab sync)
socket.join(`arcade:${userId}`)
// If this session is part of a room, also join the game room
if (roomId) {
socket.join(`game:${roomId}`)
console.log(`🎮 User ${userId} joined game room ${roomId}`)
}
// Send current session state...
})
```
#### 2. Broadcast to Both Rooms
When processing moves for room-based sessions:
```typescript
// socket-server.ts - modify game-move handler
socket.on('game-move', async (data) => {
const result = await applyGameMove(data.userId, data.move)
if (result.success && result.session) {
const moveAcceptedData = {
gameState: result.session.gameState,
version: result.session.version,
move: data.move,
}
// Broadcast to user's own tabs (for optimistic update reconciliation)
io.to(`arcade:${data.userId}`).emit('move-accepted', moveAcceptedData)
// If this is a room-based session, ALSO broadcast to all room members
if (result.session.roomId) {
io.to(`game:${result.session.roomId}`).emit('move-accepted', moveAcceptedData)
console.log(`📢 Broadcasted move to room ${result.session.roomId}`)
}
}
})
```
**Why broadcast to both?**
- `arcade:${userId}` - So the acting user's tabs can reconcile their optimistic updates
- `game:${roomId}` - So all other users in the room receive the update
#### 3. Client Handles Own vs. Other Moves
The client already handles this correctly via optimistic updates:
```typescript
// User A (Tab A1) - Makes move
sendMove({ type: 'FLIP_CARD', ... })
// → Applies optimistically immediately
// → Sends to server
// → Receives move-accepted
// → Reconciles: removes from pending queue
// User B (Tab B1) - Sees move from User A
// → Receives move-accepted (unexpected)
// → Reconciles: clears pending queue, syncs with server state
// → Result: sees User A's move immediately
```
The beauty is that `handleMoveAccepted()` already handles both cases:
- **Own move**: Remove from pending queue
- **Other's move**: Clear pending queue (since server state is now ahead)
#### 4. Pass roomId in join-arcade-session
Client needs to send roomId when joining:
```typescript
// hooks/useArcadeSocket.ts
const joinSession = useCallback((userId: string, roomId?: string) => {
if (!socket) return
socket.emit('join-arcade-session', { userId, roomId })
}, [socket])
// hooks/useArcadeSession.ts
useEffect(() => {
if (connected && autoJoin && userId) {
// Get roomId from session or room context
const roomId = getRoomId() // Need to provide this
joinSession(userId, roomId)
}
}, [connected, autoJoin, userId, joinSession])
```
---
## Implementation Plan
### Phase 1: Server-Side Changes
**File: `socket-server.ts`**
1. ✅ Accept `roomId` in `join-arcade-session` event
```typescript
socket.on('join-arcade-session', async ({ userId, roomId }) => {
socket.join(`arcade:${userId}`)
// Join game room if session is room-based
if (roomId) {
socket.join(`game:${roomId}`)
}
// Rest of logic...
})
```
2. ✅ Broadcast to room in `game-move` handler
```typescript
if (result.success && result.session) {
const moveData = {
gameState: result.session.gameState,
version: result.session.version,
move: data.move,
}
// Broadcast to user's tabs
io.to(`arcade:${data.userId}`).emit('move-accepted', moveData)
// ALSO broadcast to room if room-based session
if (result.session.roomId) {
io.to(`game:${result.session.roomId}`).emit('move-accepted', moveData)
}
}
```
3. ✅ Handle room disconnects
```typescript
socket.on('disconnect', () => {
// Leave all rooms (handled automatically by socket.io)
// But log for debugging
if (currentUserId && currentRoomId) {
console.log(`User ${currentUserId} left game room ${currentRoomId}`)
}
})
```
### Phase 2: Client-Side Changes
**File: `hooks/useArcadeSocket.ts`**
1. ✅ Add roomId parameter to joinSession
```typescript
export interface UseArcadeSocketReturn {
// ... existing
joinSession: (userId: string, roomId?: string) => void
}
const joinSession = useCallback((userId: string, roomId?: string) => {
if (!socket) return
socket.emit('join-arcade-session', { userId, roomId })
}, [socket])
```
**File: `hooks/useArcadeSession.ts`**
2. ✅ Accept roomId in options
```typescript
export interface UseArcadeSessionOptions<TState> {
userId: string
roomId?: string // NEW
initialState: TState
applyMove: (state: TState, move: GameMove) => TState
// ... rest
}
export function useArcadeSession<TState>(options: UseArcadeSessionOptions<TState>) {
const { userId, roomId, ...optimisticOptions } = options
// Auto-join with roomId
useEffect(() => {
if (connected && autoJoin && userId) {
joinSession(userId, roomId)
}
}, [connected, autoJoin, userId, roomId, joinSession])
// ... rest
}
```
**File: `app/arcade/matching/context/ArcadeMemoryPairsContext.tsx`**
3. ✅ Get roomId from room data and pass to session
```typescript
import { useRoomData } from '@/hooks/useRoomData'
export function ArcadeMemoryPairsProvider({ children }: { children: ReactNode }) {
const { data: viewerId } = useViewerId()
const { roomData } = useRoomData()
// Arcade session integration
const { state, sendMove, ... } = useArcadeSession<MemoryPairsState>({
userId: viewerId || '',
roomId: roomData?.id, // NEW - pass room ID
initialState,
applyMove: applyMoveOptimistically,
})
// ... rest stays the same
}
```
### Phase 3: Testing
1. **Multi-Tab Test (Single User)**
- Open `/arcade/room` in 2 tabs as User A
- Make move in Tab 1
- Verify Tab 2 updates immediately
2. **Multi-User Test (Different Users)**
- User A opens `/arcade/room` in 1 tab
- User B opens `/arcade/room` in 1 tab (same room)
- User A makes move
- Verify User B sees move immediately
3. **Multi-User Multi-Tab Test**
- User A: 2 tabs (Tab A1, Tab A2)
- User B: 2 tabs (Tab B1, Tab B2)
- User A makes move in Tab A1
- Verify all 4 tabs update
4. **Rapid Move Test**
- User A and User B both make moves rapidly
- Verify no conflicts
- Verify all moves are processed in order
---
## Edge Cases to Handle
### 1. User Leaves Room Mid-Game
**Current behavior:** Session persists, user can rejoin
**Required behavior:**
- If user leaves room (HTTP POST to `/api/arcade/rooms/[roomId]/leave`):
- Delete their session
- Emit `session-ended` to their tabs
- Other users continue playing
### 2. Version Conflicts
**Already handled** by optimistic locking:
- Each move increments version
- Client tracks server version
- If conflict detected, reconciliation happens automatically
### 3. Session Without Room
**Already handled** by session-manager.ts:
- Sessions without `roomId` are considered orphaned
- They're cleaned up on next access (lines 111-115)
### 4. Multiple Users Same Move
**Handled by server validation:**
- Server processes moves sequentially
- First valid move wins
- Second move gets rejected if it's now invalid
- Client rolls back rejected move
---
## Benefits of This Architecture
1. **Reuses existing optimistic update system**
- No changes needed to client-side optimistic logic
- Already handles own vs. others' moves
2. **Minimal changes required**
- Add `roomId` parameter (3 places)
- Add one `io.to()` broadcast (1 place)
- Wire up roomId from context (1 place)
3. **Backward compatible**
- Non-room sessions still work (roomId is optional)
- Solo play unaffected
4. **Scalable**
- Socket.io handles multiple rooms efficiently
- No N² broadcasting (room-based is O(N))
5. **Already tested pattern**
- Multi-tab sync proves the broadcast pattern works
- Just extending to more sockets (different users)
---
## Security Considerations
### 1. Validate Room Membership
Before processing moves, verify user is in the room:
```typescript
// session-manager.ts - in applyGameMove()
const session = await getArcadeSession(userId)
if (session.roomId) {
// Verify user is a member of this room
const membership = await getRoomMember(session.roomId, userId)
if (!membership) {
return { success: false, error: 'User not in room' }
}
}
```
### 2. Verify Player Ownership
Ensure users can only make moves for their own players:
```typescript
// Already handled in validator
// move.playerId must be in session.activePlayers
// activePlayers are owned by the userId making the move
```
This is already enforced by how activePlayers are set up in the room.
---
## Performance Considerations
### 1. Broadcasting Overhead
- **Current**: 1 user × N tabs = N broadcasts per move
- **New**: M users × N tabs each = (M×N) broadcasts per move
- **Impact**: Linear with room size, not quadratic
- **Acceptable**: Socket.io is optimized for this
### 2. Database Queries
- No change: Still 1 database write per move
- Session is stored per-user, not per-room
- Room data is separate (cached, not updated per move)
### 3. Memory
- Each socket joins 2 rooms instead of 1
- Negligible: Socket.io uses efficient room data structures
---
## Testing Checklist
### Unit Tests
- [ ] `useArcadeSocket` accepts and passes roomId
- [ ] `useArcadeSession` accepts and passes roomId
- [ ] Server joins `game:${roomId}` room when roomId provided
### Integration Tests
- [ ] Single user, 2 tabs: both tabs sync
- [ ] 2 users, 1 tab each: both users sync
- [ ] 2 users, 2 tabs each: all 4 tabs sync
- [ ] User leaves room: session deleted, others continue
- [ ] Rapid concurrent moves: all processed correctly
### Manual Tests
- [ ] Open room in 2 browsers (different users)
- [ ] Play full game to completion
- [ ] Verify scores sync correctly
- [ ] Verify turn changes sync correctly
- [ ] Verify game completion syncs correctly

View File

@@ -0,0 +1,109 @@
# Arcade Rooms Implementation Task List
This is the detailed implementation task list for the arcade rooms feature. Use this to restore the TodoWrite list if the session is interrupted.
## Phase 1: Database & API Foundation
- [ ] Phase 1.1: Create database migration for arcade_rooms and room_members tables
- [ ] Phase 1.2: Implement room-manager.ts with CRUD operations (create, get, update, delete)
- [ ] Phase 1.3: Implement room-membership.ts for member management
- [ ] Phase 1.4: Build API endpoints for room CRUD (/api/arcade/rooms/*)
- [ ] Phase 1.5: Add room code generation utility
- [ ] Phase 1.6: Implement TTL cleanup system for rooms
### Testing Checkpoint 1
- [ ] TESTING CHECKPOINT 1: Write unit tests for all room manager functions
- [ ] TESTING CHECKPOINT 1: Write unit tests for room membership functions
- [ ] TESTING CHECKPOINT 1: Write API endpoint tests for room CRUD operations
- [ ] TESTING CHECKPOINT 1: Manual testing of room creation, joining, and TTL cleanup
## Phase 2: Socket.IO Integration
- [ ] Phase 2.1: Update socket-server.ts for room namespacing
- [ ] Phase 2.2: Implement room-scoped broadcasts in socket handlers
- [ ] Phase 2.3: Add presence tracking for room members
- [ ] Phase 2.4: Update session-manager.ts to support roomId
- [ ] Phase 2.5: Update game state sync to respect room boundaries
### Testing Checkpoint 2
- [ ] TESTING CHECKPOINT 2: Write integration tests for multi-user room sessions
- [ ] TESTING CHECKPOINT 2: Write tests for room-scoped broadcasts
- [ ] TESTING CHECKPOINT 2: Manual testing of multi-tab synchronization within rooms
- [ ] TESTING CHECKPOINT 2: Verify backward compatibility with solo play (no roomId)
## Phase 3: Guest User System
- [ ] Phase 3.1: Implement guest ID generation and storage
- [ ] Phase 3.2: Create useGuestUser hook for guest authentication
- [ ] Phase 3.3: Update auth flow to support optional guest access
- [ ] Phase 3.4: Update API endpoints to accept guest user IDs
### Testing Checkpoint 3
- [ ] TESTING CHECKPOINT 3: Write unit tests for guest ID system
- [ ] TESTING CHECKPOINT 3: Manual testing of guest join flow
- [ ] TESTING CHECKPOINT 3: Test guest user persistence across page refreshes
## Phase 4: UI Components
- [ ] Phase 4.1: Build CreateRoomDialog component with form validation
- [ ] Phase 4.2: Build RoomLobby component showing current room state
- [ ] Phase 4.3: Build RoomLobbyBrowser for public room discovery
- [ ] Phase 4.4: Add RoomContextIndicator to navigation bar
- [ ] Phase 4.5: Wire up useRoom and useRoomMembership hooks
- [ ] Phase 4.6: Implement player selection UI when joining room
### Testing Checkpoint 4
- [ ] TESTING CHECKPOINT 4: Write component unit tests for all room UI components
- [ ] TESTING CHECKPOINT 4: Manual UI testing of room creation flow
- [ ] TESTING CHECKPOINT 4: Manual UI testing of room browser and filtering
- [ ] TESTING CHECKPOINT 4: Test player selection flow when joining rooms
## Phase 5: Routes & Navigation
- [ ] Phase 5.1: Create /arcade/rooms route structure
- [ ] Phase 5.2: Create /arcade/rooms/:roomId route with room lobby
- [ ] Phase 5.3: Create /arcade/rooms/:roomId/:game routes for in-room gameplay
- [ ] Phase 5.4: Update arcade home to include room access entry points
- [ ] Phase 5.5: Add room selector/switcher to navigation
- [ ] Phase 5.6: Implement join-by-code flow with code input dialog
- [ ] Phase 5.7: Add share room functionality (copy link, share code)
### Testing Checkpoint 5
- [ ] TESTING CHECKPOINT 5: Write E2E tests for room creation navigation flow
- [ ] TESTING CHECKPOINT 5: Write E2E tests for join-by-URL flow
- [ ] TESTING CHECKPOINT 5: Write E2E tests for join-by-code flow
- [ ] TESTING CHECKPOINT 5: Manual testing of room navigation across different states
## Phase 6: Final Testing & Polish
- [ ] Phase 6.1: Write E2E tests for complete room creation and join flow
- [ ] Phase 6.2: Write E2E tests for multi-user gameplay in rooms
- [ ] Phase 6.3: Write tests for TTL expiration and cleanup behavior
- [ ] Phase 6.4: Write E2E tests for guest user complete flow
- [ ] Phase 6.5: Write tests for room creator permissions (kick, lock, delete)
- [ ] Phase 6.6: Performance testing with multiple concurrent rooms
- [ ] Phase 6.7: Performance testing with many users in single room
- [ ] Phase 6.8: Add error states and loading states to all UI components
- [ ] Phase 6.9: Add user feedback toasts for room operations
- [ ] Phase 6.10: Final manual user testing of complete room system
- [ ] Phase 6.11: Cross-browser testing (Chrome, Firefox, Safari)
- [ ] Phase 6.12: Mobile responsiveness testing for room UI
---
## Notes
- Total: 62 tasks across 6 phases
- 20 dedicated testing tasks (32% of total)
- Reference: `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/docs/arcade-rooms-technical-plan.md`
## Restoring TodoWrite
To restore this list to TodoWrite format, convert each task to:
```json
{
"content": "Task description",
"status": "pending|in_progress|completed",
"activeForm": "Present continuous form (e.g., 'Creating...')"
}
```

View File

@@ -0,0 +1,895 @@
# 🎮 Arcade Room System - Complete Technical Plan
**Date:** 2025-01-06
**Status:** Ready for Implementation
## Executive Summary
Transform the current singleton arcade session into a multi-room system where users can create, manage, and share public game rooms. Rooms are URL-addressable, support guest users, have configurable TTL, and give creators full moderation control.
---
## 1. Core Requirements
### Room Features
-**Public by default** - All rooms visible in public lobby
-**No capacity limits** - Unlimited players per room
-**Configurable TTL** - Rooms expire based on inactivity (similar to existing session TTL)
-**URL-addressable** - Direct links to join rooms (`/arcade/rooms/{roomId}`)
-**Guest access** - Unauthenticated users can join with temp guest IDs
-**Anyone can create** - No authentication required to create rooms
-**Creator moderation** - Only room creator can kick, lock, or delete room
---
## 2. Database Schema
### New Tables
```typescript
// arcade_rooms table
interface ArcadeRoom {
id: string // UUID - primary key
code: string // 6-char join code (e.g., "ABC123") - unique
name: string // User-defined room name (max 50 chars)
// Creator info
createdBy: string // User/guest ID of creator
creatorName: string // Display name at creation time
createdAt: Date
// Lifecycle
lastActivity: Date // Updated on any room activity
ttlMinutes: number // Time to live in minutes (default: 60)
isLocked: boolean // Creator can lock room (no new joins)
// Game configuration
gameName: string // 'matching', 'complement-race', etc.
gameConfig: JSON // Game-specific settings (difficulty, etc.)
// Current state
status: 'lobby' | 'playing' | 'finished'
currentSessionId: string | null // FK to arcade_sessions (when game active)
// Metadata
totalGamesPlayed: number // Track room usage
}
// room_members table
interface RoomMember {
id: string // UUID - primary key
roomId: string // FK to arcade_rooms - indexed
userId: string // User/guest ID - indexed
displayName: string // Name shown in room
isCreator: boolean // True for room creator
joinedAt: Date
lastSeen: Date // Updated on any activity
isOnline: boolean // Currently connected via socket
}
// Modify existing: arcade_sessions
interface ArcadeSession {
id: string
userId: string
// ... existing fields
roomId: string | null // FK to arcade_rooms (null for solo play)
// When roomId is set, session is shared across room members
}
```
### Indexes
```sql
CREATE INDEX idx_rooms_code ON arcade_rooms(code);
CREATE INDEX idx_rooms_status ON arcade_rooms(status);
CREATE INDEX idx_rooms_last_activity ON arcade_rooms(lastActivity);
CREATE INDEX idx_room_members_room_id ON room_members(roomId);
CREATE INDEX idx_room_members_user_id ON room_members(userId);
CREATE INDEX idx_room_members_online ON room_members(isOnline) WHERE isOnline = true;
```
---
## 3. API Endpoints
### Room CRUD (`/api/arcade/rooms`)
```typescript
// Create room
POST /api/arcade/rooms
Body: {
name: string // Room name
gameName: string // Which game
gameConfig?: object // Game settings
ttlMinutes?: number // Default: 60
creatorName: string // Display name
}
Response: {
room: ArcadeRoom
joinUrl: string // Full URL to share
}
// Get room details
GET /api/arcade/rooms/:roomId
Response: {
room: ArcadeRoom
members: RoomMember[]
canModerate: boolean // True if requester is creator
}
// Update room (creator only)
PATCH /api/arcade/rooms/:roomId
Body: {
name?: string
isLocked?: boolean
ttlMinutes?: number
}
Response: { room: ArcadeRoom }
// Delete room (creator only)
DELETE /api/arcade/rooms/:roomId
Response: { success: boolean }
// Join room by code
GET /api/arcade/rooms/join/:code
Response: {
roomId: string
redirectUrl: string
}
```
### Room Discovery
```typescript
// Public room lobby (list all active rooms)
GET /api/arcade/rooms/lobby
Query: {
gameName?: string // Filter by game
status?: string // Filter by status
limit?: number // Default: 50
offset?: number
}
Response: {
rooms: Array<{
id: string
name: string
code: string
gameName: string
status: string
memberCount: number
createdAt: Date
creatorName: string
}>
total: number
}
// Get user's rooms (all rooms user has joined)
GET /api/arcade/rooms/my-rooms
Query: { userId: string }
Response: {
rooms: Array<RoomWithMemberInfo>
}
```
### Room Membership
```typescript
// Join room
POST /api/arcade/rooms/:roomId/join
Body: {
userId: string // User or guest ID
displayName: string
}
Response: {
member: RoomMember
room: ArcadeRoom
}
// Leave room
POST /api/arcade/rooms/:roomId/leave
Body: { userId: string }
Response: { success: boolean }
// Get members
GET /api/arcade/rooms/:roomId/members
Response: {
members: RoomMember[]
onlineCount: number
}
// Kick member (creator only)
DELETE /api/arcade/rooms/:roomId/members/:userId
Response: { success: boolean }
```
### Room Game Session
```typescript
// Start game in room
POST /api/arcade/rooms/:roomId/start-game
Body: {
initiatedBy: string // Must be room member
activePlayers: string[] // Subset of room members
}
Response: {
sessionId: string
gameState: any
}
// End game (return to lobby)
POST /api/arcade/rooms/:roomId/end-game
Body: { initiatedBy: string }
Response: { success: boolean }
```
---
## 4. WebSocket Protocol
### Socket.IO Room Namespacing
```typescript
// Join room's socket.io room
socket.emit('join-room', {
roomId: string
userId: string
})
// Leave room
socket.emit('leave-room', {
roomId: string
userId: string
})
// Update member presence
socket.emit('update-presence', {
roomId: string
userId: string
isOnline: boolean
})
```
### Server → Client Events (room-scoped broadcasts)
```typescript
// Room state changes
socket.on('room-updated', {
room: ArcadeRoom
})
// Member events
socket.on('member-joined', {
member: RoomMember
memberCount: number
})
socket.on('member-left', {
userId: string
memberCount: number
})
socket.on('member-kicked', {
kickedUserId: string
reason: string
})
socket.on('members-updated', {
members: RoomMember[]
})
// Game session events
socket.on('game-starting', {
sessionId: string
activePlayers: string[]
})
socket.on('game-ended', {
results: any
})
// Room lifecycle
socket.on('room-locked', {
isLocked: boolean
})
socket.on('room-deleted', {
reason: string
})
// Existing game moves (now room-scoped)
socket.on('game-move', { roomId, userId, move })
socket.on('move-accepted', { roomId, gameState, version })
socket.on('move-rejected', { roomId, error })
```
---
## 5. URL Structure & Routing
### New Routes
```typescript
/arcade/rooms // Public room lobby (list all rooms)
/arcade/rooms/create // Create room modal/page
/arcade/rooms/:roomId // Room lobby (pre-game)
/arcade/rooms/:roomId/matching // Game with room context
/arcade/rooms/:roomId/complement-race // Another game
/arcade/join/:code // Short link: redirects to room
// Existing routes (backward compatible)
/arcade // My rooms + quick play
/arcade/matching // Solo play (no room)
```
### Navigation Flow
```
User Journey A: Create Room
1. /arcade → Click "Create Room"
2. /arcade/rooms/create → Fill form, submit
3. /arcade/rooms/{roomId} → Room lobby, share link
4. Click "Start Game" → /arcade/rooms/{roomId}/matching
User Journey B: Join via Link
1. Receive link: example.com/arcade/rooms/{roomId}
2. Opens lobby, automatically joins
3. Wait for game start or click ready
User Journey C: Join via Code
1. /arcade → Click "Join Room", enter ABC123
2. Resolves code → /arcade/rooms/{roomId}
3. Join and wait
User Journey D: Browse Lobby
1. /arcade/rooms → See public room list
2. Click room → /arcade/rooms/{roomId}
3. Join and play
```
---
## 6. UI Components Architecture
### Component Hierarchy
```
/src/components/arcade/rooms/
├── RoomLobbyBrowser.tsx // Public room list (/arcade/rooms)
│ ├── RoomCard.tsx // Individual room preview
│ └── RoomFilters.tsx // Filter by game, status
├── RoomLobby.tsx // Pre-game lobby (/arcade/rooms/:roomId)
│ ├── RoomHeader.tsx // Room name, code, share button
│ ├── RoomMemberList.tsx // Online members
│ ├── RoomSettings.tsx // Creator-only settings
│ └── RoomActions.tsx // Start game, leave, etc.
├── CreateRoomDialog.tsx // Room creation modal
│ ├── GameSelector.tsx // Choose game type
│ ├── RoomNameInput.tsx // Name the room
│ └── AdvancedSettings.tsx // TTL, etc.
├── JoinRoomDialog.tsx // Join by code modal
├── RoomContextIndicator.tsx // Shows room info during game
│ └── RoomMemberAvatars.tsx // Small member list
└── RoomModeration.tsx // Creator controls (kick, lock, delete)
```
### Navigation Updates
```typescript
// Update GameContextNav.tsx
interface GameContextNavProps {
// ... existing props
roomContext?: {
roomId: string
roomName: string
roomCode: string
memberCount: number
isCreator: boolean
}
}
// Shows in nav during room gameplay:
// [🏠 Friday Night (ABC123) • 5 players ▼]
```
---
## 7. State Management
### New Hooks
```typescript
// useArcadeRoom.ts - Room state and membership
export function useArcadeRoom(roomId: string) {
return {
room: ArcadeRoom | null
members: RoomMember[]
isCreator: boolean
isOnline: boolean
joinRoom: (displayName: string) => Promise<void>
leaveRoom: () => Promise<void>
updateRoom: (updates: Partial<ArcadeRoom>) => Promise<void>
deleteRoom: () => Promise<void>
kickMember: (userId: string) => Promise<void>
startGame: (activePlayers: string[]) => Promise<void>
endGame: () => Promise<void>
}
}
// useRoomMembers.ts - Real-time member presence
export function useRoomMembers(roomId: string) {
return {
members: RoomMember[]
onlineMembers: RoomMember[]
onlineCount: number
updatePresence: (isOnline: boolean) => void
}
}
// useRoomLobby.ts - Public room discovery
export function useRoomLobby(filters?: RoomFilters) {
return {
rooms: RoomPreview[]
loading: boolean
refresh: () => void
loadMore: () => void
}
}
// Update useArcadeSession.ts
export function useArcadeSession<TState>(options: {
userId: string
roomId?: string // NEW: optional room context
// ... existing options
}) {
// If roomId provided, session is room-scoped
// All moves broadcast to room members
}
```
---
## 8. Server Implementation
### New Files
```
/src/lib/arcade/
├── room-manager.ts # Core room operations
│ ├── createRoom()
│ ├── getRoomById()
│ ├── updateRoom()
│ ├── deleteRoom()
│ ├── getRoomByCode()
│ └── getPublicRooms()
├── room-membership.ts # Member management
│ ├── joinRoom()
│ ├── leaveRoom()
│ ├── kickMember()
│ ├── getRoomMembers()
│ └── updateMemberPresence()
├── room-validation.ts # Access control
│ ├── canModerateRoom()
│ ├── canJoinRoom()
│ ├── canStartGame()
│ └── validateRoomName()
├── room-ttl.ts # TTL management (reuse existing pattern)
│ ├── scheduleRoomCleanup()
│ ├── updateRoomActivity()
│ └── cleanupExpiredRooms()
└── session-manager.ts # Update for room support
└── createArcadeSession() - accept roomId param
```
### Socket Server Updates
```typescript
// socket-server.ts modifications
io.on('connection', (socket) => {
// Join room (socket.io namespace)
socket.on('join-room', async ({ roomId, userId }) => {
// Validate membership
const member = await getRoomMember(roomId, userId)
if (!member) {
socket.emit('room-error', { error: 'Not a room member' })
return
}
// Join socket.io room
socket.join(`room:${roomId}`)
// Update presence
await updateMemberPresence(roomId, userId, true)
// Broadcast to room
io.to(`room:${roomId}`).emit('member-joined', { member })
// Send current state
const room = await getRoomById(roomId)
const members = await getRoomMembers(roomId)
socket.emit('room-state', { room, members })
})
// Leave room
socket.on('leave-room', async ({ roomId, userId }) => {
socket.leave(`room:${roomId}`)
await updateMemberPresence(roomId, userId, false)
io.to(`room:${roomId}`).emit('member-left', { userId })
})
// Game moves (room-scoped)
socket.on('game-move', async ({ roomId, userId, move }) => {
// Validate room membership
const member = await getRoomMember(roomId, userId)
if (!member) return
// Apply move to room's session
const result = await applyGameMove(userId, move, roomId)
if (result.success) {
// Broadcast to all room members
io.to(`room:${roomId}`).emit('move-accepted', {
gameState: result.session.gameState,
version: result.session.version,
move
})
}
})
// Disconnect handling
socket.on('disconnect', () => {
// Update presence for all rooms user was in
updateAllUserPresence(userId, false)
})
})
```
---
## 9. Guest User System
### Guest ID Generation
```typescript
// /src/lib/guest-id.ts
export function generateGuestId(): string {
// Format: guest_{timestamp}_{random}
// Example: guest_1704556800000_a3f2e1
const timestamp = Date.now()
const random = Math.random().toString(36).substring(2, 8)
return `guest_${timestamp}_${random}`
}
export function isGuestId(userId: string): boolean {
return userId.startsWith('guest_')
}
export function getGuestDisplayName(guestId: string): string {
// Generate friendly name like "Guest 1234"
const hash = guestId.split('_')[2]
const num = parseInt(hash, 36) % 10000
return `Guest ${num}`
}
```
### Guest Session Storage
```typescript
// Store guest ID in localStorage
const GUEST_ID_KEY = 'soroban_guest_id'
export function getOrCreateGuestId(): string {
let guestId = localStorage.getItem(GUEST_ID_KEY)
if (!guestId) {
guestId = generateGuestId()
localStorage.setItem(GUEST_ID_KEY, guestId)
}
return guestId
}
```
---
## 10. TTL Implementation
### Reuse Existing Session TTL Pattern
```typescript
// room-ttl.ts
const DEFAULT_ROOM_TTL_MINUTES = 60
// Cleanup job (run every 5 minutes)
setInterval(async () => {
await cleanupExpiredRooms()
}, 5 * 60 * 1000)
async function cleanupExpiredRooms() {
const now = new Date()
// Find expired rooms
const expiredRooms = await db.query(`
SELECT id FROM arcade_rooms
WHERE status != 'playing'
AND lastActivity < NOW() - INTERVAL '1 minute' * ttlMinutes
`)
for (const room of expiredRooms) {
// Notify members
io.to(`room:${room.id}`).emit('room-deleted', {
reason: 'Room expired due to inactivity'
})
// Delete room and members
await deleteRoom(room.id)
}
}
// Update activity on any room action
export async function touchRoom(roomId: string) {
await db.query(`
UPDATE arcade_rooms
SET lastActivity = NOW()
WHERE id = $1
`, [roomId])
}
```
---
## 11. Room Code Generation
```typescript
// /src/lib/arcade/room-code.ts
const CODE_CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' // No ambiguous chars
const CODE_LENGTH = 6
export async function generateUniqueRoomCode(): Promise<string> {
let attempts = 0
const maxAttempts = 10
while (attempts < maxAttempts) {
const code = generateCode()
const existing = await getRoomByCode(code)
if (!existing) return code
attempts++
}
throw new Error('Failed to generate unique room code')
}
function generateCode(): string {
let code = ''
for (let i = 0; i < CODE_LENGTH; i++) {
const idx = Math.floor(Math.random() * CODE_CHARS.length)
code += CODE_CHARS[idx]
}
return code
}
```
---
## 12. Migration Plan
### Phase 1: Database & API Foundation (Day 1-2)
1. Create database tables and indexes
2. Implement room-manager.ts and room-membership.ts
3. Build API endpoints
4. Add room-code generation
5. Implement TTL cleanup
6. Write unit tests
### Phase 2: Socket.IO Integration (Day 2-3)
1. Update socket-server.ts for room namespacing
2. Implement room-scoped broadcasts
3. Add presence tracking
4. Update session-manager.ts for roomId support
5. Test multi-user room sessions
### Phase 3: Guest User System (Day 3)
1. Implement guest ID generation
2. Add guest user hooks
3. Update auth flow for optional guest access
4. Test guest join/leave flow
### Phase 4: UI Components (Day 4-5)
1. Build CreateRoomDialog
2. Build RoomLobby component
3. Build RoomLobbyBrowser (public lobby)
4. Add RoomContextIndicator to nav
5. Wire up all hooks
### Phase 5: Routes & Navigation (Day 5-6)
1. Create /arcade/rooms routes
2. Update arcade home for room access
3. Add room selector to nav
4. Implement join-by-code flow
5. Add share room functionality
### Phase 6: Testing & Polish (Day 6-7)
1. E2E tests for room creation/join
2. Multi-user gameplay tests
3. TTL and cleanup tests
4. Guest user flow tests
5. Performance testing
6. UI polish and error states
---
## 13. Backward Compatibility
### Solo Play Preserved
- Existing `/arcade/matching` routes work unchanged
- `roomId = null` for solo sessions
- No breaking changes to `useArcadeSession`
- All current functionality remains intact
### Migration Strategy
- Add `roomId` column to `arcade_sessions` (nullable)
- Existing sessions have `roomId = null`
- New room-based sessions have `roomId` populated
- Session logic checks: `if (roomId) { /* room mode */ }`
---
## 14. Security Considerations
### Room Access
- ✅ Validate room membership before allowing game moves
- ✅ Check `isCreator` flag for moderation actions
- ✅ Prevent joining locked rooms
- ✅ Rate limit room creation per IP/user
- ✅ Sanitize room names (max length, no XSS)
### Guest Users
- ✅ Guest IDs stored client-side only (localStorage)
- ✅ No sensitive data in guest profiles
- ✅ Guest names sanitized
- ✅ Rate limit guest actions
- ✅ Allow authenticated users to "claim" guest activity
### Room Moderation
- ✅ Only creator can kick/lock/delete
- ✅ Kicked users cannot rejoin unless creator unlocks
- ✅ Room deletion clears all associated data
- ✅ Audit log for moderation actions
---
## 15. Testing Strategy
### Unit Tests
- Room CRUD operations
- Member join/leave logic
- Code generation uniqueness
- TTL cleanup
- Guest ID generation
- Access control validation
### Integration Tests
- Full room creation → join → play → leave flow
- Multi-user concurrent gameplay
- Socket.IO room broadcasts
- Session synchronization across tabs
- TTL expiration and cleanup
### E2E Tests (Playwright)
- Create room → share link → join as guest
- Browse lobby → join room → play game
- Creator kicks member
- Room locks and unlock
- Room TTL expiration
### Load Tests
- 100+ concurrent rooms
- 10+ players per room
- Rapid join/leave cycles
- Socket.IO message throughput
---
## 16. Performance Optimizations
### Database
- Index on `room_members.roomId` for fast member queries
- Index on `arcade_rooms.code` for quick code lookups
- Index on `room_members.isOnline` for presence queries
- Partition `arcade_rooms` by `createdAt` for TTL cleanup
### Caching
- Cache active room list (1-minute TTL)
- Cache room member counts
- Redis pub/sub for cross-server socket broadcasts (future)
### Socket.IO
- Use socket.io rooms for efficient broadcasting
- Batch presence updates (debounce member online status)
- Compress socket messages for large game states
---
## 17. Future Enhancements (Post-MVP)
1. **Room Templates** - Save room configurations
2. **Private Rooms** - Invite-only with passwords
3. **Room Chat** - Text chat in lobby
4. **Spectator Mode** - Watch games without playing
5. **Room History** - Past games and stats
6. **Tournament Brackets** - Multi-round competitions
7. **Room Search** - Search by name/tag
8. **Room Tags** - Categorize rooms
9. **Friend Integration** - Invite friends directly
10. **Room Analytics** - Popular times, average players, etc.
---
## 18. Open Questions / Decisions Needed
1. **Room Name Validation** - Max length? Profanity filter?
2. **Default TTL** - 60 minutes good default?
3. **Code Reuse** - Can codes be reused after room expires?
4. **Member Limit** - Even though unlimited, warn at certain threshold?
5. **Lobby Pagination** - How many rooms to show initially?
---
## 19. Success Metrics
- ✅ Users can create and join rooms
- ✅ Guest users can participate without auth
- ✅ Multi-user gameplay synchronized across all room members
- ✅ Room creators can moderate effectively
- ✅ Rooms expire correctly based on TTL
- ✅ Public lobby shows active rooms
- ✅ No regressions in solo play mode
- ✅ All tests passing (unit, integration, e2e)
---
## 20. Dependencies
### Existing Systems to Leverage
- ✅ Current arcade session management
- ✅ Existing WebSocket infrastructure (socket-server.ts)
- ✅ Database setup (PostgreSQL)
- ✅ Guest player system (from GameModeContext)
### New Dependencies (if needed)
- None! All can be built with existing stack
---
## Implementation Checklist
- [ ] Create database migration
- [ ] Implement room-manager.ts
- [ ] Implement room-membership.ts
- [ ] Build API endpoints
- [ ] Add room code generation
- [ ] Update socket-server.ts
- [ ] Implement guest ID system
- [ ] Build CreateRoomDialog
- [ ] Build RoomLobby component
- [ ] Build RoomLobbyBrowser
- [ ] Add room routes
- [ ] Update navigation
- [ ] Write unit tests
- [ ] Write integration tests
- [ ] Write e2e tests
- [ ] Documentation
- [ ] Deploy
---
**Ready to implement! 🚀**

View File

@@ -0,0 +1,153 @@
# User vs Player vs Room Member - Terminology Guide
**Critical Distinction**: Users, Players, and Room Members are three different concepts in the system.
## Core Concepts
### 1. **USER** (Identity Layer)
- **Table**: `users`
- **Purpose**: Identity - guest or authenticated account
- **Identified by**: `guestId` (HttpOnly cookie)
- **Retrieved via**: `useViewerId()` hook
- **Scope**: One per browser/account
- **Example**: A person visiting the site
### 2. **PLAYER** (Game Avatar Layer)
- **Table**: `players`
- **Purpose**: Game profiles/avatars that represent a participant in the game
- **Belongs to**: USER (via `userId` FK)
- **Properties**: name, emoji, color, `isActive`
- **Scope**: A USER can have MULTIPLE players (e.g., "Alice 👧", "Bob 👦", "Charlie 🧒")
- **Used in**: All game contexts - both local and online multiplayer
- **Active Players**: Players where `isActive = true` are the ones currently participating
### 3. **ROOM MEMBER** (Room Participation Layer)
- **Table**: `room_members`
- **Purpose**: Tracks a USER's participation in a multiplayer room
- **Identified by**: `userId` (references the guest/user)
- **Properties**: `displayName`, `isCreator`, `isOnline`, `joinedAt`
- **Scope**: One record per USER per room
## How They Work Together
### When a USER joins a room:
1. **Room Member Created**: A `room_members` record is created with the USER's ID
2. **Active Players Join**: The USER's ACTIVE PLAYERS (where `isActive = true`) participate in the game
3. **Arcade Session**: The `arcade_sessions.activePlayers` field contains the PLAYER IDs (from `players` table)
### Example Flow:
```
USER: guest_abc123 (Jane)
├─ PLAYER: player_001 (name: "Alice 👧", isActive: true)
├─ PLAYER: player_002 (name: "Bob 👦", isActive: true)
└─ PLAYER: player_003 (name: "Charlie 🧒", isActive: false)
When USER joins ROOM "Math Masters":
→ ROOM_MEMBER created: {userId: "guest_abc123", displayName: "Jane", roomId: "room_xyz"}
→ PLAYERS joining game: ["player_001", "player_002"] (only active ones)
→ ARCADE_SESSION.activePlayers: ["player_001", "player_002"]
```
### Multi-User Room Example:
```
ROOM "Math Masters" (room_xyz):
ROOM_MEMBER 1:
userId: guest_abc123 (Jane)
└─ PLAYERS in game: ["player_001" (Alice), "player_002" (Bob)]
ROOM_MEMBER 2:
userId: guest_def456 (Mark)
└─ PLAYERS in game: ["player_003" (Mario)]
ROOM_MEMBER 3:
userId: guest_ghi789 (Sara)
└─ PLAYERS in game: ["player_004" (Luna), "player_005" (Nova), "player_006" (Star)]
Total PLAYERS in this game: 6 players across 3 users
```
## Database Schema Relationships
```
users (1) ──< (many) players
└──< (many) room_members
└──< belongs to arcade_rooms
arcade_sessions:
- userId: references users.id
- activePlayers: JSON array of player.id values
- roomId: references arcade_rooms.id (null for solo play)
```
## Implementation Rules
### ✅ Correct Usage
- **Room membership**: Track by USER ID
- **Game participation**: Track by PLAYER IDs
- **Presence/online status**: Track by USER ID (room member)
- **Scores/moves**: Track by PLAYER ID
- **Room creator**: Track by USER ID
### ❌ Common Mistakes
- ❌ Using USER ID where PLAYER ID is needed
- ❌ Assuming one USER = one PLAYER
- ❌ Tracking scores by USER instead of PLAYER
- ❌ Mixing room_members.displayName with players.name
## API Design Patterns
### When a USER joins a room:
```typescript
// 1. Add user as room member
POST /api/arcade/rooms/:roomId/join
Body: {
userId: string // USER ID (from useViewerId)
displayName: string // Room member display name
}
// 2. System retrieves user's active players
const activePlayers = await db.query.players.findMany({
where: and(
eq(players.userId, userId),
eq(players.isActive, true)
)
})
// 3. Game starts with those player IDs
const session = {
userId,
activePlayers: activePlayers.map(p => p.id), // PLAYER IDs
roomId
}
```
### Socket Events
```typescript
// User joins room (presence)
socket.emit('join-room', { roomId, userId })
// Player makes a move (game action)
socket.emit('game-move', {
roomId,
playerId, // PLAYER ID, not USER ID
move
})
```
## Summary
- **USER** = Identity/account (one per person)
- **PLAYER** = Game avatar/profile (multiple per user)
- **ROOM MEMBER** = USER's participation in a room
- **When USER joins room** → Their ACTIVE PLAYERS join the game
- **`activePlayers` field** → Array of PLAYER IDs from `players` table

View File

@@ -0,0 +1,12 @@
import type { Config } from 'drizzle-kit'
export default {
schema: './src/db/schema/index.ts',
out: './drizzle',
dialect: 'sqlite',
dbCredentials: {
url: process.env.DATABASE_URL || './data/sqlite.db',
},
verbose: true,
strict: true,
} satisfies Config

View File

@@ -0,0 +1,32 @@
CREATE TABLE `users` (
`id` text PRIMARY KEY NOT NULL,
`guest_id` text NOT NULL,
`created_at` integer NOT NULL,
`upgraded_at` integer,
`email` text,
`name` text
);
--> statement-breakpoint
CREATE UNIQUE INDEX `users_guest_id_unique` ON `users` (`guest_id`);--> statement-breakpoint
CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`);--> statement-breakpoint
CREATE TABLE `players` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`name` text NOT NULL,
`emoji` text NOT NULL,
`color` text NOT NULL,
`is_active` integer DEFAULT false NOT NULL,
`created_at` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `players_user_id_idx` ON `players` (`user_id`);--> statement-breakpoint
CREATE TABLE `user_stats` (
`user_id` text PRIMARY KEY NOT NULL,
`games_played` integer DEFAULT 0 NOT NULL,
`total_wins` integer DEFAULT 0 NOT NULL,
`favorite_game_type` text,
`best_time` integer,
`highest_accuracy` real DEFAULT 0 NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);

View File

@@ -0,0 +1,16 @@
CREATE TABLE `abacus_settings` (
`user_id` text PRIMARY KEY NOT NULL,
`color_scheme` text DEFAULT 'place-value' NOT NULL,
`bead_shape` text DEFAULT 'diamond' NOT NULL,
`color_palette` text DEFAULT 'default' NOT NULL,
`hide_inactive_beads` integer DEFAULT false NOT NULL,
`colored_numerals` integer DEFAULT false NOT NULL,
`scale_factor` real DEFAULT 1 NOT NULL,
`show_numbers` integer DEFAULT true NOT NULL,
`animated` integer DEFAULT true NOT NULL,
`interactive` integer DEFAULT false NOT NULL,
`gestures` integer DEFAULT false NOT NULL,
`sound_enabled` integer DEFAULT true NOT NULL,
`sound_volume` real DEFAULT 0.8 NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);

View File

@@ -0,0 +1,13 @@
CREATE TABLE `arcade_sessions` (
`user_id` text PRIMARY KEY NOT NULL,
`current_game` text NOT NULL,
`game_url` text NOT NULL,
`game_state` text NOT NULL,
`active_players` text NOT NULL,
`started_at` integer NOT NULL,
`last_activity_at` integer NOT NULL,
`expires_at` integer NOT NULL,
`is_active` integer DEFAULT true NOT NULL,
`version` integer DEFAULT 1 NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);

View File

@@ -0,0 +1,31 @@
CREATE TABLE `arcade_rooms` (
`id` text PRIMARY KEY NOT NULL,
`code` text(6) NOT NULL,
`name` text(50) NOT NULL,
`created_by` text NOT NULL,
`creator_name` text(50) NOT NULL,
`created_at` integer NOT NULL,
`last_activity` integer NOT NULL,
`ttl_minutes` integer DEFAULT 60 NOT NULL,
`is_locked` integer DEFAULT false NOT NULL,
`game_name` text NOT NULL,
`game_config` text NOT NULL,
`status` text DEFAULT 'lobby' NOT NULL,
`current_session_id` text,
`total_games_played` integer DEFAULT 0 NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `arcade_rooms_code_unique` ON `arcade_rooms` (`code`);--> statement-breakpoint
CREATE TABLE `room_members` (
`id` text PRIMARY KEY NOT NULL,
`room_id` text NOT NULL,
`user_id` text NOT NULL,
`display_name` text(50) NOT NULL,
`is_creator` integer DEFAULT false NOT NULL,
`joined_at` integer NOT NULL,
`last_seen` integer NOT NULL,
`is_online` integer DEFAULT true NOT NULL,
FOREIGN KEY (`room_id`) REFERENCES `arcade_rooms`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
ALTER TABLE `arcade_sessions` ADD `room_id` text REFERENCES arcade_rooms(id);

View File

@@ -0,0 +1,15 @@
-- Step 1: Clean up any duplicate room memberships
-- Keep only the most recent membership for each user (by last_seen timestamp)
DELETE FROM `room_members`
WHERE `id` NOT IN (
SELECT `id` FROM (
SELECT `id`, ROW_NUMBER() OVER (
PARTITION BY `user_id`
ORDER BY `last_seen` DESC, `joined_at` DESC
) as rn
FROM `room_members`
) WHERE rn = 1
);--> statement-breakpoint
-- Step 2: Add unique constraint to enforce one room per user
CREATE UNIQUE INDEX `idx_room_members_user_id_unique` ON `room_members` (`user_id`);

View File

@@ -0,0 +1,222 @@
{
"version": "6",
"dialect": "sqlite",
"id": "949424bf-1933-497c-af2d-cab6ee81083d",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"guest_id": {
"name": "guest_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"upgraded_at": {
"name": "upgraded_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"users_guest_id_unique": {
"name": "users_guest_id_unique",
"columns": ["guest_id"],
"isUnique": true
},
"users_email_unique": {
"name": "users_email_unique",
"columns": ["email"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"players": {
"name": "players",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"emoji": {
"name": "emoji",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"color": {
"name": "color",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_active": {
"name": "is_active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"players_user_id_idx": {
"name": "players_user_id_idx",
"columns": ["user_id"],
"isUnique": false
}
},
"foreignKeys": {
"players_user_id_users_id_fk": {
"name": "players_user_id_users_id_fk",
"tableFrom": "players",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user_stats": {
"name": "user_stats",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"games_played": {
"name": "games_played",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"total_wins": {
"name": "total_wins",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"favorite_game_type": {
"name": "favorite_game_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"best_time": {
"name": "best_time",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"highest_accuracy": {
"name": "highest_accuracy",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {},
"foreignKeys": {
"user_stats_user_id_users_id_fk": {
"name": "user_stats_user_id_users_id_fk",
"tableFrom": "user_stats",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,345 @@
{
"version": "6",
"dialect": "sqlite",
"id": "25d98633-0bae-4f6e-845b-ed87f53fc233",
"prevId": "949424bf-1933-497c-af2d-cab6ee81083d",
"tables": {
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"guest_id": {
"name": "guest_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"upgraded_at": {
"name": "upgraded_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"users_guest_id_unique": {
"name": "users_guest_id_unique",
"columns": ["guest_id"],
"isUnique": true
},
"users_email_unique": {
"name": "users_email_unique",
"columns": ["email"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"players": {
"name": "players",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"emoji": {
"name": "emoji",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"color": {
"name": "color",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_active": {
"name": "is_active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"players_user_id_idx": {
"name": "players_user_id_idx",
"columns": ["user_id"],
"isUnique": false
}
},
"foreignKeys": {
"players_user_id_users_id_fk": {
"name": "players_user_id_users_id_fk",
"tableFrom": "players",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user_stats": {
"name": "user_stats",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"games_played": {
"name": "games_played",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"total_wins": {
"name": "total_wins",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"favorite_game_type": {
"name": "favorite_game_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"best_time": {
"name": "best_time",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"highest_accuracy": {
"name": "highest_accuracy",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {},
"foreignKeys": {
"user_stats_user_id_users_id_fk": {
"name": "user_stats_user_id_users_id_fk",
"tableFrom": "user_stats",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"abacus_settings": {
"name": "abacus_settings",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"color_scheme": {
"name": "color_scheme",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'place-value'"
},
"bead_shape": {
"name": "bead_shape",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'diamond'"
},
"color_palette": {
"name": "color_palette",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'default'"
},
"hide_inactive_beads": {
"name": "hide_inactive_beads",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"colored_numerals": {
"name": "colored_numerals",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"scale_factor": {
"name": "scale_factor",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"show_numbers": {
"name": "show_numbers",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"animated": {
"name": "animated",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"interactive": {
"name": "interactive",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"gestures": {
"name": "gestures",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"sound_enabled": {
"name": "sound_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"sound_volume": {
"name": "sound_volume",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0.8
}
},
"indexes": {},
"foreignKeys": {
"abacus_settings_user_id_users_id_fk": {
"name": "abacus_settings_user_id_users_id_fk",
"tableFrom": "abacus_settings",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,437 @@
{
"version": "6",
"dialect": "sqlite",
"id": "194ccc68-7173-44c9-879a-55d20cf3ae1f",
"prevId": "25d98633-0bae-4f6e-845b-ed87f53fc233",
"tables": {
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"guest_id": {
"name": "guest_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"upgraded_at": {
"name": "upgraded_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"users_guest_id_unique": {
"name": "users_guest_id_unique",
"columns": ["guest_id"],
"isUnique": true
},
"users_email_unique": {
"name": "users_email_unique",
"columns": ["email"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"players": {
"name": "players",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"emoji": {
"name": "emoji",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"color": {
"name": "color",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_active": {
"name": "is_active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"players_user_id_idx": {
"name": "players_user_id_idx",
"columns": ["user_id"],
"isUnique": false
}
},
"foreignKeys": {
"players_user_id_users_id_fk": {
"name": "players_user_id_users_id_fk",
"tableFrom": "players",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user_stats": {
"name": "user_stats",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"games_played": {
"name": "games_played",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"total_wins": {
"name": "total_wins",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"favorite_game_type": {
"name": "favorite_game_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"best_time": {
"name": "best_time",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"highest_accuracy": {
"name": "highest_accuracy",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {},
"foreignKeys": {
"user_stats_user_id_users_id_fk": {
"name": "user_stats_user_id_users_id_fk",
"tableFrom": "user_stats",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"abacus_settings": {
"name": "abacus_settings",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"color_scheme": {
"name": "color_scheme",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'place-value'"
},
"bead_shape": {
"name": "bead_shape",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'diamond'"
},
"color_palette": {
"name": "color_palette",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'default'"
},
"hide_inactive_beads": {
"name": "hide_inactive_beads",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"colored_numerals": {
"name": "colored_numerals",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"scale_factor": {
"name": "scale_factor",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"show_numbers": {
"name": "show_numbers",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"animated": {
"name": "animated",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"interactive": {
"name": "interactive",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"gestures": {
"name": "gestures",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"sound_enabled": {
"name": "sound_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"sound_volume": {
"name": "sound_volume",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0.8
}
},
"indexes": {},
"foreignKeys": {
"abacus_settings_user_id_users_id_fk": {
"name": "abacus_settings_user_id_users_id_fk",
"tableFrom": "abacus_settings",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"arcade_sessions": {
"name": "arcade_sessions",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"current_game": {
"name": "current_game",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"game_url": {
"name": "game_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"game_state": {
"name": "game_state",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"active_players": {
"name": "active_players",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"started_at": {
"name": "started_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"last_activity_at": {
"name": "last_activity_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_active": {
"name": "is_active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"version": {
"name": "version",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
}
},
"indexes": {},
"foreignKeys": {
"arcade_sessions_user_id_users_id_fk": {
"name": "arcade_sessions_user_id_users_id_fk",
"tableFrom": "arcade_sessions",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,649 @@
{
"version": "6",
"dialect": "sqlite",
"id": "68cc273f-0d84-4a46-ae41-124a3e06096b",
"prevId": "194ccc68-7173-44c9-879a-55d20cf3ae1f",
"tables": {
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"guest_id": {
"name": "guest_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"upgraded_at": {
"name": "upgraded_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"users_guest_id_unique": {
"name": "users_guest_id_unique",
"columns": ["guest_id"],
"isUnique": true
},
"users_email_unique": {
"name": "users_email_unique",
"columns": ["email"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"players": {
"name": "players",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"emoji": {
"name": "emoji",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"color": {
"name": "color",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_active": {
"name": "is_active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"players_user_id_idx": {
"name": "players_user_id_idx",
"columns": ["user_id"],
"isUnique": false
}
},
"foreignKeys": {
"players_user_id_users_id_fk": {
"name": "players_user_id_users_id_fk",
"tableFrom": "players",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user_stats": {
"name": "user_stats",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"games_played": {
"name": "games_played",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"total_wins": {
"name": "total_wins",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"favorite_game_type": {
"name": "favorite_game_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"best_time": {
"name": "best_time",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"highest_accuracy": {
"name": "highest_accuracy",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {},
"foreignKeys": {
"user_stats_user_id_users_id_fk": {
"name": "user_stats_user_id_users_id_fk",
"tableFrom": "user_stats",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"abacus_settings": {
"name": "abacus_settings",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"color_scheme": {
"name": "color_scheme",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'place-value'"
},
"bead_shape": {
"name": "bead_shape",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'diamond'"
},
"color_palette": {
"name": "color_palette",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'default'"
},
"hide_inactive_beads": {
"name": "hide_inactive_beads",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"colored_numerals": {
"name": "colored_numerals",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"scale_factor": {
"name": "scale_factor",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"show_numbers": {
"name": "show_numbers",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"animated": {
"name": "animated",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"interactive": {
"name": "interactive",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"gestures": {
"name": "gestures",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"sound_enabled": {
"name": "sound_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"sound_volume": {
"name": "sound_volume",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0.8
}
},
"indexes": {},
"foreignKeys": {
"abacus_settings_user_id_users_id_fk": {
"name": "abacus_settings_user_id_users_id_fk",
"tableFrom": "abacus_settings",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"arcade_rooms": {
"name": "arcade_rooms",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"code": {
"name": "code",
"type": "text(6)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_by": {
"name": "created_by",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"creator_name": {
"name": "creator_name",
"type": "text(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"last_activity": {
"name": "last_activity",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ttl_minutes": {
"name": "ttl_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 60
},
"is_locked": {
"name": "is_locked",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"game_name": {
"name": "game_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"game_config": {
"name": "game_config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'lobby'"
},
"current_session_id": {
"name": "current_session_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"total_games_played": {
"name": "total_games_played",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {
"arcade_rooms_code_unique": {
"name": "arcade_rooms_code_unique",
"columns": ["code"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"room_members": {
"name": "room_members",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"room_id": {
"name": "room_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"display_name": {
"name": "display_name",
"type": "text(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_creator": {
"name": "is_creator",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"joined_at": {
"name": "joined_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"last_seen": {
"name": "last_seen",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_online": {
"name": "is_online",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
}
},
"indexes": {},
"foreignKeys": {
"room_members_room_id_arcade_rooms_id_fk": {
"name": "room_members_room_id_arcade_rooms_id_fk",
"tableFrom": "room_members",
"tableTo": "arcade_rooms",
"columnsFrom": ["room_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"arcade_sessions": {
"name": "arcade_sessions",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"current_game": {
"name": "current_game",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"game_url": {
"name": "game_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"game_state": {
"name": "game_state",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"active_players": {
"name": "active_players",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"room_id": {
"name": "room_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"started_at": {
"name": "started_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"last_activity_at": {
"name": "last_activity_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_active": {
"name": "is_active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"version": {
"name": "version",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
}
},
"indexes": {},
"foreignKeys": {
"arcade_sessions_user_id_users_id_fk": {
"name": "arcade_sessions_user_id_users_id_fk",
"tableFrom": "arcade_sessions",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"arcade_sessions_room_id_arcade_rooms_id_fk": {
"name": "arcade_sessions_room_id_arcade_rooms_id_fk",
"tableFrom": "arcade_sessions",
"tableTo": "arcade_rooms",
"columnsFrom": ["room_id"],
"columnsTo": ["id"],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,660 @@
{
"version": "6",
"dialect": "sqlite",
"id": "cbd94d51-1454-467c-a471-ccbfca886a1a",
"prevId": "68cc273f-0d84-4a46-ae41-124a3e06096b",
"tables": {
"abacus_settings": {
"name": "abacus_settings",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"color_scheme": {
"name": "color_scheme",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'place-value'"
},
"bead_shape": {
"name": "bead_shape",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'diamond'"
},
"color_palette": {
"name": "color_palette",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'default'"
},
"hide_inactive_beads": {
"name": "hide_inactive_beads",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"colored_numerals": {
"name": "colored_numerals",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"scale_factor": {
"name": "scale_factor",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"show_numbers": {
"name": "show_numbers",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"animated": {
"name": "animated",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"interactive": {
"name": "interactive",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"gestures": {
"name": "gestures",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"sound_enabled": {
"name": "sound_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"sound_volume": {
"name": "sound_volume",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0.8
}
},
"indexes": {},
"foreignKeys": {
"abacus_settings_user_id_users_id_fk": {
"name": "abacus_settings_user_id_users_id_fk",
"tableFrom": "abacus_settings",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"arcade_rooms": {
"name": "arcade_rooms",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"code": {
"name": "code",
"type": "text(6)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_by": {
"name": "created_by",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"creator_name": {
"name": "creator_name",
"type": "text(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"last_activity": {
"name": "last_activity",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ttl_minutes": {
"name": "ttl_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 60
},
"is_locked": {
"name": "is_locked",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"game_name": {
"name": "game_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"game_config": {
"name": "game_config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'lobby'"
},
"current_session_id": {
"name": "current_session_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"total_games_played": {
"name": "total_games_played",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {
"arcade_rooms_code_unique": {
"name": "arcade_rooms_code_unique",
"columns": ["code"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"arcade_sessions": {
"name": "arcade_sessions",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"current_game": {
"name": "current_game",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"game_url": {
"name": "game_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"game_state": {
"name": "game_state",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"active_players": {
"name": "active_players",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"room_id": {
"name": "room_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"started_at": {
"name": "started_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"last_activity_at": {
"name": "last_activity_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_active": {
"name": "is_active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"version": {
"name": "version",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
}
},
"indexes": {},
"foreignKeys": {
"arcade_sessions_user_id_users_id_fk": {
"name": "arcade_sessions_user_id_users_id_fk",
"tableFrom": "arcade_sessions",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"arcade_sessions_room_id_arcade_rooms_id_fk": {
"name": "arcade_sessions_room_id_arcade_rooms_id_fk",
"tableFrom": "arcade_sessions",
"tableTo": "arcade_rooms",
"columnsFrom": ["room_id"],
"columnsTo": ["id"],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"players": {
"name": "players",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"emoji": {
"name": "emoji",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"color": {
"name": "color",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_active": {
"name": "is_active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"players_user_id_idx": {
"name": "players_user_id_idx",
"columns": ["user_id"],
"isUnique": false
}
},
"foreignKeys": {
"players_user_id_users_id_fk": {
"name": "players_user_id_users_id_fk",
"tableFrom": "players",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"room_members": {
"name": "room_members",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"room_id": {
"name": "room_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"display_name": {
"name": "display_name",
"type": "text(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_creator": {
"name": "is_creator",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"joined_at": {
"name": "joined_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"last_seen": {
"name": "last_seen",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_online": {
"name": "is_online",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
}
},
"indexes": {
"room_members_user_id_unique": {
"name": "room_members_user_id_unique",
"columns": ["user_id"],
"isUnique": true
},
"idx_room_members_user_id_unique": {
"name": "idx_room_members_user_id_unique",
"columns": ["user_id"],
"isUnique": true
}
},
"foreignKeys": {
"room_members_room_id_arcade_rooms_id_fk": {
"name": "room_members_room_id_arcade_rooms_id_fk",
"tableFrom": "room_members",
"tableTo": "arcade_rooms",
"columnsFrom": ["room_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user_stats": {
"name": "user_stats",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"games_played": {
"name": "games_played",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"total_wins": {
"name": "total_wins",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"favorite_game_type": {
"name": "favorite_game_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"best_time": {
"name": "best_time",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"highest_accuracy": {
"name": "highest_accuracy",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {},
"foreignKeys": {
"user_stats_user_id_users_id_fk": {
"name": "user_stats_user_id_users_id_fk",
"tableFrom": "user_stats",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"guest_id": {
"name": "guest_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"upgraded_at": {
"name": "upgraded_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"users_guest_id_unique": {
"name": "users_guest_id_unique",
"columns": ["guest_id"],
"isUnique": true
},
"users_email_unique": {
"name": "users_email_unique",
"columns": ["email"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,41 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1759701472375,
"tag": "0000_third_carnage",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1759706755851,
"tag": "0001_friendly_stingray",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1759752980924,
"tag": "0002_loose_ultimatum",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1759781243105,
"tag": "0003_naive_reptil",
"breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1759930182541,
"tag": "0004_shiny_madelyne_pryor",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1,338 @@
import { expect, test } from '@playwright/test'
/**
* Arcade Modal Session E2E Tests
*
* These tests verify that the arcade modal session system works correctly:
* - Users are locked into games once they start
* - Automatic redirects to active games
* - Player modification is blocked during games
* - "Return to Arcade" button properly ends sessions
*/
test.describe('Arcade Modal Session - Redirects', () => {
test.beforeEach(async ({ page }) => {
// Clear arcade session before each test
await page.goto('/arcade')
await page.waitForLoadState('networkidle')
// Click "Return to Arcade" button if it exists (to clear any existing session)
const returnButton = page.locator('button:has-text("Return to Arcade")')
if (await returnButton.isVisible({ timeout: 1000 }).catch(() => false)) {
await returnButton.click()
await page.waitForLoadState('networkidle')
}
})
test('should stay on arcade lobby when no active session', async ({ page }) => {
await page.goto('/arcade')
await page.waitForLoadState('networkidle')
// Should see "Champion Arena" title
const title = page.locator('h1:has-text("Champion Arena")')
await expect(title).toBeVisible()
// Should be able to select players
const playerSection = page.locator('text=/Player|Select|Add/i')
await expect(playerSection.first()).toBeVisible()
})
test('should redirect from arcade to active game when session exists', async ({ page }) => {
// Start a game to create a session
await page.goto('/arcade')
await page.waitForLoadState('networkidle')
// Find and click a player card to activate
const playerCard = page.locator('[data-testid="player-card"]').first()
if (await playerCard.isVisible({ timeout: 2000 }).catch(() => false)) {
await playerCard.click()
await page.waitForTimeout(500)
}
// Navigate to matching game to create session
await page.goto('/arcade/matching')
await page.waitForLoadState('networkidle')
// Start the game (click Start button if visible)
const startButton = page.locator('button:has-text("Start")')
if (await startButton.isVisible({ timeout: 2000 }).catch(() => false)) {
await startButton.click()
await page.waitForTimeout(1000)
}
// Try to navigate back to arcade lobby
await page.goto('/arcade')
await page.waitForTimeout(2000) // Give time for redirect
// Should be redirected back to the game
await expect(page).toHaveURL(/\/arcade\/matching/)
const gameTitle = page.locator('h1:has-text("Memory Pairs")')
await expect(gameTitle).toBeVisible()
})
test('should redirect to correct game when navigating to wrong game', async ({ page }) => {
// Create a session with matching game
await page.goto('/arcade')
await page.waitForLoadState('networkidle')
// Activate a player
const addPlayerButton = page.locator('button:has-text("Add Player"), button:has-text("+")')
if (
await addPlayerButton
.first()
.isVisible({ timeout: 2000 })
.catch(() => false)
) {
await addPlayerButton.first().click()
await page.waitForTimeout(500)
}
// Go to matching game
await page.goto('/arcade/matching')
await page.waitForLoadState('networkidle')
// Start game if needed
const startButton = page.locator('button:has-text("Start")')
if (await startButton.isVisible({ timeout: 2000 }).catch(() => false)) {
await startButton.click()
await page.waitForTimeout(1000)
}
// Try to navigate to a different game
await page.goto('/arcade/memory-quiz')
await page.waitForTimeout(2000) // Give time for redirect
// Should be redirected back to matching
await expect(page).toHaveURL(/\/arcade\/matching/)
})
test('should NOT redirect when on correct game page', async ({ page }) => {
// Navigate to matching game
await page.goto('/arcade/matching')
await page.waitForLoadState('networkidle')
// Should stay on matching page
await expect(page).toHaveURL(/\/arcade\/matching/)
const gameTitle = page.locator('h1:has-text("Memory Pairs")')
await expect(gameTitle).toBeVisible()
})
})
test.describe('Arcade Modal Session - Player Modification Blocking', () => {
test.beforeEach(async ({ page }) => {
// Clear session
await page.goto('/arcade')
await page.waitForLoadState('networkidle')
const returnButton = page.locator('button:has-text("Return to Arcade")')
if (await returnButton.isVisible({ timeout: 1000 }).catch(() => false)) {
await returnButton.click()
await page.waitForLoadState('networkidle')
}
})
test('should allow player modification in arcade lobby with no session', async ({ page }) => {
await page.goto('/arcade')
await page.waitForLoadState('networkidle')
// Look for add player button (should be enabled)
const addPlayerButton = page.locator('button:has-text("Add Player"), button:has-text("+")')
const firstButton = addPlayerButton.first()
if (await firstButton.isVisible({ timeout: 2000 }).catch(() => false)) {
// Should be clickable
await expect(firstButton).toBeEnabled()
// Try to click it
await firstButton.click()
await page.waitForTimeout(500)
// Should see player added
const activePlayer = page.locator('[data-testid="active-player"]')
await expect(activePlayer.first()).toBeVisible({ timeout: 3000 })
}
})
test('should block player modification during active game', async ({ page }) => {
// Start a game
await page.goto('/arcade/matching')
await page.waitForLoadState('networkidle')
// Start game
const startButton = page.locator('button:has-text("Start")')
if (await startButton.isVisible({ timeout: 2000 }).catch(() => false)) {
await startButton.click()
await page.waitForTimeout(1000)
}
// Look for player modification controls
// They should be disabled or have reduced opacity
const playerControls = page.locator('[data-testid="player-controls"], .player-list')
if (await playerControls.isVisible({ timeout: 1000 }).catch(() => false)) {
// Check if controls have pointer-events: none or low opacity
const opacity = await playerControls.evaluate((el) => {
return window.getComputedStyle(el).opacity
})
// If controls are visible, they should be dimmed (opacity < 1)
if (parseFloat(opacity) < 1) {
expect(parseFloat(opacity)).toBeLessThan(1)
}
}
// "Add Player" button should not be visible during game
const addPlayerButton = page.locator('button:has-text("Add Player")')
if (await addPlayerButton.isVisible({ timeout: 500 }).catch(() => false)) {
// If visible, should be disabled
const isDisabled = await addPlayerButton.isDisabled()
expect(isDisabled).toBe(true)
}
})
test('should show "Return to Arcade" button during game', async ({ page }) => {
// Start a game
await page.goto('/arcade/matching')
await page.waitForLoadState('networkidle')
// Look for "Return to Arcade" button
const returnButton = page.locator('button:has-text("Return to Arcade")')
// During game setup, might see "Setup" button instead
const setupButton = page.locator('button:has-text("Setup")')
// One of these should be visible
const hasReturnButton = await returnButton.isVisible({ timeout: 2000 }).catch(() => false)
const hasSetupButton = await setupButton.isVisible({ timeout: 2000 }).catch(() => false)
expect(hasReturnButton || hasSetupButton).toBe(true)
})
test('should NOT show "Setup" button in arcade lobby with no session', async ({ page }) => {
await page.goto('/arcade')
await page.waitForLoadState('networkidle')
// Should NOT see "Return to Arcade" or "Setup" button in lobby
const returnButton = page.locator('button:has-text("Return to Arcade")')
const setupButton = page.locator('button:has-text("Setup")')
const hasReturnButton = await returnButton.isVisible({ timeout: 1000 }).catch(() => false)
const hasSetupButton = await setupButton.isVisible({ timeout: 1000 }).catch(() => false)
// Neither should be visible in empty lobby
expect(hasReturnButton).toBe(false)
expect(hasSetupButton).toBe(false)
})
})
test.describe('Arcade Modal Session - Return to Arcade Button', () => {
test.beforeEach(async ({ page }) => {
// Clear session
await page.goto('/arcade')
await page.waitForLoadState('networkidle')
})
test('should end session and return to arcade when clicking "Return to Arcade"', async ({
page,
}) => {
// Start a game
await page.goto('/arcade/matching')
await page.waitForLoadState('networkidle')
// Start game if needed
const startButton = page.locator('button:has-text("Start")')
if (await startButton.isVisible({ timeout: 2000 }).catch(() => false)) {
await startButton.click()
await page.waitForTimeout(1000)
}
// Find and click "Return to Arcade" button
const returnButton = page.locator('button:has-text("Return to Arcade")')
if (await returnButton.isVisible({ timeout: 2000 }).catch(() => false)) {
await returnButton.click()
await page.waitForTimeout(1000)
// Should be redirected to arcade lobby
await expect(page).toHaveURL(/\/arcade\/?$/)
// Should see arcade lobby title
const title = page.locator('h1:has-text("Champion Arena")')
await expect(title).toBeVisible()
// Now should be able to modify players again
const addPlayerButton = page.locator('button:has-text("Add Player"), button:has-text("+")')
if (
await addPlayerButton
.first()
.isVisible({ timeout: 2000 })
.catch(() => false)
) {
await expect(addPlayerButton.first()).toBeEnabled()
}
}
})
test('should allow navigating to different game after returning to arcade', async ({ page }) => {
// Start matching game
await page.goto('/arcade/matching')
await page.waitForLoadState('networkidle')
// Return to arcade
const returnButton = page.locator(
'button:has-text("Return to Arcade"), button:has-text("Setup")'
)
if (
await returnButton
.first()
.isVisible({ timeout: 2000 })
.catch(() => false)
) {
await returnButton.first().click()
await page.waitForTimeout(1000)
}
// Should be in arcade lobby
await expect(page).toHaveURL(/\/arcade\/?$/)
// Now navigate to different game - should NOT redirect back to matching
await page.goto('/arcade/memory-quiz')
await page.waitForTimeout(2000)
// Should stay on memory-quiz (not redirect back to matching)
await expect(page).toHaveURL(/\/arcade\/memory-quiz/)
// Should see memory quiz title
const title = page.locator('h1:has-text("Memory Lightning")')
await expect(title).toBeVisible({ timeout: 3000 })
})
})
test.describe('Arcade Modal Session - Session Persistence', () => {
test('should maintain active session across page reloads', async ({ page }) => {
// Start a game
await page.goto('/arcade/matching')
await page.waitForLoadState('networkidle')
// Start game
const startButton = page.locator('button:has-text("Start")')
if (await startButton.isVisible({ timeout: 2000 }).catch(() => false)) {
await startButton.click()
await page.waitForTimeout(1000)
}
// Reload the page
await page.reload()
await page.waitForLoadState('networkidle')
// Should still be on matching game
await expect(page).toHaveURL(/\/arcade\/matching/)
const gameTitle = page.locator('h1:has-text("Memory Pairs")')
await expect(gameTitle).toBeVisible()
// Try to navigate to arcade
await page.goto('/arcade')
await page.waitForTimeout(2000)
// Should be redirected back to matching
await expect(page).toHaveURL(/\/arcade\/matching/)
})
})

View File

@@ -1,7 +1,9 @@
import { test, expect } from '@playwright/test'
import { expect, test } from '@playwright/test'
test.describe('Mini Navigation Game Name Persistence', () => {
test('should not show game name when navigating back to games page from a specific game', async ({ page }) => {
test('should not show game name when navigating back to games page from a specific game', async ({
page,
}) => {
// Override baseURL for this test to match running dev server
const baseURL = 'http://localhost:3000'
@@ -73,7 +75,9 @@ test.describe('Mini Navigation Game Name Persistence', () => {
await expect(page.locator('text=🧠 Memory Lightning')).not.toBeVisible()
})
test('should not persist game name when navigating through intermediate pages', async ({ page }) => {
test('should not persist game name when navigating through intermediate pages', async ({
page,
}) => {
// Override baseURL for this test to match running dev server
const baseURL = 'http://localhost:3000'
@@ -108,4 +112,4 @@ test.describe('Mini Navigation Game Name Persistence', () => {
await expect(memoryLightningName).not.toBeVisible()
await expect(memoryPairsName).not.toBeVisible()
})
})
})

View File

@@ -1,7 +1,9 @@
import { test, expect } from '@playwright/test'
import { expect, test } from '@playwright/test'
test.describe('Game navigation slots', () => {
test('should show Memory Pairs game name in nav when navigating to matching game', async ({ page }) => {
test('should show Memory Pairs game name in nav when navigating to matching game', async ({
page,
}) => {
await page.goto('/games/matching')
// Wait for the page to load
@@ -13,7 +15,9 @@ test.describe('Game navigation slots', () => {
await expect(gameNav).toContainText('Memory Pairs')
})
test('should show Memory Lightning game name in nav when navigating to memory quiz', async ({ page }) => {
test('should show Memory Lightning game name in nav when navigating to memory quiz', async ({
page,
}) => {
await page.goto('/games/memory-quiz')
// Wait for the page to load
@@ -70,4 +74,4 @@ test.describe('Game navigation slots', () => {
const gameNavs = page.locator('h1:has-text("Memory Pairs"), h1:has-text("Memory Lightning")')
await expect(gameNavs).toHaveCount(0)
})
})
})

View File

@@ -1,4 +1,4 @@
import { test, expect } from '@playwright/test'
import { expect, test } from '@playwright/test'
test.describe('Sound Settings Persistence', () => {
test.beforeEach(async ({ page }) => {
@@ -14,13 +14,13 @@ test.describe('Sound Settings Persistence', () => {
await page.getByRole('button', { name: /style/i }).click()
// Find and toggle the sound switch (should be off by default)
const soundSwitch = page.locator('[role="switch"]').filter({ hasText: /sound/i }).or(
page.locator('input[type="checkbox"]').filter({ hasText: /sound/i })
).or(
page.getByLabel(/sound/i)
).or(
page.locator('button').filter({ hasText: /sound/i })
).first()
const soundSwitch = page
.locator('[role="switch"]')
.filter({ hasText: /sound/i })
.or(page.locator('input[type="checkbox"]').filter({ hasText: /sound/i }))
.or(page.getByLabel(/sound/i))
.or(page.locator('button').filter({ hasText: /sound/i }))
.first()
await soundSwitch.click()
@@ -37,13 +37,13 @@ test.describe('Sound Settings Persistence', () => {
await page.reload()
await page.getByRole('button', { name: /style/i }).click()
const soundSwitchAfterReload = page.locator('[role="switch"]').filter({ hasText: /sound/i }).or(
page.locator('input[type="checkbox"]').filter({ hasText: /sound/i })
).or(
page.getByLabel(/sound/i)
).or(
page.locator('button').filter({ hasText: /sound/i })
).first()
const soundSwitchAfterReload = page
.locator('[role="switch"]')
.filter({ hasText: /sound/i })
.or(page.locator('input[type="checkbox"]').filter({ hasText: /sound/i }))
.or(page.getByLabel(/sound/i))
.or(page.locator('button').filter({ hasText: /sound/i }))
.first()
await expect(soundSwitchAfterReload).toBeChecked()
})
@@ -55,9 +55,10 @@ test.describe('Sound Settings Persistence', () => {
await page.getByRole('button', { name: /style/i }).click()
// Find volume slider
const volumeSlider = page.locator('input[type="range"]').or(
page.locator('[role="slider"]')
).first()
const volumeSlider = page
.locator('input[type="range"]')
.or(page.locator('[role="slider"]'))
.first()
// Set volume to a specific value (e.g., 0.6)
await volumeSlider.fill('60') // Assuming 0-100 range
@@ -75,9 +76,10 @@ test.describe('Sound Settings Persistence', () => {
await page.reload()
await page.getByRole('button', { name: /style/i }).click()
const volumeSliderAfterReload = page.locator('input[type="range"]').or(
page.locator('[role="slider"]')
).first()
const volumeSliderAfterReload = page
.locator('input[type="range"]')
.or(page.locator('[role="slider"]'))
.first()
const volumeValue = await volumeSliderAfterReload.inputValue()
expect(parseFloat(volumeValue)).toBeCloseTo(60, 0) // Allow for some variance
@@ -116,4 +118,4 @@ test.describe('Sound Settings Persistence', () => {
expect(storedConfig.soundEnabled).toBe(true)
expect(storedConfig.soundVolume).toBe(0.8)
})
})
})

42
apps/web/eslint.config.js Normal file
View File

@@ -0,0 +1,42 @@
// Minimal ESLint flat config ONLY for react-hooks rules
import tsParser from '@typescript-eslint/parser'
import reactHooks from 'eslint-plugin-react-hooks'
const config = [
{ ignores: ['dist', '.next', 'coverage', 'node_modules', 'styled-system', 'storybook-static'] },
{
files: ['**/*.tsx', '**/*.ts', '**/*.jsx', '**/*.js'],
languageOptions: {
parser: tsParser,
ecmaVersion: 2022,
sourceType: 'module',
globals: {
React: 'readonly',
JSX: 'readonly',
console: 'readonly',
process: 'readonly',
module: 'readonly',
require: 'readonly',
window: 'readonly',
document: 'readonly',
localStorage: 'readonly',
sessionStorage: 'readonly',
fetch: 'readonly',
global: 'readonly',
Buffer: 'readonly',
__dirname: 'readonly',
__filename: 'readonly',
},
},
plugins: {
'react-hooks': reactHooks,
},
rules: {
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'off',
},
},
]
export default config

View File

@@ -1,6 +1,5 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
eslint: {
ignoreDuringBuilds: true,
},
@@ -64,4 +63,4 @@ const nextConfig = {
},
}
module.exports = nextConfig
module.exports = nextConfig

View File

@@ -3,27 +3,34 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "concurrently \"next dev\" \"npx @pandacss/dev --watch\"",
"build": "next build",
"start": "next start",
"lint": "next lint",
"dev": "tsc -p tsconfig.server.json && tsc-alias -p tsconfig.server.json && concurrently \"node server.js\" \"npx @pandacss/dev --watch\"",
"build": "node scripts/generate-build-info.js && tsc -p tsconfig.server.json && tsc-alias -p tsconfig.server.json && next build",
"start": "NODE_ENV=production node server.js",
"lint": "npx @biomejs/biome lint . && npx eslint .",
"lint:fix": "npx @biomejs/biome lint . --write && npx eslint . --fix",
"format": "npx @biomejs/biome format . --write",
"format:check": "npx @biomejs/biome format .",
"check": "npx @biomejs/biome check .",
"pre-commit": "npm run type-check && npm run format && npm run lint:fix && npm run lint",
"test": "vitest",
"test:run": "vitest run",
"type-check": "tsc --noEmit",
"clean": "rm -rf .next",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
"build-storybook": "storybook build",
"db:generate": "drizzle-kit generate",
"db:migrate": "tsx src/db/migrate.ts",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"db:drop": "drizzle-kit drop"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@myriaddreamin/typst-all-in-one.ts": "0.6.1-rc3",
"@myriaddreamin/typst-ts-renderer": "0.6.1-rc3",
"@myriaddreamin/typst-ts-web-compiler": "0.6.1-rc3",
"@myriaddreamin/typst.ts": "0.6.1-rc3",
"@number-flow/react": "^0.5.10",
"@pandacss/dev": "^0.20.0",
"@paralleldrive/cuid2": "^2.2.2",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.5",
@@ -45,15 +52,23 @@
"@soroban/core": "workspace:*",
"@soroban/templates": "workspace:*",
"@tanstack/react-form": "^0.19.0",
"@tanstack/react-query": "^5.90.2",
"@types/jsdom": "^21.1.7",
"better-sqlite3": "^12.4.1",
"drizzle-orm": "^0.44.6",
"emojibase-data": "^16.0.3",
"jose": "^6.1.0",
"lucide-react": "^0.294.0",
"make-plural": "^7.4.0",
"nanoid": "^5.1.6",
"next": "^14.2.32",
"next-auth": "5.0.0-beta.29",
"python-bridge": "^1.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-resizable-layout": "^0.7.3"
"react-resizable-layout": "^0.7.3",
"socket.io": "^4.8.1",
"socket.io-client": "^4.8.1"
},
"devDependencies": {
"@playwright/test": "^1.55.1",
@@ -62,17 +77,21 @@
"@storybook/nextjs": "^9.1.7",
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/react": "^16.3.0",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^20.0.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^5.0.2",
"concurrently": "^8.0.0",
"drizzle-kit": "^0.31.5",
"eslint": "^8.0.0",
"eslint-config-next": "^14.0.0",
"eslint-plugin-storybook": "^9.1.7",
"happy-dom": "^18.0.1",
"jsdom": "^27.0.0",
"storybook": "^9.1.7",
"tsc-alias": "^1.8.16",
"tsx": "^4.20.5",
"typescript": "^5.0.0",
"vitest": "^1.0.0"
},

View File

@@ -36,19 +36,81 @@ export default defineConfig({
wood: { value: '#8B4513' },
bead: { value: '#2C1810' },
inactive: { value: '#D3D3D3' },
bar: { value: '#654321' }
}
bar: { value: '#654321' },
},
},
fonts: {
body: { value: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif' },
heading: { value: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif' },
mono: { value: 'Monaco, "Cascadia Code", "Roboto Mono", Consolas, "Courier New", monospace' }
body: {
value: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
},
heading: {
value: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
},
mono: {
value: 'Monaco, "Cascadia Code", "Roboto Mono", Consolas, "Courier New", monospace',
},
},
shadows: {
card: { value: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)' },
modal: { value: '0 25px 50px -12px rgba(0, 0, 0, 0.25)' }
}
}
}
}
})
modal: { value: '0 25px 50px -12px rgba(0, 0, 0, 0.25)' },
},
animations: {
// Shake animation for errors (web_generator.py line 3419)
shake: { value: 'shake 0.5s ease-in-out' },
// Pulse animation for success feedback (line 2004)
successPulse: { value: 'successPulse 0.5s ease' },
pulse: { value: 'pulse 2s infinite' },
// Error shake with larger amplitude (line 2009)
errorShake: { value: 'errorShake 0.5s ease' },
// Bounce animations (line 6271, 5065)
bounce: { value: 'bounce 1s infinite alternate' },
bounceIn: { value: 'bounceIn 1s ease-out' },
// Glow animation (line 6260)
glow: { value: 'glow 1s ease-in-out infinite alternate' },
},
},
keyframes: {
// Shake - horizontal oscillation for errors (line 3419)
shake: {
'0%, 100%': { transform: 'translateX(0)' },
'25%': { transform: 'translateX(-5px)' },
'75%': { transform: 'translateX(5px)' },
},
// Success pulse - gentle scale for correct answers (line 2004)
successPulse: {
'0%, 100%': { transform: 'scale(1)' },
'50%': { transform: 'scale(1.05)' },
},
// Pulse - continuous breathing effect (line 6255)
pulse: {
'0%, 100%': { transform: 'scale(1)' },
'50%': { transform: 'scale(1.05)' },
},
// Error shake - stronger horizontal oscillation (line 2009)
errorShake: {
'0%, 100%': { transform: 'translateX(0)' },
'25%': { transform: 'translateX(-10px)' },
'75%': { transform: 'translateX(10px)' },
},
// Bounce - vertical oscillation (line 6271)
bounce: {
'0%, 100%': { transform: 'translateY(0)' },
'50%': { transform: 'translateY(-10px)' },
},
// Bounce in - entry animation with scale and rotate (line 6265)
bounceIn: {
'0%': { transform: 'scale(0.3) rotate(-10deg)', opacity: '0' },
'50%': { transform: 'scale(1.1) rotate(5deg)' },
'100%': { transform: 'scale(1) rotate(0deg)', opacity: '1' },
},
// Glow - expanding box shadow (line 6260)
glow: {
'0%': { boxShadow: '0 0 5px rgba(255, 255, 255, 0.5)' },
'100%': {
boxShadow: '0 0 20px rgba(255, 255, 255, 0.8), 0 0 30px rgba(255, 255, 255, 0.6)',
},
},
},
},
},
})

View File

@@ -24,4 +24,4 @@ export default defineConfig({
url: 'http://localhost:3002',
reuseExistingServer: !process.env.CI,
},
})
})

445
apps/web/pnpm-lock.yaml generated Normal file
View File

@@ -0,0 +1,445 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
'@radix-ui/react-tooltip':
specifier: ^1.2.8
version: 1.2.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@soroban/abacus-react':
specifier: workspace:*
version: link:../../packages/abacus-react
'@soroban/client':
specifier: workspace:*
version: link:../../packages/core/client/typescript
'@soroban/core':
specifier: workspace:*
version: link:../../packages/core/client/node
'@soroban/templates':
specifier: workspace:*
version: link:../../packages/templates
react:
specifier: ^18.2.0
version: 18.3.1
react-dom:
specifier: ^18.2.0
version: 18.3.1(react@18.3.1)
packages:
'@floating-ui/core@1.7.3':
resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==}
'@floating-ui/dom@1.7.4':
resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==}
'@floating-ui/react-dom@2.1.6':
resolution: {integrity: sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@floating-ui/utils@0.2.10':
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
'@radix-ui/primitive@1.1.3':
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
'@radix-ui/react-arrow@1.1.7':
resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-compose-refs@1.1.2':
resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-context@1.1.2':
resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-dismissable-layer@1.1.11':
resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-id@1.1.1':
resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-popper@1.2.8':
resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-portal@1.1.9':
resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-presence@1.1.5':
resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-primitive@2.1.3':
resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-slot@1.2.3':
resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-tooltip@1.2.8':
resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-use-callback-ref@1.1.1':
resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-controllable-state@1.2.2':
resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-effect-event@0.0.2':
resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-escape-keydown@1.1.1':
resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-layout-effect@1.1.1':
resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-rect@1.1.1':
resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-size@1.1.1':
resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-visually-hidden@1.2.3':
resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/rect@1.1.1':
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
loose-envify@1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
react-dom@18.3.1:
resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==}
peerDependencies:
react: ^18.3.1
react@18.3.1:
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
engines: {node: '>=0.10.0'}
scheduler@0.23.2:
resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==}
snapshots:
'@floating-ui/core@1.7.3':
dependencies:
'@floating-ui/utils': 0.2.10
'@floating-ui/dom@1.7.4':
dependencies:
'@floating-ui/core': 1.7.3
'@floating-ui/utils': 0.2.10
'@floating-ui/react-dom@2.1.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@floating-ui/dom': 1.7.4
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@floating-ui/utils@0.2.10': {}
'@radix-ui/primitive@1.1.3': {}
'@radix-ui/react-arrow@1.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/react-primitive': 2.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@radix-ui/react-compose-refs@1.1.2(react@18.3.1)':
dependencies:
react: 18.3.1
'@radix-ui/react-context@1.1.2(react@18.3.1)':
dependencies:
react: 18.3.1
'@radix-ui/react-dismissable-layer@1.1.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(react@18.3.1)
'@radix-ui/react-primitive': 2.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-use-callback-ref': 1.1.1(react@18.3.1)
'@radix-ui/react-use-escape-keydown': 1.1.1(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@radix-ui/react-id@1.1.1(react@18.3.1)':
dependencies:
'@radix-ui/react-use-layout-effect': 1.1.1(react@18.3.1)
react: 18.3.1
'@radix-ui/react-popper@1.2.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@floating-ui/react-dom': 2.1.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-arrow': 1.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-compose-refs': 1.1.2(react@18.3.1)
'@radix-ui/react-context': 1.1.2(react@18.3.1)
'@radix-ui/react-primitive': 2.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-use-callback-ref': 1.1.1(react@18.3.1)
'@radix-ui/react-use-layout-effect': 1.1.1(react@18.3.1)
'@radix-ui/react-use-rect': 1.1.1(react@18.3.1)
'@radix-ui/react-use-size': 1.1.1(react@18.3.1)
'@radix-ui/rect': 1.1.1
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@radix-ui/react-portal@1.1.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/react-primitive': 2.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-use-layout-effect': 1.1.1(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@radix-ui/react-presence@1.1.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(react@18.3.1)
'@radix-ui/react-use-layout-effect': 1.1.1(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@radix-ui/react-primitive@2.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/react-slot': 1.2.3(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@radix-ui/react-slot@1.2.3(react@18.3.1)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(react@18.3.1)
react: 18.3.1
'@radix-ui/react-tooltip@1.2.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(react@18.3.1)
'@radix-ui/react-context': 1.1.2(react@18.3.1)
'@radix-ui/react-dismissable-layer': 1.1.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-id': 1.1.1(react@18.3.1)
'@radix-ui/react-popper': 1.2.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-portal': 1.1.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-presence': 1.1.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-primitive': 2.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-slot': 1.2.3(react@18.3.1)
'@radix-ui/react-use-controllable-state': 1.2.2(react@18.3.1)
'@radix-ui/react-visually-hidden': 1.2.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@radix-ui/react-use-callback-ref@1.1.1(react@18.3.1)':
dependencies:
react: 18.3.1
'@radix-ui/react-use-controllable-state@1.2.2(react@18.3.1)':
dependencies:
'@radix-ui/react-use-effect-event': 0.0.2(react@18.3.1)
'@radix-ui/react-use-layout-effect': 1.1.1(react@18.3.1)
react: 18.3.1
'@radix-ui/react-use-effect-event@0.0.2(react@18.3.1)':
dependencies:
'@radix-ui/react-use-layout-effect': 1.1.1(react@18.3.1)
react: 18.3.1
'@radix-ui/react-use-escape-keydown@1.1.1(react@18.3.1)':
dependencies:
'@radix-ui/react-use-callback-ref': 1.1.1(react@18.3.1)
react: 18.3.1
'@radix-ui/react-use-layout-effect@1.1.1(react@18.3.1)':
dependencies:
react: 18.3.1
'@radix-ui/react-use-rect@1.1.1(react@18.3.1)':
dependencies:
'@radix-ui/rect': 1.1.1
react: 18.3.1
'@radix-ui/react-use-size@1.1.1(react@18.3.1)':
dependencies:
'@radix-ui/react-use-layout-effect': 1.1.1(react@18.3.1)
react: 18.3.1
'@radix-ui/react-visually-hidden@1.2.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/react-primitive': 2.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@radix-ui/rect@1.1.1': {}
js-tokens@4.0.0: {}
loose-envify@1.4.0:
dependencies:
js-tokens: 4.0.0
react-dom@18.3.1(react@18.3.1):
dependencies:
loose-envify: 1.4.0
react: 18.3.1
scheduler: 0.23.2
react@18.3.1:
dependencies:
loose-envify: 1.4.0
scheduler@0.23.2:
dependencies:
loose-envify: 1.4.0

View File

@@ -0,0 +1,58 @@
#!/usr/bin/env node
/**
* Generate build information for deployment tracking
* This script captures git commit, branch, timestamp, and other metadata
*/
const { execSync } = require('child_process')
const fs = require('fs')
const path = require('path')
function exec(command) {
try {
return execSync(command, { encoding: 'utf-8' }).trim()
} catch (_error) {
return null
}
}
function getBuildInfo() {
const gitCommit = exec('git rev-parse HEAD')
const gitCommitShort = exec('git rev-parse --short HEAD')
const gitBranch = exec('git rev-parse --abbrev-ref HEAD')
const gitTag = exec('git describe --tags --exact-match 2>/dev/null')
const gitDirty = exec('git diff --quiet || echo "dirty"') === 'dirty'
const packageJson = require('../package.json')
return {
version: packageJson.version,
buildTime: new Date().toISOString(),
buildTimestamp: Date.now(),
git: {
commit: gitCommit,
commitShort: gitCommitShort,
branch: gitBranch,
tag: gitTag,
isDirty: gitDirty,
},
environment: process.env.NODE_ENV || 'development',
buildNumber: process.env.BUILD_NUMBER || null,
nodeVersion: process.version,
}
}
const buildInfo = getBuildInfo()
const outputPath = path.join(__dirname, '..', 'src', 'generated', 'build-info.json')
// Ensure directory exists
const dir = path.dirname(outputPath)
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true })
}
fs.writeFileSync(outputPath, JSON.stringify(buildInfo, null, 2))
console.log('✅ Build info generated:', outputPath)
console.log(JSON.stringify(buildInfo, null, 2))

49
apps/web/server.js Normal file
View File

@@ -0,0 +1,49 @@
const { createServer } = require('http')
const { parse } = require('url')
const next = require('next')
const dev = process.env.NODE_ENV !== 'production'
const hostname = 'localhost'
const port = parseInt(process.env.PORT || '3000', 10)
const app = next({ dev, hostname, port })
const handle = app.getRequestHandler()
// Run migrations before starting server
console.log('🔄 Running database migrations...')
const { migrate } = require('drizzle-orm/better-sqlite3/migrator')
const { db } = require('./src/db/index.js')
try {
migrate(db, { migrationsFolder: './drizzle' })
console.log('✅ Migrations complete')
} catch (error) {
console.error('❌ Migration failed:', error)
process.exit(1)
}
app.prepare().then(() => {
const server = createServer(async (req, res) => {
try {
const parsedUrl = parse(req.url, true)
await handle(req, res, parsedUrl)
} catch (err) {
console.error('Error occurred handling', req.url, err)
res.statusCode = 500
res.end('internal server error')
}
})
// Initialize Socket.IO
const { initializeSocketServer } = require('./socket-server.js')
initializeSocketServer(server)
server
.once('error', (err) => {
console.error(err)
process.exit(1)
})
.listen(port, () => {
console.log(`> Ready on http://${hostname}:${port}`)
})
})

319
apps/web/socket-server.js Normal file
View File

@@ -0,0 +1,319 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.getSocketIO = getSocketIO;
exports.initializeSocketServer = initializeSocketServer;
const socket_io_1 = require("socket.io");
const session_manager_1 = require("./src/lib/arcade/session-manager");
const room_manager_1 = require("./src/lib/arcade/room-manager");
const room_membership_1 = require("./src/lib/arcade/room-membership");
const player_manager_1 = require("./src/lib/arcade/player-manager");
const MatchingGameValidator_1 = require("./src/lib/arcade/validation/MatchingGameValidator");
/**
* Get the socket.io server instance
* Returns null if not initialized
*/
function getSocketIO() {
return globalThis.__socketIO || null;
}
function initializeSocketServer(httpServer) {
const io = new socket_io_1.Server(httpServer, {
path: '/api/socket',
cors: {
origin: process.env.NEXT_PUBLIC_URL || 'http://localhost:3000',
credentials: true,
},
});
io.on('connection', (socket) => {
console.log('🔌 Client connected:', socket.id);
let currentUserId = null;
// Join arcade session room
socket.on('join-arcade-session', async ({ userId, roomId }) => {
currentUserId = userId;
socket.join(`arcade:${userId}`);
console.log(`👤 User ${userId} joined arcade room`);
// If this session is part of a room, also join the game room for multi-user sync
if (roomId) {
socket.join(`game:${roomId}`);
console.log(`🎮 User ${userId} joined game room ${roomId}`);
}
// Send current session state if exists
// For room-based games, look up shared room session
try {
const session = roomId
? await (0, session_manager_1.getArcadeSessionByRoom)(roomId)
: await (0, session_manager_1.getArcadeSession)(userId);
if (session) {
console.log('[join-arcade-session] Found session:', {
userId,
roomId,
version: session.version,
sessionUserId: session.userId,
});
socket.emit('session-state', {
gameState: session.gameState,
currentGame: session.currentGame,
gameUrl: session.gameUrl,
activePlayers: session.activePlayers,
version: session.version,
});
}
else {
console.log('[join-arcade-session] No active session found for:', {
userId,
roomId,
});
socket.emit('no-active-session');
}
}
catch (error) {
console.error('Error fetching session:', error);
socket.emit('session-error', { error: 'Failed to fetch session' });
}
});
// Handle game moves
socket.on('game-move', async (data) => {
console.log('🎮 Game move received:', {
userId: data.userId,
moveType: data.move.type,
playerId: data.move.playerId,
timestamp: data.move.timestamp,
roomId: data.roomId,
fullMove: JSON.stringify(data.move, null, 2),
});
try {
// Special handling for START_GAME - create session if it doesn't exist
if (data.move.type === 'START_GAME') {
// For room-based games, check if room session exists
const existingSession = data.roomId
? await (0, session_manager_1.getArcadeSessionByRoom)(data.roomId)
: await (0, session_manager_1.getArcadeSession)(data.userId);
if (!existingSession) {
console.log('🎯 Creating new session for START_GAME');
// activePlayers must be provided in the START_GAME move data
const activePlayers = data.move.data?.activePlayers;
if (!activePlayers || activePlayers.length === 0) {
console.error('❌ START_GAME move missing activePlayers');
socket.emit('move-rejected', {
error: 'START_GAME requires at least one active player',
move: data.move,
});
return;
}
// Get initial state from validator
const initialState = MatchingGameValidator_1.matchingGameValidator.getInitialState({
difficulty: 6,
gameType: 'abacus-numeral',
turnTimer: 30,
});
// Check if user is already in a room for this game
const userRoomIds = await (0, room_membership_1.getUserRooms)(data.userId);
let room = null;
// Look for an existing active room for this game
for (const roomId of userRoomIds) {
const existingRoom = await (0, room_manager_1.getRoomById)(roomId);
if (existingRoom &&
existingRoom.gameName === 'matching' &&
existingRoom.status !== 'finished') {
room = existingRoom;
console.log('🏠 Using existing room:', room.code);
break;
}
}
// If no suitable room exists, create a new one
if (!room) {
room = await (0, room_manager_1.createRoom)({
name: 'Auto-generated Room',
createdBy: data.userId,
creatorName: 'Player',
gameName: 'matching',
gameConfig: {
difficulty: 6,
gameType: 'abacus-numeral',
turnTimer: 30,
},
ttlMinutes: 60,
});
console.log('🏠 Created new room:', room.code);
}
// Now create the session linked to the room
await (0, session_manager_1.createArcadeSession)({
userId: data.userId,
gameName: 'matching',
gameUrl: '/arcade/room', // Room-based sessions use /arcade/room
initialState,
activePlayers,
roomId: room.id,
});
console.log('✅ Session created successfully with room association');
// Notify all connected clients about the new session
const newSession = await (0, session_manager_1.getArcadeSession)(data.userId);
if (newSession) {
io.to(`arcade:${data.userId}`).emit('session-state', {
gameState: newSession.gameState,
currentGame: newSession.currentGame,
gameUrl: newSession.gameUrl,
activePlayers: newSession.activePlayers,
version: newSession.version,
});
console.log('📢 Emitted session-state to notify clients of new session');
}
}
}
// Apply game move - use roomId for room-based games to access shared session
const result = await (0, session_manager_1.applyGameMove)(data.userId, data.move, data.roomId);
if (result.success && result.session) {
const moveAcceptedData = {
gameState: result.session.gameState,
version: result.session.version,
move: data.move,
};
// Broadcast the updated state to all devices for this user
io.to(`arcade:${data.userId}`).emit('move-accepted', moveAcceptedData);
// If this is a room-based session, ALSO broadcast to all users in the room
if (result.session.roomId) {
io.to(`game:${result.session.roomId}`).emit('move-accepted', moveAcceptedData);
console.log(`📢 Broadcasted move to game room ${result.session.roomId}`);
}
// Update activity timestamp
await (0, session_manager_1.updateSessionActivity)(data.userId);
}
else {
// Send rejection only to the requesting socket
socket.emit('move-rejected', {
error: result.error,
move: data.move,
versionConflict: result.versionConflict,
});
}
}
catch (error) {
console.error('Error processing move:', error);
socket.emit('move-rejected', {
error: 'Server error processing move',
move: data.move,
});
}
});
// Handle session exit
socket.on('exit-arcade-session', async ({ userId }) => {
console.log('🚪 User exiting arcade session:', userId);
try {
await (0, session_manager_1.deleteArcadeSession)(userId);
io.to(`arcade:${userId}`).emit('session-ended');
}
catch (error) {
console.error('Error ending session:', error);
socket.emit('session-error', { error: 'Failed to end session' });
}
});
// Keep-alive ping
socket.on('ping-session', async ({ userId }) => {
try {
await (0, session_manager_1.updateSessionActivity)(userId);
socket.emit('pong-session');
}
catch (error) {
console.error('Error updating activity:', error);
}
});
// Room: Join
socket.on('join-room', async ({ roomId, userId }) => {
console.log(`🏠 User ${userId} joining room ${roomId}`);
try {
// Join the socket room
socket.join(`room:${roomId}`);
// Mark member as online
await (0, room_membership_1.setMemberOnline)(roomId, userId, true);
// Get room data
const members = await (0, room_membership_1.getRoomMembers)(roomId);
const memberPlayers = await (0, player_manager_1.getRoomActivePlayers)(roomId);
// Convert memberPlayers Map to object for JSON serialization
const memberPlayersObj = {};
for (const [uid, players] of memberPlayers.entries()) {
memberPlayersObj[uid] = players;
}
// Send current room state to the joining user
socket.emit('room-joined', {
roomId,
members,
memberPlayers: memberPlayersObj,
});
// Notify all other members in the room
socket.to(`room:${roomId}`).emit('member-joined', {
roomId,
userId,
members,
memberPlayers: memberPlayersObj,
});
console.log(`✅ User ${userId} joined room ${roomId}`);
}
catch (error) {
console.error('Error joining room:', error);
socket.emit('room-error', { error: 'Failed to join room' });
}
});
// Room: Leave
socket.on('leave-room', async ({ roomId, userId }) => {
console.log(`🚪 User ${userId} leaving room ${roomId}`);
try {
// Leave the socket room
socket.leave(`room:${roomId}`);
// Mark member as offline
await (0, room_membership_1.setMemberOnline)(roomId, userId, false);
// Get updated members
const members = await (0, room_membership_1.getRoomMembers)(roomId);
const memberPlayers = await (0, player_manager_1.getRoomActivePlayers)(roomId);
// Convert memberPlayers Map to object
const memberPlayersObj = {};
for (const [uid, players] of memberPlayers.entries()) {
memberPlayersObj[uid] = players;
}
// Notify remaining members
io.to(`room:${roomId}`).emit('member-left', {
roomId,
userId,
members,
memberPlayers: memberPlayersObj,
});
console.log(`✅ User ${userId} left room ${roomId}`);
}
catch (error) {
console.error('Error leaving room:', error);
}
});
// Room: Players updated
socket.on('players-updated', async ({ roomId, userId }) => {
console.log(`🎯 Players updated for user ${userId} in room ${roomId}`);
try {
// Get updated player data
const memberPlayers = await (0, player_manager_1.getRoomActivePlayers)(roomId);
// Convert memberPlayers Map to object
const memberPlayersObj = {};
for (const [uid, players] of memberPlayers.entries()) {
memberPlayersObj[uid] = players;
}
// Broadcast to all members in the room (including sender)
io.to(`room:${roomId}`).emit('room-players-updated', {
roomId,
memberPlayers: memberPlayersObj,
});
console.log(`✅ Broadcasted player updates for room ${roomId}`);
}
catch (error) {
console.error('Error updating room players:', error);
socket.emit('room-error', { error: 'Failed to update players' });
}
});
socket.on('disconnect', () => {
console.log('🔌 Client disconnected:', socket.id);
if (currentUserId) {
// Don't delete session on disconnect - it persists across devices
console.log(`👤 User ${currentUserId} disconnected but session persists`);
}
});
});
// Store in globalThis to make accessible across module boundaries
globalThis.__socketIO = io;
console.log('✅ Socket.IO initialized on /api/socket');
return io;
}

375
apps/web/socket-server.ts Normal file
View File

@@ -0,0 +1,375 @@
import type { Server as HTTPServer } from 'http'
import { Server as SocketIOServer } from 'socket.io'
import type { Server as SocketIOServerType } from 'socket.io'
import {
applyGameMove,
createArcadeSession,
deleteArcadeSession,
getArcadeSession,
getArcadeSessionByRoom,
updateSessionActivity,
} from './src/lib/arcade/session-manager'
import { createRoom, getRoomById } from './src/lib/arcade/room-manager'
import { getRoomMembers, getUserRooms, setMemberOnline } from './src/lib/arcade/room-membership'
import { getRoomActivePlayers } from './src/lib/arcade/player-manager'
import type { GameMove, GameName } from './src/lib/arcade/validation'
import { matchingGameValidator } from './src/lib/arcade/validation/MatchingGameValidator'
// Use globalThis to store socket.io instance to avoid module isolation issues
// This ensures the same instance is accessible across dynamic imports
declare global {
var __socketIO: SocketIOServerType | undefined
}
/**
* Get the socket.io server instance
* Returns null if not initialized
*/
export function getSocketIO(): SocketIOServerType | null {
return globalThis.__socketIO || null
}
export function initializeSocketServer(httpServer: HTTPServer) {
const io = new SocketIOServer(httpServer, {
path: '/api/socket',
cors: {
origin: process.env.NEXT_PUBLIC_URL || 'http://localhost:3000',
credentials: true,
},
})
io.on('connection', (socket) => {
console.log('🔌 Client connected:', socket.id)
let currentUserId: string | null = null
// Join arcade session room
socket.on(
'join-arcade-session',
async ({ userId, roomId }: { userId: string; roomId?: string }) => {
currentUserId = userId
socket.join(`arcade:${userId}`)
console.log(`👤 User ${userId} joined arcade room`)
// If this session is part of a room, also join the game room for multi-user sync
if (roomId) {
socket.join(`game:${roomId}`)
console.log(`🎮 User ${userId} joined game room ${roomId}`)
}
// Send current session state if exists
// For room-based games, look up shared room session
try {
const session = roomId
? await getArcadeSessionByRoom(roomId)
: await getArcadeSession(userId)
if (session) {
console.log('[join-arcade-session] Found session:', {
userId,
roomId,
version: session.version,
sessionUserId: session.userId,
})
socket.emit('session-state', {
gameState: session.gameState,
currentGame: session.currentGame,
gameUrl: session.gameUrl,
activePlayers: session.activePlayers,
version: session.version,
})
} else {
console.log('[join-arcade-session] No active session found for:', {
userId,
roomId,
})
socket.emit('no-active-session')
}
} catch (error) {
console.error('Error fetching session:', error)
socket.emit('session-error', { error: 'Failed to fetch session' })
}
}
)
// Handle game moves
socket.on('game-move', async (data: { userId: string; move: GameMove; roomId?: string }) => {
console.log('🎮 Game move received:', {
userId: data.userId,
moveType: data.move.type,
playerId: data.move.playerId,
timestamp: data.move.timestamp,
roomId: data.roomId,
fullMove: JSON.stringify(data.move, null, 2),
})
try {
// Special handling for START_GAME - create session if it doesn't exist
if (data.move.type === 'START_GAME') {
// For room-based games, check if room session exists
const existingSession = data.roomId
? await getArcadeSessionByRoom(data.roomId)
: await getArcadeSession(data.userId)
if (!existingSession) {
console.log('🎯 Creating new session for START_GAME')
// activePlayers must be provided in the START_GAME move data
const activePlayers = (data.move.data as any)?.activePlayers
if (!activePlayers || activePlayers.length === 0) {
console.error('❌ START_GAME move missing activePlayers')
socket.emit('move-rejected', {
error: 'START_GAME requires at least one active player',
move: data.move,
})
return
}
// Get initial state from validator
const initialState = matchingGameValidator.getInitialState({
difficulty: 6,
gameType: 'abacus-numeral',
turnTimer: 30,
})
// Check if user is already in a room for this game
const userRoomIds = await getUserRooms(data.userId)
let room = null
// Look for an existing active room for this game
for (const roomId of userRoomIds) {
const existingRoom = await getRoomById(roomId)
if (
existingRoom &&
existingRoom.gameName === 'matching' &&
existingRoom.status !== 'finished'
) {
room = existingRoom
console.log('🏠 Using existing room:', room.code)
break
}
}
// If no suitable room exists, create a new one
if (!room) {
room = await createRoom({
name: 'Auto-generated Room',
createdBy: data.userId,
creatorName: 'Player',
gameName: 'matching' as GameName,
gameConfig: {
difficulty: 6,
gameType: 'abacus-numeral',
turnTimer: 30,
},
ttlMinutes: 60,
})
console.log('🏠 Created new room:', room.code)
}
// Now create the session linked to the room
await createArcadeSession({
userId: data.userId,
gameName: 'matching',
gameUrl: '/arcade/room', // Room-based sessions use /arcade/room
initialState,
activePlayers,
roomId: room.id,
})
console.log('✅ Session created successfully with room association')
// Notify all connected clients about the new session
const newSession = await getArcadeSession(data.userId)
if (newSession) {
io!.to(`arcade:${data.userId}`).emit('session-state', {
gameState: newSession.gameState,
currentGame: newSession.currentGame,
gameUrl: newSession.gameUrl,
activePlayers: newSession.activePlayers,
version: newSession.version,
})
console.log('📢 Emitted session-state to notify clients of new session')
}
}
}
// Apply game move - use roomId for room-based games to access shared session
const result = await applyGameMove(data.userId, data.move, data.roomId)
if (result.success && result.session) {
const moveAcceptedData = {
gameState: result.session.gameState,
version: result.session.version,
move: data.move,
}
// Broadcast the updated state to all devices for this user
io!.to(`arcade:${data.userId}`).emit('move-accepted', moveAcceptedData)
// If this is a room-based session, ALSO broadcast to all users in the room
if (result.session.roomId) {
io!.to(`game:${result.session.roomId}`).emit('move-accepted', moveAcceptedData)
console.log(`📢 Broadcasted move to game room ${result.session.roomId}`)
}
// Update activity timestamp
await updateSessionActivity(data.userId)
} else {
// Send rejection only to the requesting socket
socket.emit('move-rejected', {
error: result.error,
move: data.move,
versionConflict: result.versionConflict,
})
}
} catch (error) {
console.error('Error processing move:', error)
socket.emit('move-rejected', {
error: 'Server error processing move',
move: data.move,
})
}
})
// Handle session exit
socket.on('exit-arcade-session', async ({ userId }: { userId: string }) => {
console.log('🚪 User exiting arcade session:', userId)
try {
await deleteArcadeSession(userId)
io!.to(`arcade:${userId}`).emit('session-ended')
} catch (error) {
console.error('Error ending session:', error)
socket.emit('session-error', { error: 'Failed to end session' })
}
})
// Keep-alive ping
socket.on('ping-session', async ({ userId }: { userId: string }) => {
try {
await updateSessionActivity(userId)
socket.emit('pong-session')
} catch (error) {
console.error('Error updating activity:', error)
}
})
// Room: Join
socket.on('join-room', async ({ roomId, userId }: { roomId: string; userId: string }) => {
console.log(`🏠 User ${userId} joining room ${roomId}`)
try {
// Join the socket room
socket.join(`room:${roomId}`)
// Mark member as online
await setMemberOnline(roomId, userId, true)
// Get room data
const members = await getRoomMembers(roomId)
const memberPlayers = await getRoomActivePlayers(roomId)
// Convert memberPlayers Map to object for JSON serialization
const memberPlayersObj: Record<string, any[]> = {}
for (const [uid, players] of memberPlayers.entries()) {
memberPlayersObj[uid] = players
}
// Send current room state to the joining user
socket.emit('room-joined', {
roomId,
members,
memberPlayers: memberPlayersObj,
})
// Notify all other members in the room
socket.to(`room:${roomId}`).emit('member-joined', {
roomId,
userId,
members,
memberPlayers: memberPlayersObj,
})
console.log(`✅ User ${userId} joined room ${roomId}`)
} catch (error) {
console.error('Error joining room:', error)
socket.emit('room-error', { error: 'Failed to join room' })
}
})
// Room: Leave
socket.on('leave-room', async ({ roomId, userId }: { roomId: string; userId: string }) => {
console.log(`🚪 User ${userId} leaving room ${roomId}`)
try {
// Leave the socket room
socket.leave(`room:${roomId}`)
// Mark member as offline
await setMemberOnline(roomId, userId, false)
// Get updated members
const members = await getRoomMembers(roomId)
const memberPlayers = await getRoomActivePlayers(roomId)
// Convert memberPlayers Map to object
const memberPlayersObj: Record<string, any[]> = {}
for (const [uid, players] of memberPlayers.entries()) {
memberPlayersObj[uid] = players
}
// Notify remaining members
io!.to(`room:${roomId}`).emit('member-left', {
roomId,
userId,
members,
memberPlayers: memberPlayersObj,
})
console.log(`✅ User ${userId} left room ${roomId}`)
} catch (error) {
console.error('Error leaving room:', error)
}
})
// Room: Players updated
socket.on('players-updated', async ({ roomId, userId }: { roomId: string; userId: string }) => {
console.log(`🎯 Players updated for user ${userId} in room ${roomId}`)
try {
// Get updated player data
const memberPlayers = await getRoomActivePlayers(roomId)
// Convert memberPlayers Map to object
const memberPlayersObj: Record<string, any[]> = {}
for (const [uid, players] of memberPlayers.entries()) {
memberPlayersObj[uid] = players
}
// Broadcast to all members in the room (including sender)
io!.to(`room:${roomId}`).emit('room-players-updated', {
roomId,
memberPlayers: memberPlayersObj,
})
console.log(`✅ Broadcasted player updates for room ${roomId}`)
} catch (error) {
console.error('Error updating room players:', error)
socket.emit('room-error', { error: 'Failed to update players' })
}
})
socket.on('disconnect', () => {
console.log('🔌 Client disconnected:', socket.id)
if (currentUserId) {
// Don't delete session on disconnect - it persists across devices
console.log(`👤 User ${currentUserId} disconnected but session persists`)
}
})
})
// Store in globalThis to make accessible across module boundaries
globalThis.__socketIO = io
console.log('✅ Socket.IO initialized on /api/socket')
return io
}

View File

@@ -1,62 +1,33 @@
import { render, screen } from '@testing-library/react'
import RootLayout from '../layout'
// Mock AppNavBar to verify it receives the nav prop
const MockAppNavBar = ({ navSlot }: { navSlot?: React.ReactNode }) => (
<div data-testid="app-nav-bar">
{navSlot && <div data-testid="nav-slot-content">{navSlot}</div>}
</div>
)
jest.mock('../../components/AppNavBar', () => ({
AppNavBar: MockAppNavBar,
// Mock ClientProviders
vi.mock('../../components/ClientProviders', () => ({
ClientProviders: ({ children }: { children: React.ReactNode }) => (
<div data-testid="client-providers">{children}</div>
),
}))
// Mock all context providers
jest.mock('../../contexts/AbacusDisplayContext', () => ({
AbacusDisplayProvider: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
jest.mock('../../contexts/UserProfileContext', () => ({
UserProfileProvider: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
jest.mock('../../contexts/GameModeContext', () => ({
GameModeProvider: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
jest.mock('../../contexts/FullscreenContext', () => ({
FullscreenProvider: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
describe('RootLayout with nav slot', () => {
it('passes nav slot to AppNavBar', () => {
const navContent = <div>Memory Lightning</div>
describe('RootLayout', () => {
it('renders children with ClientProviders', () => {
const pageContent = <div>Page content</div>
render(
<RootLayout nav={navContent}>
{pageContent}
</RootLayout>
)
render(<RootLayout>{pageContent}</RootLayout>)
expect(screen.getByTestId('app-nav-bar')).toBeInTheDocument()
expect(screen.getByTestId('nav-slot-content')).toBeInTheDocument()
expect(screen.getByText('Memory Lightning')).toBeInTheDocument()
expect(screen.getByTestId('client-providers')).toBeInTheDocument()
expect(screen.getByText('Page content')).toBeInTheDocument()
})
it('works without nav slot', () => {
const pageContent = <div>Page content</div>
it('renders html and body tags', () => {
const pageContent = <div>Test content</div>
render(
<RootLayout nav={null}>
{pageContent}
</RootLayout>
)
const { container } = render(<RootLayout>{pageContent}</RootLayout>)
expect(screen.getByTestId('app-nav-bar')).toBeInTheDocument()
expect(screen.queryByTestId('nav-slot-content')).not.toBeInTheDocument()
expect(screen.getByText('Page content')).toBeInTheDocument()
const html = container.querySelector('html')
const body = container.querySelector('body')
expect(html).toBeInTheDocument()
expect(html).toHaveAttribute('lang', 'en')
expect(body).toBeInTheDocument()
})
})
})

View File

@@ -1,8 +1,8 @@
'use client'
import { AbacusReact } from '@soroban/abacus-react'
import { css } from '../../../styled-system/css'
import { useState } from 'react'
import { css } from '../../../styled-system/css'
export default function AbacusTestPage() {
const [value, setValue] = useState(0)
@@ -15,32 +15,36 @@ export default function AbacusTestPage() {
}
return (
<div className={css({
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
bg: 'gray.50',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '4'
})}>
<div
className={css({
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
bg: 'gray.50',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '4',
})}
>
{/* Debug info */}
<div className={css({
position: 'absolute',
top: '4',
left: '4',
bg: 'white',
p: '3',
rounded: 'md',
border: '1px solid',
borderColor: 'gray.300',
fontSize: 'sm',
fontFamily: 'mono'
})}>
<div
className={css({
position: 'absolute',
top: '4',
left: '4',
bg: 'white',
p: '3',
rounded: 'md',
border: '1px solid',
borderColor: 'gray.300',
fontSize: 'sm',
fontFamily: 'mono',
})}
>
<div>Current Value: {value}</div>
<div>{debugInfo}</div>
<button
@@ -53,7 +57,7 @@ export default function AbacusTestPage() {
color: 'white',
rounded: 'sm',
fontSize: 'xs',
cursor: 'pointer'
cursor: 'pointer',
})}
>
Reset to 0
@@ -68,20 +72,22 @@ export default function AbacusTestPage() {
color: 'white',
rounded: 'sm',
fontSize: 'xs',
cursor: 'pointer'
cursor: 'pointer',
})}
>
Set to 12345
</button>
</div>
<div style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<AbacusReact
value={value}
columns={5}
@@ -97,4 +103,4 @@ export default function AbacusTestPage() {
</div>
</div>
)
}
}

View File

@@ -0,0 +1,101 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { db } from '@/db'
import * as schema from '@/db/schema'
import { getViewerId } from '@/lib/viewer'
/**
* GET /api/abacus-settings
* Fetch abacus display settings for the current user
*/
export async function GET() {
try {
const viewerId = await getViewerId()
const user = await getOrCreateUser(viewerId)
// Find or create abacus settings
let settings = await db.query.abacusSettings.findFirst({
where: eq(schema.abacusSettings.userId, user.id),
})
// If no settings exist, create with defaults
if (!settings) {
const [newSettings] = await db
.insert(schema.abacusSettings)
.values({ userId: user.id })
.returning()
settings = newSettings
}
return NextResponse.json({ settings })
} catch (error) {
console.error('Failed to fetch abacus settings:', error)
return NextResponse.json({ error: 'Failed to fetch abacus settings' }, { status: 500 })
}
}
/**
* PATCH /api/abacus-settings
* Update abacus display settings for the current user
*/
export async function PATCH(req: NextRequest) {
try {
const viewerId = await getViewerId()
const body = await req.json()
// Security: Strip userId from request body - it must come from session only
const { userId: _, ...updates } = body
const user = await getOrCreateUser(viewerId)
// Ensure settings exist
const existingSettings = await db.query.abacusSettings.findFirst({
where: eq(schema.abacusSettings.userId, user.id),
})
if (!existingSettings) {
// Create new settings with updates
const [newSettings] = await db
.insert(schema.abacusSettings)
.values({ userId: user.id, ...updates })
.returning()
return NextResponse.json({ settings: newSettings })
}
// Update existing settings
const [updatedSettings] = await db
.update(schema.abacusSettings)
.set(updates)
.where(eq(schema.abacusSettings.userId, user.id))
.returning()
return NextResponse.json({ settings: updatedSettings })
} catch (error) {
console.error('Failed to update abacus settings:', error)
return NextResponse.json({ error: 'Failed to update abacus settings' }, { status: 500 })
}
}
/**
* Get or create a user record for the given viewer ID (guest or user)
*/
async function getOrCreateUser(viewerId: string) {
// Try to find existing user by guest ID
let user = await db.query.users.findFirst({
where: eq(schema.users.guestId, viewerId),
})
// If no user exists, create one
if (!user) {
const [newUser] = await db
.insert(schema.users)
.values({
guestId: viewerId,
})
.returning()
user = newUser
}
return user
}

View File

@@ -0,0 +1,176 @@
import { eq } from 'drizzle-orm'
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { db, schema } from '@/db'
import { deleteArcadeSession } from '@/lib/arcade/session-manager'
import { DELETE, GET, POST } from '../route'
describe('Arcade Session API Routes', () => {
const testUserId = 'test-user-for-api-routes'
const testGuestId = 'test-guest-id-api-routes'
const baseUrl = 'http://localhost:3000'
beforeEach(async () => {
// Create test user
await db
.insert(schema.users)
.values({
id: testUserId,
guestId: testGuestId,
createdAt: new Date(),
})
.onConflictDoNothing()
})
afterEach(async () => {
// Clean up
await deleteArcadeSession(testUserId)
await db.delete(schema.users).where(eq(schema.users.id, testUserId))
})
describe('POST /api/arcade-session', () => {
it('should create a new session', async () => {
const request = new NextRequest(`${baseUrl}/api/arcade-session`, {
method: 'POST',
body: JSON.stringify({
userId: testUserId,
gameName: 'matching',
gameUrl: '/arcade/matching',
initialState: { test: 'state' },
activePlayers: [1],
}),
})
const response = await POST(request)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.session).toBeDefined()
expect(data.session.currentGame).toBe('matching')
expect(data.session.version).toBe(1)
})
it('should return 400 for missing fields', async () => {
const request = new NextRequest(`${baseUrl}/api/arcade-session`, {
method: 'POST',
body: JSON.stringify({
userId: testUserId,
// Missing required fields
}),
})
const response = await POST(request)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.error).toBe('Missing required fields')
})
it('should return 500 for non-existent user (foreign key constraint)', async () => {
const request = new NextRequest(`${baseUrl}/api/arcade-session`, {
method: 'POST',
body: JSON.stringify({
userId: 'non-existent-user',
gameName: 'matching',
gameUrl: '/arcade/matching',
initialState: {},
activePlayers: [1],
}),
})
const response = await POST(request)
expect(response.status).toBe(500)
})
})
describe('GET /api/arcade-session', () => {
it('should retrieve an existing session', async () => {
// Create session first
const createRequest = new NextRequest(`${baseUrl}/api/arcade-session`, {
method: 'POST',
body: JSON.stringify({
userId: testUserId,
gameName: 'matching',
gameUrl: '/arcade/matching',
initialState: { test: 'state' },
activePlayers: [1],
}),
})
await POST(createRequest)
// Now retrieve it
const request = new NextRequest(`${baseUrl}/api/arcade-session?userId=${testUserId}`)
const response = await GET(request)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.session).toBeDefined()
expect(data.session.currentGame).toBe('matching')
})
it('should return 404 for non-existent session', async () => {
const request = new NextRequest(`${baseUrl}/api/arcade-session?userId=non-existent`)
const response = await GET(request)
expect(response.status).toBe(404)
})
it('should return 400 for missing userId', async () => {
const request = new NextRequest(`${baseUrl}/api/arcade-session`)
const response = await GET(request)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.error).toBe('userId required')
})
})
describe('DELETE /api/arcade-session', () => {
it('should delete an existing session', async () => {
// Create session first
const createRequest = new NextRequest(`${baseUrl}/api/arcade-session`, {
method: 'POST',
body: JSON.stringify({
userId: testUserId,
gameName: 'matching',
gameUrl: '/arcade/matching',
initialState: {},
activePlayers: [1],
}),
})
await POST(createRequest)
// Now delete it
const request = new NextRequest(`${baseUrl}/api/arcade-session?userId=${testUserId}`, {
method: 'DELETE',
})
const response = await DELETE(request)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
// Verify it's deleted
const getRequest = new NextRequest(`${baseUrl}/api/arcade-session?userId=${testUserId}`)
const getResponse = await GET(getRequest)
expect(getResponse.status).toBe(404)
})
it('should return 400 for missing userId', async () => {
const request = new NextRequest(`${baseUrl}/api/arcade-session`, {
method: 'DELETE',
})
const response = await DELETE(request)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.error).toBe('userId required')
})
})
})

View File

@@ -0,0 +1,106 @@
import { type NextRequest, NextResponse } from 'next/server'
import {
createArcadeSession,
deleteArcadeSession,
getArcadeSession,
} from '@/lib/arcade/session-manager'
import type { GameName } from '@/lib/arcade/validation'
/**
* GET /api/arcade-session?userId=xxx
* Get the active arcade session for a user
*/
export async function GET(request: NextRequest) {
try {
const userId = request.nextUrl.searchParams.get('userId')
if (!userId) {
return NextResponse.json({ error: 'userId required' }, { status: 400 })
}
const session = await getArcadeSession(userId)
if (!session) {
return NextResponse.json({ error: 'No active session' }, { status: 404 })
}
return NextResponse.json({
session: {
currentGame: session.currentGame,
gameUrl: session.gameUrl,
gameState: session.gameState,
activePlayers: session.activePlayers,
version: session.version,
expiresAt: session.expiresAt,
},
})
} catch (error) {
console.error('Error fetching arcade session:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
/**
* POST /api/arcade-session
* Create a new arcade session
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { userId, gameName, gameUrl, initialState, activePlayers, roomId } = body
if (!userId || !gameName || !gameUrl || !initialState || !activePlayers || !roomId) {
return NextResponse.json(
{
error:
'Missing required fields (userId, gameName, gameUrl, initialState, activePlayers, roomId)',
},
{ status: 400 }
)
}
const session = await createArcadeSession({
userId,
gameName: gameName as GameName,
gameUrl,
initialState,
activePlayers,
roomId,
})
return NextResponse.json({
session: {
currentGame: session.currentGame,
gameUrl: session.gameUrl,
gameState: session.gameState,
activePlayers: session.activePlayers,
version: session.version,
expiresAt: session.expiresAt,
},
})
} catch (error) {
console.error('Error creating arcade session:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
/**
* DELETE /api/arcade-session?userId=xxx
* Delete an arcade session
*/
export async function DELETE(request: NextRequest) {
try {
const userId = request.nextUrl.searchParams.get('userId')
if (!userId) {
return NextResponse.json({ error: 'userId required' }, { status: 400 })
}
await deleteArcadeSession(userId)
return NextResponse.json({ success: true })
} catch (error) {
console.error('Error deleting arcade session:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -0,0 +1,18 @@
/**
* API response types for /api/arcade-session
*/
export interface ArcadeSessionResponse {
session: {
currentGame: string
gameUrl: string
gameState: unknown
activePlayers: number[]
version: number
expiresAt: Date | string
}
}
export interface ArcadeSessionErrorResponse {
error: string
}

View File

@@ -0,0 +1,125 @@
import { type NextRequest, NextResponse } from 'next/server'
import { getRoomById, touchRoom } from '@/lib/arcade/room-manager'
import { addRoomMember, getRoomMembers } from '@/lib/arcade/room-membership'
import { getActivePlayers, getRoomActivePlayers } from '@/lib/arcade/player-manager'
import { getViewerId } from '@/lib/viewer'
import { getSocketIO } from '@/lib/socket-io'
type RouteContext = {
params: Promise<{ roomId: string }>
}
/**
* POST /api/arcade/rooms/:roomId/join
* Join a room
* Body:
* - displayName?: string (optional, will generate from viewerId if not provided)
*/
export async function POST(req: NextRequest, context: RouteContext) {
try {
const { roomId } = await context.params
const viewerId = await getViewerId()
const body = await req.json().catch(() => ({}))
// Get room
const room = await getRoomById(roomId)
if (!room) {
return NextResponse.json({ error: 'Room not found' }, { status: 404 })
}
// Check if room is locked
if (room.isLocked) {
return NextResponse.json({ error: 'Room is locked' }, { status: 403 })
}
// Get or generate display name
const displayName = body.displayName || `Guest ${viewerId.slice(-4)}`
// Validate display name length
if (displayName.length > 50) {
return NextResponse.json(
{ error: 'Display name too long (max 50 characters)' },
{ status: 400 }
)
}
// Add member (with auto-leave logic for modal room enforcement)
const { member, autoLeaveResult } = await addRoomMember({
roomId,
userId: viewerId,
displayName,
isCreator: false,
})
// Fetch user's active players (these will participate in the game)
const activePlayers = await getActivePlayers(viewerId)
// Update room activity to refresh TTL
await touchRoom(roomId)
// Broadcast to all users in the room via socket
const io = await getSocketIO()
if (io) {
try {
const members = await getRoomMembers(roomId)
const memberPlayers = await getRoomActivePlayers(roomId)
// Convert memberPlayers Map to object for JSON serialization
const memberPlayersObj: Record<string, any[]> = {}
for (const [uid, players] of memberPlayers.entries()) {
memberPlayersObj[uid] = players
}
// Broadcast to all users in this room
io.to(`room:${roomId}`).emit('member-joined', {
roomId,
userId: viewerId,
members,
memberPlayers: memberPlayersObj,
})
console.log(`[Join API] Broadcasted member-joined for user ${viewerId} in room ${roomId}`)
} catch (socketError) {
// Log but don't fail the request if socket broadcast fails
console.error('[Join API] Failed to broadcast member-joined:', socketError)
}
}
// Build response with auto-leave info if applicable
return NextResponse.json(
{
member,
room,
activePlayers, // The user's active players that will join the game
autoLeave: autoLeaveResult
? {
leftRooms: autoLeaveResult.leftRooms,
roomCount: autoLeaveResult.leftRooms.length,
message: `You were automatically removed from ${autoLeaveResult.leftRooms.length} other room(s)`,
}
: undefined,
},
{ status: 201 }
)
} catch (error: any) {
console.error('Failed to join room:', error)
// Handle specific constraint violation error
if (error.message?.includes('ROOM_MEMBERSHIP_CONFLICT')) {
return NextResponse.json(
{
error: 'You are already in another room',
code: 'ROOM_MEMBERSHIP_CONFLICT',
message:
'You can only be in one room at a time. Please leave your current room before joining a new one.',
userMessage:
'⚠️ Already in Another Room\n\nYou can only be in one room at a time. Please refresh the page and try again.',
},
{ status: 409 } // 409 Conflict
)
}
// Generic error
return NextResponse.json({ error: 'Failed to join room' }, { status: 500 })
}
}

View File

@@ -0,0 +1,69 @@
import { type NextRequest, NextResponse } from 'next/server'
import { getRoomById } from '@/lib/arcade/room-manager'
import { getRoomMembers, isMember, removeMember } from '@/lib/arcade/room-membership'
import { getRoomActivePlayers } from '@/lib/arcade/player-manager'
import { getViewerId } from '@/lib/viewer'
import { getSocketIO } from '@/lib/socket-io'
type RouteContext = {
params: Promise<{ roomId: string }>
}
/**
* POST /api/arcade/rooms/:roomId/leave
* Leave a room
*/
export async function POST(_req: NextRequest, context: RouteContext) {
try {
const { roomId } = await context.params
const viewerId = await getViewerId()
// Get room
const room = await getRoomById(roomId)
if (!room) {
return NextResponse.json({ error: 'Room not found' }, { status: 404 })
}
// Check if member
const isMemberOfRoom = await isMember(roomId, viewerId)
if (!isMemberOfRoom) {
return NextResponse.json({ error: 'Not a member of this room' }, { status: 400 })
}
// Remove member
await removeMember(roomId, viewerId)
// Broadcast to all remaining users in the room via socket
const io = await getSocketIO()
if (io) {
try {
const members = await getRoomMembers(roomId)
const memberPlayers = await getRoomActivePlayers(roomId)
// Convert memberPlayers Map to object for JSON serialization
const memberPlayersObj: Record<string, any[]> = {}
for (const [uid, players] of memberPlayers.entries()) {
memberPlayersObj[uid] = players
}
// Broadcast to all users in this room
io.to(`room:${roomId}`).emit('member-left', {
roomId,
userId: viewerId,
members,
memberPlayers: memberPlayersObj,
})
console.log(`[Leave API] Broadcasted member-left for user ${viewerId} in room ${roomId}`)
} catch (socketError) {
// Log but don't fail the request if socket broadcast fails
console.error('[Leave API] Failed to broadcast member-left:', socketError)
}
}
return NextResponse.json({ success: true })
} catch (error) {
console.error('Failed to leave room:', error)
return NextResponse.json({ error: 'Failed to leave room' }, { status: 500 })
}
}

View File

@@ -0,0 +1,50 @@
import { type NextRequest, NextResponse } from 'next/server'
import { getRoomById, isRoomCreator } from '@/lib/arcade/room-manager'
import { isMember, removeMember } from '@/lib/arcade/room-membership'
import { getViewerId } from '@/lib/viewer'
type RouteContext = {
params: Promise<{ roomId: string; userId: string }>
}
/**
* DELETE /api/arcade/rooms/:roomId/members/:userId
* Kick a member from room (creator only)
*/
export async function DELETE(_req: NextRequest, context: RouteContext) {
try {
const { roomId, userId } = await context.params
const viewerId = await getViewerId()
// Get room
const room = await getRoomById(roomId)
if (!room) {
return NextResponse.json({ error: 'Room not found' }, { status: 404 })
}
// Check if requester is room creator
const isCreator = await isRoomCreator(roomId, viewerId)
if (!isCreator) {
return NextResponse.json({ error: 'Only room creator can kick members' }, { status: 403 })
}
// Cannot kick self
if (userId === viewerId) {
return NextResponse.json({ error: 'Cannot kick yourself' }, { status: 400 })
}
// Check if target user is a member
const isTargetMember = await isMember(roomId, userId)
if (!isTargetMember) {
return NextResponse.json({ error: 'User is not a member of this room' }, { status: 404 })
}
// Remove member
await removeMember(roomId, userId)
return NextResponse.json({ success: true })
} catch (error) {
console.error('Failed to kick member:', error)
return NextResponse.json({ error: 'Failed to kick member' }, { status: 500 })
}
}

View File

@@ -0,0 +1,35 @@
import { type NextRequest, NextResponse } from 'next/server'
import { getRoomById } from '@/lib/arcade/room-manager'
import { getOnlineMemberCount, getRoomMembers } from '@/lib/arcade/room-membership'
type RouteContext = {
params: Promise<{ roomId: string }>
}
/**
* GET /api/arcade/rooms/:roomId/members
* Get all members in a room
*/
export async function GET(_req: NextRequest, context: RouteContext) {
try {
const { roomId } = await context.params
// Get room
const room = await getRoomById(roomId)
if (!room) {
return NextResponse.json({ error: 'Room not found' }, { status: 404 })
}
// Get members
const members = await getRoomMembers(roomId)
const onlineCount = await getOnlineMemberCount(roomId)
return NextResponse.json({
members,
onlineCount,
})
} catch (error) {
console.error('Failed to fetch members:', error)
return NextResponse.json({ error: 'Failed to fetch members' }, { status: 500 })
}
}

View File

@@ -0,0 +1,132 @@
import { type NextRequest, NextResponse } from 'next/server'
import {
deleteRoom,
getRoomById,
isRoomCreator,
touchRoom,
updateRoom,
} from '@/lib/arcade/room-manager'
import { getRoomMembers } from '@/lib/arcade/room-membership'
import { getActivePlayers } from '@/lib/arcade/player-manager'
import { getViewerId } from '@/lib/viewer'
type RouteContext = {
params: Promise<{ roomId: string }>
}
/**
* GET /api/arcade/rooms/:roomId
* Get room details including members
*/
export async function GET(_req: NextRequest, context: RouteContext) {
try {
const { roomId } = await context.params
const viewerId = await getViewerId()
const room = await getRoomById(roomId)
if (!room) {
return NextResponse.json({ error: 'Room not found' }, { status: 404 })
}
const members = await getRoomMembers(roomId)
const canModerate = await isRoomCreator(roomId, viewerId)
// Fetch active players for each member
// This creates a map of userId -> Player[]
const memberPlayers: Record<string, any[]> = {}
for (const member of members) {
const activePlayers = await getActivePlayers(member.userId)
memberPlayers[member.userId] = activePlayers
}
// Update room activity when viewing (keeps active rooms fresh)
await touchRoom(roomId)
return NextResponse.json({
room,
members,
memberPlayers, // Map of userId -> active Player[] for each member
canModerate,
})
} catch (error) {
console.error('Failed to fetch room:', error)
return NextResponse.json({ error: 'Failed to fetch room' }, { status: 500 })
}
}
/**
* PATCH /api/arcade/rooms/:roomId
* Update room (creator only)
* Body:
* - name?: string
* - isLocked?: boolean
* - status?: 'lobby' | 'playing' | 'finished'
*/
export async function PATCH(req: NextRequest, context: RouteContext) {
try {
const { roomId } = await context.params
const viewerId = await getViewerId()
const body = await req.json()
// Check if user is room creator
const isCreator = await isRoomCreator(roomId, viewerId)
if (!isCreator) {
return NextResponse.json({ error: 'Only room creator can update room' }, { status: 403 })
}
// Validate name length if provided
if (body.name && body.name.length > 50) {
return NextResponse.json({ error: 'Room name too long (max 50 characters)' }, { status: 400 })
}
// Validate status if provided
if (body.status && !['lobby', 'playing', 'finished'].includes(body.status)) {
return NextResponse.json({ error: 'Invalid status' }, { status: 400 })
}
const updates: {
name?: string
isLocked?: boolean
status?: 'lobby' | 'playing' | 'finished'
} = {}
if (body.name !== undefined) updates.name = body.name
if (body.isLocked !== undefined) updates.isLocked = body.isLocked
if (body.status !== undefined) updates.status = body.status
const room = await updateRoom(roomId, updates)
if (!room) {
return NextResponse.json({ error: 'Room not found' }, { status: 404 })
}
return NextResponse.json({ room })
} catch (error) {
console.error('Failed to update room:', error)
return NextResponse.json({ error: 'Failed to update room' }, { status: 500 })
}
}
/**
* DELETE /api/arcade/rooms/:roomId
* Delete room (creator only)
*/
export async function DELETE(_req: NextRequest, context: RouteContext) {
try {
const { roomId } = await context.params
const viewerId = await getViewerId()
// Check if user is room creator
const isCreator = await isRoomCreator(roomId, viewerId)
if (!isCreator) {
return NextResponse.json({ error: 'Only room creator can delete room' }, { status: 403 })
}
await deleteRoom(roomId)
return NextResponse.json({ success: true })
} catch (error) {
console.error('Failed to delete room:', error)
return NextResponse.json({ error: 'Failed to delete room' }, { status: 500 })
}
}

View File

@@ -0,0 +1,39 @@
import { type NextRequest, NextResponse } from 'next/server'
import { getRoomByCode } from '@/lib/arcade/room-manager'
import { normalizeRoomCode } from '@/lib/arcade/room-code'
type RouteContext = {
params: Promise<{ code: string }>
}
/**
* GET /api/arcade/rooms/code/:code
* Get room by join code (for resolving codes to room IDs)
*/
export async function GET(_req: NextRequest, context: RouteContext) {
try {
const { code } = await context.params
// Normalize the code (uppercase, remove spaces/dashes)
const normalizedCode = normalizeRoomCode(code)
// Get room
const room = await getRoomByCode(normalizedCode)
if (!room) {
return NextResponse.json({ error: 'Room not found' }, { status: 404 })
}
// Generate redirect URL
const baseUrl = process.env.NEXT_PUBLIC_URL || 'http://localhost:3000'
const redirectUrl = `${baseUrl}/arcade/rooms/${room.id}`
return NextResponse.json({
roomId: room.id,
redirectUrl,
room,
})
} catch (error) {
console.error('Failed to find room by code:', error)
return NextResponse.json({ error: 'Failed to find room by code' }, { status: 500 })
}
}

View File

@@ -0,0 +1,52 @@
import { NextResponse } from 'next/server'
import { getUserRooms } from '@/lib/arcade/room-membership'
import { getRoomById } from '@/lib/arcade/room-manager'
import { getRoomMembers } from '@/lib/arcade/room-membership'
import { getRoomActivePlayers } from '@/lib/arcade/player-manager'
import { getViewerId } from '@/lib/viewer'
/**
* GET /api/arcade/rooms/current
* Returns the user's current room (if any)
*/
export async function GET() {
try {
const userId = await getViewerId()
// Get all rooms user is in (should be at most 1 due to modal room enforcement)
const roomIds = await getUserRooms(userId)
if (roomIds.length === 0) {
return NextResponse.json({ room: null }, { status: 200 })
}
const roomId = roomIds[0]
// Get room data
const room = await getRoomById(roomId)
if (!room) {
return NextResponse.json({ error: 'Room not found' }, { status: 404 })
}
// Get members
const members = await getRoomMembers(roomId)
// Get active players for all members
const memberPlayers = await getRoomActivePlayers(roomId)
// Convert Map to object for JSON serialization
const memberPlayersObj: Record<string, any[]> = {}
for (const [uid, players] of memberPlayers.entries()) {
memberPlayersObj[uid] = players
}
return NextResponse.json({
room,
members,
memberPlayers: memberPlayersObj,
})
} catch (error) {
console.error('[Current Room API] Error:', error)
return NextResponse.json({ error: 'Failed to fetch current room' }, { status: 500 })
}
}

View File

@@ -0,0 +1,126 @@
import { type NextRequest, NextResponse } from 'next/server'
import { createRoom, listActiveRooms } from '@/lib/arcade/room-manager'
import { addRoomMember, getRoomMembers, isMember } from '@/lib/arcade/room-membership'
import { getRoomActivePlayers } from '@/lib/arcade/player-manager'
import { getViewerId } from '@/lib/viewer'
import type { GameName } from '@/lib/arcade/validation'
/**
* GET /api/arcade/rooms
* List all active public rooms (lobby view)
* Query params:
* - gameName?: string - Filter by game
*/
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url)
const gameName = searchParams.get('gameName') as GameName | null
const viewerId = await getViewerId()
const rooms = await listActiveRooms(gameName || undefined)
// Enrich with member counts, player counts, and membership status
const roomsWithCounts = await Promise.all(
rooms.map(async (room) => {
const members = await getRoomMembers(room.id)
const playerMap = await getRoomActivePlayers(room.id)
const userIsMember = await isMember(room.id, viewerId)
let totalPlayers = 0
for (const players of playerMap.values()) {
totalPlayers += players.length
}
return {
id: room.id,
name: room.name,
code: room.code,
gameName: room.gameName,
status: room.status,
createdAt: room.createdAt,
creatorName: room.creatorName,
isLocked: room.isLocked,
memberCount: members.length,
playerCount: totalPlayers,
isMember: userIsMember,
}
})
)
return NextResponse.json({ rooms: roomsWithCounts })
} catch (error) {
console.error('Failed to fetch rooms:', error)
return NextResponse.json({ error: 'Failed to fetch rooms' }, { status: 500 })
}
}
/**
* POST /api/arcade/rooms
* Create a new room
* Body:
* - name: string
* - gameName: string
* - gameConfig?: object
* - ttlMinutes?: number
*/
export async function POST(req: NextRequest) {
try {
const viewerId = await getViewerId()
const body = await req.json()
// Validate required fields
if (!body.name || !body.gameName) {
return NextResponse.json(
{ error: 'Missing required fields: name, gameName' },
{ status: 400 }
)
}
// Validate game name
const validGames: GameName[] = ['matching', 'memory-quiz', 'complement-race']
if (!validGames.includes(body.gameName)) {
return NextResponse.json({ error: 'Invalid game name' }, { status: 400 })
}
// Validate name length
if (body.name.length > 50) {
return NextResponse.json({ error: 'Room name too long (max 50 characters)' }, { status: 400 })
}
// Get display name from body or generate from viewerId
const displayName = body.creatorName || `Guest ${viewerId.slice(-4)}`
// Create room
const room = await createRoom({
name: body.name,
createdBy: viewerId,
creatorName: displayName,
gameName: body.gameName,
gameConfig: body.gameConfig || {},
ttlMinutes: body.ttlMinutes,
})
// Add creator as first member
await addRoomMember({
roomId: room.id,
userId: viewerId,
displayName,
isCreator: true,
})
// Generate join URL
const baseUrl = process.env.NEXT_PUBLIC_URL || 'http://localhost:3000'
const joinUrl = `${baseUrl}/arcade/rooms/${room.id}`
return NextResponse.json(
{
room,
joinUrl,
},
{ status: 201 }
)
} catch (error) {
console.error('Failed to create room:', error)
return NextResponse.json({ error: 'Failed to create room' }, { status: 500 })
}
}

View File

@@ -0,0 +1,17 @@
/**
* NextAuth v5 API route handlers
*
* Handles all NextAuth routes:
* - GET /api/auth/signin
* - POST /api/auth/signin/:provider
* - GET /api/auth/signout
* - POST /api/auth/signout
* - GET /api/auth/session
* - GET /api/auth/csrf
* - POST /api/auth/callback/:provider
* - etc.
*/
import { handlers } from '@/auth'
export const { GET, POST } = handlers

View File

@@ -0,0 +1,6 @@
import { NextResponse } from 'next/server'
import buildInfo from '@/generated/build-info.json'
export async function GET() {
return NextResponse.json(buildInfo)
}

View File

@@ -0,0 +1,57 @@
import { NextResponse } from 'next/server'
import { getViewerId } from '@/lib/viewer'
import { getActivePlayers } from '@/lib/arcade/player-manager'
import { db, schema } from '@/db'
import { eq } from 'drizzle-orm'
/**
* GET /api/debug/active-players
* Debug endpoint to check active players for current user
*/
export async function GET() {
try {
const viewerId = await getViewerId()
// Get user record
const user = await db.query.users.findFirst({
where: eq(schema.users.guestId, viewerId),
})
if (!user) {
return NextResponse.json({ error: 'User not found', viewerId }, { status: 404 })
}
// Get ALL players for this user
const allPlayers = await db.query.players.findMany({
where: eq(schema.players.userId, user.id),
})
// Get active players using the helper
const activePlayers = await getActivePlayers(viewerId)
return NextResponse.json({
viewerId,
userId: user.id,
allPlayers: allPlayers.map((p) => ({
id: p.id,
name: p.name,
emoji: p.emoji,
isActive: p.isActive,
})),
activePlayers: activePlayers.map((p) => ({
id: p.id,
name: p.name,
emoji: p.emoji,
isActive: p.isActive,
})),
activeCount: activePlayers.length,
totalCount: allPlayers.length,
})
} catch (error) {
console.error('Failed to fetch active players:', error)
return NextResponse.json(
{ error: 'Failed to fetch active players', details: String(error) },
{ status: 500 }
)
}
}

View File

@@ -1,10 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'
import { type NextRequest, NextResponse } from 'next/server'
import { assetStore } from '@/lib/asset-store'
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
export async function GET(_request: NextRequest, { params }: { params: { id: string } }) {
try {
const { id } = params
@@ -15,30 +12,35 @@ export async function GET(
const asset = await assetStore.get(id)
if (!asset) {
console.log('❌ Asset not found in store')
return NextResponse.json({
error: 'Asset not found or expired'
}, { status: 404 })
return NextResponse.json(
{
error: 'Asset not found or expired',
},
{ status: 404 }
)
}
console.log('✅ Asset found, serving download')
// Return file with appropriate headers
return new NextResponse(asset.data, {
return new NextResponse(new Uint8Array(asset.data), {
status: 200,
headers: {
'Content-Type': asset.mimeType,
'Content-Disposition': `attachment; filename="${asset.filename}"`,
'Content-Length': asset.data.length.toString(),
'Cache-Control': 'private, no-cache, no-store, must-revalidate',
'Expires': '0',
'Pragma': 'no-cache'
}
Expires: '0',
Pragma: 'no-cache',
},
})
} catch (error) {
console.error('❌ Download failed:', error)
return NextResponse.json({
error: 'Failed to download file'
}, { status: 500 })
return NextResponse.json(
{
error: 'Failed to download file',
},
{ status: 500 }
)
}
}
}

View File

@@ -1,19 +1,13 @@
import { NextRequest, NextResponse } from 'next/server'
import { type NextRequest, NextResponse } from 'next/server'
import { assetStore } from '@/lib/asset-store'
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
export async function GET(_request: NextRequest, { params }: { params: { id: string } }) {
try {
const { id } = params
const asset = await assetStore.get(id)
if (!asset) {
return NextResponse.json(
{ error: 'Asset not found' },
{ status: 404 }
)
return NextResponse.json({ error: 'Asset not found' }, { status: 404 })
}
// Set appropriate headers for download
@@ -23,16 +17,12 @@ export async function GET(
headers.set('Content-Length', asset.data.length.toString())
headers.set('Cache-Control', 'no-cache, no-store, must-revalidate')
return new NextResponse(asset.data, {
return new NextResponse(new Uint8Array(asset.data), {
status: 200,
headers
headers,
})
} catch (error) {
console.error('Asset download error:', error)
return NextResponse.json(
{ error: 'Failed to download asset' },
{ status: 500 }
)
return NextResponse.json({ error: 'Failed to download asset' }, { status: 500 })
}
}
}

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'
import { SorobanGenerator } from '@soroban/core'
import { type NextRequest, NextResponse } from 'next/server'
import path from 'path'
// Global generator instance for better performance
@@ -36,14 +36,17 @@ export async function POST(request: NextRequest) {
// Check dependencies before generating
const deps = await gen.checkDependencies?.()
if (deps && (!deps.python || !deps.typst)) {
return NextResponse.json({
error: 'Missing system dependencies',
details: {
python: deps.python ? '✅ Available' : '❌ Missing Python 3',
typst: deps.typst ? '✅ Available' : '❌ Missing Typst',
qpdf: deps.qpdf ? '✅ Available' : '⚠️ Missing qpdf (optional)'
}
}, { status: 500 })
return NextResponse.json(
{
error: 'Missing system dependencies',
details: {
python: deps.python ? '✅ Available' : '❌ Missing Python 3',
typst: deps.typst ? '✅ Available' : ' Missing Typst',
qpdf: deps.qpdf ? '✅ Available' : '⚠️ Missing qpdf (optional)',
},
},
{ status: 500 }
)
}
// Generate flashcards using Python via TypeScript bindings
@@ -52,36 +55,38 @@ export async function POST(request: NextRequest) {
// SorobanGenerator.generate() returns PDF data directly as Buffer
if (!Buffer.isBuffer(result)) {
throw new Error('Expected PDF Buffer from generator, got: ' + typeof result)
throw new Error(`Expected PDF Buffer from generator, got: ${typeof result}`)
}
const pdfBuffer = result
// Create filename for download
const filename = `soroban-flashcards-${config.range || 'cards'}.pdf`
// Return PDF directly as download
return new NextResponse(pdfBuffer, {
return new NextResponse(new Uint8Array(pdfBuffer), {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${filename}"`,
'Content-Length': pdfBuffer.length.toString()
}
'Content-Length': pdfBuffer.length.toString(),
},
})
} catch (error) {
console.error('❌ Generation failed:', error)
return NextResponse.json({
error: 'Failed to generate flashcards',
details: error instanceof Error ? error.message : 'Unknown error',
success: false
}, { status: 500 })
return NextResponse.json(
{
error: 'Failed to generate flashcards',
details: error instanceof Error ? error.message : 'Unknown error',
success: false,
},
{ status: 500 }
)
}
}
// Helper functions to calculate metadata
function calculateCardCount(range: string, step: number): number {
function _calculateCardCount(range: string, step: number): number {
if (range.includes('-')) {
const [start, end] = range.split('-').map(n => parseInt(n) || 0)
const [start, end] = range.split('-').map((n) => parseInt(n, 10) || 0)
return Math.floor((end - start + 1) / step)
}
@@ -92,9 +97,9 @@ function calculateCardCount(range: string, step: number): number {
return 1
}
function generateNumbersFromRange(range: string, step: number): number[] {
function _generateNumbersFromRange(range: string, step: number): number[] {
if (range.includes('-')) {
const [start, end] = range.split('-').map(n => parseInt(n) || 0)
const [start, end] = range.split('-').map((n) => parseInt(n, 10) || 0)
const numbers: number[] = []
for (let i = start; i <= end; i += step) {
numbers.push(i)
@@ -104,26 +109,29 @@ function generateNumbersFromRange(range: string, step: number): number[] {
}
if (range.includes(',')) {
return range.split(',').map(n => parseInt(n.trim()) || 0)
return range.split(',').map((n) => parseInt(n.trim(), 10) || 0)
}
return [parseInt(range) || 0]
return [parseInt(range, 10) || 0]
}
// Health check endpoint
export async function GET() {
try {
const gen = await getGenerator()
const deps = await gen.checkDependencies?.() || { python: true, typst: true, qpdf: true }
const deps = (await gen.checkDependencies?.()) || { python: true, typst: true, qpdf: true }
return NextResponse.json({
status: 'healthy',
dependencies: deps
dependencies: deps,
})
} catch (error) {
return NextResponse.json({
status: 'unhealthy',
error: error instanceof Error ? error.message : 'Unknown error'
}, { status: 500 })
return NextResponse.json(
{
status: 'unhealthy',
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
}
}

View File

@@ -0,0 +1,100 @@
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { db, schema } from '@/db'
import { getViewerId } from '@/lib/viewer'
/**
* PATCH /api/players/[id]
* Update a player (only if it belongs to the current viewer)
*/
export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) {
try {
const viewerId = await getViewerId()
const body = await req.json()
// Get user record (must exist if player exists)
const user = await db.query.users.findFirst({
where: eq(schema.users.guestId, viewerId),
})
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 })
}
// Check if user has an active arcade session
// If so, prevent changing isActive status (players are locked during games)
if (body.isActive !== undefined) {
const activeSession = await db.query.arcadeSessions.findFirst({
where: eq(schema.arcadeSessions.userId, viewerId),
})
if (activeSession) {
return NextResponse.json(
{
error: 'Cannot modify active players during an active game session',
activeGame: activeSession.currentGame,
gameUrl: activeSession.gameUrl,
},
{ status: 403 }
)
}
}
// Security: Only allow updating specific fields (excludes userId)
// Update player (only if it belongs to this user)
const [updatedPlayer] = await db
.update(schema.players)
.set({
...(body.name !== undefined && { name: body.name }),
...(body.emoji !== undefined && { emoji: body.emoji }),
...(body.color !== undefined && { color: body.color }),
...(body.isActive !== undefined && { isActive: body.isActive }),
// userId is explicitly NOT included - it comes from session
})
.where(and(eq(schema.players.id, params.id), eq(schema.players.userId, user.id)))
.returning()
if (!updatedPlayer) {
return NextResponse.json({ error: 'Player not found or unauthorized' }, { status: 404 })
}
return NextResponse.json({ player: updatedPlayer })
} catch (error) {
console.error('Failed to update player:', error)
return NextResponse.json({ error: 'Failed to update player' }, { status: 500 })
}
}
/**
* DELETE /api/players/[id]
* Delete a player (only if it belongs to the current viewer)
*/
export async function DELETE(_req: NextRequest, { params }: { params: { id: string } }) {
try {
const viewerId = await getViewerId()
// Get user record (must exist if player exists)
const user = await db.query.users.findFirst({
where: eq(schema.users.guestId, viewerId),
})
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 })
}
// Delete player (only if it belongs to this user)
const [deletedPlayer] = await db
.delete(schema.players)
.where(and(eq(schema.players.id, params.id), eq(schema.players.userId, user.id)))
.returning()
if (!deletedPlayer) {
return NextResponse.json({ error: 'Player not found or unauthorized' }, { status: 404 })
}
return NextResponse.json({ success: true, player: deletedPlayer })
} catch (error) {
console.error('Failed to delete player:', error)
return NextResponse.json({ error: 'Failed to delete player' }, { status: 500 })
}
}

View File

@@ -0,0 +1,255 @@
/**
* @vitest-environment node
*/
import { eq } from 'drizzle-orm'
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { db, schema } from '../../../../db'
import { PATCH } from '../[id]/route'
/**
* Arcade Session Validation E2E Tests
*
* These tests verify that the PATCH /api/players/[id] endpoint
* correctly prevents isActive changes when user has an active arcade session.
*/
describe('PATCH /api/players/[id] - Arcade Session Validation', () => {
let testUserId: string
let testGuestId: string
let testPlayerId: string
beforeEach(async () => {
// Create a test user with unique guest ID
testGuestId = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}`
const [user] = await db.insert(schema.users).values({ guestId: testGuestId }).returning()
testUserId = user.id
// Create a test player
const [player] = await db
.insert(schema.players)
.values({
userId: testUserId,
name: 'Test Player',
emoji: '😀',
color: '#3b82f6',
isActive: false,
})
.returning()
testPlayerId = player.id
})
afterEach(async () => {
// Clean up: delete test arcade session (if exists)
await db.delete(schema.arcadeSessions).where(eq(schema.arcadeSessions.userId, testGuestId))
// Clean up: delete test user (cascade deletes players)
await db.delete(schema.users).where(eq(schema.users.id, testUserId))
})
it('should return 403 when trying to change isActive with active arcade session', async () => {
// Create an active arcade session
const now = new Date()
await db.insert(schema.arcadeSessions).values({
userId: testGuestId,
currentGame: 'matching',
gameUrl: '/arcade/matching',
gameState: JSON.stringify({}),
activePlayers: JSON.stringify([testPlayerId]),
startedAt: now,
lastActivityAt: now,
expiresAt: new Date(now.getTime() + 3600000), // 1 hour from now
version: 1,
})
// Mock request to change isActive
const mockRequest = new NextRequest(`http://localhost:3000/api/players/${testPlayerId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Cookie: `guest_id=${testGuestId}`,
},
body: JSON.stringify({ isActive: true }),
})
// Mock getViewerId by setting cookie
const response = await PATCH(mockRequest, { params: { id: testPlayerId } })
const data = await response.json()
// Should be rejected with 403
expect(response.status).toBe(403)
expect(data.error).toContain('Cannot modify active players during an active game session')
expect(data.activeGame).toBe('matching')
expect(data.gameUrl).toBe('/arcade/matching')
// Verify player isActive was NOT changed
const player = await db.query.players.findFirst({
where: eq(schema.players.id, testPlayerId),
})
expect(player?.isActive).toBe(false) // Still false
})
it('should allow isActive change when no active arcade session', async () => {
// No arcade session created
// Mock request to change isActive
const mockRequest = new NextRequest(`http://localhost:3000/api/players/${testPlayerId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Cookie: `guest_id=${testGuestId}`,
},
body: JSON.stringify({ isActive: true }),
})
const response = await PATCH(mockRequest, { params: { id: testPlayerId } })
const data = await response.json()
// Should succeed
expect(response.status).toBe(200)
expect(data.player.isActive).toBe(true)
// Verify player isActive was changed
const player = await db.query.players.findFirst({
where: eq(schema.players.id, testPlayerId),
})
expect(player?.isActive).toBe(true)
})
it('should allow non-isActive changes even with active arcade session', async () => {
// Create an active arcade session
const now = new Date()
await db.insert(schema.arcadeSessions).values({
userId: testGuestId,
currentGame: 'matching',
gameUrl: '/arcade/matching',
gameState: JSON.stringify({}),
activePlayers: JSON.stringify([testPlayerId]),
startedAt: now,
lastActivityAt: now,
expiresAt: new Date(now.getTime() + 3600000), // 1 hour from now
version: 1,
})
// Mock request to change name/emoji/color (NOT isActive)
const mockRequest = new NextRequest(`http://localhost:3000/api/players/${testPlayerId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Cookie: `guest_id=${testGuestId}`,
},
body: JSON.stringify({
name: 'Updated Name',
emoji: '🎉',
color: '#ff0000',
}),
})
const response = await PATCH(mockRequest, { params: { id: testPlayerId } })
const data = await response.json()
// Should succeed
expect(response.status).toBe(200)
expect(data.player.name).toBe('Updated Name')
expect(data.player.emoji).toBe('🎉')
expect(data.player.color).toBe('#ff0000')
// Verify changes were applied
const player = await db.query.players.findFirst({
where: eq(schema.players.id, testPlayerId),
})
expect(player?.name).toBe('Updated Name')
expect(player?.emoji).toBe('🎉')
expect(player?.color).toBe('#ff0000')
})
it('should allow isActive change after arcade session ends', async () => {
// Create an active arcade session
const now = new Date()
await db.insert(schema.arcadeSessions).values({
userId: testGuestId,
currentGame: 'matching',
gameUrl: '/arcade/matching',
gameState: JSON.stringify({}),
activePlayers: JSON.stringify([testPlayerId]),
startedAt: now,
lastActivityAt: now,
expiresAt: new Date(now.getTime() + 3600000), // 1 hour from now
version: 1,
})
// End the session
await db.delete(schema.arcadeSessions).where(eq(schema.arcadeSessions.userId, testGuestId))
// Mock request to change isActive
const mockRequest = new NextRequest(`http://localhost:3000/api/players/${testPlayerId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Cookie: `guest_id=${testGuestId}`,
},
body: JSON.stringify({ isActive: true }),
})
const response = await PATCH(mockRequest, { params: { id: testPlayerId } })
const data = await response.json()
// Should succeed
expect(response.status).toBe(200)
expect(data.player.isActive).toBe(true)
})
it('should handle multiple players with different isActive states', async () => {
// Create additional players
const [player2] = await db
.insert(schema.players)
.values({
userId: testUserId,
name: 'Player 2',
emoji: '😎',
color: '#8b5cf6',
isActive: true,
})
.returning()
// Create arcade session
const now2 = new Date()
await db.insert(schema.arcadeSessions).values({
userId: testGuestId,
currentGame: 'matching',
gameUrl: '/arcade/matching',
gameState: JSON.stringify({}),
activePlayers: JSON.stringify([testPlayerId, player2.id]),
startedAt: now2,
lastActivityAt: now2,
expiresAt: new Date(now2.getTime() + 3600000), // 1 hour from now
version: 1,
})
// Try to toggle player1 (inactive -> active) - should fail
const request1 = new NextRequest(`http://localhost:3000/api/players/${testPlayerId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Cookie: `guest_id=${testGuestId}`,
},
body: JSON.stringify({ isActive: true }),
})
const response1 = await PATCH(request1, { params: { id: testPlayerId } })
expect(response1.status).toBe(403)
// Try to toggle player2 (active -> inactive) - should also fail
const request2 = new NextRequest(`http://localhost:3000/api/players/${player2.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Cookie: `guest_id=${testGuestId}`,
},
body: JSON.stringify({ isActive: false }),
})
const response2 = await PATCH(request2, { params: { id: player2.id } })
expect(response2.status).toBe(403)
})
})

View File

@@ -0,0 +1,91 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { db, schema } from '@/db'
import { getViewerId } from '@/lib/viewer'
/**
* GET /api/players
* List all players for the current viewer (guest or user)
*/
export async function GET() {
try {
const viewerId = await getViewerId()
// Get or create user record
const user = await getOrCreateUser(viewerId)
// Get all players for this user
const players = await db.query.players.findMany({
where: eq(schema.players.userId, user.id),
orderBy: (players, { desc }) => [desc(players.createdAt)],
})
return NextResponse.json({ players })
} catch (error) {
console.error('Failed to fetch players:', error)
return NextResponse.json({ error: 'Failed to fetch players' }, { status: 500 })
}
}
/**
* POST /api/players
* Create a new player for the current viewer
*/
export async function POST(req: NextRequest) {
try {
const viewerId = await getViewerId()
const body = await req.json()
// Validate required fields
if (!body.name || !body.emoji || !body.color) {
return NextResponse.json(
{ error: 'Missing required fields: name, emoji, color' },
{ status: 400 }
)
}
// Get or create user record
const user = await getOrCreateUser(viewerId)
// Create player
const [player] = await db
.insert(schema.players)
.values({
userId: user.id,
name: body.name,
emoji: body.emoji,
color: body.color,
isActive: body.isActive ?? false,
})
.returning()
return NextResponse.json({ player }, { status: 201 })
} catch (error) {
console.error('Failed to create player:', error)
return NextResponse.json({ error: 'Failed to create player' }, { status: 500 })
}
}
/**
* Get or create a user record for the given viewer ID (guest or user)
*/
async function getOrCreateUser(viewerId: string) {
// Try to find existing user by guest ID
let user = await db.query.users.findFirst({
where: eq(schema.users.guestId, viewerId),
})
// If no user exists, create one
if (!user) {
const [newUser] = await db
.insert(schema.users)
.values({
guestId: viewerId,
})
.returning()
user = newUser
}
return user
}

View File

@@ -1,164 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import { generateSorobanSVG } from '@/lib/typst-soroban'
export async function POST(request: NextRequest) {
try {
const config = await request.json()
// Debug: log the received config
console.log('🔍 Preview config:', JSON.stringify(config, null, 2))
// Ensure range is set with a default
if (!config.range) {
config.range = '0-9'
}
// For preview, limit to a few numbers and use SVG format for fast rendering
const previewConfig = {
...config,
range: getPreviewRange(config.range),
format: 'svg', // Use SVG format for preview
cardsPerPage: 6 // Standard card layout
}
console.log('🔍 Processed preview config:', JSON.stringify(previewConfig, null, 2))
// Generate real SVG preview using typst.ts
console.log('🚀 Generating soroban SVG preview via typst.ts')
try {
// Parse the numbers from the range for individual cards
const numbers = parseNumbersFromRange(getPreviewRange(config.range))
console.log('🔍 Generating individual SVGs for numbers:', numbers)
// Generate individual SVGs for each number using typst.ts
const samples = []
for (const number of numbers) {
try {
const typstConfig = {
number: number,
beadShape: previewConfig.beadShape || 'diamond',
colorScheme: previewConfig.colorScheme || 'place-value',
hideInactiveBeads: previewConfig.hideInactiveBeads || false,
scaleFactor: previewConfig.scaleFactor || 1.0,
width: '200pt',
height: '250pt'
}
console.log(`🔍 Generating typst.ts SVG for number ${number}`)
const svg = await generateSorobanSVG(typstConfig)
console.log(`✅ Generated typst.ts SVG for ${number}, length: ${svg.length}`)
samples.push({
number,
front: svg,
back: number.toString()
})
} catch (error) {
console.error(`❌ Failed to generate SVG for number ${number}:`, error instanceof Error ? error.message : error)
samples.push({
number,
front: `<svg width="200" height="300" viewBox="0 0 200 300" xmlns="http://www.w3.org/2000/svg">
<rect x="10" y="10" width="180" height="280" fill="none" stroke="#ccc" stroke-width="2"/>
<line x1="20" y1="150" x2="180" y2="150" stroke="#ccc" stroke-width="2"/>
<text x="100" y="160" text-anchor="middle" font-size="24" fill="#666">SVG Error</text>
<text x="100" y="180" text-anchor="middle" font-size="16" fill="#999">${number}</text>
</svg>`,
back: number.toString()
})
}
}
return NextResponse.json({
count: numbers.length,
samples,
note: 'Real individual SVGs generated by typst.ts'
})
} catch (error) {
console.error('⚠️ Typst.ts SVG generation failed, using fallback preview:', error instanceof Error ? error.message : error)
return NextResponse.json(getMockPreviewData(config))
}
} catch (error) {
console.error('❌ Preview generation failed:', error)
// Always fall back to mock data for preview
const config = await request.json().catch(() => ({ range: '0-9' }))
return NextResponse.json(getMockPreviewData(config))
}
}
// Helper function to parse numbers from range string
function parseNumbersFromRange(range: string): number[] {
if (!range) return [0, 1, 2]
if (range.includes('-')) {
const [start] = range.split('-')
const startNum = parseInt(start) || 0
return [startNum, startNum + 1, startNum + 2]
}
if (range.includes(',')) {
return range.split(',').slice(0, 3).map(n => parseInt(n.trim()) || 0)
}
const num = parseInt(range) || 0
return [num, num + 1, num + 2]
}
// Helper function to limit range for preview
function getPreviewRange(range: string): string {
if (!range) return '0,1,2'
if (range.includes('-')) {
const [start] = range.split('-')
const startNum = parseInt(start) || 0
return `${startNum},${startNum + 1},${startNum + 2}`
}
if (range.includes(',')) {
const numbers = range.split(',').slice(0, 3)
return numbers.join(',')
}
return range
}
// Mock preview data for development and fallback
function getMockPreviewData(config: any) {
const range = config.range || '0-9'
let numbers: number[]
if (range.includes('-')) {
const [start] = range.split('-')
const startNum = parseInt(start) || 0
numbers = [startNum, startNum + 1, startNum + 2]
} else if (range.includes(',')) {
numbers = range.split(',').slice(0, 3).map((n: string) => parseInt(n.trim()) || 0)
} else {
const num = parseInt(range) || 0
numbers = [num, num + 1, num + 2]
}
return {
count: numbers.length,
samples: numbers.map(number => ({
number,
front: `<svg width="200" height="300" viewBox="0 0 200 300" xmlns="http://www.w3.org/2000/svg">
<rect x="10" y="10" width="180" height="280" fill="none" stroke="#ccc" stroke-width="2"/>
<line x1="20" y1="150" x2="180" y2="150" stroke="#ccc" stroke-width="2"/>
<text x="100" y="160" text-anchor="middle" font-size="24" fill="#666">Preview Error</text>
<text x="100" y="180" text-anchor="middle" font-size="16" fill="#999">${number}</text>
</svg>`,
back: number.toString()
}))
}
}
// Health check endpoint
export async function GET() {
return NextResponse.json({
status: 'healthy',
endpoint: 'preview',
message: 'Preview API is running'
})
}

View File

@@ -1,154 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import { $typst } from '@myriaddreamin/typst.ts/dist/esm/contrib/snippet.mjs'
import fs from 'fs'
import path from 'path'
export interface TypstSVGRequest {
number: number
beadShape?: 'diamond' | 'circle' | 'square'
colorScheme?: 'monochrome' | 'place-value' | 'heaven-earth' | 'alternating'
colorPalette?: 'default' | 'colorblind' | 'mnemonic' | 'grayscale' | 'nature'
hideInactiveBeads?: boolean
showEmptyColumns?: boolean
columns?: number | 'auto'
scaleFactor?: number
width?: string
height?: string
fontSize?: string
fontFamily?: string
transparent?: boolean
coloredNumerals?: boolean
}
// Cache for template content
let flashcardsTemplate: string | null = null
async function getFlashcardsTemplate(): Promise<string> {
if (flashcardsTemplate) {
return flashcardsTemplate
}
try {
const { getTemplatePath } = require('@soroban/templates')
const templatePath = getTemplatePath('flashcards.typ')
flashcardsTemplate = fs.readFileSync(templatePath, 'utf-8')
return flashcardsTemplate
} catch (error) {
console.error('Failed to load flashcards template:', error)
throw new Error('Template loading failed')
}
}
function processBeadAnnotations(svg: string): string {
const { extractBeadAnnotations } = require('@soroban/templates')
const result = extractBeadAnnotations(svg)
if (result.warnings.length > 0) {
console.log(' SVG bead processing warnings:', result.warnings)
}
console.log(`🔗 Processed ${result.count} bead links into data attributes`)
return result.processedSVG
}
function createTypstContent(config: TypstSVGRequest, template: string): string {
const {
number,
beadShape = 'diamond',
colorScheme = 'place-value',
colorPalette = 'default',
hideInactiveBeads = false,
showEmptyColumns = false,
columns = 'auto',
scaleFactor = 1.0,
width = '120pt',
height = '160pt',
fontSize = '48pt',
fontFamily = 'DejaVu Sans',
transparent = false,
coloredNumerals = false
} = config
return `
${template}
#set page(
width: ${width},
height: ${height},
margin: 0pt,
fill: ${transparent ? 'none' : 'white'}
)
#set text(font: "${fontFamily}", size: ${fontSize}, fallback: true)
#align(center + horizon)[
#box(
width: ${width} - 2 * (${width} * 0.05),
height: ${height} - 2 * (${height} * 0.05)
)[
#align(center + horizon)[
#scale(x: ${scaleFactor * 100}%, y: ${scaleFactor * 100}%)[
#draw-soroban(
${number},
columns: ${columns},
show-empty: ${showEmptyColumns},
hide-inactive: ${hideInactiveBeads},
bead-shape: "${beadShape}",
color-scheme: "${colorScheme}",
color-palette: "${colorPalette}",
base-size: 1.0
)
]
]
]
]
`
}
export async function POST(request: NextRequest) {
try {
const config: TypstSVGRequest = await request.json()
console.log('🎨 Generating typst.ts SVG for number:', config.number)
// Load template
const template = await getFlashcardsTemplate()
// Create typst content
const typstContent = createTypstContent(config, template)
// Generate SVG using typst.ts
const rawSvg = await $typst.svg({ mainContent: typstContent })
// Post-process to convert bead annotations to data attributes
const svg = processBeadAnnotations(rawSvg)
console.log('✅ Generated and processed typst.ts SVG, length:', svg.length)
return NextResponse.json({
svg,
success: true,
number: config.number
})
} catch (error) {
console.error('❌ Typst SVG generation failed:', error)
return NextResponse.json(
{
error: error instanceof Error ? error.message : 'Unknown error',
success: false
},
{ status: 500 }
)
}
}
// Health check
export async function GET() {
return NextResponse.json({
status: 'healthy',
endpoint: 'typst-svg',
message: 'Typst.ts SVG generation API is running'
})
}

View File

@@ -1,25 +0,0 @@
import { NextResponse } from 'next/server'
import fs from 'fs'
import { getTemplatePath } from '@soroban/templates'
// API endpoint to serve the flashcards.typ template content
export async function GET() {
try {
const templatePath = getTemplatePath('flashcards.typ');
const flashcardsTemplate = fs.readFileSync(templatePath, 'utf-8')
return NextResponse.json({
template: flashcardsTemplate,
success: true
})
} catch (error) {
console.error('Failed to load typst template:', error)
return NextResponse.json(
{
error: 'Failed to load template',
success: false
},
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,120 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { db, schema } from '@/db'
import { getViewerId } from '@/lib/viewer'
/**
* GET /api/user-stats
* Get user statistics for the current viewer
*/
export async function GET() {
try {
const viewerId = await getViewerId()
// Get user record
const user = await db.query.users.findFirst({
where: eq(schema.users.guestId, viewerId),
})
if (!user) {
// No user yet, return default stats
return NextResponse.json({
stats: {
gamesPlayed: 0,
totalWins: 0,
favoriteGameType: null,
bestTime: null,
highestAccuracy: 0,
},
})
}
// Get stats record
let stats = await db.query.userStats.findFirst({
where: eq(schema.userStats.userId, user.id),
})
// If no stats record exists, create one with defaults
if (!stats) {
const [newStats] = await db
.insert(schema.userStats)
.values({
userId: user.id,
})
.returning()
stats = newStats
}
return NextResponse.json({ stats })
} catch (error) {
console.error('Failed to fetch user stats:', error)
return NextResponse.json({ error: 'Failed to fetch user stats' }, { status: 500 })
}
}
/**
* PATCH /api/user-stats
* Update user statistics for the current viewer
*/
export async function PATCH(req: NextRequest) {
try {
const viewerId = await getViewerId()
const body = await req.json()
// Get or create user record
let user = await db.query.users.findFirst({
where: eq(schema.users.guestId, viewerId),
})
if (!user) {
// Create user if it doesn't exist
const [newUser] = await db
.insert(schema.users)
.values({
guestId: viewerId,
})
.returning()
user = newUser
}
// Get existing stats
const stats = await db.query.userStats.findFirst({
where: eq(schema.userStats.userId, user.id),
})
// Prepare update values
const updates: any = {}
if (body.gamesPlayed !== undefined) updates.gamesPlayed = body.gamesPlayed
if (body.totalWins !== undefined) updates.totalWins = body.totalWins
if (body.favoriteGameType !== undefined) updates.favoriteGameType = body.favoriteGameType
if (body.bestTime !== undefined) updates.bestTime = body.bestTime
if (body.highestAccuracy !== undefined) updates.highestAccuracy = body.highestAccuracy
if (stats) {
// Update existing stats
const [updatedStats] = await db
.update(schema.userStats)
.set(updates)
.where(eq(schema.userStats.userId, user.id))
.returning()
return NextResponse.json({ stats: updatedStats })
} else {
// Create new stats record
const [newStats] = await db
.insert(schema.userStats)
.values({
userId: user.id,
...updates,
})
.returning()
return NextResponse.json({ stats: newStats }, { status: 201 })
}
} catch (error) {
console.error('Failed to update user stats:', error)
return NextResponse.json({ error: 'Failed to update user stats' }, { status: 500 })
}
}

View File

@@ -0,0 +1,16 @@
import { NextResponse } from 'next/server'
import { getViewerId } from '@/lib/viewer'
/**
* GET /api/viewer
*
* Returns the current viewer's ID (guest or authenticated user)
*/
export async function GET() {
try {
const viewerId = await getViewerId()
return NextResponse.json({ viewerId })
} catch (_error) {
return NextResponse.json({ error: 'No valid viewer session found' }, { status: 401 })
}
}

View File

@@ -0,0 +1,665 @@
'use client'
import { useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { io, type Socket } from 'socket.io-client'
import { css } from '../../../../styled-system/css'
import { PageWithNav } from '@/components/PageWithNav'
import { useViewerId } from '@/hooks/useViewerId'
interface Room {
id: string
code: string
name: string
gameName: string
status: 'lobby' | 'playing' | 'finished'
createdBy: string
creatorName: string
isLocked: boolean
}
interface Member {
id: string
userId: string
displayName: string
isCreator: boolean
isOnline: boolean
joinedAt: Date
}
interface Player {
id: string
userId: string
name: string
emoji: string
color: string
isActive: boolean
}
export default function RoomDetailPage() {
const params = useParams()
const router = useRouter()
const roomId = params.roomId as string
const { data: guestId } = useViewerId()
const [room, setRoom] = useState<Room | null>(null)
const [members, setMembers] = useState<Member[]>([])
const [memberPlayers, setMemberPlayers] = useState<Record<string, Player[]>>({})
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [socket, setSocket] = useState<Socket | null>(null)
const [isConnected, setIsConnected] = useState(false)
useEffect(() => {
fetchRoom()
}, [roomId])
useEffect(() => {
if (!guestId || !roomId) return
// Connect to socket
const sock = io({ path: '/api/socket' })
setSocket(sock)
sock.on('connect', () => {
setIsConnected(true)
// Join the room
sock.emit('join-room', { roomId, userId: guestId })
})
sock.on('disconnect', () => {
setIsConnected(false)
})
sock.on('room-joined', (data) => {
console.log('Joined room:', data)
if (data.members) {
setMembers(data.members)
}
if (data.memberPlayers) {
setMemberPlayers(data.memberPlayers)
}
})
sock.on('member-joined', (data) => {
console.log('Member joined:', data)
if (data.members) {
setMembers(data.members)
}
if (data.memberPlayers) {
setMemberPlayers(data.memberPlayers)
}
})
sock.on('member-left', (data) => {
console.log('Member left:', data)
if (data.members) {
setMembers(data.members)
}
if (data.memberPlayers) {
setMemberPlayers(data.memberPlayers)
}
})
sock.on('room-error', (error) => {
console.error('Room error:', error)
setError(error.error)
})
sock.on('room-players-updated', (data) => {
console.log('Room players updated:', data)
if (data.memberPlayers) {
setMemberPlayers(data.memberPlayers)
}
})
return () => {
sock.emit('leave-room', { roomId, userId: guestId })
sock.disconnect()
}
}, [roomId, guestId])
// Notify room when window regains focus (user might have changed players in another tab)
useEffect(() => {
if (!socket || !guestId || !roomId) return
const handleFocus = () => {
console.log('Window focused, notifying room of potential player changes')
socket.emit('players-updated', { roomId, userId: guestId })
}
window.addEventListener('focus', handleFocus)
return () => window.removeEventListener('focus', handleFocus)
}, [socket, roomId, guestId])
const fetchRoom = async () => {
try {
setLoading(true)
const response = await fetch(`/api/arcade/rooms/${roomId}`)
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const data = await response.json()
setRoom(data.room)
setMembers(data.members || [])
setMemberPlayers(data.memberPlayers || {})
setError(null)
} catch (err) {
console.error('Failed to fetch room:', err)
setError('Failed to load room')
} finally {
setLoading(false)
}
}
const startGame = () => {
if (!room) return
// Navigate to the room game page
router.push('/arcade/room')
}
const joinRoom = async () => {
try {
const response = await fetch(`/api/arcade/rooms/${roomId}/join`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ displayName: 'Player' }),
})
if (!response.ok) {
const errorData = await response.json()
// Handle specific room membership conflict
if (errorData.code === 'ROOM_MEMBERSHIP_CONFLICT') {
alert(errorData.userMessage || errorData.message)
// Refresh the page to update room state
await fetchRoom()
return
}
throw new Error(errorData.error || `HTTP ${response.status}`)
}
const data = await response.json()
// Show notification if user was auto-removed from other rooms
if (data.autoLeave) {
console.log(`[Room Join] ${data.autoLeave.message}`)
// Could show a toast notification here in the future
}
// Refresh room data to update membership UI
await fetchRoom()
} catch (err) {
console.error('Failed to join room:', err)
alert('Failed to join room')
}
}
const leaveRoom = async () => {
try {
const response = await fetch(`/api/arcade/rooms/${roomId}/leave`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || `HTTP ${response.status}`)
}
// Navigate to arcade home after successfully leaving
router.push('/arcade')
} catch (err) {
console.error('Failed to leave room:', err)
alert('Failed to leave room')
}
}
if (loading) {
return (
<PageWithNav>
<div
className={css({
minH: 'calc(100vh - 80px)',
bg: 'linear-gradient(135deg, #0f0f23 0%, #1a1a3a 50%, #2d1b69 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
fontSize: 'xl',
})}
>
Loading room...
</div>
</PageWithNav>
)
}
if (error || !room) {
return (
<PageWithNav>
<div
className={css({
minH: 'calc(100vh - 80px)',
bg: 'linear-gradient(135deg, #0f0f23 0%, #1a1a3a 50%, #2d1b69 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
p: '8',
})}
>
<div
className={css({
bg: 'rgba(255, 255, 255, 0.05)',
backdropFilter: 'blur(10px)',
border: '1px solid rgba(255, 255, 255, 0.1)',
rounded: 'lg',
p: '12',
textAlign: 'center',
maxW: '500px',
})}
>
<p className={css({ fontSize: 'xl', color: 'white', mb: '4' })}>
{error || 'Room not found'}
</p>
<button
onClick={() => router.push('/arcade-rooms')}
className={css({
px: '6',
py: '3',
bg: '#3b82f6',
color: 'white',
rounded: 'lg',
fontWeight: '600',
cursor: 'pointer',
_hover: { bg: '#2563eb' },
})}
>
Back to Rooms
</button>
</div>
</div>
</PageWithNav>
)
}
const onlineMembers = members.filter((m) => m.isOnline)
// Check if current user is a member
const isMember = members.some((m) => m.userId === guestId)
// Calculate union of all active players in the room
const allPlayers: Player[] = []
const playerIds = new Set<string>()
for (const userId in memberPlayers) {
for (const player of memberPlayers[userId]) {
if (!playerIds.has(player.id)) {
playerIds.add(player.id)
allPlayers.push(player)
}
}
}
return (
<PageWithNav>
<div
className={css({
minH: 'calc(100vh - 80px)',
bg: 'linear-gradient(135deg, #0f0f23 0%, #1a1a3a 50%, #2d1b69 100%)',
p: '8',
})}
>
<div className={css({ maxW: '1000px', mx: 'auto' })}>
{/* Header */}
<div
className={css({
bg: 'rgba(255, 255, 255, 0.05)',
backdropFilter: 'blur(10px)',
border: '1px solid rgba(255, 255, 255, 0.1)',
rounded: 'lg',
p: '8',
mb: '6',
})}
>
<div className={css({ mb: '4' })}>
<button
onClick={() => router.push('/arcade-rooms')}
className={css({
display: 'inline-flex',
alignItems: 'center',
gap: '2',
color: '#a0a0ff',
fontSize: 'sm',
cursor: 'pointer',
_hover: { color: '#60a5fa' },
mb: '3',
})}
>
Back to Rooms
</button>
</div>
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: '4',
})}
>
<div>
<h1
className={css({ fontSize: '3xl', fontWeight: 'bold', color: 'white', mb: '2' })}
>
{room.name}
</h1>
<div
className={css({ display: 'flex', gap: '4', color: '#a0a0ff', fontSize: 'sm' })}
>
<span>🎮 {room.gameName}</span>
<span>👤 Host: {room.creatorName}</span>
<span
className={css({
px: '3',
py: '1',
bg: 'rgba(255, 255, 255, 0.1)',
color: '#fbbf24',
rounded: 'full',
fontWeight: '600',
fontFamily: 'monospace',
})}
>
Code: {room.code}
</span>
</div>
</div>
<div className={css({ display: 'flex', gap: '3', alignItems: 'center' })}>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '2',
px: '3',
py: '2',
bg: isConnected ? 'rgba(16, 185, 129, 0.2)' : 'rgba(239, 68, 68, 0.2)',
border: `1px solid ${isConnected ? '#10b981' : '#ef4444'}`,
rounded: 'full',
})}
>
<div
className={css({
w: '2',
h: '2',
bg: isConnected ? '#10b981' : '#ef4444',
rounded: 'full',
})}
/>
<span
className={css({ color: isConnected ? '#10b981' : '#ef4444', fontSize: 'sm' })}
>
{isConnected ? 'Connected' : 'Disconnected'}
</span>
</div>
</div>
</div>
</div>
{/* Game Players - Union of all active players */}
<div
className={css({
bg: 'rgba(255, 255, 255, 0.05)',
backdropFilter: 'blur(10px)',
border: '1px solid rgba(255, 255, 255, 0.1)',
rounded: 'lg',
p: '8',
mb: '6',
})}
>
<h2 className={css({ fontSize: '2xl', fontWeight: 'bold', color: 'white', mb: '2' })}>
🎯 Game Players ({allPlayers.length})
</h2>
<p className={css({ color: '#a0a0ff', fontSize: 'sm', mb: '4' })}>
These players will participate when the game starts
</p>
{allPlayers.length > 0 ? (
<div className={css({ display: 'flex', gap: '2', flexWrap: 'wrap' })}>
{allPlayers.map((player) => (
<div
key={player.id}
className={css({
display: 'flex',
alignItems: 'center',
gap: '2',
px: '3',
py: '2',
bg: 'rgba(59, 130, 246, 0.15)',
border: '2px solid rgba(59, 130, 246, 0.4)',
rounded: 'lg',
color: '#60a5fa',
fontWeight: '600',
})}
>
<span className={css({ fontSize: 'xl' })}>{player.emoji}</span>
<span>{player.name}</span>
</div>
))}
</div>
) : (
<div
className={css({
color: '#6b7280',
fontStyle: 'italic',
textAlign: 'center',
py: '4',
})}
>
No active players yet. Members need to set up their players.
</div>
)}
</div>
{/* Members List */}
<div
className={css({
bg: 'rgba(255, 255, 255, 0.05)',
backdropFilter: 'blur(10px)',
border: '1px solid rgba(255, 255, 255, 0.1)',
rounded: 'lg',
p: '8',
mb: '6',
})}
>
<h2 className={css({ fontSize: '2xl', fontWeight: 'bold', color: 'white', mb: '2' })}>
👥 Room Members ({onlineMembers.length}/{members.length})
</h2>
<p className={css({ color: '#a0a0ff', fontSize: 'sm', mb: '4' })}>
Users in this room and their active players
</p>
<div className={css({ display: 'grid', gap: '3' })}>
{members.map((member) => {
const players = memberPlayers[member.userId] || []
return (
<div
key={member.id}
className={css({
display: 'flex',
flexDirection: 'column',
gap: '2',
p: '4',
bg: 'rgba(255, 255, 255, 0.05)',
border: '1px solid rgba(255, 255, 255, 0.1)',
rounded: 'lg',
opacity: member.isOnline ? 1 : 0.5,
})}
>
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
})}
>
<div className={css({ display: 'flex', alignItems: 'center', gap: '3' })}>
<div
className={css({
w: '3',
h: '3',
bg: member.isOnline ? '#10b981' : '#6b7280',
rounded: 'full',
})}
/>
<span className={css({ color: 'white', fontWeight: '600' })}>
{member.displayName}
</span>
{member.isCreator && (
<span
className={css({
px: '2',
py: '1',
bg: 'rgba(251, 191, 36, 0.2)',
color: '#fbbf24',
rounded: 'full',
fontSize: 'xs',
fontWeight: '600',
})}
>
HOST
</span>
)}
</div>
<span className={css({ color: '#a0a0ff', fontSize: 'sm' })}>
{member.isOnline ? '🟢 Online' : '⚫ Offline'}
</span>
</div>
{players.length > 0 && (
<div
className={css({ display: 'flex', gap: '2', flexWrap: 'wrap', ml: '6' })}
>
<span className={css({ color: '#a0a0ff', fontSize: 'xs', mr: '1' })}>
Players:
</span>
{players.map((player) => (
<span
key={player.id}
className={css({
px: '2',
py: '1',
bg: 'rgba(59, 130, 246, 0.2)',
color: '#60a5fa',
border: '1px solid rgba(59, 130, 246, 0.3)',
rounded: 'full',
fontSize: 'xs',
fontWeight: '600',
})}
>
{player.emoji} {player.name}
</span>
))}
</div>
)}
{players.length === 0 && (
<div
className={css({
ml: '6',
color: '#6b7280',
fontSize: 'xs',
fontStyle: 'italic',
})}
>
No active players
</div>
)}
</div>
)
})}
</div>
</div>
{/* Actions */}
<div className={css({ display: 'flex', gap: '4' })}>
{isMember ? (
<>
<button
onClick={leaveRoom}
className={css({
flex: 1,
px: '6',
py: '4',
bg: 'rgba(255, 255, 255, 0.1)',
color: 'white',
rounded: 'lg',
fontWeight: '600',
cursor: 'pointer',
_hover: { bg: 'rgba(255, 255, 255, 0.15)' },
})}
>
Leave Room
</button>
<button
onClick={startGame}
disabled={allPlayers.length < 1}
className={css({
flex: 2,
px: '6',
py: '4',
bg: allPlayers.length < 1 ? '#6b7280' : '#10b981',
color: 'white',
rounded: 'lg',
fontSize: 'xl',
fontWeight: '600',
cursor: allPlayers.length < 1 ? 'not-allowed' : 'pointer',
opacity: allPlayers.length < 1 ? 0.5 : 1,
_hover: allPlayers.length < 1 ? {} : { bg: '#059669' },
})}
>
{allPlayers.length < 1
? 'Waiting for players...'
: `🎮 Start Game (${allPlayers.length} players)`}
</button>
</>
) : (
<>
<button
onClick={() => router.push('/arcade-rooms')}
className={css({
flex: 1,
px: '6',
py: '4',
bg: 'rgba(255, 255, 255, 0.1)',
color: 'white',
rounded: 'lg',
fontWeight: '600',
cursor: 'pointer',
_hover: { bg: 'rgba(255, 255, 255, 0.15)' },
})}
>
Back to Rooms
</button>
<button
onClick={joinRoom}
disabled={room.isLocked}
className={css({
flex: 2,
px: '6',
py: '4',
bg: room.isLocked ? '#6b7280' : '#3b82f6',
color: 'white',
rounded: 'lg',
fontSize: 'xl',
fontWeight: '600',
cursor: room.isLocked ? 'not-allowed' : 'pointer',
opacity: room.isLocked ? 0.5 : 1,
_hover: room.isLocked ? {} : { bg: '#2563eb' },
})}
>
{room.isLocked ? '🔒 Room Locked' : 'Join Room'}
</button>
</>
)}
</div>
</div>
</div>
</PageWithNav>
)
}

View File

@@ -0,0 +1,316 @@
import { render, screen, waitFor } from '@testing-library/react'
import * as nextNavigation from 'next/navigation'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as arcadeGuard from '@/hooks/useArcadeGuard'
import * as roomData from '@/hooks/useRoomData'
import * as viewerId from '@/hooks/useViewerId'
// Mock Next.js navigation
vi.mock('next/navigation', () => ({
useRouter: vi.fn(),
usePathname: vi.fn(),
useParams: vi.fn(),
}))
// Mock hooks
vi.mock('@/hooks/useArcadeGuard')
vi.mock('@/hooks/useRoomData')
vi.mock('@/hooks/useViewerId')
vi.mock('@/hooks/useUserPlayers', () => ({
useUserPlayers: () => ({ data: [], isLoading: false }),
useCreatePlayer: () => ({ mutate: vi.fn() }),
useUpdatePlayer: () => ({ mutate: vi.fn() }),
useDeletePlayer: () => ({ mutate: vi.fn() }),
}))
vi.mock('@/hooks/useArcadeSocket', () => ({
useArcadeSocket: () => ({
connected: false,
joinSession: vi.fn(),
socket: null,
sendMove: vi.fn(),
exitSession: vi.fn(),
pingSession: vi.fn(),
}),
}))
// Mock styled-system
vi.mock('../../../../styled-system/css', () => ({
css: () => '',
}))
// Mock components
vi.mock('@/components/PageWithNav', () => ({
PageWithNav: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
// Import pages after mocks
import RoomBrowserPage from '../page'
describe('Room Navigation with Active Sessions', () => {
const mockRouter = {
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(nextNavigation, 'useRouter').mockReturnValue(mockRouter as any)
vi.spyOn(nextNavigation, 'usePathname').mockReturnValue('/arcade-rooms')
vi.spyOn(viewerId, 'useViewerId').mockReturnValue({
data: 'test-user',
isLoading: false,
isPending: false,
error: null,
} as any)
global.fetch = vi.fn()
})
describe('RoomBrowserPage', () => {
it('should render room browser without redirecting when user has active game session', async () => {
// User has an active game session
vi.spyOn(arcadeGuard, 'useArcadeGuard').mockReturnValue({
hasActiveSession: true,
loading: false,
activeSession: {
gameUrl: '/arcade/room',
currentGame: 'matching',
},
})
// User is in a room
vi.spyOn(roomData, 'useRoomData').mockReturnValue({
roomData: {
id: 'room-1',
name: 'Test Room',
code: 'ABC123',
gameName: 'matching',
members: [],
memberPlayers: {},
},
isLoading: false,
isInRoom: true,
notifyRoomOfPlayerUpdate: vi.fn(),
})
// Mock rooms API
;(global.fetch as any).mockResolvedValue({
ok: true,
json: async () => ({
rooms: [
{
id: 'room-1',
code: 'ABC123',
name: 'Test Room',
gameName: 'matching',
status: 'lobby',
createdAt: new Date(),
creatorName: 'Test User',
isLocked: false,
},
],
}),
})
render(<RoomBrowserPage />)
// Should render the page
await waitFor(() => {
expect(screen.getByText('🎮 Multiplayer Rooms')).toBeInTheDocument()
})
// Should NOT redirect to /arcade/room
expect(mockRouter.push).not.toHaveBeenCalled()
})
it('should NOT redirect when PageWithNav uses arcade guard with enabled=false', async () => {
// Simulate PageWithNav calling useArcadeGuard with enabled=false
const arcadeGuardSpy = vi.spyOn(arcadeGuard, 'useArcadeGuard')
// User has an active game session
arcadeGuardSpy.mockReturnValue({
hasActiveSession: true,
loading: false,
activeSession: {
gameUrl: '/arcade/room',
currentGame: 'matching',
},
})
vi.spyOn(roomData, 'useRoomData').mockReturnValue({
roomData: null,
isLoading: false,
isInRoom: false,
notifyRoomOfPlayerUpdate: vi.fn(),
})
;(global.fetch as any).mockResolvedValue({
ok: true,
json: async () => ({ rooms: [] }),
})
render(<RoomBrowserPage />)
await waitFor(() => {
expect(screen.getByText('🎮 Multiplayer Rooms')).toBeInTheDocument()
})
// PageWithNav should have called useArcadeGuard with enabled=false
// This is tested in PageWithNav's own tests, but we verify no redirect happened
expect(mockRouter.push).not.toHaveBeenCalled()
})
it('should allow navigation to room detail even with active session', async () => {
vi.spyOn(arcadeGuard, 'useArcadeGuard').mockReturnValue({
hasActiveSession: true,
loading: false,
activeSession: {
gameUrl: '/arcade/room',
currentGame: 'matching',
},
})
vi.spyOn(roomData, 'useRoomData').mockReturnValue({
roomData: null,
isLoading: false,
isInRoom: false,
notifyRoomOfPlayerUpdate: vi.fn(),
})
;(global.fetch as any).mockResolvedValue({
ok: true,
json: async () => ({
rooms: [
{
id: 'room-1',
code: 'ABC123',
name: 'Test Room',
gameName: 'matching',
status: 'lobby',
createdAt: new Date(),
creatorName: 'Test User',
isLocked: false,
isMember: true,
},
],
}),
})
render(<RoomBrowserPage />)
await waitFor(() => {
expect(screen.getByText('Test Room')).toBeInTheDocument()
})
// Click on the room card
const roomCard = screen.getByText('Test Room').parentElement
roomCard?.click()
// Should navigate to room detail, not to /arcade/room
await waitFor(() => {
expect(mockRouter.push).toHaveBeenCalledWith('/arcade-rooms/room-1')
})
})
})
describe('Room navigation edge cases', () => {
it('should handle rapid navigation between room pages without redirect loops', async () => {
vi.spyOn(arcadeGuard, 'useArcadeGuard').mockReturnValue({
hasActiveSession: true,
loading: false,
activeSession: {
gameUrl: '/arcade/room',
currentGame: 'matching',
},
})
vi.spyOn(roomData, 'useRoomData').mockReturnValue({
roomData: null,
isLoading: false,
isInRoom: false,
notifyRoomOfPlayerUpdate: vi.fn(),
})
;(global.fetch as any).mockResolvedValue({
ok: true,
json: async () => ({ rooms: [] }),
})
const { rerender } = render(<RoomBrowserPage />)
await waitFor(() => {
expect(screen.getByText('🎮 Multiplayer Rooms')).toBeInTheDocument()
})
// Simulate pathname changes (navigating between room pages)
vi.spyOn(nextNavigation, 'usePathname').mockReturnValue('/arcade-rooms/room-1')
rerender(<RoomBrowserPage />)
vi.spyOn(nextNavigation, 'usePathname').mockReturnValue('/arcade-rooms')
rerender(<RoomBrowserPage />)
// Should never redirect to game page
expect(mockRouter.push).not.toHaveBeenCalledWith('/arcade/room')
})
it('should allow user to leave room and browse other rooms during active game', async () => {
// User is in a room with an active game
vi.spyOn(arcadeGuard, 'useArcadeGuard').mockReturnValue({
hasActiveSession: true,
loading: false,
activeSession: {
gameUrl: '/arcade/room',
currentGame: 'matching',
},
})
vi.spyOn(roomData, 'useRoomData').mockReturnValue({
roomData: {
id: 'room-1',
name: 'Current Room',
code: 'ABC123',
gameName: 'matching',
members: [],
memberPlayers: {},
},
isLoading: false,
isInRoom: true,
notifyRoomOfPlayerUpdate: vi.fn(),
})
;(global.fetch as any).mockResolvedValue({
ok: true,
json: async () => ({
rooms: [
{
id: 'room-1',
name: 'Current Room',
code: 'ABC123',
gameName: 'matching',
status: 'playing',
isMember: true,
},
{
id: 'room-2',
name: 'Other Room',
code: 'DEF456',
gameName: 'memory-quiz',
status: 'lobby',
isMember: false,
},
],
}),
})
render(<RoomBrowserPage />)
await waitFor(() => {
expect(screen.getByText('Current Room')).toBeInTheDocument()
expect(screen.getByText('Other Room')).toBeInTheDocument()
})
// Should be able to view both rooms without redirect
expect(mockRouter.push).not.toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,468 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { css } from '../../../styled-system/css'
import { PageWithNav } from '@/components/PageWithNav'
interface Room {
id: string
code: string
name: string
gameName: string
status: 'lobby' | 'playing' | 'finished'
createdAt: Date
creatorName: string
isLocked: boolean
memberCount?: number
playerCount?: number
isMember?: boolean
}
export default function RoomBrowserPage() {
const router = useRouter()
const [rooms, setRooms] = useState<Room[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showCreateModal, setShowCreateModal] = useState(false)
useEffect(() => {
fetchRooms()
}, [])
const fetchRooms = async () => {
try {
setLoading(true)
const response = await fetch('/api/arcade/rooms')
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const data = await response.json()
setRooms(data.rooms)
setError(null)
} catch (err) {
console.error('Failed to fetch rooms:', err)
setError('Failed to load rooms')
} finally {
setLoading(false)
}
}
const createRoom = async (name: string, gameName: string) => {
try {
const response = await fetch('/api/arcade/rooms', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
gameName,
creatorName: 'Player',
gameConfig: { difficulty: 6 },
}),
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const data = await response.json()
router.push(`/arcade-rooms/${data.room.id}`)
} catch (err) {
console.error('Failed to create room:', err)
alert('Failed to create room')
}
}
const joinRoom = async (roomId: string) => {
try {
const response = await fetch(`/api/arcade/rooms/${roomId}/join`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ displayName: 'Player' }),
})
if (!response.ok) {
const errorData = await response.json()
// Handle specific room membership conflict
if (errorData.code === 'ROOM_MEMBERSHIP_CONFLICT') {
alert(errorData.userMessage || errorData.message)
// Refresh the page to update room list state
await fetchRooms()
return
}
throw new Error(errorData.error || `HTTP ${response.status}`)
}
const data = await response.json()
// Show notification if user was auto-removed from other rooms
if (data.autoLeave) {
console.log(`[Room Join] ${data.autoLeave.message}`)
// Could show a toast notification here in the future
}
router.push(`/arcade-rooms/${roomId}`)
} catch (err) {
console.error('Failed to join room:', err)
alert('Failed to join room')
}
}
return (
<PageWithNav>
<div
className={css({
minH: 'calc(100vh - 80px)',
bg: 'linear-gradient(135deg, #0f0f23 0%, #1a1a3a 50%, #2d1b69 100%)',
p: '8',
})}
>
<div className={css({ maxW: '1200px', mx: 'auto' })}>
{/* Header */}
<div className={css({ mb: '8', textAlign: 'center' })}>
<h1
className={css({
fontSize: '4xl',
fontWeight: 'bold',
color: 'white',
mb: '4',
})}
>
🎮 Multiplayer Rooms
</h1>
<p className={css({ color: '#a0a0ff', fontSize: 'lg', mb: '6' })}>
Join a room or create your own to play with friends
</p>
<button
onClick={() => setShowCreateModal(true)}
className={css({
px: '6',
py: '3',
bg: '#10b981',
color: 'white',
rounded: 'lg',
fontSize: 'lg',
fontWeight: '600',
cursor: 'pointer',
_hover: { bg: '#059669' },
transition: 'all 0.2s',
})}
>
+ Create New Room
</button>
</div>
{/* Room List */}
{loading && (
<div className={css({ textAlign: 'center', color: 'white', py: '12' })}>
Loading rooms...
</div>
)}
{error && (
<div
className={css({
bg: '#fef2f2',
border: '1px solid #fecaca',
color: '#991b1b',
p: '4',
rounded: 'lg',
textAlign: 'center',
})}
>
{error}
</div>
)}
{!loading && !error && rooms.length === 0 && (
<div
className={css({
bg: 'rgba(255, 255, 255, 0.05)',
backdropFilter: 'blur(10px)',
border: '1px solid rgba(255, 255, 255, 0.1)',
rounded: 'lg',
p: '12',
textAlign: 'center',
color: 'white',
})}
>
<p className={css({ fontSize: 'xl', mb: '2' })}>No rooms available</p>
<p className={css({ color: '#a0a0ff' })}>Be the first to create one!</p>
</div>
)}
{!loading && !error && rooms.length > 0 && (
<div className={css({ display: 'grid', gap: '4' })}>
{rooms.map((room) => (
<div
key={room.id}
className={css({
bg: 'rgba(255, 255, 255, 0.05)',
backdropFilter: 'blur(10px)',
border: '1px solid rgba(255, 255, 255, 0.1)',
rounded: 'lg',
p: '6',
transition: 'all 0.2s',
_hover: {
bg: 'rgba(255, 255, 255, 0.08)',
borderColor: 'rgba(255, 255, 255, 0.2)',
},
})}
>
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
})}
>
<div
onClick={() => router.push(`/arcade-rooms/${room.id}`)}
className={css({ flex: 1, cursor: 'pointer' })}
>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '3',
mb: '2',
})}
>
<h3
className={css({ fontSize: '2xl', fontWeight: 'bold', color: 'white' })}
>
{room.name}
</h3>
<span
className={css({
px: '3',
py: '1',
bg: 'rgba(255, 255, 255, 0.1)',
color: '#fbbf24',
rounded: 'full',
fontSize: 'sm',
fontWeight: '600',
fontFamily: 'monospace',
})}
>
{room.code}
</span>
{room.isLocked && (
<span className={css({ color: '#f87171', fontSize: 'sm' })}>
🔒 Locked
</span>
)}
</div>
<div
className={css({
display: 'flex',
gap: '4',
color: '#a0a0ff',
fontSize: 'sm',
flexWrap: 'wrap',
})}
>
<span>👤 Host: {room.creatorName}</span>
<span>🎮 {room.gameName}</span>
{room.memberCount !== undefined && (
<span>
👥 {room.memberCount} member{room.memberCount !== 1 ? 's' : ''}
</span>
)}
{room.playerCount !== undefined && room.playerCount > 0 && (
<span>
🎯 {room.playerCount} player{room.playerCount !== 1 ? 's' : ''}
</span>
)}
<span
className={css({
color:
room.status === 'lobby'
? '#10b981'
: room.status === 'playing'
? '#fbbf24'
: '#6b7280',
})}
>
{room.status === 'lobby'
? '⏳ Waiting'
: room.status === 'playing'
? '🎮 Playing'
: '✓ Finished'}
</span>
</div>
</div>
{room.isMember ? (
<div
className={css({
px: '6',
py: '3',
bg: '#10b981',
color: 'white',
rounded: 'lg',
fontWeight: '600',
display: 'flex',
alignItems: 'center',
gap: '2',
})}
>
Joined
</div>
) : (
<button
onClick={(e) => {
e.stopPropagation()
joinRoom(room.id)
}}
disabled={room.isLocked}
className={css({
px: '6',
py: '3',
bg: room.isLocked ? '#6b7280' : '#3b82f6',
color: 'white',
rounded: 'lg',
fontWeight: '600',
cursor: room.isLocked ? 'not-allowed' : 'pointer',
opacity: room.isLocked ? 0.5 : 1,
_hover: room.isLocked ? {} : { bg: '#2563eb' },
transition: 'all 0.2s',
})}
>
Join Room
</button>
)}
</div>
</div>
))}
</div>
)}
</div>
{/* Create Room Modal */}
{showCreateModal && (
<div
className={css({
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
bg: 'rgba(0, 0, 0, 0.7)',
backdropFilter: 'blur(4px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 50,
})}
onClick={() => setShowCreateModal(false)}
>
<div
className={css({
bg: 'white',
rounded: 'xl',
p: '8',
maxW: '500px',
w: 'full',
mx: '4',
})}
onClick={(e) => e.stopPropagation()}
>
<h2 className={css({ fontSize: '2xl', fontWeight: 'bold', mb: '6' })}>
Create New Room
</h2>
<form
onSubmit={(e) => {
e.preventDefault()
const formData = new FormData(e.currentTarget)
const name = formData.get('name') as string
const gameName = formData.get('gameName') as string
if (name && gameName) {
createRoom(name, gameName)
}
}}
>
<div className={css({ mb: '4' })}>
<label className={css({ display: 'block', mb: '2', fontWeight: '600' })}>
Room Name
</label>
<input
name="name"
type="text"
required
placeholder="My Awesome Room"
className={css({
w: 'full',
px: '4',
py: '3',
border: '1px solid #d1d5db',
rounded: 'lg',
_focus: { outline: 'none', borderColor: '#3b82f6' },
})}
/>
</div>
<div className={css({ mb: '6' })}>
<label className={css({ display: 'block', mb: '2', fontWeight: '600' })}>
Game
</label>
<select
name="gameName"
required
className={css({
w: 'full',
px: '4',
py: '3',
border: '1px solid #d1d5db',
rounded: 'lg',
_focus: { outline: 'none', borderColor: '#3b82f6' },
})}
>
<option value="matching">Memory Matching</option>
<option value="memory-quiz">Memory Quiz</option>
<option value="complement-race">Complement Race</option>
</select>
</div>
<div className={css({ display: 'flex', gap: '3' })}>
<button
type="button"
onClick={() => setShowCreateModal(false)}
className={css({
flex: 1,
px: '6',
py: '3',
bg: '#e5e7eb',
color: '#374151',
rounded: 'lg',
fontWeight: '600',
cursor: 'pointer',
_hover: { bg: '#d1d5db' },
})}
>
Cancel
</button>
<button
type="submit"
className={css({
flex: 1,
px: '6',
py: '3',
bg: '#10b981',
color: 'white',
rounded: 'lg',
fontWeight: '600',
cursor: 'pointer',
_hover: { bg: '#059669' },
})}
>
Create Room
</button>
</div>
</form>
</div>
</div>
)}
</div>
</PageWithNav>
)
}

View File

@@ -0,0 +1,62 @@
'use client'
import { useEffect, useState } from 'react'
interface SpeechBubbleProps {
message: string
onHide: () => void
}
export function SpeechBubble({ message, onHide }: SpeechBubbleProps) {
const [isVisible, setIsVisible] = useState(true)
useEffect(() => {
// Auto-hide after 3.5s (line 11749-11752)
const timer = setTimeout(() => {
setIsVisible(false)
setTimeout(onHide, 300) // Wait for fade-out animation
}, 3500)
return () => clearTimeout(timer)
}, [onHide])
return (
<div
style={{
position: 'absolute',
bottom: 'calc(100% + 10px)',
left: '50%',
transform: 'translateX(-50%)',
background: 'white',
borderRadius: '15px',
padding: '10px 15px',
boxShadow: '0 4px 12px rgba(0,0,0,0.2)',
fontSize: '14px',
whiteSpace: 'nowrap',
opacity: isVisible ? 1 : 0,
transition: 'opacity 0.3s ease',
zIndex: 10,
pointerEvents: 'none',
maxWidth: '250px',
textAlign: 'center',
}}
>
{message}
{/* Tail pointing down */}
<div
style={{
position: 'absolute',
bottom: '-8px',
left: '50%',
transform: 'translateX(-50%)',
width: 0,
height: 0,
borderLeft: '8px solid transparent',
borderRight: '8px solid transparent',
borderTop: '8px solid white',
filter: 'drop-shadow(0 2px 2px rgba(0,0,0,0.1))',
}}
/>
</div>
)
}

View File

@@ -0,0 +1,154 @@
import type { AIRacer } from '../../lib/gameTypes'
export type CommentaryContext =
| 'ahead'
| 'behind'
| 'adaptive_struggle'
| 'adaptive_mastery'
| 'player_passed'
| 'ai_passed'
| 'lapped'
| 'desperate_catchup'
// Swift AI - Competitive personality (lines 11768-11834)
export const swiftAICommentary: Record<CommentaryContext, string[]> = {
ahead: [
'💨 Eat my dust!',
'🔥 Too slow for me!',
"⚡ You can't catch me!",
"🚀 I'm built for speed!",
'🏃‍♂️ This is way too easy!',
],
behind: [
'😤 Not over yet!',
"💪 I'm just getting started!",
'🔥 Watch me catch up to you!',
"⚡ I'm coming for you!",
'🏃‍♂️ This is my comeback!',
],
adaptive_struggle: [
'😏 You struggling much?',
'🤖 Math is easy for me!',
'⚡ You need to think faster!',
'🔥 Need me to slow down?',
],
adaptive_mastery: [
"😮 You're actually impressive!",
"🤔 You're getting faster...",
'😤 Time for me to step it up!',
'⚡ Not bad for a human!',
],
player_passed: [
'😠 No way you just passed me!',
"🔥 This isn't over!",
"💨 I'm just getting warmed up!",
"😤 Your lucky streak won't last!",
"⚡ I'll be back in front of you soon!",
],
ai_passed: [
'💨 See ya later, slowpoke!',
'😎 Thanks for the warm-up!',
"🔥 This is how it's done!",
"⚡ I'll see you at the finish line!",
'💪 Try to keep up with me!',
],
lapped: [
'😡 You just lapped me?! No way!',
'🤬 This is embarrassing for me!',
"😤 I'm not going down without a fight!",
'💢 How did you get so far ahead?!',
'🔥 Time to show you my real speed!',
"😠 You won't stay ahead for long!",
],
desperate_catchup: [
"🚨 TURBO MODE ACTIVATED! I'm coming for you!",
'💥 You forced me to unleash my true power!',
'🔥 NO MORE MR. NICE AI! Time to go all out!',
"⚡ I'm switching to MAXIMUM OVERDRIVE!",
"😤 You made me angry - now you'll see what I can do!",
"🚀 AFTERBURNERS ENGAGED! This isn't over!",
],
}
// Math Bot - Analytical personality (lines 11835-11901)
export const mathBotCommentary: Record<CommentaryContext, string[]> = {
ahead: [
'📊 My performance is optimal!',
'🤖 My logic beats your speed!',
'📈 I have 87% win probability!',
"⚙️ I'm perfectly calibrated!",
'🔬 Science prevails over you!',
],
behind: [
'🤔 Recalculating my strategy...',
"📊 You're exceeding my projections!",
'⚙️ Adjusting my parameters!',
"🔬 I'm analyzing your technique!",
"📈 You're a statistical anomaly!",
],
adaptive_struggle: [
'📊 I detect inefficiencies in you!',
'🔬 You should focus on patterns!',
'⚙️ Use that extra time wisely!',
'📈 You have room for improvement!',
],
adaptive_mastery: [
'🤖 Your optimization is excellent!',
'📊 Your metrics are impressive!',
"⚙️ I'm updating my models because of you!",
'🔬 You have near-AI efficiency!',
],
player_passed: [
'🤖 Your strategy is fascinating!',
"📊 You're an unexpected variable!",
"⚙️ I'm adjusting my algorithms...",
'🔬 Your execution is impressive!',
"📈 I'm recalculating the odds!",
],
ai_passed: [
'🤖 My efficiency is optimized!',
'📊 Just as I calculated!',
'⚙️ All my systems nominal!',
'🔬 My logic prevails over you!',
"📈 I'm at 96% confidence level!",
],
lapped: [
'🤖 Error: You have exceeded my projections!',
'📊 This outcome has 0.3% probability!',
'⚙️ I need to recalibrate my systems!',
'🔬 Your performance is... statistically improbable!',
'📈 My confidence level just dropped to 12%!',
'🤔 I must analyze your methodology!',
],
desperate_catchup: [
'🤖 EMERGENCY PROTOCOL ACTIVATED! Initiating maximum speed!',
'🚨 CRITICAL GAP DETECTED! Engaging catchup algorithms!',
'⚙️ OVERCLOCKING MY PROCESSORS! Prepare for rapid acceleration!',
'📊 PROBABILITY OF FAILURE: UNACCEPTABLE! Switching to turbo mode!',
"🔬 HYPOTHESIS: You're about to see my true potential!",
'📈 CONFIDENCE LEVEL: RISING! My comeback protocol is online!',
],
}
// Get AI commentary message (lines 11636-11657)
export function getAICommentary(
racer: AIRacer,
context: CommentaryContext,
_playerProgress: number,
_aiProgress: number
): string | null {
// Check cooldown (line 11759-11761)
const now = Date.now()
if (now - racer.lastComment < racer.commentCooldown) {
return null
}
// Select message set based on personality and context
const messages =
racer.personality === 'competitive' ? swiftAICommentary[context] : mathBotCommentary[context]
if (!messages || messages.length === 0) return null
// Return random message
return messages[Math.floor(Math.random() * messages.length)]
}

View File

@@ -0,0 +1,36 @@
'use client'
import { AbacusReact } from '@soroban/abacus-react'
interface AbacusTargetProps {
number: number // The complement number to display
}
/**
* Displays a small abacus showing a complement number inline in the equation
* Used to help learners recognize the abacus representation of complement numbers
*/
export function AbacusTarget({ number }: AbacusTargetProps) {
return (
<div
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
lineHeight: 0,
}}
>
<AbacusReact
value={number}
columns={1}
interactive={false}
showNumbers={false}
hideInactiveBeads={true}
scaleFactor={0.72}
customStyles={{
columnPosts: { opacity: 0 },
}}
/>
</div>
)
}

View File

@@ -0,0 +1,373 @@
'use client'
import { useComplementRace } from '../context/ComplementRaceContext'
import { GameControls } from './GameControls'
import { GameCountdown } from './GameCountdown'
import { GameDisplay } from './GameDisplay'
import { GameIntro } from './GameIntro'
import { GameResults } from './GameResults'
export function ComplementRaceGame() {
const { state } = useComplementRace()
return (
<div
data-component="game-page-root"
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
padding: '20px 8px',
minHeight: '100vh',
maxHeight: '100vh',
background:
state.style === 'sprint'
? 'linear-gradient(to bottom, #2563eb 0%, #60a5fa 100%)'
: 'radial-gradient(ellipse at center, #8db978 0%, #7ba565 40%, #6a9354 100%)',
position: 'relative',
}}
>
{/* Background pattern - subtle grass texture */}
{state.style !== 'sprint' && (
<div
style={{
position: 'absolute',
inset: 0,
overflow: 'hidden',
pointerEvents: 'none',
zIndex: 0,
opacity: 0.15,
}}
>
<svg width="100%" height="100%">
<defs>
<pattern
id="grass-texture"
x="0"
y="0"
width="40"
height="40"
patternUnits="userSpaceOnUse"
>
<rect width="40" height="40" fill="transparent" />
<line x1="2" y1="5" x2="8" y2="5" stroke="#2d5016" strokeWidth="1" opacity="0.3" />
<line
x1="15"
y1="8"
x2="20"
y2="8"
stroke="#2d5016"
strokeWidth="1"
opacity="0.25"
/>
<line
x1="25"
y1="12"
x2="32"
y2="12"
stroke="#2d5016"
strokeWidth="1"
opacity="0.2"
/>
<line
x1="5"
y1="18"
x2="12"
y2="18"
stroke="#2d5016"
strokeWidth="1"
opacity="0.3"
/>
<line
x1="28"
y1="22"
x2="35"
y2="22"
stroke="#2d5016"
strokeWidth="1"
opacity="0.25"
/>
<line
x1="10"
y1="30"
x2="16"
y2="30"
stroke="#2d5016"
strokeWidth="1"
opacity="0.2"
/>
<line
x1="22"
y1="35"
x2="28"
y2="35"
stroke="#2d5016"
strokeWidth="1"
opacity="0.3"
/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#grass-texture)" />
</svg>
</div>
)}
{/* Subtle tree clusters around edges - top-down view with gentle sway */}
{state.style !== 'sprint' && (
<div
style={{
position: 'absolute',
inset: 0,
overflow: 'hidden',
pointerEvents: 'none',
zIndex: 0,
}}
>
{/* Top-left tree cluster */}
<div
style={{
position: 'absolute',
top: '5%',
left: '3%',
width: '80px',
height: '80px',
borderRadius: '50%',
background: 'radial-gradient(circle, #4a7c3a 0%, #3d6630 60%, transparent 70%)',
opacity: 0.2,
filter: 'blur(4px)',
animation: 'treeSway1 8s ease-in-out infinite',
}}
/>
{/* Top-right tree cluster */}
<div
style={{
position: 'absolute',
top: '8%',
right: '5%',
width: '100px',
height: '100px',
borderRadius: '50%',
background: 'radial-gradient(circle, #4a7c3a 0%, #3d6630 60%, transparent 70%)',
opacity: 0.18,
filter: 'blur(5px)',
animation: 'treeSway2 10s ease-in-out infinite',
}}
/>
{/* Bottom-left tree cluster */}
<div
style={{
position: 'absolute',
bottom: '10%',
left: '8%',
width: '90px',
height: '90px',
borderRadius: '50%',
background: 'radial-gradient(circle, #4a7c3a 0%, #3d6630 60%, transparent 70%)',
opacity: 0.15,
filter: 'blur(4px)',
animation: 'treeSway1 9s ease-in-out infinite reverse',
}}
/>
{/* Bottom-right tree cluster */}
<div
style={{
position: 'absolute',
bottom: '5%',
right: '4%',
width: '110px',
height: '110px',
borderRadius: '50%',
background: 'radial-gradient(circle, #4a7c3a 0%, #3d6630 60%, transparent 70%)',
opacity: 0.2,
filter: 'blur(6px)',
animation: 'treeSway2 11s ease-in-out infinite',
}}
/>
{/* Additional smaller clusters for depth */}
<div
style={{
position: 'absolute',
top: '40%',
left: '2%',
width: '60px',
height: '60px',
borderRadius: '50%',
background: 'radial-gradient(circle, #4a7c3a 0%, #3d6630 60%, transparent 70%)',
opacity: 0.12,
filter: 'blur(3px)',
animation: 'treeSway1 7s ease-in-out infinite',
}}
/>
<div
style={{
position: 'absolute',
top: '55%',
right: '3%',
width: '70px',
height: '70px',
borderRadius: '50%',
background: 'radial-gradient(circle, #4a7c3a 0%, #3d6630 60%, transparent 70%)',
opacity: 0.14,
filter: 'blur(4px)',
animation: 'treeSway2 8.5s ease-in-out infinite reverse',
}}
/>
</div>
)}
{/* Flying bird shadows - very subtle from aerial view */}
{state.style !== 'sprint' && (
<div
style={{
position: 'absolute',
inset: 0,
overflow: 'hidden',
pointerEvents: 'none',
zIndex: 0,
}}
>
<div
style={{
position: 'absolute',
top: '30%',
left: '-5%',
width: '15px',
height: '8px',
background: 'rgba(0, 0, 0, 0.08)',
borderRadius: '50%',
filter: 'blur(2px)',
animation: 'birdFly1 20s linear infinite',
}}
/>
<div
style={{
position: 'absolute',
top: '60%',
left: '-5%',
width: '12px',
height: '6px',
background: 'rgba(0, 0, 0, 0.06)',
borderRadius: '50%',
filter: 'blur(2px)',
animation: 'birdFly2 28s linear infinite',
}}
/>
<div
style={{
position: 'absolute',
top: '45%',
left: '-5%',
width: '10px',
height: '5px',
background: 'rgba(0, 0, 0, 0.05)',
borderRadius: '50%',
filter: 'blur(1px)',
animation: 'birdFly1 35s linear infinite',
animationDelay: '-12s',
}}
/>
</div>
)}
{/* Subtle cloud shadows moving across field */}
{state.style !== 'sprint' && (
<div
style={{
position: 'absolute',
inset: 0,
overflow: 'hidden',
pointerEvents: 'none',
zIndex: 0,
}}
>
<div
style={{
position: 'absolute',
top: '-10%',
left: '-20%',
width: '300px',
height: '200px',
background: 'radial-gradient(ellipse, rgba(0, 0, 0, 0.03) 0%, transparent 60%)',
borderRadius: '50%',
filter: 'blur(20px)',
animation: 'cloudShadow1 45s linear infinite',
}}
/>
<div
style={{
position: 'absolute',
top: '-10%',
left: '-20%',
width: '250px',
height: '180px',
background: 'radial-gradient(ellipse, rgba(0, 0, 0, 0.025) 0%, transparent 60%)',
borderRadius: '50%',
filter: 'blur(25px)',
animation: 'cloudShadow2 60s linear infinite',
animationDelay: '-20s',
}}
/>
</div>
)}
{/* CSS animations */}
<style>{`
@keyframes treeSway1 {
0%, 100% { transform: scale(1) translate(0, 0); }
25% { transform: scale(1.02) translate(2px, -1px); }
50% { transform: scale(0.98) translate(-1px, 1px); }
75% { transform: scale(1.01) translate(-2px, -1px); }
}
@keyframes treeSway2 {
0%, 100% { transform: scale(1) translate(0, 0); }
30% { transform: scale(1.015) translate(-2px, 1px); }
60% { transform: scale(0.985) translate(2px, -1px); }
80% { transform: scale(1.01) translate(1px, 1px); }
}
@keyframes birdFly1 {
0% { transform: translate(0, 0); }
100% { transform: translate(calc(100vw + 100px), -20vh); }
}
@keyframes birdFly2 {
0% { transform: translate(0, 0); }
100% { transform: translate(calc(100vw + 100px), 15vh); }
}
@keyframes cloudShadow1 {
0% { transform: translate(0, 0); }
100% { transform: translate(calc(100vw + 400px), 30vh); }
}
@keyframes cloudShadow2 {
0% { transform: translate(0, 0); }
100% { transform: translate(calc(100vw + 350px), -20vh); }
}
`}</style>
<div
style={{
maxWidth: '100%',
margin: '0 auto',
flex: 1,
display: 'flex',
flexDirection: 'column',
position: 'relative',
zIndex: 1,
}}
>
{state.gamePhase === 'intro' && <GameIntro />}
{state.gamePhase === 'controls' && <GameControls />}
{state.gamePhase === 'countdown' && <GameCountdown />}
{state.gamePhase === 'playing' && <GameDisplay />}
{state.gamePhase === 'results' && <GameResults />}
</div>
</div>
)
}

View File

@@ -0,0 +1,475 @@
'use client'
import { useComplementRace } from '../context/ComplementRaceContext'
import type { ComplementDisplay, GameMode, GameStyle, TimeoutSetting } from '../lib/gameTypes'
import { AbacusTarget } from './AbacusTarget'
export function GameControls() {
const { state, dispatch } = useComplementRace()
const handleModeSelect = (mode: GameMode) => {
dispatch({ type: 'SET_MODE', mode })
}
const handleStyleSelect = (style: GameStyle) => {
dispatch({ type: 'SET_STYLE', style })
// Start the game immediately - no navigation needed
if (style === 'sprint') {
dispatch({ type: 'BEGIN_GAME' })
} else {
dispatch({ type: 'START_COUNTDOWN' })
}
}
const handleTimeoutSelect = (timeout: TimeoutSetting) => {
dispatch({ type: 'SET_TIMEOUT', timeout })
}
return (
<div
style={{
height: '100vh',
display: 'flex',
flexDirection: 'column',
background: 'linear-gradient(to bottom, #0f172a 0%, #1e293b 50%, #334155 100%)',
overflow: 'hidden',
position: 'relative',
}}
>
{/* Animated background pattern */}
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundImage:
'radial-gradient(circle at 20% 50%, rgba(59, 130, 246, 0.1) 0%, transparent 50%), radial-gradient(circle at 80% 80%, rgba(139, 92, 246, 0.1) 0%, transparent 50%)',
pointerEvents: 'none',
}}
/>
{/* Header */}
<div
style={{
textAlign: 'center',
padding: '20px',
position: 'relative',
zIndex: 1,
}}
>
<h1
style={{
fontSize: '32px',
fontWeight: 'bold',
background: 'linear-gradient(135deg, #60a5fa 0%, #a78bfa 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
margin: 0,
letterSpacing: '-0.5px',
}}
>
Complement Race
</h1>
</div>
{/* Settings Bar */}
<div
style={{
padding: '0 20px 16px',
display: 'flex',
flexDirection: 'column',
gap: '12px',
position: 'relative',
zIndex: 1,
}}
>
{/* Number Mode & Display */}
<div
style={{
background: 'rgba(30, 41, 59, 0.8)',
backdropFilter: 'blur(20px)',
borderRadius: '16px',
padding: '16px',
border: '1px solid rgba(148, 163, 184, 0.2)',
}}
>
<div
style={{
display: 'flex',
gap: '20px',
flexWrap: 'wrap',
alignItems: 'center',
}}
>
{/* Number Mode Pills */}
<div
style={{
display: 'flex',
gap: '8px',
alignItems: 'center',
flex: 1,
minWidth: '200px',
}}
>
<span
style={{
fontSize: '13px',
color: '#94a3b8',
fontWeight: '600',
marginRight: '4px',
}}
>
Mode:
</span>
{[
{ mode: 'friends5' as GameMode, label: '5' },
{ mode: 'friends10' as GameMode, label: '10' },
{ mode: 'mixed' as GameMode, label: 'Mix' },
].map(({ mode, label }) => (
<button
key={mode}
onClick={() => handleModeSelect(mode)}
style={{
padding: '8px 16px',
borderRadius: '20px',
border: 'none',
background:
state.mode === mode
? 'linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%)'
: 'rgba(148, 163, 184, 0.2)',
color: state.mode === mode ? 'white' : '#94a3b8',
fontWeight: 'bold',
cursor: 'pointer',
transition: 'all 0.2s ease',
fontSize: '13px',
}}
>
{label}
</button>
))}
</div>
{/* Complement Display Pills */}
<div
style={{
display: 'flex',
gap: '8px',
alignItems: 'center',
flex: 1,
minWidth: '200px',
}}
>
<span
style={{
fontSize: '13px',
color: '#94a3b8',
fontWeight: '600',
marginRight: '4px',
}}
>
Show:
</span>
{(['number', 'abacus', 'random'] as ComplementDisplay[]).map((displayMode) => (
<button
key={displayMode}
onClick={() => dispatch({ type: 'SET_COMPLEMENT_DISPLAY', display: displayMode })}
style={{
padding: '8px 16px',
borderRadius: '20px',
border: 'none',
background:
state.complementDisplay === displayMode
? 'linear-gradient(135deg, #10b981 0%, #059669 100%)'
: 'rgba(148, 163, 184, 0.2)',
color: state.complementDisplay === displayMode ? 'white' : '#94a3b8',
fontWeight: 'bold',
cursor: 'pointer',
transition: 'all 0.2s ease',
fontSize: '13px',
}}
>
{displayMode === 'number' ? '123' : displayMode === 'abacus' ? '🧮' : '🎲'}
</button>
))}
</div>
{/* Speed Pills */}
<div
style={{
display: 'flex',
gap: '6px',
alignItems: 'center',
flex: 1,
minWidth: '200px',
flexWrap: 'wrap',
}}
>
<span
style={{
fontSize: '13px',
color: '#94a3b8',
fontWeight: '600',
marginRight: '4px',
}}
>
Speed:
</span>
{(
[
'preschool',
'kindergarten',
'relaxed',
'slow',
'normal',
'fast',
'expert',
] as TimeoutSetting[]
).map((timeout) => (
<button
key={timeout}
onClick={() => handleTimeoutSelect(timeout)}
style={{
padding: '6px 12px',
borderRadius: '16px',
border: 'none',
background:
state.timeoutSetting === timeout
? 'linear-gradient(135deg, #ec4899 0%, #be185d 100%)'
: 'rgba(148, 163, 184, 0.2)',
color: state.timeoutSetting === timeout ? 'white' : '#94a3b8',
fontWeight: state.timeoutSetting === timeout ? 'bold' : 'normal',
cursor: 'pointer',
transition: 'all 0.2s ease',
fontSize: '11px',
}}
>
{timeout === 'preschool'
? 'Pre'
: timeout === 'kindergarten'
? 'K'
: timeout.charAt(0).toUpperCase()}
</button>
))}
</div>
</div>
{/* Preview - compact */}
<div
style={{
marginTop: '12px',
padding: '12px',
borderRadius: '12px',
background: 'rgba(15, 23, 42, 0.6)',
border: '1px solid rgba(148, 163, 184, 0.2)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '12px',
}}
>
<span style={{ fontSize: '11px', color: '#94a3b8', fontWeight: '600' }}>Preview:</span>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
fontSize: '20px',
fontWeight: 'bold',
color: 'white',
}}
>
<div
style={{
background: 'linear-gradient(135deg, #3b82f6, #8b5cf6)',
color: 'white',
padding: '2px 10px',
borderRadius: '6px',
}}
>
?
</div>
<span style={{ fontSize: '16px', color: '#64748b' }}>+</span>
{state.complementDisplay === 'number' ? (
<span>3</span>
) : state.complementDisplay === 'abacus' ? (
<div style={{ transform: 'scale(0.8)' }}>
<AbacusTarget number={3} />
</div>
) : (
<span style={{ fontSize: '14px' }}>🎲</span>
)}
<span style={{ fontSize: '16px', color: '#64748b' }}>=</span>
<span style={{ color: '#10b981' }}>
{state.mode === 'friends5' ? '5' : state.mode === 'friends10' ? '10' : '?'}
</span>
</div>
</div>
</div>
</div>
{/* HERO SECTION - Race Cards */}
<div
data-component="race-cards-container"
style={{
flex: 1,
padding: '0 20px 20px',
display: 'flex',
flexDirection: 'column',
gap: '16px',
position: 'relative',
zIndex: 1,
overflow: 'auto',
}}
>
{[
{
style: 'practice' as GameStyle,
emoji: '🏁',
title: 'Practice Race',
desc: 'Race against AI to 20 correct answers',
gradient: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
shadowColor: 'rgba(16, 185, 129, 0.5)',
accentColor: '#34d399',
},
{
style: 'sprint' as GameStyle,
emoji: '🚂',
title: 'Steam Sprint',
desc: 'High-speed 60-second train journey',
gradient: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
shadowColor: 'rgba(245, 158, 11, 0.5)',
accentColor: '#fbbf24',
},
{
style: 'survival' as GameStyle,
emoji: '🔄',
title: 'Survival Circuit',
desc: 'Endless laps - beat your best time',
gradient: 'linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%)',
shadowColor: 'rgba(139, 92, 246, 0.5)',
accentColor: '#a78bfa',
},
].map(({ style, emoji, title, desc, gradient, shadowColor, accentColor }) => (
<button
key={style}
onClick={() => handleStyleSelect(style)}
style={{
position: 'relative',
padding: '0',
border: 'none',
borderRadius: '24px',
background: gradient,
cursor: 'pointer',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
boxShadow: `0 10px 40px ${shadowColor}, 0 0 0 1px rgba(255, 255, 255, 0.1)`,
transform: 'translateY(0)',
flex: 1,
minHeight: '140px',
overflow: 'hidden',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-8px) scale(1.02)'
e.currentTarget.style.boxShadow = `0 20px 60px ${shadowColor}, 0 0 0 2px ${accentColor}`
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0) scale(1)'
e.currentTarget.style.boxShadow = `0 10px 40px ${shadowColor}, 0 0 0 1px rgba(255, 255, 255, 0.1)`
}}
>
{/* Shine effect overlay */}
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'linear-gradient(135deg, rgba(255,255,255,0.2) 0%, transparent 50%)',
pointerEvents: 'none',
}}
/>
<div
style={{
padding: '28px 32px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
position: 'relative',
zIndex: 1,
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '20px',
flex: 1,
}}
>
<div
style={{
fontSize: '64px',
lineHeight: 1,
filter: 'drop-shadow(0 4px 8px rgba(0,0,0,0.3))',
}}
>
{emoji}
</div>
<div style={{ textAlign: 'left', flex: 1 }}>
<div
style={{
fontSize: '28px',
fontWeight: 'bold',
color: 'white',
marginBottom: '6px',
textShadow: '0 2px 8px rgba(0,0,0,0.3)',
}}
>
{title}
</div>
<div
style={{
fontSize: '15px',
color: 'rgba(255, 255, 255, 0.9)',
textShadow: '0 1px 4px rgba(0,0,0,0.2)',
}}
>
{desc}
</div>
</div>
</div>
{/* PLAY NOW button */}
<div
style={{
background: 'white',
color: gradient.includes('10b981')
? '#047857'
: gradient.includes('f59e0b')
? '#d97706'
: '#6b21a8',
padding: '16px 32px',
borderRadius: '16px',
fontWeight: 'bold',
fontSize: '18px',
boxShadow: '0 8px 24px rgba(0,0,0,0.25)',
display: 'flex',
alignItems: 'center',
gap: '10px',
whiteSpace: 'nowrap',
}}
>
<span>PLAY</span>
<span style={{ fontSize: '24px' }}></span>
</div>
</div>
</button>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,104 @@
'use client'
import { useEffect, useState } from 'react'
import { useComplementRace } from '../context/ComplementRaceContext'
import { useSoundEffects } from '../hooks/useSoundEffects'
export function GameCountdown() {
const { dispatch } = useComplementRace()
const { playSound } = useSoundEffects()
const [count, setCount] = useState(3)
const [showGo, setShowGo] = useState(false)
useEffect(() => {
const countdownInterval = setInterval(() => {
setCount((prevCount) => {
if (prevCount > 1) {
// Play countdown beep (volume 0.4)
playSound('countdown', 0.4)
return prevCount - 1
} else if (prevCount === 1) {
// Show GO!
setShowGo(true)
// Play race start fanfare (volume 0.6)
playSound('race_start', 0.6)
return 0
}
return prevCount
})
}, 1000)
return () => clearInterval(countdownInterval)
}, [playSound])
useEffect(() => {
if (showGo) {
// Hide countdown and start game after GO animation
const timer = setTimeout(() => {
dispatch({ type: 'BEGIN_GAME' })
}, 1000)
return () => clearTimeout(timer)
}
}, [showGo, dispatch])
return (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
background: 'rgba(0, 0, 0, 0.9)',
zIndex: 1000,
}}
>
<div
style={{
fontSize: showGo ? '120px' : '160px',
fontWeight: 'bold',
color: showGo ? '#10b981' : 'white',
textShadow: '0 4px 20px rgba(0, 0, 0, 0.5)',
animation: showGo ? 'scaleUp 1s ease-out' : 'pulse 0.5s ease-in-out',
transition: 'all 0.3s ease',
}}
>
{showGo ? 'GO!' : count}
</div>
{!showGo && (
<div
style={{
marginTop: '32px',
fontSize: '24px',
color: 'rgba(255, 255, 255, 0.8)',
fontWeight: '500',
}}
>
Get Ready!
</div>
)}
<style
dangerouslySetInnerHTML={{
__html: `
@keyframes pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.1); opacity: 0.8; }
}
@keyframes scaleUp {
0% { transform: scale(0.5); opacity: 0; }
50% { transform: scale(1.2); opacity: 1; }
100% { transform: scale(1); opacity: 1; }
}
`,
}}
/>
</div>
)
}

View File

@@ -0,0 +1,409 @@
'use client'
import { useEffect, useState } from 'react'
import { useComplementRace } from '../context/ComplementRaceContext'
import { useAdaptiveDifficulty } from '../hooks/useAdaptiveDifficulty'
import { useAIRacers } from '../hooks/useAIRacers'
import { useSoundEffects } from '../hooks/useSoundEffects'
import { useSteamJourney } from '../hooks/useSteamJourney'
import { generatePassengers } from '../lib/passengerGenerator'
import { AbacusTarget } from './AbacusTarget'
import { CircularTrack } from './RaceTrack/CircularTrack'
import { LinearTrack } from './RaceTrack/LinearTrack'
import { SteamTrainJourney } from './RaceTrack/SteamTrainJourney'
import { RouteCelebration } from './RouteCelebration'
type FeedbackAnimation = 'correct' | 'incorrect' | null
export function GameDisplay() {
const { state, dispatch } = useComplementRace()
useAIRacers() // Activate AI racer updates (not used in sprint mode)
const { trackPerformance, getAdaptiveFeedbackMessage } = useAdaptiveDifficulty()
const { boostMomentum } = useSteamJourney()
const { playSound } = useSoundEffects()
const [feedbackAnimation, setFeedbackAnimation] = useState<FeedbackAnimation>(null)
// Clear feedback animation after it plays (line 1996, 2001)
useEffect(() => {
if (feedbackAnimation) {
const timer = setTimeout(() => {
setFeedbackAnimation(null)
}, 500) // Match animation duration
return () => clearTimeout(timer)
}
}, [feedbackAnimation])
// Show adaptive feedback with auto-hide
useEffect(() => {
if (state.adaptiveFeedback) {
const timer = setTimeout(() => {
dispatch({ type: 'CLEAR_ADAPTIVE_FEEDBACK' })
}, 3000)
return () => clearTimeout(timer)
}
}, [state.adaptiveFeedback, dispatch])
// Check for finish line (player reaches race goal) - only for practice mode
useEffect(() => {
if (
state.correctAnswers >= state.raceGoal &&
state.isGameActive &&
state.style === 'practice'
) {
// Play celebration sound (line 14182)
playSound('celebration')
// End the game
dispatch({ type: 'END_RACE' })
// Show results after a short delay
setTimeout(() => {
dispatch({ type: 'SHOW_RESULTS' })
}, 1500)
}
}, [state.correctAnswers, state.raceGoal, state.isGameActive, state.style, dispatch, playSound])
// For survival mode (endless circuit), track laps but never end
// For sprint mode (steam sprint), end after 60 seconds (will implement later)
// Handle keyboard input
useEffect(() => {
const handleKeyPress = (e: KeyboardEvent) => {
// Only process number keys
if (/^[0-9]$/.test(e.key)) {
const newInput = state.currentInput + e.key
dispatch({ type: 'UPDATE_INPUT', input: newInput })
// Check if answer is complete
if (state.currentQuestion) {
const answer = parseInt(newInput, 10)
const correctAnswer = state.currentQuestion.correctAnswer
// If we have enough digits to match the answer, submit
if (newInput.length >= correctAnswer.toString().length) {
const responseTime = Date.now() - state.questionStartTime
const isCorrect = answer === correctAnswer
const pairKey = `${state.currentQuestion.number}_${state.currentQuestion.correctAnswer}_${state.currentQuestion.targetSum}`
if (isCorrect) {
// Correct answer
dispatch({ type: 'SUBMIT_ANSWER', answer })
trackPerformance(true, responseTime)
// Trigger correct answer animation (line 1996)
setFeedbackAnimation('correct')
// Play appropriate sound based on performance (from web_generator.py lines 11530-11542)
const newStreak = state.streak + 1
if (newStreak > 0 && newStreak % 5 === 0) {
// Epic streak sound for every 5th correct answer
playSound('streak')
} else if (responseTime < 800) {
// Whoosh sound for very fast responses (under 800ms)
playSound('whoosh')
} else if (responseTime < 1200 && state.streak >= 3) {
// Combo sound for rapid answers while on a streak
playSound('combo')
} else {
// Regular correct sound
playSound('correct')
}
// Boost momentum for sprint mode
if (state.style === 'sprint') {
boostMomentum()
// Play train whistle for milestones in sprint mode (line 13222-13235)
if (newStreak >= 5 && newStreak % 3 === 0) {
// Major milestone - play train whistle
setTimeout(() => {
playSound('train_whistle', 0.4)
}, 200)
} else if (state.momentum >= 90) {
// High momentum celebration - occasional whistle
if (Math.random() < 0.3) {
setTimeout(() => {
playSound('train_whistle', 0.25)
}, 150)
}
}
}
// Show adaptive feedback
const feedback = getAdaptiveFeedbackMessage(pairKey, true, responseTime)
if (feedback) {
dispatch({ type: 'SHOW_ADAPTIVE_FEEDBACK', feedback })
}
dispatch({ type: 'NEXT_QUESTION' })
} else {
// Incorrect answer
trackPerformance(false, responseTime)
// Trigger incorrect answer animation (line 2001)
setFeedbackAnimation('incorrect')
// Play incorrect sound (from web_generator.py line 11589)
playSound('incorrect')
// Show adaptive feedback
const feedback = getAdaptiveFeedbackMessage(pairKey, false, responseTime)
if (feedback) {
dispatch({ type: 'SHOW_ADAPTIVE_FEEDBACK', feedback })
}
dispatch({ type: 'UPDATE_INPUT', input: '' })
}
}
}
} else if (e.key === 'Backspace') {
dispatch({ type: 'UPDATE_INPUT', input: state.currentInput.slice(0, -1) })
}
}
window.addEventListener('keydown', handleKeyPress)
return () => window.removeEventListener('keydown', handleKeyPress)
}, [
state.currentInput,
state.currentQuestion,
state.questionStartTime,
state.style,
state.streak,
dispatch,
trackPerformance,
getAdaptiveFeedbackMessage,
boostMomentum,
playSound,
state.momentum,
])
// Handle route celebration continue
const handleContinueToNextRoute = () => {
const nextRoute = state.currentRoute + 1
// Start new route (this also hides celebration)
dispatch({
type: 'START_NEW_ROUTE',
routeNumber: nextRoute,
stations: state.stations, // Keep same stations for now
})
// Generate new passengers
const newPassengers = generatePassengers(state.stations)
dispatch({ type: 'GENERATE_PASSENGERS', passengers: newPassengers })
}
if (!state.currentQuestion) return null
return (
<div
data-component="game-display"
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
width: '100%',
}}
>
{/* Adaptive Feedback */}
{state.adaptiveFeedback && (
<div
data-component="adaptive-feedback"
style={{
position: 'fixed',
top: '80px',
left: '50%',
transform: 'translateX(-50%)',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: 'white',
padding: '12px 24px',
borderRadius: '12px',
boxShadow: '0 4px 20px rgba(102, 126, 234, 0.4)',
fontSize: '16px',
fontWeight: 'bold',
zIndex: 1000,
animation: 'slideDown 0.3s ease-out',
maxWidth: '600px',
textAlign: 'center',
}}
>
{state.adaptiveFeedback.message}
</div>
)}
{/* Stats Header - constrained width, hidden for sprint mode */}
{state.style !== 'sprint' && (
<div
data-component="stats-container"
style={{
maxWidth: '1200px',
margin: '0 auto',
width: '100%',
padding: '0 20px',
marginTop: '10px',
}}
>
<div
data-component="stats-header"
style={{
display: 'flex',
justifyContent: 'space-around',
marginBottom: '10px',
background: 'white',
borderRadius: '12px',
padding: '10px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
}}
>
<div data-stat="score" style={{ textAlign: 'center' }}>
<div style={{ color: '#6b7280', fontSize: '14px', marginBottom: '4px' }}>Score</div>
<div style={{ fontWeight: 'bold', fontSize: '24px', color: '#3b82f6' }}>
{state.score}
</div>
</div>
<div data-stat="streak" style={{ textAlign: 'center' }}>
<div style={{ color: '#6b7280', fontSize: '14px', marginBottom: '4px' }}>Streak</div>
<div style={{ fontWeight: 'bold', fontSize: '24px', color: '#10b981' }}>
{state.streak} 🔥
</div>
</div>
<div data-stat="progress" style={{ textAlign: 'center' }}>
<div style={{ color: '#6b7280', fontSize: '14px', marginBottom: '4px' }}>
Progress
</div>
<div style={{ fontWeight: 'bold', fontSize: '24px', color: '#f59e0b' }}>
{state.correctAnswers}/{state.raceGoal}
</div>
</div>
</div>
</div>
)}
{/* Race Track - full width, break out of padding */}
<div
data-component="track-container"
style={{
width: '100vw',
position: 'relative',
left: '50%',
right: '50%',
marginLeft: '-50vw',
marginRight: '-50vw',
padding: state.style === 'sprint' ? '0' : '0 20px',
display: 'flex',
justifyContent: state.style === 'sprint' ? 'stretch' : 'center',
background: 'transparent',
flex: state.style === 'sprint' ? 1 : 'initial',
minHeight: state.style === 'sprint' ? 0 : 'initial',
}}
>
{state.style === 'survival' ? (
<CircularTrack
playerProgress={state.correctAnswers}
playerLap={state.playerLap}
aiRacers={state.aiRacers}
aiLaps={state.aiLaps}
/>
) : state.style === 'sprint' ? (
<SteamTrainJourney
momentum={state.momentum}
trainPosition={state.trainPosition}
pressure={state.pressure}
elapsedTime={state.elapsedTime}
currentQuestion={state.currentQuestion}
currentInput={state.currentInput}
/>
) : (
<LinearTrack
playerProgress={state.correctAnswers}
aiRacers={state.aiRacers}
raceGoal={state.raceGoal}
showFinishLine={true}
/>
)}
</div>
{/* Question Display - only for non-sprint modes */}
{state.style !== 'sprint' && (
<div
data-component="question-container"
style={{
maxWidth: '1200px',
margin: '0 auto',
width: '100%',
padding: '0 20px',
display: 'flex',
justifyContent: 'center',
marginTop: '20px',
}}
>
<div
data-component="question-display"
style={{
background: 'rgba(255, 255, 255, 0.98)',
borderRadius: '24px',
padding: '28px 50px',
boxShadow: '0 16px 40px rgba(0, 0, 0, 0.3), 0 0 0 5px rgba(59, 130, 246, 0.4)',
backdropFilter: 'blur(12px)',
border: '4px solid rgba(255, 255, 255, 0.95)',
}}
>
{/* Complement equation as main focus */}
<div
data-element="question-equation"
style={{
fontSize: '96px',
fontWeight: 'bold',
color: '#1f2937',
lineHeight: '1.1',
display: 'flex',
alignItems: 'center',
gap: '20px',
justifyContent: 'center',
}}
>
<span
style={{
background: 'linear-gradient(135deg, #3b82f6, #8b5cf6)',
color: 'white',
padding: '12px 32px',
borderRadius: '16px',
minWidth: '140px',
display: 'inline-block',
textShadow: '0 3px 10px rgba(0, 0, 0, 0.3)',
}}
>
{state.currentInput || '?'}
</span>
<span style={{ color: '#6b7280' }}>+</span>
{state.currentQuestion.showAsAbacus ? (
<div
style={{
transform: 'scale(2.4) translateY(8%)',
transformOrigin: 'center center',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<AbacusTarget number={state.currentQuestion.number} />
</div>
) : (
<span>{state.currentQuestion.number}</span>
)}
<span style={{ color: '#6b7280' }}>=</span>
<span style={{ color: '#10b981' }}>{state.currentQuestion.targetSum}</span>
</div>
</div>
</div>
)}
{/* Route Celebration Modal */}
{state.showRouteCelebration && state.style === 'sprint' && (
<RouteCelebration
completedRouteNumber={state.currentRoute}
nextRouteNumber={state.currentRoute + 1}
onContinue={handleContinueToNextRoute}
/>
)}
</div>
)
}

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