Compare commits

...

45 Commits

Author SHA1 Message Date
semantic-release-bot
426973e3c4 chore(release): 2.18.0 [skip ci]
## [2.18.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.3...v2.18.0) (2025-10-10)

### Features

* add centralized player ownership utilities module ([52e0de0](52e0de022f))
2025-10-10 13:54:04 +00:00
Thomas Hallock
52e0de022f feat: add centralized player ownership utilities module
Created src/lib/arcade/player-ownership.ts to consolidate scattered
player ownership logic into a single, well-tested module.

New utilities:
- buildPlayerOwnershipMap() - server-side, DB-based (async)
- buildPlayerOwnershipFromRoomData() - client-side, from RoomData (sync)
- isPlayerOwnedByUser() - check if player belongs to user
- getPlayerOwner() - get owner userId for a player
- isUsersTurn() - check if it's a user's turn
- buildPlayerMetadata() - combine ownership + player data
- getUserIdFromGuestId() - convert guestId to internal userId

Benefits:
- Single source of truth for ownership logic
- Consistent behavior across server and client
- Comprehensive test coverage (19 unit tests)
- Type-safe PlayerOwnershipMap type
- Clear JSDoc documentation

This replaces duplicated logic previously found in:
- session-manager.ts (lines 232-239)
- RoomMemoryPairsProvider.tsx (lines 370-403)
- Multiple UI components
- Validators

Tests cover:
- Building ownership maps from different sources
- Ownership checking edge cases
- Real-world "Your turn" vs "Their turn" scenarios
- Empty/null/undefined handling

Next steps: Migrate existing code to use these utilities.

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

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

### Bug Fixes

* correct build-info.json import path in type declaration ([22f0be4](22f0be4d04))

### Documentation

* add plan for centralizing player ownership logic ([d3ff89a](d3ff89a0ee))
2025-10-10 13:51:00 +00:00
Thomas Hallock
9f90678151 chore: expand Claude Code auto-approved commands for tooling
Added additional tool commands to auto-approval list:
- npx tsc (TypeScript compiler direct invocation)
- npx @biomejs/biome format (direct Biome formatter)
- npx @biomejs/biome check (direct Biome checker)
- npm run lint:fix (linting with auto-fix)

This allows Claude Code to run these common development tools without
requiring manual approval for each invocation, improving workflow
efficiency during code quality checks.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-10 08:50:07 -05:00
Thomas Hallock
22f0be4d04 fix: correct build-info.json import path in type declaration
Changed from '@/generated/build-info.json' to '../generated/build-info.json'
to use correct relative path instead of alias.

This ensures the type declaration correctly resolves to the generated
build info file location.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-10 08:49:56 -05:00
Thomas Hallock
19bc0b65d9 chore: apply auto-formatting to LocalMemoryPairsProvider and tsconfig.server.json
Auto-formatter made minor formatting adjustments:
- Multi-line formatting for long function call arguments
- Single-line array formatting in tsconfig exclude

No functional changes.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-10 08:49:48 -05:00
Thomas Hallock
d3ff89a0ee docs: add plan for centralizing player ownership logic
Created comprehensive plan to consolidate scattered player ownership
checking logic into a single, well-tested module accessible from both
server-side and client-side code.

Current problem:
- Player ownership logic duplicated in 4+ places
- Different implementations lead to bugs (e.g., recent userId bug)
- Hard to maintain and test

Proposed solution:
- Create src/lib/arcade/player-ownership.ts with utilities
- Server-side: buildPlayerOwnershipMap(roomId) - async DB queries
- Client-side: buildPlayerOwnershipFromRoomData(roomData) - sync
- Shared helpers: isPlayerOwnedByUser(), getPlayerOwner(), etc.

Implementation plan: 7 commits, one per phase
1. Create utilities module with tests
2. Update session-manager.ts
3. Update player-manager.ts
4. Update RoomMemoryPairsProvider.tsx
5. Update UI components
6. Update API endpoints (if needed)
7. Add integration tests

Benefits:
- Single source of truth
- Consistent behavior across codebase
- Better testability
- Type-safe shared types
- Easier maintenance

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-10 08:48:39 -05:00
semantic-release-bot
8473b6d670 chore(release): 2.17.2 [skip ci]
## [2.17.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.1...v2.17.2) (2025-10-10)

### Bug Fixes

* correct playerMetadata userId assignment for room-based multiplayer ([53797db](53797dbb2d))
2025-10-10 13:45:17 +00:00
Thomas Hallock
53797dbb2d fix: correct playerMetadata userId assignment for room-based multiplayer
The bug: In RoomMemoryPairsProvider, playerMetadata[playerId].userId was
being set to the LOCAL viewerId for ALL players, including remote players
from other room members. This caused:
1. Turn indicator showing "Your turn" even when it was a remote player's turn
2. Incorrect player ownership validation

Root cause: Lines 378-390 and 442-454 in RoomMemoryPairsProvider were using
`userId: viewerId` for all players without checking actual ownership.

Fix:
- Added buildPlayerMetadata helper that builds a reverse mapping from
  roomData.memberPlayers (userId -> players[]) to determine correct ownership
- Uses playerOwnership map to assign correct userId to each player
- Updated both startGame and resetGame to use this helper

Testing:
- Added unit test documenting the bug and correct behavior
- Test verifies that local players get local userId and remote players
  get their actual owner's userId

Related files:
- PlayerStatusBar.tsx: Already correctly uses player.userId === viewerId
- MemoryGrid.tsx: Correctly filters avatars by current player

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-10 08:44:25 -05:00
semantic-release-bot
6b890b30f4 chore(release): 2.17.1 [skip ci]
## [2.17.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.0...v2.17.1) (2025-10-10)

### Bug Fixes

* correct hover avatar and turn indicator to show only current player ([0596ef6](0596ef6587))
2025-10-10 13:31:44 +00:00
Thomas Hallock
0596ef6587 fix: correct hover avatar and turn indicator to show only current player
Previously, hover avatars were showing for remote players while the
current player's avatar was hidden. Also, the turn indicator was
incorrectly showing "Your turn" for all players regardless of whether
they belonged to the current viewer.

Changes:
- MemoryGrid: Filter hover avatars to show only for current player
  (playerId === state.currentPlayer) instead of remote players
- PlayerStatusBar: Check player ownership by comparing player.userId
  with viewerId instead of hardcoded gameMode check

This ensures:
1. Only the current player (whose turn it is) displays their hover avatar
2. Turn indicator correctly shows "Your turn" vs "Their turn" based on
   whether the current player belongs to the local viewer

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-10 08:30:58 -05:00
semantic-release-bot
debf786ed9 chore(release): 2.17.0 [skip ci]
## [2.17.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.16.7...v2.17.0) (2025-10-10)

### Features

* hide hover avatar when card is flipped to reveal value ([a2aada2](a2aada2e69))
2025-10-10 13:18:37 +00:00
Thomas Hallock
a2aada2e69 feat: hide hover avatar when card is flipped to reveal value
Avatar now fades out when the card it's hovering over is flipped, ensuring
all users can clearly see revealed card values.

Changes:
- Add isCardFlipped prop to HoverAvatar component
- Check if hovered card is in flippedCards array or matched
- Update opacity calculation to hide avatar when card is flipped
- Avatar smoothly fades out via react-spring when card reveals

This ensures remote players' consideration doesn't obscure card values
during gameplay.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-10 08:17:37 -05:00
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
44 changed files with 3960 additions and 265 deletions

View File

@@ -1,3 +1,149 @@
## [2.18.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.3...v2.18.0) (2025-10-10)
### Features
* add centralized player ownership utilities module ([52e0de0](https://github.com/antialias/soroban-abacus-flashcards/commit/52e0de022fcd332fc4cfffa7bfcfe6adc69cb3ff))
## [2.17.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.2...v2.17.3) (2025-10-10)
### Bug Fixes
* correct build-info.json import path in type declaration ([22f0be4](https://github.com/antialias/soroban-abacus-flashcards/commit/22f0be4d045c6afdcb98b876f709d63a904f9449))
### Documentation
* add plan for centralizing player ownership logic ([d3ff89a](https://github.com/antialias/soroban-abacus-flashcards/commit/d3ff89a0ee53c32cc68ed01bf460919aa889d6a0))
## [2.17.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.1...v2.17.2) (2025-10-10)
### Bug Fixes
* correct playerMetadata userId assignment for room-based multiplayer ([53797db](https://github.com/antialias/soroban-abacus-flashcards/commit/53797dbb2d5ccb80e61cbc186ca0a344fe1fbd96))
## [2.17.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.0...v2.17.1) (2025-10-10)
### Bug Fixes
* correct hover avatar and turn indicator to show only current player ([0596ef6](https://github.com/antialias/soroban-abacus-flashcards/commit/0596ef65879a303f1f71863ef307af69bf270c70))
## [2.17.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.16.7...v2.17.0) (2025-10-10)
### Features
* hide hover avatar when card is flipped to reveal value ([a2aada2](https://github.com/antialias/soroban-abacus-flashcards/commit/a2aada2e6922fb3af363e0d191275e06b8f8f040))
## [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)

View File

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

@@ -0,0 +1,176 @@
# Player Ownership Centralization Plan
## Problem Statement
Player ownership logic is currently scattered across multiple locations in the codebase, leading to bugs and inconsistencies. The same pattern appears in:
1. **Server-side** (`session-manager.ts:232-239`): Builds `playerOwnership` map from database
2. **Client-side** (`RoomMemoryPairsProvider.tsx:370-403`): Builds `playerOwnership` from `roomData.memberPlayers`
3. **UI Components** (`PlayerStatusBar.tsx:31`, `MemoryGrid.tsx:388`): Check `player.userId === viewerId`
4. **Validation** (`MatchingGameValidator.ts:88-102`): Validates player ownership
## Current Implementations
### Server-Side (session-manager.ts)
```typescript
// Lines 232-238
const players = await db.query.players.findMany({
columns: { id: true, userId: true }
})
playerOwnership = Object.fromEntries(players.map(p => [p.id, p.userId]))
```
### Client-Side (RoomMemoryPairsProvider.tsx)
```typescript
// Lines 370-403
const buildPlayerMetadata = useCallback((playerIds: string[]) => {
const playerOwnership = new Map<string, string>()
if (roomData?.memberPlayers) {
for (const [userId, userPlayers] of Object.entries(roomData.memberPlayers)) {
for (const player of userPlayers) {
playerOwnership.set(player.id, userId)
}
}
}
// ... use playerOwnership to assign correct userId
}, [players, roomData, viewerId])
```
## Centralization Strategy
### Phase 1: Create Shared Utilities Module
**File**: `src/lib/arcade/player-ownership.ts`
Create a module with:
1. **Server-side utility**: `buildPlayerOwnershipMap(roomId?: string)`
- Fetches from database
- Returns `Record<playerId, userId>`
- Used by `session-manager.ts`
2. **Client-side utility**: `buildPlayerOwnershipFromRoomData(roomData)`
- Builds from `RoomData.memberPlayers`
- Returns `Map<playerId, userId>` or `Record<playerId, userId>`
- Used by React components
3. **Shared types**: `PlayerOwnershipMap = Record<string, string>`
4. **Helper functions**:
- `isPlayerOwnedByUser(playerId, userId, ownershipMap)`
- `getPlayerOwner(playerId, ownershipMap)`
- `buildPlayerMetadata(playerIds, ownershipMap, playersMap)` - combines ownership + player data
### Phase 2: Update Server-Side Code
**Files**:
- `src/lib/arcade/session-manager.ts`
- `src/lib/arcade/player-manager.ts`
Changes:
1. Import utilities from `player-ownership.ts`
2. Replace inline ownership building with `buildPlayerOwnershipMap()`
3. Add new function to `player-manager.ts`: `getPlayerOwnershipMap(roomId)`
### Phase 3: Update Client-Side Providers
**Files**:
- `src/app/arcade/matching/context/RoomMemoryPairsProvider.tsx`
- Any other game providers that need this logic
Changes:
1. Import utilities from `player-ownership.ts`
2. Replace `buildPlayerMetadata` with centralized version
3. Use shared helper functions for ownership checks
### Phase 4: Update UI Components
**Files**:
- `src/app/arcade/matching/components/PlayerStatusBar.tsx`
- `src/app/arcade/matching/components/MemoryGrid.tsx`
- `src/components/PageWithNav.tsx`
- `src/contexts/GameModeContext.tsx`
Changes:
1. Use centralized `isPlayerOwnedByUser()` helper
2. Consistent API across all components
### Phase 5: Add to API Endpoints (if needed)
**Files**:
- `src/app/api/arcade/rooms/[roomId]/route.ts`
- Any endpoints that return player data
Changes:
1. Include `playerOwnership` map in API responses where practical
2. Document in API response types
## Benefits
1. **Single Source of Truth**: All ownership logic in one place
2. **Consistency**: Same algorithm server-side and client-side
3. **Testability**: Can unit test ownership logic in isolation
4. **Type Safety**: Shared types across client/server boundary
5. **Maintainability**: Bug fixes only need to be made once
6. **Documentation**: Central location for ownership algorithm docs
## Implementation Order (One Commit Per Phase)
1.**Commit 1**: Create `src/lib/arcade/player-ownership.ts` with all utilities and tests
2.**Commit 2**: Update `session-manager.ts` to use new utilities
3.**Commit 3**: Update `player-manager.ts` to export ownership helper
4.**Commit 4**: Update `RoomMemoryPairsProvider.tsx` to use utilities
5.**Commit 5**: Update UI components to use helper functions
6.**Commit 6**: Update API endpoints to include ownership data (if needed)
7.**Commit 7**: Add comprehensive integration tests
## Key Design Decisions
### Server vs Client Implementations
**Why separate implementations?**
- Server uses database queries (async)
- Client uses in-memory `RoomData` (sync)
- Different data sources, same logic
**Shared interface:**
```typescript
type PlayerOwnershipMap = Record<string, string> // playerId -> userId
// Server-side (async)
async function buildPlayerOwnershipMap(roomId: string): Promise<PlayerOwnershipMap>
// Client-side (sync)
function buildPlayerOwnershipFromRoomData(
roomData: RoomData
): PlayerOwnershipMap
```
### Type Consistency
Both return the same structure:
```typescript
{
"player-id-1": "user-id-1",
"player-id-2": "user-id-1",
"player-id-3": "user-id-2"
}
```
This allows validators, helpers, and checks to work identically regardless of source.
## Migration Path
1. Create new module alongside existing code
2. Add tests for new utilities
3. Gradually migrate files one at a time
4. Remove old implementations after migration complete
5. Deprecate old patterns in documentation
## Testing Strategy
1. **Unit tests** for utilities in isolation
2. **Integration tests** for server-side flow
3. **Component tests** for client-side usage
4. **E2E tests** for full multiplayer scenarios
## Documentation
Add to existing docs:
- Update `ARCADE_ARCHITECTURE.md` with new utilities section
- Update `MULTIPLAYER_SYNC_ARCHITECTURE.md` with ownership flow
- Add JSDoc comments to all exported functions

View File

@@ -9,7 +9,13 @@
"Bash(git push:*)",
"Bash(git pull:*)",
"Bash(git stash:*)",
"Bash(npm run format:*)"
"Bash(npm run format:*)",
"Bash(npm run pre-commit:*)",
"Bash(npm run type-check:*)",
"Bash(npx tsc:*)",
"Bash(npx @biomejs/biome format:*)",
"Bash(npx @biomejs/biome check:*)",
"Bash(npm run lint:fix:*)"
],
"deny": [],
"ask": []

View File

@@ -1,6 +1,5 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
eslint: {
ignoreDuringBuilds: true,
},

View File

@@ -3,8 +3,8 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "concurrently \"node server.js\" \"npx @pandacss/dev --watch\"",
"build": "node scripts/generate-build-info.js && next build",
"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",
@@ -90,6 +90,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

@@ -11,9 +11,8 @@ const handle = app.getRequestHandler()
// Run migrations before starting server
console.log('🔄 Running database migrations...')
require('tsx/cjs')
const { migrate } = require('drizzle-orm/better-sqlite3/migrator')
const { db } = require('./src/db/index.ts')
const { db } = require('./src/db/index.js')
try {
migrate(db, { migrationsFolder: './drizzle' })
@@ -35,9 +34,8 @@ app.prepare().then(() => {
}
})
// Initialize Socket.IO (load TypeScript with tsx)
require('tsx/cjs')
const { initializeSocketServer } = require('./socket-server.ts')
// Initialize Socket.IO
const { initializeSocketServer } = require('./socket-server.js')
initializeSocketServer(server)
server

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

View File

@@ -1,6 +1,8 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { animated, useSpring } from '@react-spring/web'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useViewerId } from '@/hooks/useViewerId'
import { css } from '../../../../../styled-system/css'
import { useMemoryPairs } from '../context/MemoryPairsContext'
import { getGridConfiguration } from '../utils/cardGeneration'
@@ -80,8 +82,114 @@ function useGridDimensions(gridConfig: any, totalCards: number) {
return gridDimensions
}
// Animated hover avatar component
function HoverAvatar({
playerId,
playerInfo,
cardElement,
isPlayersTurn,
isCardFlipped,
}: {
playerId: string
playerInfo: { emoji: string; name: string; color?: string }
cardElement: HTMLElement | null
isPlayersTurn: boolean
isCardFlipped: boolean
}) {
const [position, setPosition] = useState<{ x: number; y: number } | null>(null)
const isFirstRender = useRef(true)
// Update position when card element changes
useEffect(() => {
if (cardElement) {
const rect = cardElement.getBoundingClientRect()
// Calculate the center of the card for avatar positioning
const avatarCenterX = rect.left + rect.width / 2
const avatarCenterY = rect.top + rect.height / 2
setPosition({
x: avatarCenterX,
y: avatarCenterY,
})
}
}, [cardElement])
// Smooth spring animation for position changes
const springProps = useSpring({
x: position?.x ?? 0,
y: position?.y ?? 0,
// Hide avatar if: no position, not player's turn, no card element, OR card is flipped
opacity: position && isPlayersTurn && cardElement && !isCardFlipped ? 1 : 0,
config: {
tension: 280,
friction: 60,
mass: 1,
},
immediate: isFirstRender.current, // Skip animation on first render only
})
// Clear first render flag after initial render
useEffect(() => {
if (position && isFirstRender.current) {
isFirstRender.current = false
}
}, [position])
// Don't render until we have a position
if (!position) return null
return (
<animated.div
style={{
position: 'fixed',
// Don't use translate, just position directly at the calculated point
left: springProps.x.to((x) => `${x}px`),
top: springProps.y.to((y) => `${y}px`),
opacity: springProps.opacity,
width: '80px',
height: '80px',
marginLeft: '-40px', // Center horizontally (half of width)
marginTop: '-40px', // Center vertically (half of height)
borderRadius: '50%',
background: playerInfo.color || 'linear-gradient(135deg, #667eea, #764ba2)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '48px',
// 3D elevation effect
boxShadow:
'0 12px 30px rgba(0,0,0,0.5), 0 6px 12px rgba(0,0,0,0.4), 0 0 40px rgba(102, 126, 234, 0.8)',
border: '4px solid white',
zIndex: 1000,
pointerEvents: 'none',
filter: 'drop-shadow(0 0 12px rgba(102, 126, 234, 0.9))',
}}
className={css({
animation: 'hoverFloat 2s ease-in-out infinite',
})}
title={`${playerInfo.name} is considering this card`}
>
{playerInfo.emoji}
</animated.div>
)
}
export function MemoryGrid() {
const { state, flipCard } = useMemoryPairs()
const { state, flipCard, hoverCard, gameMode } = useMemoryPairs()
const { data: viewerId } = useViewerId()
// Track card element refs for positioning hover avatars
const cardRefs = useRef<Map<string, HTMLElement>>(new Map())
// Check if it's the local player's turn
const isMyTurn = useMemo(() => {
if (gameMode === 'single') return true // Always your turn in single player
// In local games, all players belong to current user, so always their turn
// In room games, check if current player belongs to this user
const currentPlayerMetadata = state.playerMetadata?.[state.currentPlayer]
return currentPlayerMetadata?.userId === viewerId
}, [state.currentPlayer, state.playerMetadata, viewerId, gameMode])
// Hooks must be called before early return
const gridConfig = useMemo(() => getGridConfiguration(state.difficulty), [state.difficulty])
@@ -95,6 +203,28 @@ export function MemoryGrid() {
flipCard(cardId)
}
// Get player metadata for hover avatars
const getPlayerHoverInfo = (playerId: string) => {
// Get player info from game state metadata
const player = state.playerMetadata?.[playerId]
return player
? {
emoji: player.emoji,
name: player.name,
color: player.color,
}
: null
}
// Set card ref callback
const setCardRef = (cardId: string) => (element: HTMLDivElement | null) => {
if (element) {
cardRefs.current.set(cardId, element)
} else {
cardRefs.current.delete(cardId)
}
}
return (
<div
className={css({
@@ -162,6 +292,7 @@ export function MemoryGrid() {
return (
<div
key={card.id}
ref={setCardRef(card.id)}
className={css({
aspectRatio: '3/4',
// Fully responsive card sizing - no fixed pixel sizes
@@ -172,7 +303,20 @@ export function MemoryGrid() {
opacity: isDimmed ? 0.3 : 1,
transition: 'opacity 0.3s ease',
filter: isDimmed ? 'grayscale(0.7)' : 'none',
position: 'relative',
})}
onMouseEnter={() => {
// Only send hover if it's your turn and card is not matched
if (hoverCard && !isMatched && isMyTurn) {
hoverCard(card.id)
}
}}
onMouseLeave={() => {
// Clear hover state when mouse leaves card
if (hoverCard && !isMatched && isMyTurn) {
hoverCard(null)
}
}}
>
<GameCard
card={card}
@@ -233,23 +377,68 @@ export function MemoryGrid() {
})}
/>
)}
{/* Animated Hover Avatars - Rendered as fixed positioned elements that smoothly transition */}
{/* Render one avatar per player - key by playerId to keep component alive */}
{state.playerHovers &&
Object.entries(state.playerHovers)
.filter(([playerId]) => {
// Only show avatar for the CURRENT player whose turn it is
// Don't show for other players (they're waiting for their turn)
return playerId === state.currentPlayer
})
.map(([playerId, cardId]) => {
const playerInfo = getPlayerHoverInfo(playerId)
// Get card element if player is hovering (cardId might be null)
const cardElement = cardId ? cardRefs.current.get(cardId) : null
// Check if it's this player's turn
const isPlayersTurn = state.currentPlayer === playerId
// Check if the card being hovered is flipped
const hoveredCard = cardId ? state.gameCards.find((c) => c.id === cardId) : null
const isCardFlipped = hoveredCard
? state.flippedCards.some((c) => c.id === hoveredCard.id) || hoveredCard.matched
: false
if (!playerInfo) return null
// Render avatar even if no cardElement (it will handle hiding itself)
return (
<HoverAvatar
key={playerId} // Key by playerId keeps component alive across card changes!
playerId={playerId}
playerInfo={playerInfo}
cardElement={cardElement}
isPlayersTurn={isPlayersTurn}
isCardFlipped={isCardFlipped}
/>
)
})}
</div>
)
}
// Add shake animation for mismatch feedback
const shakeAnimation = `
// Add animations for mismatch feedback and hover avatars
const gridAnimations = `
@keyframes shake {
0%, 100% { transform: translate(-50%, -50%) translateX(0); }
25% { transform: translate(-50%, -50%) translateX(-5px); }
75% { transform: translate(-50%, -50%) translateX(5px); }
}
@keyframes hoverFloat {
0%, 100% {
transform: translateY(0px);
}
50% {
transform: translateY(-6px);
}
}
`
// Inject animation styles
if (typeof document !== 'undefined' && !document.getElementById('memory-grid-animations')) {
const style = document.createElement('style')
style.id = 'memory-grid-animations'
style.textContent = shakeAnimation
style.textContent = gridAnimations
document.head.appendChild(style)
}

View File

@@ -3,8 +3,6 @@
import { useRouter } from 'next/navigation'
import { useEffect, useRef } from 'react'
import { PageWithNav } from '@/components/PageWithNav'
import { useArcadeRedirect } from '@/hooks/useArcadeRedirect'
import { useRoomData } from '@/hooks/useRoomData'
import { css } from '../../../../../styled-system/css'
import { StandardGameLayout } from '../../../../components/StandardGameLayout'
import { useFullscreen } from '../../../../contexts/FullscreenContext'
@@ -15,13 +13,8 @@ import { SetupPhase } from './SetupPhase'
export function MemoryPairsGame() {
const router = useRouter()
const { state, exitSession, resetGame, goToSetup } = useMemoryPairs()
const { state, exitSession, resetGame, goToSetup, canModifyPlayers } = useMemoryPairs()
const { setFullscreenElement } = useFullscreen()
const { isInRoom } = useRoomData()
const arcadeRedirect = useArcadeRedirect({ currentGame: 'matching' })
// In rooms, always show buttons (canModifyPlayers = false shows buttons)
// In arcade sessions, use normal arcade redirect logic
const canModifyPlayers = isInRoom ? false : arcadeRedirect.canModifyPlayers
const gameRef = useRef<HTMLDivElement>(null)
useEffect(() => {

View File

@@ -1,7 +1,7 @@
'use client'
import { useViewerId } from '@/hooks/useViewerId'
import { css } from '../../../../../styled-system/css'
import { useGameMode } from '../../../../contexts/GameModeContext'
import { gamePlurals } from '../../../../utils/pluralization'
import { useMemoryPairs } from '../context/MemoryPairsContext'
@@ -10,12 +10,13 @@ interface PlayerStatusBarProps {
}
export function PlayerStatusBar({ className }: PlayerStatusBarProps) {
const { players: playerMap, activePlayers: activePlayerIds } = useGameMode()
const { state } = useMemoryPairs()
const { data: viewerId } = useViewerId()
// Get active players array
const activePlayersData = Array.from(activePlayerIds)
.map((id) => playerMap.get(id))
// Get active players from game state (not GameModeContext)
// This ensures we only show players actually in this game
const activePlayersData = state.activePlayers
.map((id) => state.playerMetadata?.[id])
.filter((p): p is NonNullable<typeof p> => p !== undefined)
// Map active players to display data with scores
@@ -26,7 +27,8 @@ export function PlayerStatusBar({ className }: PlayerStatusBarProps) {
displayEmoji: player.emoji,
score: state.scores[player.id] || 0,
consecutiveMatches: state.consecutiveMatches?.[player.id] || 0,
isLocalPlayer: player.isLocal !== false, // Local if not explicitly marked as remote
// Check if this player belongs to the current viewer
isLocalPlayer: player.userId === viewerId,
}))
// Check if current player is local (your turn) or remote (waiting)

View File

@@ -1,15 +1,16 @@
'use client'
import { type ReactNode, useCallback, useEffect, useMemo } from 'react'
import { useArcadeSession } from '@/hooks/useArcadeSession'
import { type ReactNode, useCallback, useEffect, useMemo, useReducer } from 'react'
import { useRouter } from 'next/navigation'
import { useArcadeRedirect } from '@/hooks/useArcadeRedirect'
import { useViewerId } from '@/hooks/useViewerId'
import type { GameMove } from '@/lib/arcade/validation'
import { useGameMode } from '../../../../contexts/GameModeContext'
import { useUserPlayers } from '@/hooks/useUserPlayers'
import { generateGameCards } from '../utils/cardGeneration'
import { validateMatch } from '../utils/matchValidation'
import { MemoryPairsContext } from './MemoryPairsContext'
import type { GameMode, GameStatistics, MemoryPairsContextValue, MemoryPairsState } from './types'
// Initial state
// Initial state for local-only games
const initialState: MemoryPairsState = {
cards: [],
gameCards: [],
@@ -18,13 +19,13 @@ const initialState: MemoryPairsState = {
difficulty: 6,
turnTimer: 30,
gamePhase: 'setup',
currentPlayer: '', // Will be set to first player ID on START_GAME
currentPlayer: '',
matchedPairs: 0,
totalPairs: 6,
moves: 0,
scores: {},
activePlayers: [],
playerMetadata: {}, // Player metadata for cross-user visibility
playerMetadata: {},
consecutiveMatches: {},
gameStartTime: null,
gameEndTime: null,
@@ -34,36 +35,46 @@ const initialState: MemoryPairsState = {
isProcessingMove: false,
showMismatchFeedback: false,
lastMatchedPair: null,
// PAUSE/RESUME: Initialize paused game fields
originalConfig: undefined,
pausedGamePhase: undefined,
pausedGameState: undefined,
playerHovers: {},
}
/**
* Optimistic move application (client-side prediction)
* The server will validate and send back the authoritative state
*/
function applyMoveOptimistically(state: MemoryPairsState, move: GameMove): MemoryPairsState {
switch (move.type) {
// Action types for local reducer
type LocalAction =
| { type: 'START_GAME'; cards: any[]; activePlayers: string[]; playerMetadata: any }
| { type: 'FLIP_CARD'; cardId: string }
| { type: 'MATCH_FOUND'; cardIds: [string, string]; playerId: string }
| { type: 'MATCH_FAILED'; cardIds: [string, string] }
| { type: 'CLEAR_MISMATCH' }
| { type: 'SWITCH_PLAYER' }
| { type: 'GO_TO_SETUP' }
| { type: 'SET_CONFIG'; field: string; value: any }
| { type: 'RESUME_GAME' }
| { type: 'HOVER_CARD'; playerId: string; cardId: string | null }
| { type: 'END_GAME' }
// Pure client-side reducer with complete game logic
function localMemoryPairsReducer(state: MemoryPairsState, action: LocalAction): MemoryPairsState {
switch (action.type) {
case 'START_GAME':
// Generate cards and initialize game
return {
...state,
gamePhase: 'playing',
gameCards: move.data.cards,
cards: move.data.cards,
gameCards: action.cards,
cards: action.cards,
flippedCards: [],
matchedPairs: 0,
moves: 0,
scores: move.data.activePlayers.reduce((acc: any, p: string) => ({ ...acc, [p]: 0 }), {}),
consecutiveMatches: move.data.activePlayers.reduce(
scores: action.activePlayers.reduce((acc: any, p: string) => ({ ...acc, [p]: 0 }), {}),
consecutiveMatches: action.activePlayers.reduce(
(acc: any, p: string) => ({ ...acc, [p]: 0 }),
{}
),
activePlayers: move.data.activePlayers,
playerMetadata: move.data.playerMetadata || {}, // Include player metadata
currentPlayer: move.data.activePlayers[0] || '',
activePlayers: action.activePlayers,
playerMetadata: action.playerMetadata,
currentPlayer: action.activePlayers[0] || '',
gameStartTime: Date.now(),
gameEndTime: null,
currentMoveStartTime: Date.now(),
@@ -71,7 +82,6 @@ function applyMoveOptimistically(state: MemoryPairsState, move: GameMove): Memor
isProcessingMove: false,
showMismatchFeedback: false,
lastMatchedPair: null,
// PAUSE/RESUME: Save original config and clear paused state
originalConfig: {
gameType: state.gameType,
difficulty: state.difficulty,
@@ -82,8 +92,7 @@ function applyMoveOptimistically(state: MemoryPairsState, move: GameMove): Memor
}
case 'FLIP_CARD': {
// Optimistically flip the card
const card = state.gameCards.find((c) => c.id === move.data.cardId)
const card = state.gameCards.find((c) => c.id === action.cardId)
if (!card) return state
const newFlippedCards = [...state.flippedCards, card]
@@ -93,13 +102,68 @@ function applyMoveOptimistically(state: MemoryPairsState, move: GameMove): Memor
flippedCards: newFlippedCards,
currentMoveStartTime:
state.flippedCards.length === 0 ? Date.now() : state.currentMoveStartTime,
isProcessingMove: newFlippedCards.length === 2, // Processing if 2 cards flipped
isProcessingMove: newFlippedCards.length === 2,
showMismatchFeedback: false,
}
}
case 'MATCH_FOUND': {
const [id1, id2] = action.cardIds
const updatedCards = state.gameCards.map((card) =>
card.id === id1 || card.id === id2
? { ...card, matched: true, matchedBy: action.playerId }
: card
)
const newMatchedPairs = state.matchedPairs + 1
const newScores = {
...state.scores,
[action.playerId]: (state.scores[action.playerId] || 0) + 1,
}
const newConsecutiveMatches = {
...state.consecutiveMatches,
[action.playerId]: (state.consecutiveMatches[action.playerId] || 0) + 1,
}
// Check if game is complete
const gameComplete = newMatchedPairs >= state.totalPairs
return {
...state,
gameCards: updatedCards,
cards: updatedCards,
flippedCards: [],
matchedPairs: newMatchedPairs,
moves: state.moves + 1,
scores: newScores,
consecutiveMatches: newConsecutiveMatches,
lastMatchedPair: action.cardIds,
isProcessingMove: false,
showMismatchFeedback: false,
gamePhase: gameComplete ? 'results' : state.gamePhase,
gameEndTime: gameComplete ? Date.now() : null,
// Player keeps their turn on match
}
}
case 'MATCH_FAILED': {
// Reset consecutive matches for current player
const newConsecutiveMatches = {
...state.consecutiveMatches,
[state.currentPlayer]: 0,
}
return {
...state,
moves: state.moves + 1,
showMismatchFeedback: true,
isProcessingMove: true,
consecutiveMatches: newConsecutiveMatches,
// Don't clear flipped cards yet - CLEAR_MISMATCH will do that
}
}
case 'CLEAR_MISMATCH': {
// Clear mismatched cards and feedback
return {
...state,
flippedCards: [],
@@ -108,14 +172,24 @@ function applyMoveOptimistically(state: MemoryPairsState, move: GameMove): Memor
}
}
case 'SWITCH_PLAYER': {
const currentIndex = state.activePlayers.indexOf(state.currentPlayer)
const nextIndex = (currentIndex + 1) % state.activePlayers.length
const nextPlayer = state.activePlayers[nextIndex]
return {
...state,
currentPlayer: nextPlayer,
currentMoveStartTime: Date.now(),
}
}
case 'GO_TO_SETUP': {
// Return to setup phase - pause game if coming from playing/results
const isPausingGame = state.gamePhase === 'playing' || state.gamePhase === 'results'
return {
...state,
gamePhase: 'setup',
// PAUSE: Save game state if pausing from active game
pausedGamePhase: isPausingGame ? state.gamePhase : undefined,
pausedGameState: isPausingGame
? {
@@ -125,12 +199,11 @@ function applyMoveOptimistically(state: MemoryPairsState, move: GameMove): Memor
moves: state.moves,
scores: state.scores,
activePlayers: state.activePlayers,
playerMetadata: state.playerMetadata,
playerMetadata: state.playerMetadata || {},
consecutiveMatches: state.consecutiveMatches,
gameStartTime: state.gameStartTime,
}
: undefined,
// Reset visible game state
gameCards: [],
cards: [],
flippedCards: [],
@@ -152,16 +225,12 @@ function applyMoveOptimistically(state: MemoryPairsState, move: GameMove): Memor
}
case 'SET_CONFIG': {
// Update configuration field optimistically
const { field, value } = move.data as { field: string; value: any }
const clearPausedGame = !!state.pausedGamePhase
return {
...state,
[field]: value,
// Update totalPairs if difficulty changes
...(field === 'difficulty' ? { totalPairs: value } : {}),
// Clear paused game if config changed
[action.field]: action.value,
...(action.field === 'difficulty' ? { totalPairs: action.value } : {}),
...(clearPausedGame
? { pausedGamePhase: undefined, pausedGameState: undefined, originalConfig: undefined }
: {}),
@@ -169,9 +238,8 @@ function applyMoveOptimistically(state: MemoryPairsState, move: GameMove): Memor
}
case 'RESUME_GAME': {
// Resume paused game
if (!state.pausedGamePhase || !state.pausedGameState) {
return state // No paused game, no-op
return state
}
return {
@@ -187,116 +255,139 @@ function applyMoveOptimistically(state: MemoryPairsState, move: GameMove): Memor
playerMetadata: state.pausedGameState.playerMetadata,
consecutiveMatches: state.pausedGameState.consecutiveMatches,
gameStartTime: state.pausedGameState.gameStartTime,
// Clear paused state
pausedGamePhase: undefined,
pausedGameState: undefined,
}
}
case 'HOVER_CARD': {
return {
...state,
playerHovers: {
...state.playerHovers,
[action.playerId]: action.cardId,
},
}
}
case 'END_GAME': {
return {
...state,
gamePhase: 'results',
gameEndTime: Date.now(),
}
}
default:
return state
}
}
// Provider component for LOCAL play (no network sync)
// Provider component for LOCAL-ONLY play (no network, no arcade session)
export function LocalMemoryPairsProvider({ children }: { children: ReactNode }) {
const router = useRouter()
const { data: viewerId } = useViewerId()
// NOTE: We deliberately do NOT call useRoomData() for local play
const { activePlayerCount, activePlayers: activePlayerIds, players } = useGameMode()
// Get active player IDs directly as strings (UUIDs)
const activePlayers = Array.from(activePlayerIds)
// LOCAL-ONLY: Get only the current user's players (no room members)
const { data: userPlayers = [] } = useUserPlayers()
// Use arcade redirect to determine button visibility
const { canModifyPlayers } = useArcadeRedirect({ currentGame: 'matching' })
// Build players map from current user's players only
const players = useMemo(() => {
const map = new Map()
userPlayers.forEach((player) => {
map.set(player.id, {
id: player.id,
name: player.name,
emoji: player.emoji,
color: player.color,
isLocal: true,
})
})
return map
}, [userPlayers])
// Get active player IDs from current user's players only
const activePlayers = useMemo(() => {
return userPlayers.filter((p) => p.isActive).map((p) => p.id)
}, [userPlayers])
// Derive game mode from active player count
const gameMode = activePlayerCount > 1 ? 'multiplayer' : 'single'
const gameMode = activePlayers.length > 1 ? 'multiplayer' : 'single'
// NO LOCAL STATE - Configuration lives in session state
// Changes are sent as moves and synchronized (even in local mode for consistency)
// Pure client-side state with useReducer
const [state, dispatch] = useReducer(localMemoryPairsReducer, initialState)
// Arcade session integration WITHOUT room sync
const {
state,
sendMove,
connected: _connected,
exitSession,
} = useArcadeSession<MemoryPairsState>({
userId: viewerId || '',
roomId: undefined, // CRITICAL: No roomId means no network sync
initialState,
applyMove: applyMoveOptimistically,
})
// Handle mismatch feedback timeout
// Handle mismatch feedback timeout and player switching
useEffect(() => {
if (state.showMismatchFeedback && state.flippedCards.length === 2) {
// After 1.5 seconds, send CLEAR_MISMATCH
// Server will validate that cards are still in mismatch state before clearing
const timeout = setTimeout(() => {
sendMove({
type: 'CLEAR_MISMATCH',
playerId: state.currentPlayer,
data: {},
})
dispatch({ type: 'CLEAR_MISMATCH' })
// Switch to next player after mismatch
dispatch({ type: 'SWITCH_PLAYER' })
}, 1500)
return () => clearTimeout(timeout)
}
}, [state.showMismatchFeedback, state.flippedCards.length, sendMove, state.currentPlayer])
}, [state.showMismatchFeedback, state.flippedCards.length])
// Handle automatic match checking when 2 cards flipped
useEffect(() => {
if (state.flippedCards.length === 2 && !state.showMismatchFeedback) {
const [card1, card2] = state.flippedCards
const isMatch = validateMatch(card1, card2)
const timeout = setTimeout(() => {
if (isMatch.isValid) {
dispatch({
type: 'MATCH_FOUND',
cardIds: [card1.id, card2.id],
playerId: state.currentPlayer,
})
// Player keeps turn on match - no SWITCH_PLAYER
} else {
dispatch({
type: 'MATCH_FAILED',
cardIds: [card1.id, card2.id],
})
// SWITCH_PLAYER will happen after CLEAR_MISMATCH timeout
}
}, 600) // Small delay to show both cards
return () => clearTimeout(timeout)
}
}, [state.flippedCards, state.showMismatchFeedback, state.currentPlayer])
// Computed values
const isGameActive = state.gamePhase === 'playing'
const canFlipCard = useCallback(
(cardId: string): boolean => {
console.log('[LocalProvider][canFlipCard] Checking card:', {
cardId,
isGameActive,
isProcessingMove: state.isProcessingMove,
currentPlayer: state.currentPlayer,
flippedCardsCount: state.flippedCards.length,
})
if (!isGameActive || state.isProcessingMove) {
console.log('[LocalProvider][canFlipCard] Blocked: game not active or processing')
return false
}
const card = state.gameCards.find((c) => c.id === cardId)
if (!card || card.matched) {
console.log('[LocalProvider][canFlipCard] Blocked: card not found or already matched')
return false
}
// Can't flip if already flipped
if (state.flippedCards.some((c) => c.id === cardId)) {
console.log('[LocalProvider][canFlipCard] Blocked: card already flipped')
return false
}
// Can't flip more than 2 cards
if (state.flippedCards.length >= 2) {
console.log('[LocalProvider][canFlipCard] Blocked: 2 cards already flipped')
return false
}
// In local play, we allow the current player to flip
// Authorization is simpler - just check if it's this player's turn
// In local play, all local players can flip during their turn
const currentPlayerData = players.get(state.currentPlayer)
console.log('[LocalProvider][canFlipCard] Authorization check:', {
currentPlayerId: state.currentPlayer,
currentPlayerFound: !!currentPlayerData,
currentPlayerIsLocal: currentPlayerData?.isLocal,
})
// Block if current player is explicitly marked as remote (shouldn't happen in local play)
if (currentPlayerData && currentPlayerData.isLocal === false) {
console.log(
'[LocalProvider][canFlipCard] BLOCKED: Current player is remote (unexpected in local play)'
)
return false
}
console.log('[LocalProvider][canFlipCard] ALLOWED: All checks passed')
return true
},
[
@@ -324,7 +415,6 @@ export function LocalMemoryPairsProvider({ children }: { children: ReactNode })
[state.moves, state.matchedPairs, state.totalPairs, state.gameStartTime, state.gameEndTime]
)
// PAUSE/RESUME: Computed values for pause/resume functionality
const hasConfigChanged = useMemo(() => {
if (!state.originalConfig) return false
return (
@@ -338,16 +428,13 @@ export function LocalMemoryPairsProvider({ children }: { children: ReactNode })
return !!state.pausedGamePhase && !!state.pausedGameState && !hasConfigChanged
}, [state.pausedGamePhase, state.pausedGameState, hasConfigChanged])
// Action creators - send moves to arcade session
// Action creators
const startGame = useCallback(() => {
// Must have at least one active player
if (activePlayers.length === 0) {
console.error('[LocalMemoryPairs] Cannot start game without active players')
return
}
// Capture player metadata from local players map
// This ensures all room members can display player info even if they don't own the players
const playerMetadata: { [playerId: string]: any } = {}
for (const playerId of activePlayers) {
const playerData = players.get(playerId)
@@ -362,56 +449,31 @@ export function LocalMemoryPairsProvider({ children }: { children: ReactNode })
}
}
// Use current session state configuration (no local state!)
const cards = generateGameCards(state.gameType, state.difficulty)
// Use first active player as playerId for START_GAME move
const firstPlayer = activePlayers[0]
sendMove({
dispatch({
type: 'START_GAME',
playerId: firstPlayer,
data: {
cards,
activePlayers,
playerMetadata,
},
cards,
activePlayers,
playerMetadata,
})
}, [state.gameType, state.difficulty, activePlayers, players, viewerId, sendMove])
}, [state.gameType, state.difficulty, activePlayers, players, viewerId])
const flipCard = useCallback(
(cardId: string) => {
console.log('[LocalProvider] flipCard called:', {
cardId,
viewerId,
currentPlayer: state.currentPlayer,
activePlayers: state.activePlayers,
gamePhase: state.gamePhase,
canFlip: canFlipCard(cardId),
})
if (!canFlipCard(cardId)) {
console.log('[LocalProvider] Cannot flip card - canFlipCard returned false')
return
}
const move = {
type: 'FLIP_CARD' as const,
playerId: state.currentPlayer, // Use the current player ID from game state (database player ID)
data: { cardId },
}
console.log('[LocalProvider] Sending FLIP_CARD move via sendMove:', move)
sendMove(move)
dispatch({ type: 'FLIP_CARD', cardId })
},
[canFlipCard, sendMove, viewerId, state.currentPlayer, state.activePlayers, state.gamePhase]
[canFlipCard]
)
const resetGame = useCallback(() => {
// Must have at least one active player
if (activePlayers.length === 0) {
console.error('[LocalMemoryPairs] Cannot reset game without active players')
return
}
// Capture player metadata from local players map
const playerMetadata: { [playerId: string]: any } = {}
for (const playerId of activePlayers) {
const playerData = players.get(playerId)
@@ -426,97 +488,71 @@ export function LocalMemoryPairsProvider({ children }: { children: ReactNode })
}
}
// Use current session state configuration (no local state!)
const cards = generateGameCards(state.gameType, state.difficulty)
// Use first active player as playerId for START_GAME move
const firstPlayer = activePlayers[0]
sendMove({
dispatch({
type: 'START_GAME',
playerId: firstPlayer,
data: {
cards,
activePlayers,
playerMetadata,
},
cards,
activePlayers,
playerMetadata,
})
}, [state.gameType, state.difficulty, activePlayers, players, viewerId, sendMove])
}, [state.gameType, state.difficulty, activePlayers, players, viewerId])
const setGameType = useCallback(
(gameType: typeof state.gameType) => {
// Use first active player as playerId, or empty string if none
const playerId = activePlayers[0] || ''
sendMove({
type: 'SET_CONFIG',
playerId,
data: { field: 'gameType', value: gameType },
})
},
[activePlayers, sendMove]
)
const setGameType = useCallback((gameType: typeof state.gameType) => {
dispatch({ type: 'SET_CONFIG', field: 'gameType', value: gameType })
}, [])
const setDifficulty = useCallback(
(difficulty: typeof state.difficulty) => {
const playerId = activePlayers[0] || ''
sendMove({
type: 'SET_CONFIG',
playerId,
data: { field: 'difficulty', value: difficulty },
})
},
[activePlayers, sendMove]
)
const setDifficulty = useCallback((difficulty: typeof state.difficulty) => {
dispatch({ type: 'SET_CONFIG', field: 'difficulty', value: difficulty })
}, [])
const setTurnTimer = useCallback(
(turnTimer: typeof state.turnTimer) => {
const playerId = activePlayers[0] || ''
sendMove({
type: 'SET_CONFIG',
playerId,
data: { field: 'turnTimer', value: turnTimer },
})
},
[activePlayers, sendMove]
)
const setTurnTimer = useCallback((turnTimer: typeof state.turnTimer) => {
dispatch({ type: 'SET_CONFIG', field: 'turnTimer', value: turnTimer })
}, [])
const resumeGame = useCallback(() => {
// PAUSE/RESUME: Resume paused game if config unchanged
if (!canResumeGame) {
console.warn('[LocalMemoryPairs] Cannot resume - no paused game or config changed')
return
}
const playerId = activePlayers[0] || state.currentPlayer || ''
sendMove({
type: 'RESUME_GAME',
playerId,
data: {},
})
}, [canResumeGame, activePlayers, state.currentPlayer, sendMove])
dispatch({ type: 'RESUME_GAME' })
}, [canResumeGame])
const goToSetup = useCallback(() => {
// Send GO_TO_SETUP move - synchronized across all room members
const playerId = activePlayers[0] || state.currentPlayer || ''
sendMove({
type: 'GO_TO_SETUP',
playerId,
data: {},
})
}, [activePlayers, state.currentPlayer, sendMove])
dispatch({ type: 'GO_TO_SETUP' })
}, [])
const hoverCard = useCallback(
(cardId: string | null) => {
const playerId = state.currentPlayer || activePlayers[0] || ''
if (!playerId) return
dispatch({
type: 'HOVER_CARD',
playerId,
cardId,
})
},
[state.currentPlayer, activePlayers]
)
const exitSession = useCallback(() => {
router.push('/arcade')
}, [router])
// NO MORE effectiveState merging! Just use session state directly with gameMode added
const effectiveState = { ...state, gameMode } as MemoryPairsState & { gameMode: GameMode }
const contextValue: MemoryPairsContextValue = {
state: effectiveState,
dispatch: () => {
// No-op - replaced with sendMove
console.warn('dispatch() is deprecated in arcade mode, use action creators instead')
// No-op - local provider uses action creators instead
console.warn('dispatch() is not available in local mode, use action creators instead')
},
isGameActive,
canFlipCard,
currentGameStatistics,
hasConfigChanged,
canResumeGame,
canModifyPlayers,
startGame,
resumeGame,
flipCard,
@@ -525,6 +561,7 @@ export function LocalMemoryPairsProvider({ children }: { children: ReactNode })
setGameType,
setDifficulty,
setTurnTimer,
hoverCard,
exitSession,
gameMode,
activePlayers,

View File

@@ -1,6 +1,7 @@
'use client'
import { type ReactNode, useCallback, useEffect, useMemo } from 'react'
import { useArcadeRedirect } from '@/hooks/useArcadeRedirect'
import { useArcadeSession } from '@/hooks/useArcadeSession'
import { useRoomData } from '@/hooks/useRoomData'
import { useViewerId } from '@/hooks/useViewerId'
@@ -213,6 +214,8 @@ function applyMoveOptimistically(state: MemoryPairsState, move: GameMove): Memor
}
// Provider component for ROOM-BASED play (with network sync)
// NOTE: This provider should ONLY be used for room-based multiplayer games.
// For arcade sessions without rooms, use LocalMemoryPairsProvider instead.
export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
const { data: viewerId } = useViewerId()
const { roomData } = useRoomData() // Fetch room data for room-based play
@@ -362,6 +365,43 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
return !!state.pausedGamePhase && !!state.pausedGameState && !hasConfigChanged
}, [state.pausedGamePhase, state.pausedGameState, hasConfigChanged])
// Helper to build player metadata with correct userId ownership
// This uses roomData.memberPlayers to determine which user owns which player
const buildPlayerMetadata = useCallback(
(playerIds: string[]) => {
const playerMetadata: { [playerId: string]: any } = {}
// Build reverse mapping: playerId -> userId from roomData.memberPlayers
const playerOwnership = new Map<string, string>()
if (roomData?.memberPlayers) {
for (const [userId, userPlayers] of Object.entries(roomData.memberPlayers)) {
for (const player of userPlayers) {
playerOwnership.set(player.id, userId)
}
}
}
for (const playerId of playerIds) {
const playerData = players.get(playerId)
if (playerData) {
// Get the actual owner userId from roomData, or use local viewerId as fallback
const ownerUserId = playerOwnership.get(playerId) || viewerId || ''
playerMetadata[playerId] = {
id: playerId,
name: playerData.name,
emoji: playerData.emoji,
userId: ownerUserId, // CORRECT: Use actual owner's userId
color: playerData.color,
}
}
}
return playerMetadata
},
[players, roomData, viewerId]
)
// Action creators - send moves to arcade session
const startGame = useCallback(() => {
// Must have at least one active player
@@ -372,19 +412,7 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
// Capture player metadata from local players map
// This ensures all room members can display player info even if they don't own the players
const playerMetadata: { [playerId: string]: any } = {}
for (const playerId of activePlayers) {
const playerData = players.get(playerId)
if (playerData) {
playerMetadata[playerId] = {
id: playerId,
name: playerData.name,
emoji: playerData.emoji,
userId: viewerId || '',
color: playerData.color,
}
}
}
const playerMetadata = buildPlayerMetadata(activePlayers)
// Use current session state configuration (no local state!)
const cards = generateGameCards(state.gameType, state.difficulty)
@@ -399,7 +427,7 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
playerMetadata,
},
})
}, [state.gameType, state.difficulty, activePlayers, players, viewerId, sendMove])
}, [state.gameType, state.difficulty, activePlayers, buildPlayerMetadata, sendMove])
const flipCard = useCallback(
(cardId: string) => {
@@ -435,20 +463,8 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
return
}
// Capture player metadata from local players map
const playerMetadata: { [playerId: string]: any } = {}
for (const playerId of activePlayers) {
const playerData = players.get(playerId)
if (playerData) {
playerMetadata[playerId] = {
id: playerId,
name: playerData.name,
emoji: playerData.emoji,
userId: viewerId || '',
color: playerData.color,
}
}
}
// Capture player metadata with correct userId ownership
const playerMetadata = buildPlayerMetadata(activePlayers)
// Use current session state configuration (no local state!)
const cards = generateGameCards(state.gameType, state.difficulty)
@@ -463,7 +479,7 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
playerMetadata,
},
})
}, [state.gameType, state.difficulty, activePlayers, players, viewerId, sendMove])
}, [state.gameType, state.difficulty, activePlayers, buildPlayerMetadata, sendMove])
const setGameType = useCallback(
(gameType: typeof state.gameType) => {
@@ -557,6 +573,7 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
currentGameStatistics,
hasConfigChanged,
canResumeGame,
canModifyPlayers: false, // Room-based games: always show buttons (false = show buttons)
startGame,
resumeGame,
flipCard,

View File

@@ -0,0 +1,151 @@
/**
* Unit test for player ownership bug in RoomMemoryPairsProvider
*
* Bug: playerMetadata[playerId].userId is set to the LOCAL viewerId for ALL players,
* including remote players from other room members. This causes "Your turn" to show
* even when it's a remote player's turn.
*
* Fix: Use player.isLocal from GameModeContext to determine correct userId ownership.
*/
import { describe, expect, it } from 'vitest'
describe('Player Metadata userId Assignment', () => {
it('should assign local userId to local players only', () => {
const viewerId = 'local-user-id'
const players = new Map([
[
'local-player-1',
{
id: 'local-player-1',
name: 'Local Player',
emoji: '😀',
color: '#3b82f6',
isLocal: true,
},
],
[
'remote-player-1',
{
id: 'remote-player-1',
name: 'Remote Player',
emoji: '🤠',
color: '#10b981',
isLocal: false,
},
],
])
const activePlayers = ['local-player-1', 'remote-player-1']
// CURRENT BUGGY IMPLEMENTATION (from RoomMemoryPairsProvider.tsx:378-390)
const buggyPlayerMetadata: Record<string, any> = {}
for (const playerId of activePlayers) {
const playerData = players.get(playerId)
if (playerData) {
buggyPlayerMetadata[playerId] = {
id: playerId,
name: playerData.name,
emoji: playerData.emoji,
userId: viewerId, // BUG: Always uses local viewerId!
color: playerData.color,
}
}
}
// BUG MANIFESTATION: Both players have local userId
expect(buggyPlayerMetadata['local-player-1'].userId).toBe('local-user-id')
expect(buggyPlayerMetadata['remote-player-1'].userId).toBe('local-user-id') // WRONG!
// CORRECT IMPLEMENTATION
const correctPlayerMetadata: Record<string, any> = {}
for (const playerId of activePlayers) {
const playerData = players.get(playerId)
if (playerData) {
correctPlayerMetadata[playerId] = {
id: playerId,
name: playerData.name,
emoji: playerData.emoji,
// FIX: Only use local viewerId for local players
// For remote players, we don't know their userId from this context,
// but we can mark them as NOT belonging to local user
userId: playerData.isLocal ? viewerId : `remote-user-${playerId}`,
color: playerData.color,
isLocal: playerData.isLocal, // Also include isLocal for clarity
}
}
}
// CORRECT BEHAVIOR: Each player has correct userId
expect(correctPlayerMetadata['local-player-1'].userId).toBe('local-user-id')
expect(correctPlayerMetadata['remote-player-1'].userId).not.toBe('local-user-id')
})
it('reproduces "Your turn" bug when checking current player', () => {
const viewerId = 'local-user-id'
const currentPlayer = 'remote-player-1' // Remote player's turn
// Buggy playerMetadata (all players have local userId)
const buggyPlayerMetadata = {
'local-player-1': {
id: 'local-player-1',
userId: 'local-user-id',
},
'remote-player-1': {
id: 'remote-player-1',
userId: 'local-user-id', // BUG!
},
}
// PlayerStatusBar logic (line 31 in PlayerStatusBar.tsx)
const buggyIsLocalPlayer = buggyPlayerMetadata[currentPlayer]?.userId === viewerId
// BUG: Shows "Your turn" even though it's remote player's turn!
expect(buggyIsLocalPlayer).toBe(true) // WRONG!
expect(buggyIsLocalPlayer ? 'Your turn' : 'Their turn').toBe('Your turn') // WRONG!
// Correct playerMetadata (each player has correct userId)
const correctPlayerMetadata = {
'local-player-1': {
id: 'local-player-1',
userId: 'local-user-id',
},
'remote-player-1': {
id: 'remote-player-1',
userId: 'remote-user-id', // CORRECT!
},
}
// PlayerStatusBar logic with correct data
const correctIsLocalPlayer = correctPlayerMetadata[currentPlayer]?.userId === viewerId
// CORRECT: Shows "Their turn" because it's remote player's turn
expect(correctIsLocalPlayer).toBe(false) // CORRECT!
expect(correctIsLocalPlayer ? 'Your turn' : 'Their turn').toBe('Their turn') // CORRECT!
})
it('reproduces hover avatar bug when filtering by current player', () => {
const viewerId = 'local-user-id'
const currentPlayer = 'remote-player-1' // Remote player's turn
// Buggy playerMetadata
const buggyPlayerMetadata = {
'remote-player-1': {
id: 'remote-player-1',
userId: 'local-user-id', // BUG!
},
}
// OLD WRONG logic from MemoryGrid.tsx (showed remote players)
const oldWrongFilter = buggyPlayerMetadata[currentPlayer]?.userId !== viewerId
expect(oldWrongFilter).toBe(false) // Would hide avatar incorrectly
// CURRENT logic in MemoryGrid.tsx (shows only current player)
// This is actually correct - show avatar for whoever's turn it is
const currentLogic = currentPlayer === 'remote-player-1'
expect(currentLogic).toBe(true) // Shows avatar for current player
// The REAL issue is in PlayerStatusBar showing "Your turn"
// when it should show "Their turn"
})
})

View File

@@ -120,6 +120,7 @@ export interface MemoryPairsContextValue {
currentGameStatistics: GameStatistics
gameMode: GameMode // Derived from global context
activePlayers: Player[] // Active player IDs from arena
canModifyPlayers: boolean // Whether players can be added/removed (controls button visibility)
// PAUSE/RESUME: Computed pause/resume values
hasConfigChanged?: boolean

View File

@@ -1,13 +1,13 @@
import { ArcadeGuardedPage } from '@/components/ArcadeGuardedPage'
import { MemoryPairsGame } from './components/MemoryPairsGame'
import { RoomMemoryPairsProvider } from './context/RoomMemoryPairsProvider'
import { LocalMemoryPairsProvider } from './context/LocalMemoryPairsProvider'
export default function MatchingPage() {
return (
<ArcadeGuardedPage>
<RoomMemoryPairsProvider>
<LocalMemoryPairsProvider>
<MemoryPairsGame />
</RoomMemoryPairsProvider>
</LocalMemoryPairsProvider>
</ArcadeGuardedPage>
)
}

View File

@@ -0,0 +1,3 @@
"use strict";
// TypeScript interfaces for Memory Pairs Challenge game
Object.defineProperty(exports, "__esModule", { value: true });

View File

@@ -0,0 +1,164 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.generateAbacusNumeralCards = generateAbacusNumeralCards;
exports.generateComplementCards = generateComplementCards;
exports.generateGameCards = generateGameCards;
exports.getGridConfiguration = getGridConfiguration;
exports.generateCardId = generateCardId;
// Utility function to generate unique random numbers
function generateUniqueNumbers(count, options) {
const numbers = new Set();
const { min, max } = options;
while (numbers.size < count) {
const randomNum = Math.floor(Math.random() * (max - min + 1)) + min;
numbers.add(randomNum);
}
return Array.from(numbers);
}
// Utility function to shuffle an array
function shuffleArray(array) {
const shuffled = [...array];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled;
}
// Generate cards for abacus-numeral game mode
function generateAbacusNumeralCards(pairs) {
// Generate unique numbers based on difficulty
// For easier games, use smaller numbers; for harder games, use larger ranges
const numberRanges = {
6: { min: 1, max: 50 }, // 6 pairs: 1-50
8: { min: 1, max: 100 }, // 8 pairs: 1-100
12: { min: 1, max: 200 }, // 12 pairs: 1-200
15: { min: 1, max: 300 }, // 15 pairs: 1-300
};
const range = numberRanges[pairs];
const numbers = generateUniqueNumbers(pairs, range);
const cards = [];
numbers.forEach((number) => {
// Abacus representation card
cards.push({
id: `abacus_${number}`,
type: 'abacus',
number,
matched: false,
});
// Numerical representation card
cards.push({
id: `number_${number}`,
type: 'number',
number,
matched: false,
});
});
return shuffleArray(cards);
}
// Generate cards for complement pairs game mode
function generateComplementCards(pairs) {
// Define complement pairs for friends of 5 and friends of 10
const complementPairs = [
// Friends of 5
{ pair: [0, 5], targetSum: 5 },
{ pair: [1, 4], targetSum: 5 },
{ pair: [2, 3], targetSum: 5 },
// Friends of 10
{ pair: [0, 10], targetSum: 10 },
{ pair: [1, 9], targetSum: 10 },
{ pair: [2, 8], targetSum: 10 },
{ pair: [3, 7], targetSum: 10 },
{ pair: [4, 6], targetSum: 10 },
{ pair: [5, 5], targetSum: 10 },
// Additional pairs for higher difficulties
{ pair: [6, 4], targetSum: 10 },
{ pair: [7, 3], targetSum: 10 },
{ pair: [8, 2], targetSum: 10 },
{ pair: [9, 1], targetSum: 10 },
{ pair: [10, 0], targetSum: 10 },
// More challenging pairs (can be used for expert mode)
{ pair: [11, 9], targetSum: 20 },
{ pair: [12, 8], targetSum: 20 },
];
// Select the required number of complement pairs
const selectedPairs = complementPairs.slice(0, pairs);
const cards = [];
selectedPairs.forEach(({ pair: [num1, num2], targetSum }, index) => {
// First number in the pair
cards.push({
id: `comp1_${index}_${num1}`,
type: 'complement',
number: num1,
complement: num2,
targetSum,
matched: false,
});
// Second number in the pair
cards.push({
id: `comp2_${index}_${num2}`,
type: 'complement',
number: num2,
complement: num1,
targetSum,
matched: false,
});
});
return shuffleArray(cards);
}
// Main card generation function
function generateGameCards(gameType, difficulty) {
switch (gameType) {
case 'abacus-numeral':
return generateAbacusNumeralCards(difficulty);
case 'complement-pairs':
return generateComplementCards(difficulty);
default:
throw new Error(`Unknown game type: ${gameType}`);
}
}
// Utility function to get responsive grid configuration based on difficulty and screen size
function getGridConfiguration(difficulty) {
const configs = {
6: {
totalCards: 12,
mobileColumns: 3, // 3x4 grid in portrait
tabletColumns: 4, // 4x3 grid on tablet
desktopColumns: 4, // 4x3 grid on desktop
landscapeColumns: 6, // 6x2 grid in landscape
cardSize: { width: '140px', height: '180px' },
gridTemplate: 'repeat(3, 1fr)',
},
8: {
totalCards: 16,
mobileColumns: 3, // 3x6 grid in portrait (some spillover)
tabletColumns: 4, // 4x4 grid on tablet
desktopColumns: 4, // 4x4 grid on desktop
landscapeColumns: 6, // 6x3 grid in landscape (some spillover)
cardSize: { width: '120px', height: '160px' },
gridTemplate: 'repeat(3, 1fr)',
},
12: {
totalCards: 24,
mobileColumns: 3, // 3x8 grid in portrait
tabletColumns: 4, // 4x6 grid on tablet
desktopColumns: 6, // 6x4 grid on desktop
landscapeColumns: 6, // 6x4 grid in landscape (changed from 8x3)
cardSize: { width: '100px', height: '140px' },
gridTemplate: 'repeat(3, 1fr)',
},
15: {
totalCards: 30,
mobileColumns: 3, // 3x10 grid in portrait
tabletColumns: 5, // 5x6 grid on tablet
desktopColumns: 6, // 6x5 grid on desktop
landscapeColumns: 10, // 10x3 grid in landscape
cardSize: { width: '90px', height: '120px' },
gridTemplate: 'repeat(3, 1fr)',
},
};
return configs[difficulty];
}
// Generate a unique ID for cards
function generateCardId(type, identifier) {
return `${type}_${identifier}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}

View File

@@ -0,0 +1,188 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.validateAbacusNumeralMatch = validateAbacusNumeralMatch;
exports.validateComplementMatch = validateComplementMatch;
exports.validateMatch = validateMatch;
exports.canFlipCard = canFlipCard;
exports.getMatchHint = getMatchHint;
exports.calculateMatchScore = calculateMatchScore;
exports.analyzeGamePerformance = analyzeGamePerformance;
// Validate abacus-numeral match (abacus card matches with number card of same value)
function validateAbacusNumeralMatch(card1, card2) {
// Both cards must have the same number
if (card1.number !== card2.number) {
return {
isValid: false,
reason: 'Numbers do not match',
type: 'invalid',
};
}
// Cards must be different types (one abacus, one number)
if (card1.type === card2.type) {
return {
isValid: false,
reason: 'Both cards are the same type',
type: 'invalid',
};
}
// One must be abacus, one must be number
const hasAbacus = card1.type === 'abacus' || card2.type === 'abacus';
const hasNumber = card1.type === 'number' || card2.type === 'number';
if (!hasAbacus || !hasNumber) {
return {
isValid: false,
reason: 'Must match abacus with number representation',
type: 'invalid',
};
}
// Neither should be complement type for this game mode
if (card1.type === 'complement' || card2.type === 'complement') {
return {
isValid: false,
reason: 'Complement cards not valid in abacus-numeral mode',
type: 'invalid',
};
}
return {
isValid: true,
type: 'abacus-numeral',
};
}
// Validate complement match (two numbers that add up to target sum)
function validateComplementMatch(card1, card2) {
// Both cards must be complement type
if (card1.type !== 'complement' || card2.type !== 'complement') {
return {
isValid: false,
reason: 'Both cards must be complement type',
type: 'invalid',
};
}
// Both cards must have the same target sum
if (card1.targetSum !== card2.targetSum) {
return {
isValid: false,
reason: 'Cards have different target sums',
type: 'invalid',
};
}
// Check if the numbers are actually complements
if (!card1.complement || !card2.complement) {
return {
isValid: false,
reason: 'Complement information missing',
type: 'invalid',
};
}
// Verify the complement relationship
if (card1.number !== card2.complement || card2.number !== card1.complement) {
return {
isValid: false,
reason: 'Numbers are not complements of each other',
type: 'invalid',
};
}
// Verify the sum equals the target
const sum = card1.number + card2.number;
if (sum !== card1.targetSum) {
return {
isValid: false,
reason: `Sum ${sum} does not equal target ${card1.targetSum}`,
type: 'invalid',
};
}
return {
isValid: true,
type: 'complement',
};
}
// Main validation function that determines which validation to use
function validateMatch(card1, card2) {
// Cannot match the same card with itself
if (card1.id === card2.id) {
return {
isValid: false,
reason: 'Cannot match card with itself',
type: 'invalid',
};
}
// Cannot match already matched cards
if (card1.matched || card2.matched) {
return {
isValid: false,
reason: 'Cannot match already matched cards',
type: 'invalid',
};
}
// Determine which type of match to validate based on card types
const hasComplement = card1.type === 'complement' || card2.type === 'complement';
if (hasComplement) {
// If either card is complement type, use complement validation
return validateComplementMatch(card1, card2);
}
else {
// Otherwise, use abacus-numeral validation
return validateAbacusNumeralMatch(card1, card2);
}
}
// Helper function to check if a card can be flipped
function canFlipCard(card, flippedCards, isProcessingMove) {
// Cannot flip if processing a move
if (isProcessingMove)
return false;
// Cannot flip already matched cards
if (card.matched)
return false;
// Cannot flip if already flipped
if (flippedCards.some((c) => c.id === card.id))
return false;
// Cannot flip if two cards are already flipped
if (flippedCards.length >= 2)
return false;
return true;
}
// Get hint for what kind of match the player should look for
function getMatchHint(card) {
switch (card.type) {
case 'abacus':
return `Find the number ${card.number}`;
case 'number':
return `Find the abacus showing ${card.number}`;
case 'complement':
if (card.complement !== undefined && card.targetSum !== undefined) {
return `Find ${card.complement} to make ${card.targetSum}`;
}
return 'Find the matching complement';
default:
return 'Find the matching card';
}
}
// Calculate match score based on difficulty and time
function calculateMatchScore(difficulty, timeForMatch, isComplementMatch) {
const baseScore = isComplementMatch ? 15 : 10; // Complement matches worth more
const difficultyMultiplier = difficulty / 6; // Scale with difficulty
const timeBonus = Math.max(0, (10000 - timeForMatch) / 1000); // Bonus for speed
return Math.round(baseScore * difficultyMultiplier + timeBonus);
}
// Analyze game performance
function analyzeGamePerformance(totalMoves, matchedPairs, totalPairs, gameTime) {
const accuracy = totalMoves > 0 ? (matchedPairs / totalMoves) * 100 : 0;
const efficiency = totalPairs > 0 ? (matchedPairs / (totalPairs * 2)) * 100 : 0; // Ideal is 100% (each pair found in 2 moves)
const averageTimePerMove = totalMoves > 0 ? gameTime / totalMoves : 0;
// Calculate grade based on accuracy and efficiency
let grade = 'F';
if (accuracy >= 90 && efficiency >= 80)
grade = 'A';
else if (accuracy >= 80 && efficiency >= 70)
grade = 'B';
else if (accuracy >= 70 && efficiency >= 60)
grade = 'C';
else if (accuracy >= 60 && efficiency >= 50)
grade = 'D';
return {
accuracy,
efficiency,
averageTimePerMove,
grade,
};
}

80
apps/web/src/db/index.js Normal file
View File

@@ -0,0 +1,80 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.schema = exports.db = void 0;
const better_sqlite3_1 = __importDefault(require("better-sqlite3"));
const better_sqlite3_2 = require("drizzle-orm/better-sqlite3");
const schema = __importStar(require("./schema"));
exports.schema = schema;
/**
* Database connection and client
*
* Creates a singleton SQLite connection with Drizzle ORM.
* Enables foreign key constraints (required for cascading deletes).
*
* IMPORTANT: The database connection is lazy-loaded to avoid accessing
* the database at module import time, which would cause build failures
* when the database doesn't exist (e.g., in CI/CD environments).
*/
const databaseUrl = process.env.DATABASE_URL || './data/sqlite.db';
let _sqlite = null;
let _db = null;
/**
* Get the database connection (lazy-loaded singleton)
* Only creates the connection when first accessed at runtime
*/
function getDb() {
if (!_db) {
_sqlite = new better_sqlite3_1.default(databaseUrl);
// Enable foreign keys (SQLite requires explicit enable)
_sqlite.pragma('foreign_keys = ON');
// Enable WAL mode for better concurrency
_sqlite.pragma('journal_mode = WAL');
_db = (0, better_sqlite3_2.drizzle)(_sqlite, { schema });
}
return _db;
}
/**
* Database client instance
* Uses a Proxy to lazy-load the connection on first access
*/
exports.db = new Proxy({}, {
get(_target, prop) {
return getDb()[prop];
},
});

View File

@@ -0,0 +1,22 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const migrator_1 = require("drizzle-orm/better-sqlite3/migrator");
const index_1 = require("./index");
/**
* Migration runner
*
* Runs all pending migrations in the drizzle/ folder.
* Safe to run multiple times (migrations are idempotent).
*
* Usage: pnpm db:migrate
*/
try {
console.log('🔄 Running migrations...');
(0, migrator_1.migrate)(index_1.db, { migrationsFolder: './drizzle' });
console.log('✅ Migrations complete');
process.exit(0);
}
catch (error) {
console.error('❌ Migration failed:', error);
process.exit(1);
}

View File

@@ -0,0 +1,53 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.abacusSettings = void 0;
const sqlite_core_1 = require("drizzle-orm/sqlite-core");
const users_1 = require("./users");
/**
* Abacus display settings table - UI preferences per user
*
* One-to-one with users table. Stores abacus display configuration.
* Deleted when user is deleted (cascade).
*/
exports.abacusSettings = (0, sqlite_core_1.sqliteTable)('abacus_settings', {
/** Primary key and foreign key to users table */
userId: (0, sqlite_core_1.text)('user_id')
.primaryKey()
.references(() => users_1.users.id, { onDelete: 'cascade' }),
/** Color scheme for beads */
colorScheme: (0, sqlite_core_1.text)('color_scheme', {
enum: ['monochrome', 'place-value', 'heaven-earth', 'alternating'],
})
.notNull()
.default('place-value'),
/** Bead shape */
beadShape: (0, sqlite_core_1.text)('bead_shape', {
enum: ['diamond', 'circle', 'square'],
})
.notNull()
.default('diamond'),
/** Color palette */
colorPalette: (0, sqlite_core_1.text)('color_palette', {
enum: ['default', 'colorblind', 'mnemonic', 'grayscale', 'nature'],
})
.notNull()
.default('default'),
/** Hide inactive beads */
hideInactiveBeads: (0, sqlite_core_1.integer)('hide_inactive_beads', { mode: 'boolean' }).notNull().default(false),
/** Color numerals based on place value */
coloredNumerals: (0, sqlite_core_1.integer)('colored_numerals', { mode: 'boolean' }).notNull().default(false),
/** Scale factor for abacus size */
scaleFactor: (0, sqlite_core_1.real)('scale_factor').notNull().default(1.0),
/** Show numbers below abacus */
showNumbers: (0, sqlite_core_1.integer)('show_numbers', { mode: 'boolean' }).notNull().default(true),
/** Enable animations */
animated: (0, sqlite_core_1.integer)('animated', { mode: 'boolean' }).notNull().default(true),
/** Enable interaction */
interactive: (0, sqlite_core_1.integer)('interactive', { mode: 'boolean' }).notNull().default(false),
/** Enable gesture controls */
gestures: (0, sqlite_core_1.integer)('gestures', { mode: 'boolean' }).notNull().default(false),
/** Enable sound effects */
soundEnabled: (0, sqlite_core_1.integer)('sound_enabled', { mode: 'boolean' }).notNull().default(true),
/** Sound volume (0.0 - 1.0) */
soundVolume: (0, sqlite_core_1.real)('sound_volume').notNull().default(0.8),
});

View File

@@ -0,0 +1,39 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.arcadeRooms = void 0;
const cuid2_1 = require("@paralleldrive/cuid2");
const sqlite_core_1 = require("drizzle-orm/sqlite-core");
exports.arcadeRooms = (0, sqlite_core_1.sqliteTable)('arcade_rooms', {
id: (0, sqlite_core_1.text)('id')
.primaryKey()
.$defaultFn(() => (0, cuid2_1.createId)()),
// Room identity
code: (0, sqlite_core_1.text)('code', { length: 6 }).notNull().unique(), // e.g., "ABC123"
name: (0, sqlite_core_1.text)('name', { length: 50 }).notNull(),
// Creator info
createdBy: (0, sqlite_core_1.text)('created_by').notNull(), // User/guest ID
creatorName: (0, sqlite_core_1.text)('creator_name', { length: 50 }).notNull(),
createdAt: (0, sqlite_core_1.integer)('created_at', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date()),
// Lifecycle
lastActivity: (0, sqlite_core_1.integer)('last_activity', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date()),
ttlMinutes: (0, sqlite_core_1.integer)('ttl_minutes').notNull().default(60), // Time to live
isLocked: (0, sqlite_core_1.integer)('is_locked', { mode: 'boolean' }).notNull().default(false),
// Game configuration
gameName: (0, sqlite_core_1.text)('game_name', {
enum: ['matching', 'memory-quiz', 'complement-race'],
}).notNull(),
gameConfig: (0, sqlite_core_1.text)('game_config', { mode: 'json' }).notNull(), // Game-specific settings
// Current state
status: (0, sqlite_core_1.text)('status', {
enum: ['lobby', 'playing', 'finished'],
})
.notNull()
.default('lobby'),
currentSessionId: (0, sqlite_core_1.text)('current_session_id'), // FK to arcade_sessions (nullable)
// Metadata
totalGamesPlayed: (0, sqlite_core_1.integer)('total_games_played').notNull().default(0),
});

View File

@@ -0,0 +1,30 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.arcadeSessions = void 0;
const sqlite_core_1 = require("drizzle-orm/sqlite-core");
const arcade_rooms_1 = require("./arcade-rooms");
const users_1 = require("./users");
exports.arcadeSessions = (0, sqlite_core_1.sqliteTable)('arcade_sessions', {
userId: (0, sqlite_core_1.text)('user_id')
.primaryKey()
.references(() => users_1.users.id, { onDelete: 'cascade' }),
// Session metadata
currentGame: (0, sqlite_core_1.text)('current_game', {
enum: ['matching', 'memory-quiz', 'complement-race'],
}).notNull(),
gameUrl: (0, sqlite_core_1.text)('game_url').notNull(), // e.g., '/arcade/matching'
// Game state (JSON blob)
gameState: (0, sqlite_core_1.text)('game_state', { mode: 'json' }).notNull(),
// Active players snapshot (for quick access)
activePlayers: (0, sqlite_core_1.text)('active_players', { mode: 'json' }).notNull(),
// Room association (null for solo play)
roomId: (0, sqlite_core_1.text)('room_id').references(() => arcade_rooms_1.arcadeRooms.id, { onDelete: 'set null' }),
// Timing & TTL
startedAt: (0, sqlite_core_1.integer)('started_at', { mode: 'timestamp' }).notNull(),
lastActivityAt: (0, sqlite_core_1.integer)('last_activity_at', { mode: 'timestamp' }).notNull(),
expiresAt: (0, sqlite_core_1.integer)('expires_at', { mode: 'timestamp' }).notNull(), // TTL-based
// Status
isActive: (0, sqlite_core_1.integer)('is_active', { mode: 'boolean' }).notNull().default(true),
// Version for optimistic locking
version: (0, sqlite_core_1.integer)('version').notNull().default(1),
});

View File

@@ -0,0 +1,29 @@
"use strict";
/**
* Database schema exports
*
* This is the single source of truth for the database schema.
* All tables, relations, and types are exported from here.
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __exportStar = (this && this.__exportStar) || function(m, exports) {
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
};
Object.defineProperty(exports, "__esModule", { value: true });
__exportStar(require("./abacus-settings"), exports);
__exportStar(require("./arcade-rooms"), exports);
__exportStar(require("./arcade-sessions"), exports);
__exportStar(require("./players"), exports);
__exportStar(require("./room-members"), exports);
__exportStar(require("./user-stats"), exports);
__exportStar(require("./users"), exports);

View File

@@ -0,0 +1,36 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.players = void 0;
const cuid2_1 = require("@paralleldrive/cuid2");
const sqlite_core_1 = require("drizzle-orm/sqlite-core");
const users_1 = require("./users");
/**
* Players table - user-created player profiles for games
*
* Each user can have multiple players (for multi-player modes).
* Players are scoped to a user and deleted when user is deleted.
*/
exports.players = (0, sqlite_core_1.sqliteTable)('players', {
id: (0, sqlite_core_1.text)('id')
.primaryKey()
.$defaultFn(() => (0, cuid2_1.createId)()),
/** Foreign key to users table - cascades on delete */
userId: (0, sqlite_core_1.text)('user_id')
.notNull()
.references(() => users_1.users.id, { onDelete: 'cascade' }),
/** Player display name */
name: (0, sqlite_core_1.text)('name').notNull(),
/** Player emoji avatar */
emoji: (0, sqlite_core_1.text)('emoji').notNull(),
/** Player color (hex) for UI theming */
color: (0, sqlite_core_1.text)('color').notNull(),
/** Whether this player is currently active in games */
isActive: (0, sqlite_core_1.integer)('is_active', { mode: 'boolean' }).notNull().default(false),
/** When this player was created */
createdAt: (0, sqlite_core_1.integer)('created_at', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date()),
}, (table) => ({
/** Index for fast lookups by userId */
userIdIdx: (0, sqlite_core_1.index)('players_user_id_idx').on(table.userId),
}));

View File

@@ -0,0 +1,27 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.roomMembers = void 0;
const cuid2_1 = require("@paralleldrive/cuid2");
const sqlite_core_1 = require("drizzle-orm/sqlite-core");
const arcade_rooms_1 = require("./arcade-rooms");
exports.roomMembers = (0, sqlite_core_1.sqliteTable)('room_members', {
id: (0, sqlite_core_1.text)('id')
.primaryKey()
.$defaultFn(() => (0, cuid2_1.createId)()),
roomId: (0, sqlite_core_1.text)('room_id')
.notNull()
.references(() => arcade_rooms_1.arcadeRooms.id, { onDelete: 'cascade' }),
userId: (0, sqlite_core_1.text)('user_id').notNull(), // User/guest ID - UNIQUE: one room per user (enforced by index below)
displayName: (0, sqlite_core_1.text)('display_name', { length: 50 }).notNull(),
isCreator: (0, sqlite_core_1.integer)('is_creator', { mode: 'boolean' }).notNull().default(false),
joinedAt: (0, sqlite_core_1.integer)('joined_at', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date()),
lastSeen: (0, sqlite_core_1.integer)('last_seen', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date()),
isOnline: (0, sqlite_core_1.integer)('is_online', { mode: 'boolean' }).notNull().default(true),
}, (table) => ({
// Explicit unique index for clarity and database-level enforcement
userIdIdx: (0, sqlite_core_1.uniqueIndex)('idx_room_members_user_id_unique').on(table.userId),
}));

View File

@@ -0,0 +1,29 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.userStats = void 0;
const sqlite_core_1 = require("drizzle-orm/sqlite-core");
const users_1 = require("./users");
/**
* User stats table - game statistics per user
*
* One-to-one with users table. Tracks aggregate game performance.
* Deleted when user is deleted (cascade).
*/
exports.userStats = (0, sqlite_core_1.sqliteTable)('user_stats', {
/** Primary key and foreign key to users table */
userId: (0, sqlite_core_1.text)('user_id')
.primaryKey()
.references(() => users_1.users.id, { onDelete: 'cascade' }),
/** Total number of games played */
gamesPlayed: (0, sqlite_core_1.integer)('games_played').notNull().default(0),
/** Total number of games won */
totalWins: (0, sqlite_core_1.integer)('total_wins').notNull().default(0),
/** User's most-played game type */
favoriteGameType: (0, sqlite_core_1.text)('favorite_game_type', {
enum: ['abacus-numeral', 'complement-pairs'],
}),
/** Best completion time in milliseconds */
bestTime: (0, sqlite_core_1.integer)('best_time'),
/** Highest accuracy percentage (0.0 - 1.0) */
highestAccuracy: (0, sqlite_core_1.real)('highest_accuracy').notNull().default(0),
});

View File

@@ -0,0 +1,28 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.users = void 0;
const cuid2_1 = require("@paralleldrive/cuid2");
const sqlite_core_1 = require("drizzle-orm/sqlite-core");
/**
* Users table - stores both guest and authenticated users
*
* Guest users are created automatically on first visit via middleware.
* They can upgrade to full accounts later while preserving their data.
*/
exports.users = (0, sqlite_core_1.sqliteTable)('users', {
id: (0, sqlite_core_1.text)('id')
.primaryKey()
.$defaultFn(() => (0, cuid2_1.createId)()),
/** Stable guest ID from HttpOnly cookie - unique per browser session */
guestId: (0, sqlite_core_1.text)('guest_id').notNull().unique(),
/** When this user record was created */
createdAt: (0, sqlite_core_1.integer)('created_at', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date()),
/** When guest upgraded to full account (null for guests) */
upgradedAt: (0, sqlite_core_1.integer)('upgraded_at', { mode: 'timestamp' }),
/** Email (only set after upgrade) */
email: (0, sqlite_core_1.text)('email').unique(),
/** Display name (only set after upgrade) */
name: (0, sqlite_core_1.text)('name'),
});

View File

@@ -0,0 +1,258 @@
/**
* Unit tests for player ownership utilities
*/
import { describe, expect, it } from 'vitest'
import type { RoomData } from '@/hooks/useRoomData'
import {
type PlayerOwnershipMap,
buildPlayerMetadata,
buildPlayerOwnershipFromRoomData,
getPlayerOwner,
isPlayerOwnedByUser,
isUsersTurn,
} from '../player-ownership'
describe('player-ownership utilities', () => {
describe('buildPlayerOwnershipFromRoomData', () => {
it('builds ownership map from roomData.memberPlayers', () => {
const roomData: RoomData = {
id: 'room-1',
name: 'Test Room',
code: 'ABC123',
gameName: 'matching',
members: [],
memberPlayers: {
'user-1': [
{ id: 'player-1', name: 'Player 1', emoji: '😀', color: '#3b82f6' },
{ id: 'player-2', name: 'Player 2', emoji: '😎', color: '#8b5cf6' },
],
'user-2': [{ id: 'player-3', name: 'Player 3', emoji: '🤠', color: '#10b981' }],
},
}
const ownershipMap = buildPlayerOwnershipFromRoomData(roomData)
expect(ownershipMap).toEqual({
'player-1': 'user-1',
'player-2': 'user-1',
'player-3': 'user-2',
})
})
it('returns empty object for null roomData', () => {
const ownershipMap = buildPlayerOwnershipFromRoomData(null)
expect(ownershipMap).toEqual({})
})
it('returns empty object for undefined roomData', () => {
const ownershipMap = buildPlayerOwnershipFromRoomData(undefined)
expect(ownershipMap).toEqual({})
})
it('returns empty object for roomData without memberPlayers', () => {
const roomData = {
id: 'room-1',
name: 'Test Room',
code: 'ABC123',
gameName: 'matching',
members: [],
memberPlayers: {},
} as RoomData
const ownershipMap = buildPlayerOwnershipFromRoomData(roomData)
expect(ownershipMap).toEqual({})
})
})
describe('isPlayerOwnedByUser', () => {
const ownershipMap: PlayerOwnershipMap = {
'player-1': 'user-1',
'player-2': 'user-1',
'player-3': 'user-2',
}
it('returns true when player is owned by user', () => {
expect(isPlayerOwnedByUser('player-1', 'user-1', ownershipMap)).toBe(true)
expect(isPlayerOwnedByUser('player-2', 'user-1', ownershipMap)).toBe(true)
expect(isPlayerOwnedByUser('player-3', 'user-2', ownershipMap)).toBe(true)
})
it('returns false when player is not owned by user', () => {
expect(isPlayerOwnedByUser('player-1', 'user-2', ownershipMap)).toBe(false)
expect(isPlayerOwnedByUser('player-3', 'user-1', ownershipMap)).toBe(false)
})
it('returns false for unknown player', () => {
expect(isPlayerOwnedByUser('player-unknown', 'user-1', ownershipMap)).toBe(false)
})
})
describe('getPlayerOwner', () => {
const ownershipMap: PlayerOwnershipMap = {
'player-1': 'user-1',
'player-2': 'user-1',
'player-3': 'user-2',
}
it('returns correct owner userId for player', () => {
expect(getPlayerOwner('player-1', ownershipMap)).toBe('user-1')
expect(getPlayerOwner('player-2', ownershipMap)).toBe('user-1')
expect(getPlayerOwner('player-3', ownershipMap)).toBe('user-2')
})
it('returns undefined for unknown player', () => {
expect(getPlayerOwner('player-unknown', ownershipMap)).toBeUndefined()
})
})
describe('isUsersTurn', () => {
const ownershipMap: PlayerOwnershipMap = {
'player-1': 'user-1',
'player-2': 'user-1',
'player-3': 'user-2',
}
it('returns true when current player belongs to user', () => {
expect(isUsersTurn('player-1', 'user-1', ownershipMap)).toBe(true)
expect(isUsersTurn('player-3', 'user-2', ownershipMap)).toBe(true)
})
it('returns false when current player belongs to different user', () => {
expect(isUsersTurn('player-1', 'user-2', ownershipMap)).toBe(false)
expect(isUsersTurn('player-3', 'user-1', ownershipMap)).toBe(false)
})
it('returns false for unknown player', () => {
expect(isUsersTurn('player-unknown', 'user-1', ownershipMap)).toBe(false)
})
})
describe('buildPlayerMetadata', () => {
const ownershipMap: PlayerOwnershipMap = {
'player-1': 'user-1',
'player-2': 'user-1',
'player-3': 'user-2',
}
const playersMap = new Map([
['player-1', { name: 'Player 1', emoji: '😀', color: '#3b82f6' }],
['player-2', { name: 'Player 2', emoji: '😎', color: '#8b5cf6' }],
['player-3', { name: 'Player 3', emoji: '🤠', color: '#10b981' }],
])
it('builds metadata with correct ownership', () => {
const metadata = buildPlayerMetadata(
['player-1', 'player-2', 'player-3'],
ownershipMap,
playersMap
)
expect(metadata).toEqual({
'player-1': {
id: 'player-1',
name: 'Player 1',
emoji: '😀',
userId: 'user-1',
color: '#3b82f6',
},
'player-2': {
id: 'player-2',
name: 'Player 2',
emoji: '😎',
userId: 'user-1',
color: '#8b5cf6',
},
'player-3': {
id: 'player-3',
name: 'Player 3',
emoji: '🤠',
userId: 'user-2',
color: '#10b981',
},
})
})
it('uses fallback userId when player not in ownership map', () => {
const metadata = buildPlayerMetadata(
['player-1', 'player-4'],
ownershipMap,
playersMap,
'fallback-user'
)
// player-1 has ownership, but player-4 is not in playersMap
// so it won't be in metadata at all
expect(metadata['player-1']?.userId).toBe('user-1')
expect(metadata['player-4']).toBeUndefined()
})
it('skips players not in playersMap', () => {
const metadata = buildPlayerMetadata(['player-1', 'player-unknown'], ownershipMap, playersMap)
expect(metadata['player-1']).toBeDefined()
expect(metadata['player-unknown']).toBeUndefined()
})
it('handles empty playerIds array', () => {
const metadata = buildPlayerMetadata([], ownershipMap, playersMap)
expect(metadata).toEqual({})
})
})
describe('edge cases', () => {
it('handles empty ownership map', () => {
const emptyMap: PlayerOwnershipMap = {}
expect(isPlayerOwnedByUser('player-1', 'user-1', emptyMap)).toBe(false)
expect(getPlayerOwner('player-1', emptyMap)).toBeUndefined()
expect(isUsersTurn('player-1', 'user-1', emptyMap)).toBe(false)
})
it('handles empty strings', () => {
const ownershipMap: PlayerOwnershipMap = {
'player-1': 'user-1',
}
expect(isPlayerOwnedByUser('', 'user-1', ownershipMap)).toBe(false)
expect(getPlayerOwner('', ownershipMap)).toBeUndefined()
expect(isUsersTurn('', 'user-1', ownershipMap)).toBe(false)
})
})
describe('real-world scenario: turn indicator logic', () => {
it('reproduces the "Your turn" vs "Their turn" bug and fix', () => {
const roomData: RoomData = {
id: 'room-1',
name: 'Game Room',
code: 'ABC123',
gameName: 'matching',
members: [],
memberPlayers: {
'local-user-id': [
{ id: 'local-player-1', name: 'My Player 1', emoji: '😀', color: '#3b82f6' },
{ id: 'local-player-2', name: 'My Player 2', emoji: '😎', color: '#8b5cf6' },
],
'remote-user-id': [
{ id: 'remote-player-1', name: 'Their Player', emoji: '🤠', color: '#10b981' },
],
},
}
const ownershipMap = buildPlayerOwnershipFromRoomData(roomData)
const viewerId = 'local-user-id'
// Scenario 1: It's my turn (local player is current)
const currentPlayer1 = 'local-player-1'
const isMyTurn1 = isUsersTurn(currentPlayer1, viewerId, ownershipMap)
expect(isMyTurn1).toBe(true)
expect(isMyTurn1 ? 'Your turn' : 'Their turn').toBe('Your turn')
// Scenario 2: It's their turn (remote player is current)
const currentPlayer2 = 'remote-player-1'
const isMyTurn2 = isUsersTurn(currentPlayer2, viewerId, ownershipMap)
expect(isMyTurn2).toBe(false)
expect(isMyTurn2 ? 'Your turn' : 'Their turn').toBe('Their turn')
})
})
})

View File

@@ -0,0 +1,120 @@
"use strict";
/**
* Player manager for arcade rooms
* Handles fetching and validating player participation in rooms
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.getAllPlayers = getAllPlayers;
exports.getActivePlayers = getActivePlayers;
exports.getRoomActivePlayers = getRoomActivePlayers;
exports.getRoomPlayerIds = getRoomPlayerIds;
exports.validatePlayerInRoom = validatePlayerInRoom;
exports.getPlayer = getPlayer;
exports.getPlayers = getPlayers;
const drizzle_orm_1 = require("drizzle-orm");
const db_1 = require("../../db");
/**
* Get all players for a user (regardless of isActive status)
* @param viewerId - The guestId from the cookie (same as what getViewerId() returns)
*/
async function getAllPlayers(viewerId) {
// First get the user record by guestId
const user = await db_1.db.query.users.findFirst({
where: (0, drizzle_orm_1.eq)(db_1.schema.users.guestId, viewerId),
});
if (!user) {
return [];
}
// Now query all players by the actual user.id (no isActive filter)
return await db_1.db.query.players.findMany({
where: (0, drizzle_orm_1.eq)(db_1.schema.players.userId, user.id),
orderBy: db_1.schema.players.createdAt,
});
}
/**
* Get a user's active players (solo mode)
* These are the players that will participate when the user joins a solo game
* @param viewerId - The guestId from the cookie (same as what getViewerId() returns)
*/
async function getActivePlayers(viewerId) {
// First get the user record by guestId
const user = await db_1.db.query.users.findFirst({
where: (0, drizzle_orm_1.eq)(db_1.schema.users.guestId, viewerId),
});
if (!user) {
return [];
}
// Now query players by the actual user.id
return await db_1.db.query.players.findMany({
where: (0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(db_1.schema.players.userId, user.id), (0, drizzle_orm_1.eq)(db_1.schema.players.isActive, true)),
orderBy: db_1.schema.players.createdAt,
});
}
/**
* Get active players for all members in a room
* Returns only players marked isActive=true from each room member
* Returns a map of userId -> Player[]
*/
async function getRoomActivePlayers(roomId) {
// Get all room members
const members = await db_1.db.query.roomMembers.findMany({
where: (0, drizzle_orm_1.eq)(db_1.schema.roomMembers.roomId, roomId),
});
// Fetch active players for each member (respects isActive flag)
const playerMap = new Map();
for (const member of members) {
const players = await getActivePlayers(member.userId);
playerMap.set(member.userId, players);
}
return playerMap;
}
/**
* Get all player IDs that should participate in a room game
* Flattens the player lists from all room members
*/
async function getRoomPlayerIds(roomId) {
const playerMap = await getRoomActivePlayers(roomId);
const allPlayers = [];
for (const players of playerMap.values()) {
allPlayers.push(...players.map((p) => p.id));
}
return allPlayers;
}
/**
* Validate that a player ID belongs to a user who is a member of a room
*/
async function validatePlayerInRoom(playerId, roomId) {
// Get the player
const player = await db_1.db.query.players.findFirst({
where: (0, drizzle_orm_1.eq)(db_1.schema.players.id, playerId),
});
if (!player)
return false;
// Check if the player's user is a member of the room
const member = await db_1.db.query.roomMembers.findFirst({
where: (0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(db_1.schema.roomMembers.roomId, roomId), (0, drizzle_orm_1.eq)(db_1.schema.roomMembers.userId, player.userId)),
});
return !!member;
}
/**
* Get player details by ID
*/
async function getPlayer(playerId) {
return await db_1.db.query.players.findFirst({
where: (0, drizzle_orm_1.eq)(db_1.schema.players.id, playerId),
});
}
/**
* Get multiple players by IDs
*/
async function getPlayers(playerIds) {
if (playerIds.length === 0)
return [];
const players = [];
for (const id of playerIds) {
const player = await getPlayer(id);
if (player)
players.push(player);
}
return players;
}

View File

@@ -0,0 +1,225 @@
/**
* Player Ownership Utilities
*
* Centralized module for determining player ownership across the codebase.
* Provides consistent utilities for both server-side (DB-based) and client-side
* (RoomData-based) player ownership checking.
*
* This module solves the problem of scattered player ownership logic that
* previously existed in 4+ locations with different implementations.
*/
import { eq } from 'drizzle-orm'
import { db, schema } from '@/db'
import type { RoomData } from '@/hooks/useRoomData'
/**
* Player ownership mapping: playerId -> userId
*
* This is the canonical representation of player ownership used throughout
* the application. Both server-side and client-side utilities return this type.
*/
export type PlayerOwnershipMap = Record<string, string>
/**
* Player metadata with ownership information
*
* Used when building player metadata for game state that needs to be
* shared across room members.
*/
export interface PlayerMetadata {
id: string
name: string
emoji: string
userId: string // Owner's user ID
color: string
}
/**
* SERVER-SIDE: Build player ownership map from database
*
* Queries the database to get all players and their owner userIds.
* Used by session-manager and validators for authorization checks.
*
* @param roomId - Optional room ID to filter players (currently unused, fetches all)
* @returns Promise resolving to playerOwnership map
*
* @example
* const ownership = await buildPlayerOwnershipMap()
* // { "player-uuid-1": "user-uuid-1", "player-uuid-2": "user-uuid-2" }
*/
export async function buildPlayerOwnershipMap(roomId?: string): Promise<PlayerOwnershipMap> {
// Fetch all players with their userId ownership
const players = await db.query.players.findMany({
columns: {
id: true,
userId: true,
},
})
// Convert to ownership map: playerId -> userId
return Object.fromEntries(players.map((p) => [p.id, p.userId]))
}
/**
* CLIENT-SIDE: Build player ownership map from RoomData
*
* Constructs ownership map from the memberPlayers structure in RoomData.
* Used by React components and providers for client-side ownership checks.
*
* @param roomData - Room data containing memberPlayers mapping
* @returns PlayerOwnershipMap
*
* @example
* const ownership = buildPlayerOwnershipFromRoomData(roomData)
* // { "player-uuid-1": "user-uuid-1", "player-uuid-2": "user-uuid-2" }
*/
export function buildPlayerOwnershipFromRoomData(
roomData: RoomData | null | undefined
): PlayerOwnershipMap {
if (!roomData?.memberPlayers) {
return {}
}
const ownershipMap: PlayerOwnershipMap = {}
// memberPlayers is Record<userId, RoomPlayer[]>
// We need to invert it to Record<playerId, userId>
for (const [userId, userPlayers] of Object.entries(roomData.memberPlayers)) {
for (const player of userPlayers) {
ownershipMap[player.id] = userId
}
}
return ownershipMap
}
/**
* Check if a player is owned by a specific user
*
* @param playerId - The player ID to check
* @param userId - The user ID to check ownership against
* @param ownershipMap - Player ownership mapping
* @returns true if the player belongs to the user
*
* @example
* const isOwned = isPlayerOwnedByUser(playerId, currentUserId, ownershipMap)
* if (!isOwned) {
* return { valid: false, error: 'Not your player' }
* }
*/
export function isPlayerOwnedByUser(
playerId: string,
userId: string,
ownershipMap: PlayerOwnershipMap
): boolean {
return ownershipMap[playerId] === userId
}
/**
* Get the owner userId for a player
*
* @param playerId - The player ID to look up
* @param ownershipMap - Player ownership mapping
* @returns The owner's userId, or undefined if not found
*
* @example
* const owner = getPlayerOwner(playerId, ownershipMap)
* if (owner !== currentUserId) {
* console.log('This player belongs to another user')
* }
*/
export function getPlayerOwner(
playerId: string,
ownershipMap: PlayerOwnershipMap
): string | undefined {
return ownershipMap[playerId]
}
/**
* Build player metadata with correct ownership information
*
* Combines player data with ownership information to create complete
* metadata objects. This is used when starting games or sending player
* info across the network.
*
* @param playerIds - Array of player IDs to include
* @param ownershipMap - Player ownership mapping
* @param playersMap - Map of player ID to player data (from GameModeContext)
* @param fallbackUserId - UserId to use if player not found in ownership map
* @returns Record of playerId to PlayerMetadata
*
* @example
* const metadata = buildPlayerMetadata(
* activePlayers,
* ownershipMap,
* players,
* viewerId
* )
* // Send metadata with game state
*/
export function buildPlayerMetadata(
playerIds: string[],
ownershipMap: PlayerOwnershipMap,
playersMap: Map<string, { name: string; emoji: string; color: string }>,
fallbackUserId?: string
): Record<string, PlayerMetadata> {
const metadata: Record<string, PlayerMetadata> = {}
for (const playerId of playerIds) {
const playerData = playersMap.get(playerId)
if (!playerData) continue
// Get the actual owner userId from ownership map, or use fallback
const ownerUserId = ownershipMap[playerId] || fallbackUserId || ''
metadata[playerId] = {
id: playerId,
name: playerData.name,
emoji: playerData.emoji,
userId: ownerUserId,
color: playerData.color,
}
}
return metadata
}
/**
* Check if it's a specific user's turn in a game
*
* Convenience function that combines current player check with ownership check.
*
* @param currentPlayerId - The ID of the player whose turn it is
* @param userId - The user ID to check
* @param ownershipMap - Player ownership mapping
* @returns true if it's this user's turn
*
* @example
* const isMyTurn = isUsersTurn(state.currentPlayer, viewerId, ownershipMap)
* const label = isMyTurn ? 'Your turn' : 'Their turn'
*/
export function isUsersTurn(
currentPlayerId: string,
userId: string,
ownershipMap: PlayerOwnershipMap
): boolean {
return isPlayerOwnedByUser(currentPlayerId, userId, ownershipMap)
}
/**
* SERVER-SIDE: Convert guestId to internal userId
*
* Helper to convert the guestId (from cookies) to the internal database userId.
* This is needed because the database uses internal user.id as foreign keys.
*
* @param guestId - The guest ID from the cookie
* @returns The internal user ID, or undefined if not found
*/
export async function getUserIdFromGuestId(guestId: string): Promise<string | undefined> {
const user = await db.query.users.findFirst({
where: eq(schema.users.guestId, guestId),
columns: { id: true },
})
return user?.id
}

View File

@@ -0,0 +1,37 @@
"use strict";
/**
* Room code generation utility
* Generates short, memorable codes for joining rooms
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.generateRoomCode = generateRoomCode;
exports.isValidRoomCode = isValidRoomCode;
exports.normalizeRoomCode = normalizeRoomCode;
const CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // Removed ambiguous chars: 0,O,1,I
const CODE_LENGTH = 6;
/**
* Generate a random 6-character room code
* Format: ABC123 (uppercase letters + numbers, no ambiguous chars)
*/
function generateRoomCode() {
let code = '';
for (let i = 0; i < CODE_LENGTH; i++) {
const randomIndex = Math.floor(Math.random() * CHARS.length);
code += CHARS[randomIndex];
}
return code;
}
/**
* Validate a room code format
*/
function isValidRoomCode(code) {
if (code.length !== CODE_LENGTH)
return false;
return code.split('').every((char) => CHARS.includes(char));
}
/**
* Normalize a room code (uppercase, remove spaces/dashes)
*/
function normalizeRoomCode(code) {
return code.toUpperCase().replace(/[\s-]/g, '');
}

View File

@@ -0,0 +1,154 @@
"use strict";
/**
* Arcade room manager
* Handles database operations for arcade rooms
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.createRoom = createRoom;
exports.getRoomById = getRoomById;
exports.getRoomByCode = getRoomByCode;
exports.updateRoom = updateRoom;
exports.touchRoom = touchRoom;
exports.deleteRoom = deleteRoom;
exports.listActiveRooms = listActiveRooms;
exports.cleanupExpiredRooms = cleanupExpiredRooms;
exports.isRoomCreator = isRoomCreator;
const drizzle_orm_1 = require("drizzle-orm");
const db_1 = require("../../db");
const room_code_1 = require("./room-code");
/**
* Create a new arcade room
* Generates a unique room code and creates the room in the database
*/
async function createRoom(options) {
const now = new Date();
// Generate unique room code (retry up to 5 times if collision)
let code = (0, room_code_1.generateRoomCode)();
let attempts = 0;
const MAX_ATTEMPTS = 5;
while (attempts < MAX_ATTEMPTS) {
const existing = await getRoomByCode(code);
if (!existing)
break;
code = (0, room_code_1.generateRoomCode)();
attempts++;
}
if (attempts === MAX_ATTEMPTS) {
throw new Error('Failed to generate unique room code');
}
const newRoom = {
code,
name: options.name,
createdBy: options.createdBy,
creatorName: options.creatorName,
createdAt: now,
lastActivity: now,
ttlMinutes: options.ttlMinutes || 60,
isLocked: false,
gameName: options.gameName,
gameConfig: options.gameConfig,
status: 'lobby',
currentSessionId: null,
totalGamesPlayed: 0,
};
const [room] = await db_1.db.insert(db_1.schema.arcadeRooms).values(newRoom).returning();
console.log('[Room Manager] Created room:', room.id, 'code:', room.code);
return room;
}
/**
* Get a room by ID
*/
async function getRoomById(roomId) {
return await db_1.db.query.arcadeRooms.findFirst({
where: (0, drizzle_orm_1.eq)(db_1.schema.arcadeRooms.id, roomId),
});
}
/**
* Get a room by code
*/
async function getRoomByCode(code) {
return await db_1.db.query.arcadeRooms.findFirst({
where: (0, drizzle_orm_1.eq)(db_1.schema.arcadeRooms.code, code.toUpperCase()),
});
}
/**
* Update a room
*/
async function updateRoom(roomId, updates) {
const now = new Date();
// Always update lastActivity on any room update
const updateData = {
...updates,
lastActivity: now,
};
const [updated] = await db_1.db
.update(db_1.schema.arcadeRooms)
.set(updateData)
.where((0, drizzle_orm_1.eq)(db_1.schema.arcadeRooms.id, roomId))
.returning();
return updated;
}
/**
* Update room activity timestamp
* Call this on any room activity to refresh TTL
*/
async function touchRoom(roomId) {
await db_1.db
.update(db_1.schema.arcadeRooms)
.set({ lastActivity: new Date() })
.where((0, drizzle_orm_1.eq)(db_1.schema.arcadeRooms.id, roomId));
}
/**
* Delete a room
* Cascade deletes all room members
*/
async function deleteRoom(roomId) {
await db_1.db.delete(db_1.schema.arcadeRooms).where((0, drizzle_orm_1.eq)(db_1.schema.arcadeRooms.id, roomId));
console.log('[Room Manager] Deleted room:', roomId);
}
/**
* List active rooms
* Returns rooms ordered by most recently active
*/
async function listActiveRooms(gameName) {
const whereConditions = [];
// Filter by game if specified
if (gameName) {
whereConditions.push((0, drizzle_orm_1.eq)(db_1.schema.arcadeRooms.gameName, gameName));
}
// Only return non-locked rooms in lobby or playing status
whereConditions.push((0, drizzle_orm_1.eq)(db_1.schema.arcadeRooms.isLocked, false), (0, drizzle_orm_1.or)((0, drizzle_orm_1.eq)(db_1.schema.arcadeRooms.status, 'lobby'), (0, drizzle_orm_1.eq)(db_1.schema.arcadeRooms.status, 'playing')));
return await db_1.db.query.arcadeRooms.findMany({
where: whereConditions.length > 0 ? (0, drizzle_orm_1.and)(...whereConditions) : undefined,
orderBy: [(0, drizzle_orm_1.desc)(db_1.schema.arcadeRooms.lastActivity)],
limit: 50, // Limit to 50 most recent rooms
});
}
/**
* Clean up expired rooms
* Delete rooms that have exceeded their TTL
*/
async function cleanupExpiredRooms() {
const now = new Date();
// Find rooms where lastActivity + ttlMinutes < now
const expiredRooms = await db_1.db.query.arcadeRooms.findMany({
columns: { id: true, ttlMinutes: true, lastActivity: true },
});
const toDelete = expiredRooms.filter((room) => {
const expiresAt = new Date(room.lastActivity.getTime() + room.ttlMinutes * 60 * 1000);
return expiresAt < now;
});
if (toDelete.length > 0) {
const ids = toDelete.map((r) => r.id);
await db_1.db.delete(db_1.schema.arcadeRooms).where((0, drizzle_orm_1.or)(...ids.map((id) => (0, drizzle_orm_1.eq)(db_1.schema.arcadeRooms.id, id))));
console.log(`[Room Manager] Cleaned up ${toDelete.length} expired rooms`);
}
return toDelete.length;
}
/**
* Check if a user is the creator of a room
*/
async function isRoomCreator(roomId, userId) {
const room = await getRoomById(roomId);
return room?.createdBy === userId;
}

View File

@@ -0,0 +1,179 @@
"use strict";
/**
* Room membership manager
* Handles database operations for room members
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.addRoomMember = addRoomMember;
exports.getRoomMember = getRoomMember;
exports.getRoomMembers = getRoomMembers;
exports.getOnlineRoomMembers = getOnlineRoomMembers;
exports.setMemberOnline = setMemberOnline;
exports.touchMember = touchMember;
exports.removeMember = removeMember;
exports.removeAllMembers = removeAllMembers;
exports.getOnlineMemberCount = getOnlineMemberCount;
exports.isMember = isMember;
exports.getUserRooms = getUserRooms;
const drizzle_orm_1 = require("drizzle-orm");
const db_1 = require("../../db");
/**
* Add a member to a room
* Automatically removes user from any other rooms they're in (modal room enforcement)
* Returns the new membership and info about rooms that were auto-left
*/
async function addRoomMember(options) {
const now = new Date();
// Check if member already exists in THIS room
const existing = await getRoomMember(options.roomId, options.userId);
if (existing) {
// Already in this room - just update status (no auto-leave needed)
const [updated] = await db_1.db
.update(db_1.schema.roomMembers)
.set({
isOnline: true,
lastSeen: now,
})
.where((0, drizzle_orm_1.eq)(db_1.schema.roomMembers.id, existing.id))
.returning();
return { member: updated };
}
// AUTO-LEAVE LOGIC: Remove from all other rooms before joining this one
const currentRooms = await getUserRooms(options.userId);
const autoLeaveResult = {
leftRooms: [],
previousRoomMembers: [],
};
for (const roomId of currentRooms) {
if (roomId !== options.roomId) {
// Get member info before removing (for socket events)
const memberToRemove = await getRoomMember(roomId, options.userId);
if (memberToRemove) {
autoLeaveResult.previousRoomMembers.push({
roomId,
member: memberToRemove,
});
}
// Remove from room
await removeMember(roomId, options.userId);
autoLeaveResult.leftRooms.push(roomId);
console.log(`[Room Membership] Auto-left room ${roomId} for user ${options.userId}`);
}
}
// Now add to new room
const newMember = {
roomId: options.roomId,
userId: options.userId,
displayName: options.displayName,
isCreator: options.isCreator || false,
joinedAt: now,
lastSeen: now,
isOnline: true,
};
try {
const [member] = await db_1.db.insert(db_1.schema.roomMembers).values(newMember).returning();
console.log('[Room Membership] Added member:', member.userId, 'to room:', member.roomId);
return {
member,
autoLeaveResult: autoLeaveResult.leftRooms.length > 0 ? autoLeaveResult : undefined,
};
}
catch (error) {
// Handle unique constraint violation
// This should rarely happen due to auto-leave logic above, but catch it for safety
if (error.code === 'SQLITE_CONSTRAINT' ||
error.message?.includes('UNIQUE') ||
error.message?.includes('unique')) {
console.error('[Room Membership] Unique constraint violation:', error.message);
throw new Error('ROOM_MEMBERSHIP_CONFLICT: User is already in another room. This should have been handled by auto-leave logic.');
}
throw error;
}
}
/**
* Get a specific room member
*/
async function getRoomMember(roomId, userId) {
return await db_1.db.query.roomMembers.findFirst({
where: (0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(db_1.schema.roomMembers.roomId, roomId), (0, drizzle_orm_1.eq)(db_1.schema.roomMembers.userId, userId)),
});
}
/**
* Get all members in a room
*/
async function getRoomMembers(roomId) {
return await db_1.db.query.roomMembers.findMany({
where: (0, drizzle_orm_1.eq)(db_1.schema.roomMembers.roomId, roomId),
orderBy: db_1.schema.roomMembers.joinedAt,
});
}
/**
* Get online members in a room
*/
async function getOnlineRoomMembers(roomId) {
return await db_1.db.query.roomMembers.findMany({
where: (0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(db_1.schema.roomMembers.roomId, roomId), (0, drizzle_orm_1.eq)(db_1.schema.roomMembers.isOnline, true)),
orderBy: db_1.schema.roomMembers.joinedAt,
});
}
/**
* Update member's online status
*/
async function setMemberOnline(roomId, userId, isOnline) {
await db_1.db
.update(db_1.schema.roomMembers)
.set({
isOnline,
lastSeen: new Date(),
})
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(db_1.schema.roomMembers.roomId, roomId), (0, drizzle_orm_1.eq)(db_1.schema.roomMembers.userId, userId)));
}
/**
* Update member's last seen timestamp
*/
async function touchMember(roomId, userId) {
await db_1.db
.update(db_1.schema.roomMembers)
.set({ lastSeen: new Date() })
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(db_1.schema.roomMembers.roomId, roomId), (0, drizzle_orm_1.eq)(db_1.schema.roomMembers.userId, userId)));
}
/**
* Remove a member from a room
*/
async function removeMember(roomId, userId) {
await db_1.db
.delete(db_1.schema.roomMembers)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(db_1.schema.roomMembers.roomId, roomId), (0, drizzle_orm_1.eq)(db_1.schema.roomMembers.userId, userId)));
console.log('[Room Membership] Removed member:', userId, 'from room:', roomId);
}
/**
* Remove all members from a room
*/
async function removeAllMembers(roomId) {
await db_1.db.delete(db_1.schema.roomMembers).where((0, drizzle_orm_1.eq)(db_1.schema.roomMembers.roomId, roomId));
console.log('[Room Membership] Removed all members from room:', roomId);
}
/**
* Get count of online members in a room
*/
async function getOnlineMemberCount(roomId) {
const members = await getOnlineRoomMembers(roomId);
return members.length;
}
/**
* Check if a user is a member of a room
*/
async function isMember(roomId, userId) {
const member = await getRoomMember(roomId, userId);
return !!member;
}
/**
* Get all rooms a user is a member of
*/
async function getUserRooms(userId) {
const memberships = await db_1.db.query.roomMembers.findMany({
where: (0, drizzle_orm_1.eq)(db_1.schema.roomMembers.userId, userId),
columns: { roomId: true },
});
return memberships.map((m) => m.roomId);
}

View File

@@ -0,0 +1,55 @@
"use strict";
/**
* Room TTL Cleanup Scheduler
* Periodically cleans up expired rooms
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.startRoomTTLCleanup = startRoomTTLCleanup;
exports.stopRoomTTLCleanup = stopRoomTTLCleanup;
const room_manager_1 = require("./room-manager");
// Cleanup interval: run every 5 minutes
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
let cleanupInterval = null;
/**
* Start the TTL cleanup scheduler
* Runs cleanup every 5 minutes
*/
function startRoomTTLCleanup() {
if (cleanupInterval) {
console.log('[Room TTL] Cleanup scheduler already running');
return;
}
console.log('[Room TTL] Starting cleanup scheduler (every 5 minutes)');
// Run immediately on start
(0, room_manager_1.cleanupExpiredRooms)()
.then((count) => {
if (count > 0) {
console.log(`[Room TTL] Initial cleanup removed ${count} expired rooms`);
}
})
.catch((error) => {
console.error('[Room TTL] Initial cleanup failed:', error);
});
// Then run periodically
cleanupInterval = setInterval(async () => {
try {
const count = await (0, room_manager_1.cleanupExpiredRooms)();
if (count > 0) {
console.log(`[Room TTL] Cleanup removed ${count} expired rooms`);
}
}
catch (error) {
console.error('[Room TTL] Cleanup failed:', error);
}
}, CLEANUP_INTERVAL_MS);
}
/**
* Stop the TTL cleanup scheduler
*/
function stopRoomTTLCleanup() {
if (cleanupInterval) {
clearInterval(cleanupInterval);
cleanupInterval = null;
console.log('[Room TTL] Cleanup scheduler stopped');
}
}

View File

@@ -0,0 +1,296 @@
"use strict";
/**
* Arcade session manager
* Handles database operations and validation for arcade sessions
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.getArcadeSessionByRoom = getArcadeSessionByRoom;
exports.createArcadeSession = createArcadeSession;
exports.getArcadeSession = getArcadeSession;
exports.applyGameMove = applyGameMove;
exports.deleteArcadeSession = deleteArcadeSession;
exports.updateSessionActivity = updateSessionActivity;
exports.cleanupExpiredSessions = cleanupExpiredSessions;
const drizzle_orm_1 = require("drizzle-orm");
const db_1 = require("../../db");
const validation_1 = require("./validation");
const TTL_HOURS = 24;
/**
* Helper: Get database user ID from guest ID
* The API uses guestId (from cookies) but database FKs use the internal user.id
*/
async function getUserIdFromGuestId(guestId) {
const user = await db_1.db.query.users.findFirst({
where: (0, drizzle_orm_1.eq)(db_1.schema.users.guestId, guestId),
columns: { id: true },
});
return user?.id;
}
/**
* Get arcade session by room ID (for room-based multiplayer games)
* Returns the shared session for all room members
* @param roomId - The room ID
*/
async function getArcadeSessionByRoom(roomId) {
const [session] = await db_1.db
.select()
.from(db_1.schema.arcadeSessions)
.where((0, drizzle_orm_1.eq)(db_1.schema.arcadeSessions.roomId, roomId))
.limit(1);
if (!session)
return undefined;
// Check if session has expired
if (session.expiresAt < new Date()) {
// Clean up expired room session
await db_1.db.delete(db_1.schema.arcadeSessions).where((0, drizzle_orm_1.eq)(db_1.schema.arcadeSessions.roomId, roomId));
return undefined;
}
return session;
}
/**
* Create a new arcade session
* For room-based games, checks if a session already exists for the room
*/
async function createArcadeSession(options) {
const now = new Date();
const expiresAt = new Date(now.getTime() + TTL_HOURS * 60 * 60 * 1000);
// For room-based games, check if session already exists for this room
if (options.roomId) {
const existingRoomSession = await getArcadeSessionByRoom(options.roomId);
if (existingRoomSession) {
console.log('[Session Manager] Room session already exists, returning existing:', {
roomId: options.roomId,
sessionUserId: existingRoomSession.userId,
version: existingRoomSession.version,
});
return existingRoomSession;
}
}
// Find or create user by guest ID
let user = await db_1.db.query.users.findFirst({
where: (0, drizzle_orm_1.eq)(db_1.schema.users.guestId, options.userId),
});
if (!user) {
console.log('[Session Manager] Creating new user with guestId:', options.userId);
const [newUser] = await db_1.db
.insert(db_1.schema.users)
.values({
guestId: options.userId, // Let id auto-generate via $defaultFn
createdAt: now,
})
.returning();
user = newUser;
console.log('[Session Manager] Created user with id:', user.id);
}
else {
console.log('[Session Manager] Found existing user with id:', user.id);
}
const newSession = {
userId: user.id, // Use the actual database ID, not the guestId
currentGame: options.gameName,
gameUrl: options.gameUrl,
gameState: options.initialState,
activePlayers: options.activePlayers,
roomId: options.roomId, // Associate session with room
startedAt: now,
lastActivityAt: now,
expiresAt,
isActive: true,
version: 1,
};
console.log('[Session Manager] Creating new session:', {
userId: user.id,
roomId: options.roomId,
gameName: options.gameName,
});
const [session] = await db_1.db.insert(db_1.schema.arcadeSessions).values(newSession).returning();
return session;
}
/**
* Get active arcade session for a user
* @param guestId - The guest ID from the cookie (not the database user.id)
*/
async function getArcadeSession(guestId) {
const userId = await getUserIdFromGuestId(guestId);
if (!userId)
return undefined;
const [session] = await db_1.db
.select()
.from(db_1.schema.arcadeSessions)
.where((0, drizzle_orm_1.eq)(db_1.schema.arcadeSessions.userId, userId))
.limit(1);
if (!session)
return undefined;
// Check if session has expired
if (session.expiresAt < new Date()) {
await deleteArcadeSession(guestId);
return undefined;
}
// Check if session has a valid room association
// Sessions without rooms are orphaned and should be cleaned up
if (!session.roomId) {
console.log('[Session Manager] Deleting orphaned session without room:', session.userId);
await deleteArcadeSession(guestId);
return undefined;
}
// Verify the room still exists
const room = await db_1.db.query.arcadeRooms.findFirst({
where: (0, drizzle_orm_1.eq)(db_1.schema.arcadeRooms.id, session.roomId),
});
if (!room) {
console.log('[Session Manager] Deleting session with non-existent room:', session.roomId);
await deleteArcadeSession(guestId);
return undefined;
}
return session;
}
/**
* Apply a game move to the session (with validation)
* @param userId - The guest ID from the cookie
* @param move - The game move to apply
* @param roomId - Optional room ID for room-based games (enables shared session)
*/
async function applyGameMove(userId, move, roomId) {
// For room-based games, look up the shared room session
// For solo games, look up the user's personal session
const session = roomId ? await getArcadeSessionByRoom(roomId) : await getArcadeSession(userId);
if (!session) {
return {
success: false,
error: 'No active session found',
};
}
if (!session.isActive) {
return {
success: false,
error: 'Session is not active',
};
}
// Get the validator for this game
const validator = (0, validation_1.getValidator)(session.currentGame);
console.log('[SessionManager] About to validate move:', {
moveType: move.type,
playerId: move.playerId,
gameStateCurrentPlayer: session.gameState?.currentPlayer,
gameStateActivePlayers: session.gameState?.activePlayers,
gameStatePhase: session.gameState?.gamePhase,
});
// Fetch player ownership for authorization checks (room-based games)
let playerOwnership;
let internalUserId;
if (session.roomId) {
try {
// Convert guestId to internal userId for ownership comparison
internalUserId = await getUserIdFromGuestId(userId);
if (!internalUserId) {
console.error('[SessionManager] Failed to convert guestId to userId:', userId);
return {
success: false,
error: 'User not found',
};
}
const players = await db_1.db.query.players.findMany({
columns: {
id: true,
userId: true,
},
});
playerOwnership = Object.fromEntries(players.map((p) => [p.id, p.userId]));
console.log('[SessionManager] Player ownership map:', playerOwnership);
console.log('[SessionManager] Internal userId for authorization:', internalUserId);
}
catch (error) {
console.error('[SessionManager] Failed to fetch player ownership:', error);
}
}
// Validate the move with authorization context (use internal userId, not guestId)
const validationResult = validator.validateMove(session.gameState, move, {
userId: internalUserId || userId, // Use internal userId for room-based games
playerOwnership,
});
console.log('[SessionManager] Validation result:', {
valid: validationResult.valid,
error: validationResult.error,
});
if (!validationResult.valid) {
return {
success: false,
error: validationResult.error || 'Invalid move',
};
}
// Update the session with new state (using optimistic locking)
const now = new Date();
const expiresAt = new Date(now.getTime() + TTL_HOURS * 60 * 60 * 1000);
try {
const [updatedSession] = await db_1.db
.update(db_1.schema.arcadeSessions)
.set({
gameState: validationResult.newState,
lastActivityAt: now,
expiresAt,
version: session.version + 1,
})
.where((0, drizzle_orm_1.eq)(db_1.schema.arcadeSessions.userId, session.userId) // Use the userId from the session we just fetched
)
// Version check for optimistic locking would go here
// SQLite doesn't support WHERE clauses in UPDATE with RETURNING easily
// We'll handle this by checking the version after
.returning();
if (!updatedSession) {
return {
success: false,
error: 'Failed to update session',
};
}
return {
success: true,
session: updatedSession,
};
}
catch (error) {
console.error('Error updating session:', error);
return {
success: false,
error: 'Database error',
};
}
}
/**
* Delete an arcade session
* @param guestId - The guest ID from the cookie (not the database user.id)
*/
async function deleteArcadeSession(guestId) {
const userId = await getUserIdFromGuestId(guestId);
if (!userId)
return;
await db_1.db.delete(db_1.schema.arcadeSessions).where((0, drizzle_orm_1.eq)(db_1.schema.arcadeSessions.userId, userId));
}
/**
* Update session activity timestamp (keep-alive)
* @param guestId - The guest ID from the cookie (not the database user.id)
*/
async function updateSessionActivity(guestId) {
const userId = await getUserIdFromGuestId(guestId);
if (!userId)
return;
const now = new Date();
const expiresAt = new Date(now.getTime() + TTL_HOURS * 60 * 60 * 1000);
await db_1.db
.update(db_1.schema.arcadeSessions)
.set({
lastActivityAt: now,
expiresAt,
})
.where((0, drizzle_orm_1.eq)(db_1.schema.arcadeSessions.userId, userId));
}
/**
* Clean up expired sessions (should be called periodically)
*/
async function cleanupExpiredSessions() {
const now = new Date();
const result = await db_1.db
.delete(db_1.schema.arcadeSessions)
.where((0, drizzle_orm_1.eq)(db_1.schema.arcadeSessions.expiresAt, now))
.returning();
return result.length;
}

View File

@@ -0,0 +1,469 @@
"use strict";
/**
* Server-side validator for matching game
* Validates all game moves and state transitions
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.matchingGameValidator = exports.MatchingGameValidator = void 0;
const cardGeneration_1 = require("../../../app/games/matching/utils/cardGeneration");
const matchValidation_1 = require("../../../app/games/matching/utils/matchValidation");
class MatchingGameValidator {
validateMove(state, move, context) {
switch (move.type) {
case 'FLIP_CARD':
return this.validateFlipCard(state, move.data.cardId, move.playerId, context);
case 'START_GAME':
return this.validateStartGame(state, move.data.activePlayers, move.data.cards, move.data.playerMetadata);
case 'CLEAR_MISMATCH':
return this.validateClearMismatch(state);
case 'GO_TO_SETUP':
return this.validateGoToSetup(state);
case 'SET_CONFIG':
return this.validateSetConfig(state, move.data.field, move.data.value);
case 'RESUME_GAME':
return this.validateResumeGame(state);
case 'HOVER_CARD':
return this.validateHoverCard(state, move.data.cardId, move.playerId);
default:
return {
valid: false,
error: `Unknown move type: ${move.type}`,
};
}
}
validateFlipCard(state, cardId, playerId, context) {
// Game must be in playing phase
if (state.gamePhase !== 'playing') {
return {
valid: false,
error: 'Cannot flip cards outside of playing phase',
};
}
// Check if it's the player's turn (in multiplayer)
if (state.activePlayers.length > 1 && state.currentPlayer !== playerId) {
console.log('[Validator] Turn check failed:', {
activePlayers: state.activePlayers,
currentPlayer: state.currentPlayer,
currentPlayerType: typeof state.currentPlayer,
playerId,
playerIdType: typeof playerId,
matches: state.currentPlayer === playerId,
});
return {
valid: false,
error: 'Not your turn',
};
}
// Check player ownership authorization (if context provided)
if (context?.userId && context?.playerOwnership) {
const playerOwner = context.playerOwnership[playerId];
if (playerOwner && playerOwner !== context.userId) {
console.log('[Validator] Player ownership check failed:', {
playerId,
playerOwner,
requestingUserId: context.userId,
});
return {
valid: false,
error: 'You can only move your own players',
};
}
}
// Find the card
const card = state.gameCards.find((c) => c.id === cardId);
if (!card) {
return {
valid: false,
error: 'Card not found',
};
}
// Validate using existing game logic
if (!(0, matchValidation_1.canFlipCard)(card, state.flippedCards, state.isProcessingMove)) {
return {
valid: false,
error: 'Cannot flip this card',
};
}
// Calculate new state
const newFlippedCards = [...state.flippedCards, card];
let newState = {
...state,
flippedCards: newFlippedCards,
isProcessingMove: newFlippedCards.length === 2,
// Clear mismatch feedback when player flips a new card
showMismatchFeedback: false,
};
// If two cards are flipped, check for match
if (newFlippedCards.length === 2) {
const [card1, card2] = newFlippedCards;
const matchResult = (0, matchValidation_1.validateMatch)(card1, card2);
if (matchResult.isValid) {
// Match found - update cards
newState = {
...newState,
gameCards: newState.gameCards.map((c) => c.id === card1.id || c.id === card2.id
? { ...c, matched: true, matchedBy: state.currentPlayer }
: c),
matchedPairs: state.matchedPairs + 1,
scores: {
...state.scores,
[state.currentPlayer]: (state.scores[state.currentPlayer] || 0) + 1,
},
consecutiveMatches: {
...state.consecutiveMatches,
[state.currentPlayer]: (state.consecutiveMatches[state.currentPlayer] || 0) + 1,
},
moves: state.moves + 1,
flippedCards: [],
isProcessingMove: false,
};
// Check if game is complete
if (newState.matchedPairs === newState.totalPairs) {
newState = {
...newState,
gamePhase: 'results',
gameEndTime: Date.now(),
};
}
}
else {
// Match failed - keep cards flipped briefly so player can see them
// Client will handle clearing them after a delay
const shouldSwitchPlayer = state.activePlayers.length > 1;
const nextPlayerIndex = shouldSwitchPlayer
? (state.activePlayers.indexOf(state.currentPlayer) + 1) % state.activePlayers.length
: 0;
const nextPlayer = shouldSwitchPlayer
? state.activePlayers[nextPlayerIndex]
: state.currentPlayer;
newState = {
...newState,
currentPlayer: nextPlayer,
consecutiveMatches: {
...state.consecutiveMatches,
[state.currentPlayer]: 0,
},
moves: state.moves + 1,
// Keep flippedCards so player can see both cards
flippedCards: newFlippedCards,
isProcessingMove: true, // Keep processing state so no more cards can be flipped
showMismatchFeedback: true,
};
}
}
return {
valid: true,
newState,
};
}
validateStartGame(state, activePlayers, cards, playerMetadata) {
// Allow starting a new game from any phase (for "New Game" button)
// Must have at least one player
if (!activePlayers || activePlayers.length === 0) {
return {
valid: false,
error: 'Must have at least one player',
};
}
// Use provided cards or generate new ones
const gameCards = cards || (0, cardGeneration_1.generateGameCards)(state.gameType, state.difficulty);
const newState = {
...state,
gameCards,
cards: gameCards,
activePlayers,
playerMetadata: playerMetadata || {}, // Store player metadata for cross-user visibility
gamePhase: 'playing',
gameStartTime: Date.now(),
currentPlayer: activePlayers[0],
flippedCards: [],
matchedPairs: 0,
moves: 0,
scores: activePlayers.reduce((acc, p) => ({ ...acc, [p]: 0 }), {}),
consecutiveMatches: activePlayers.reduce((acc, p) => ({ ...acc, [p]: 0 }), {}),
// PAUSE/RESUME: Save original config so we can detect changes
originalConfig: {
gameType: state.gameType,
difficulty: state.difficulty,
turnTimer: state.turnTimer,
},
// Clear any paused game state (starting fresh)
pausedGamePhase: undefined,
pausedGameState: undefined,
};
return {
valid: true,
newState,
};
}
validateClearMismatch(state) {
// Only clear if there's actually a mismatch showing
// This prevents race conditions where CLEAR_MISMATCH arrives after cards have already been cleared
if (!state.showMismatchFeedback || state.flippedCards.length === 0) {
// Nothing to clear - return current state unchanged
return {
valid: true,
newState: state,
};
}
// Clear mismatched cards and feedback
return {
valid: true,
newState: {
...state,
flippedCards: [],
showMismatchFeedback: false,
isProcessingMove: false,
},
};
}
/**
* STANDARD ARCADE PATTERN: GO_TO_SETUP
*
* Transitions the game back to setup phase, allowing players to reconfigure
* the game. This is synchronized across all room members.
*
* Can be called from any phase (setup, playing, results).
*
* PAUSE/RESUME: If called from 'playing' or 'results', saves game state
* to allow resuming later (if config unchanged).
*
* Pattern for all arcade games:
* - Validates the move is allowed
* - Sets gamePhase to 'setup'
* - Preserves current configuration (gameType, difficulty, etc.)
* - Saves game state for resume if coming from active game
* - Resets game progression state (scores, cards, etc.)
*/
validateGoToSetup(state) {
// Determine if we're pausing an active game (for Resume functionality)
const isPausingGame = state.gamePhase === 'playing' || state.gamePhase === 'results';
return {
valid: true,
newState: {
...state,
gamePhase: 'setup',
// Pause/Resume: Save game state if pausing from active game
pausedGamePhase: isPausingGame ? state.gamePhase : undefined,
pausedGameState: isPausingGame
? {
gameCards: state.gameCards,
currentPlayer: state.currentPlayer,
matchedPairs: state.matchedPairs,
moves: state.moves,
scores: state.scores,
activePlayers: state.activePlayers,
playerMetadata: state.playerMetadata,
consecutiveMatches: state.consecutiveMatches,
gameStartTime: state.gameStartTime,
}
: undefined,
// Keep originalConfig if it exists (was set when game started)
// This allows detecting if config changed while paused
// Reset visible game progression
gameCards: [],
cards: [],
flippedCards: [],
currentPlayer: '',
matchedPairs: 0,
moves: 0,
scores: {},
activePlayers: [],
playerMetadata: {},
consecutiveMatches: {},
gameStartTime: null,
gameEndTime: null,
currentMoveStartTime: null,
celebrationAnimations: [],
isProcessingMove: false,
showMismatchFeedback: false,
lastMatchedPair: null,
// Preserve configuration - players can modify in setup
// gameType, difficulty, turnTimer stay as-is
},
};
}
/**
* STANDARD ARCADE PATTERN: SET_CONFIG
*
* Updates a configuration field during setup phase. This is synchronized
* across all room members in real-time, allowing collaborative setup.
*
* Pattern for all arcade games:
* - Only allowed during setup phase
* - Validates field name and value
* - Updates the configuration field
* - Other room members see the change immediately (optimistic + server validation)
*
* @param state Current game state
* @param field Configuration field name
* @param value New value for the field
*/
validateSetConfig(state, field, value) {
// Can only change config during setup phase
if (state.gamePhase !== 'setup') {
return {
valid: false,
error: 'Cannot change configuration outside of setup phase',
};
}
// Validate field-specific values
switch (field) {
case 'gameType':
if (value !== 'abacus-numeral' && value !== 'complement-pairs') {
return { valid: false, error: `Invalid gameType: ${value}` };
}
break;
case 'difficulty':
if (![6, 8, 12, 15].includes(value)) {
return { valid: false, error: `Invalid difficulty: ${value}` };
}
break;
case 'turnTimer':
if (typeof value !== 'number' || value < 5 || value > 300) {
return { valid: false, error: `Invalid turnTimer: ${value}` };
}
break;
default:
return { valid: false, error: `Unknown config field: ${field}` };
}
// PAUSE/RESUME: If there's a paused game and config is changing,
// clear the paused game state (can't resume anymore)
const clearPausedGame = !!state.pausedGamePhase;
// Apply the configuration change
return {
valid: true,
newState: {
...state,
[field]: value,
// Update totalPairs if difficulty changes
...(field === 'difficulty' ? { totalPairs: value } : {}),
// Clear paused game if config changed
...(clearPausedGame
? { pausedGamePhase: undefined, pausedGameState: undefined, originalConfig: undefined }
: {}),
},
};
}
/**
* STANDARD ARCADE PATTERN: RESUME_GAME
*
* Resumes a paused game if configuration hasn't changed.
* Restores the saved game state from when GO_TO_SETUP was called.
*
* Pattern for all arcade games:
* - Validates there's a paused game
* - Validates config hasn't changed since pause
* - Restores game state and phase
* - Clears paused game state
*/
validateResumeGame(state) {
// Must be in setup phase
if (state.gamePhase !== 'setup') {
return {
valid: false,
error: 'Can only resume from setup phase',
};
}
// Must have a paused game
if (!state.pausedGamePhase || !state.pausedGameState) {
return {
valid: false,
error: 'No paused game to resume',
};
}
// Config must match original (no changes while paused)
if (state.originalConfig) {
const configChanged = state.gameType !== state.originalConfig.gameType ||
state.difficulty !== state.originalConfig.difficulty ||
state.turnTimer !== state.originalConfig.turnTimer;
if (configChanged) {
return {
valid: false,
error: 'Cannot resume - configuration has changed',
};
}
}
// Restore the paused game
return {
valid: true,
newState: {
...state,
gamePhase: state.pausedGamePhase,
gameCards: state.pausedGameState.gameCards,
cards: state.pausedGameState.gameCards,
currentPlayer: state.pausedGameState.currentPlayer,
matchedPairs: state.pausedGameState.matchedPairs,
moves: state.pausedGameState.moves,
scores: state.pausedGameState.scores,
activePlayers: state.pausedGameState.activePlayers,
playerMetadata: state.pausedGameState.playerMetadata,
consecutiveMatches: state.pausedGameState.consecutiveMatches,
gameStartTime: state.pausedGameState.gameStartTime,
// Clear paused state
pausedGamePhase: undefined,
pausedGameState: undefined,
// Keep originalConfig for potential future pauses
},
};
}
/**
* Validate hover state update for networked presence
*
* Hover moves are lightweight and always valid - they just update
* which card a player is hovering over for UI feedback to other players.
*/
validateHoverCard(state, cardId, playerId) {
// Hover is always valid - it's just UI state for networked presence
// Update the player's hover state
return {
valid: true,
newState: {
...state,
playerHovers: {
...state.playerHovers,
[playerId]: cardId,
},
},
};
}
isGameComplete(state) {
return state.gamePhase === 'results' || state.matchedPairs === state.totalPairs;
}
getInitialState(config) {
return {
cards: [],
gameCards: [],
flippedCards: [],
gameType: config.gameType,
difficulty: config.difficulty,
turnTimer: config.turnTimer,
gamePhase: 'setup',
currentPlayer: '',
matchedPairs: 0,
totalPairs: config.difficulty,
moves: 0,
scores: {},
activePlayers: [],
playerMetadata: {}, // Initialize empty player metadata
consecutiveMatches: {},
gameStartTime: null,
gameEndTime: null,
currentMoveStartTime: null,
timerInterval: null,
celebrationAnimations: [],
isProcessingMove: false,
showMismatchFeedback: false,
lastMatchedPair: null,
// PAUSE/RESUME: Initialize paused game fields
originalConfig: undefined,
pausedGamePhase: undefined,
pausedGameState: undefined,
// HOVER: Initialize hover state
playerHovers: {},
};
}
}
exports.MatchingGameValidator = MatchingGameValidator;
// Singleton instance
exports.matchingGameValidator = new MatchingGameValidator();

View File

@@ -0,0 +1,37 @@
"use strict";
/**
* Game validator registry
* Maps game names to their validators
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __exportStar = (this && this.__exportStar) || function(m, exports) {
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.matchingGameValidator = void 0;
exports.getValidator = getValidator;
const MatchingGameValidator_1 = require("./MatchingGameValidator");
const validators = new Map([
['matching', MatchingGameValidator_1.matchingGameValidator],
// Add other game validators here as they're implemented
]);
function getValidator(gameName) {
const validator = validators.get(gameName);
if (!validator) {
throw new Error(`No validator found for game: ${gameName}`);
}
return validator;
}
var MatchingGameValidator_2 = require("./MatchingGameValidator");
Object.defineProperty(exports, "matchingGameValidator", { enumerable: true, get: function () { return MatchingGameValidator_2.matchingGameValidator; } });
__exportStar(require("./types"), exports);

View File

@@ -0,0 +1,6 @@
"use strict";
/**
* Isomorphic game validation types
* Used on both client and server for arcade session validation
*/
Object.defineProperty(exports, "__esModule", { value: true });

View File

@@ -1,4 +1,4 @@
declare module '@/generated/build-info.json' {
declare module '../generated/build-info.json' {
interface BuildInfo {
version: string
buildTime: string

View File

@@ -0,0 +1,28 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "commonjs",
"target": "es2020",
"outDir": ".",
"rootDir": ".",
"noEmit": false,
"incremental": false,
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"moduleResolution": "node",
"types": ["node", "react"]
},
"include": [
"src/db/index.ts",
"src/db/schema.ts",
"src/db/migrate.ts",
"src/lib/arcade/**/*.ts",
"src/app/games/matching/context/types.ts",
"src/app/games/matching/utils/cardGeneration.ts",
"src/app/games/matching/utils/matchValidation.ts",
"socket-server.ts"
],
"exclude": ["node_modules", "**/*.test.ts", "**/*.test.tsx", "**/*.spec.ts"]
}

View File

@@ -1,6 +1,6 @@
{
"name": "soroban-monorepo",
"version": "2.12.3",
"version": "2.18.0",
"private": true,
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
"workspaces": [

44
pnpm-lock.yaml generated
View File

@@ -240,6 +240,9 @@ importers:
storybook:
specifier: ^9.1.7
version: 9.1.10(@testing-library/dom@9.3.4)(prettier@3.6.2)(vite@5.4.20(@types/node@20.19.19)(terser@5.44.0))
tsc-alias:
specifier: ^1.8.16
version: 1.8.16
tsx:
specifier: ^4.20.5
version: 4.20.6
@@ -4679,6 +4682,10 @@ packages:
resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==}
engines: {node: '>= 12'}
commander@9.5.0:
resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==}
engines: {node: ^12.20.0 || >=14}
common-path-prefix@3.0.0:
resolution: {integrity: sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==}
@@ -6937,6 +6944,10 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
mylas@2.1.13:
resolution: {integrity: sha512-+MrqnJRtxdF+xngFfUUkIMQrUUL0KsxbADUkn23Z/4ibGg192Q+z+CQyiYwvWTsYjJygmMR8+w3ZDa98Zh6ESg==}
engines: {node: '>=12.0.0'}
mz@2.7.0:
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
@@ -7508,6 +7519,10 @@ packages:
engines: {node: '>=18'}
hasBin: true
plimit-lit@1.6.1:
resolution: {integrity: sha512-B7+VDyb8Tl6oMJT9oSO2CW8XC/T4UcJGrwOVoNGwOQsQYhlpfajmrMj5xeejqaASq3V/EqThyOeATEOMuSEXiA==}
engines: {node: '>=12'}
pluralize@8.0.0:
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
engines: {node: '>=4'}
@@ -7744,6 +7759,10 @@ packages:
resolution: {integrity: sha512-773xhDQnZBMFobEiztv8LIl70ch5MSF/jUQVlhwFyBILqq96anmoctVIYz+ZRp0qbCKATTn6ev02M3r7Ga5vqA==}
engines: {node: '>=0.4.x'}
queue-lit@1.5.2:
resolution: {integrity: sha512-tLc36IOPeMAubu8BkW8YDBV+WyIgKlYU7zUNs0J5Vk9skSZ4JfGlPOqplP0aHdfv7HL0B2Pg6nwiq60Qc6M2Hw==}
engines: {node: '>=12'}
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@@ -8702,6 +8721,11 @@ packages:
ts-pattern@5.0.5:
resolution: {integrity: sha512-tL0w8U/pgaacOmkb9fRlYzWEUDCfVjjv9dD4wHTgZ61MjhuMt46VNWTG747NqW6vRzoWIKABVhFSOJ82FvXrfA==}
tsc-alias@1.8.16:
resolution: {integrity: sha512-QjCyu55NFyRSBAl6+MTFwplpFcnm2Pq01rR/uxfqJoLMm6X3O14KEGtaSDZpJYaE1bJBGDjD0eSuiIWPe2T58g==}
engines: {node: '>=16.20.2'}
hasBin: true
tsconfck@2.1.2:
resolution: {integrity: sha512-ghqN1b0puy3MhhviwO2kGF8SeMDNhEbnKxjK7h6+fvY9JAxqvXi8y5NAHSQv687OVboS2uZIByzGd45/YxrRHg==}
engines: {node: ^14.13.1 || ^16 || >=18}
@@ -14271,6 +14295,8 @@ snapshots:
commander@8.3.0: {}
commander@9.5.0: {}
common-path-prefix@3.0.0: {}
commondir@1.0.1: {}
@@ -16834,6 +16860,8 @@ snapshots:
ms@2.1.3: {}
mylas@2.1.13: {}
mz@2.7.0:
dependencies:
any-promise: 1.3.0
@@ -17334,6 +17362,10 @@ snapshots:
optionalDependencies:
fsevents: 2.3.2
plimit-lit@1.6.1:
dependencies:
queue-lit: 1.5.2
pluralize@8.0.0: {}
polished@4.3.1:
@@ -17583,6 +17615,8 @@ snapshots:
querystring-es3@0.2.1: {}
queue-lit@1.5.2: {}
queue-microtask@1.2.3: {}
ramda@0.29.0: {}
@@ -18698,6 +18732,16 @@ snapshots:
ts-pattern@5.0.5: {}
tsc-alias@1.8.16:
dependencies:
chokidar: 3.6.0
commander: 9.5.0
get-tsconfig: 4.11.0
globby: 11.1.0
mylas: 2.1.13
normalize-path: 3.0.0
plimit-lit: 1.6.1
tsconfck@2.1.2(typescript@5.9.3):
optionalDependencies:
typescript: 5.9.3