Compare commits

...

40 Commits

Author SHA1 Message Date
semantic-release-bot
fa827ac792 chore(release): 2.16.2 [skip ci]
## [2.16.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.16.1...v2.16.2) (2025-10-09)

### Bug Fixes

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

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

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

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

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

### Bug Fixes

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

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

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

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

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

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

### Features

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

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

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

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

### Features

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

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

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

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

### Bug Fixes

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

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

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

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

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

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

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

### Bug Fixes

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

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

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

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

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

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

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

### Bug Fixes

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

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

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

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

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

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

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

### Features

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

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

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

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

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

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

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

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

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

### Features

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

### Code Refactoring

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

### Bug Fixes

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

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

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

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

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

### Bug Fixes

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

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

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

Note: ArcadeMemoryPairsProvider can be deprecated in future cleanup.

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

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

### Bug Fixes

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

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

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

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

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

### Features

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

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

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

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

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

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

### Features

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

### Bug Fixes

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

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

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

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

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

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

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

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

Server validates all moves and sends authoritative state back.

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

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

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

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

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

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

### Bug Fixes

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

### Bug Fixes

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

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

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

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

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

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

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

### Features

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

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

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 07:56:22 -05:00
32 changed files with 4056 additions and 266 deletions

View File

@@ -1,3 +1,137 @@
## [2.16.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.16.1...v2.16.2) (2025-10-09)
### Bug Fixes
* use only local user's players in LocalMemoryPairsProvider ([c26138f](https://github.com/antialias/soroban-abacus-flashcards/commit/c26138ffb55a237a99cb6ff399c8a2ac54a22b51))
## [2.16.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.16.0...v2.16.1) (2025-10-09)
### Bug Fixes
* convert LocalMemoryPairsProvider to pure client-side with useReducer ([b128db1](https://github.com/antialias/soroban-abacus-flashcards/commit/b128db1783a8dcffe7879745c3342add2f9ffe29))
## [2.16.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.15.0...v2.16.0) (2025-10-09)
### Features
* fade out hover avatar when player stops hovering ([820eeb4](https://github.com/antialias/soroban-abacus-flashcards/commit/820eeb4fb03ad8be6a86dd0a26e089052224f427))
## [2.15.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.14.3...v2.15.0) (2025-10-09)
### Features
* implement smooth hover avatar animations with react-spring ([442c6b4](https://github.com/antialias/soroban-abacus-flashcards/commit/442c6b4529ba5c820b1fe8a64805a3d85489a8ea))
## [2.14.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.14.2...v2.14.3) (2025-10-09)
### Bug Fixes
* enable smooth spring animations between card hovers ([8d53b58](https://github.com/antialias/soroban-abacus-flashcards/commit/8d53b589aa17ebc6d0a9251b3006fd8a90f90a61))
## [2.14.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.14.1...v2.14.2) (2025-10-09)
### Bug Fixes
* correct avatar positioning to prevent fly-in animation ([573d0df](https://github.com/antialias/soroban-abacus-flashcards/commit/573d0df20dcdac41021c46feb423dbf3782728f6))
## [2.14.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.14.0...v2.14.1) (2025-10-09)
### Bug Fixes
* prevent avatar fly-in and hide local player's own hover ([7f65a67](https://github.com/antialias/soroban-abacus-flashcards/commit/7f65a67cef3d7f0ebce1bd7417972a6138acfc46))
## [2.14.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.13.0...v2.14.0) (2025-10-09)
### Features
* improve hover avatars with smooth animation and 3D elevation ([71b11f4](https://github.com/antialias/soroban-abacus-flashcards/commit/71b11f4ef08a5f9c3f1c1aaabca21ef023d5c0ce))
## [2.13.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.12.3...v2.13.0) (2025-10-09)
### Features
* implement networked hover presence for multiplayer gameplay ([62f3730](https://github.com/antialias/soroban-abacus-flashcards/commit/62f3730542334a0580f5dad1c73adc333614ee58))
### Code Refactoring
* move canModifyPlayers logic into provider layer ([db9f909](https://github.com/antialias/soroban-abacus-flashcards/commit/db9f9096b446b078e1b4dfe970723bef54a6f4ae))
* properly separate LocalMemoryPairsProvider and RoomMemoryPairsProvider ([98822ec](https://github.com/antialias/soroban-abacus-flashcards/commit/98822ecda52bf004d9950e3f4c92c834fd820e49))
## [2.12.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.12.2...v2.12.3) (2025-10-09)
### Bug Fixes
* always show game control buttons in room-based sessions ([14ba422](https://github.com/antialias/soroban-abacus-flashcards/commit/14ba422919abd648e2a134ce167a5e6fd9f84e73))
## [2.12.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.12.1...v2.12.2) (2025-10-09)
### Bug Fixes
* use RoomMemoryPairsProvider in room page ([c279731](https://github.com/antialias/soroban-abacus-flashcards/commit/c27973191f0144604e17a8a14adf0a88df476e27))
## [2.12.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.12.0...v2.12.1) (2025-10-09)
### Bug Fixes
* export MemoryPairsContext to fix provider hook error ([80ad33e](https://github.com/antialias/soroban-abacus-flashcards/commit/80ad33eec0b6946702eaa9cf1b1c246852864b00))
## [2.12.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.11.0...v2.12.0) (2025-10-09)
### Features
* add networked hover state infrastructure for multiplayer presence ([d149799](https://github.com/antialias/soroban-abacus-flashcards/commit/d14979907c5df9b793a1c110028fc5b54457f507))
## [2.11.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.10.1...v2.11.0) (2025-10-09)
### Features
* add pause/resume game state architecture ([05eacac](https://github.com/antialias/soroban-abacus-flashcards/commit/05eacac438dbaf405ce91e188c53dbbe2e9f9507))
* add Resume button and config change warning to setup UI ([b5ee04f](https://github.com/antialias/soroban-abacus-flashcards/commit/b5ee04f57651f53517468fcc4c456f0ccb65a8e2))
* implement pause/resume in game providers with optimistic updates ([ce30fca](https://github.com/antialias/soroban-abacus-flashcards/commit/ce30fcaf55270f9089249bd13ba73a25fbfa5ab4))
### Bug Fixes
* convert guestId to internal userId for player ownership check ([3a01f46](https://github.com/antialias/soroban-abacus-flashcards/commit/3a01f4637d2081c66fe37c7f8cfee229442ec744))
* implement shared session architecture for room-based multiplayer ([2856f4b](https://github.com/antialias/soroban-abacus-flashcards/commit/2856f4b83fbcc6483d96cc6e7da2fe5bc911625d))
## [2.10.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.10.1...v2.10.2) (2025-10-09)
### Bug Fixes
* convert guestId to internal userId for player ownership check ([3a01f46](https://github.com/antialias/soroban-abacus-flashcards/commit/3a01f4637d2081c66fe37c7f8cfee229442ec744))
* implement shared session architecture for room-based multiplayer ([2856f4b](https://github.com/antialias/soroban-abacus-flashcards/commit/2856f4b83fbcc6483d96cc6e7da2fe5bc911625d))
## [2.10.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.10.0...v2.10.1) (2025-10-09)
### Bug Fixes
* enforce player ownership authorization for multiplayer games ([71b0aac](https://github.com/antialias/soroban-abacus-flashcards/commit/71b0aac13c970c03fe8d296d41e9472ad72a00fa))
## [2.10.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.9.0...v2.10.0) (2025-10-09)
### Features
* implement rich Radix UI tooltips for player avatars ([d03c789](https://github.com/antialias/soroban-abacus-flashcards/commit/d03c7898799b378f912f47d7267a00bc7ce3d580))
## [2.9.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.7...v2.9.0) (2025-10-09)

View File

@@ -0,0 +1,492 @@
# Arcade Game Architecture
## Overview
The arcade system supports two distinct game modes that must remain completely isolated from each other:
1. **Local Play** - Games without network synchronization (can be single-player OR local multiplayer)
2. **Room-Based Play** - Networked games with real-time synchronization across room members
## Core Terminology
Following `docs/terminology-user-player-room.md`:
- **USER** - Identity (guest or authenticated account), retrieved via `useViewerId()`, one per browser/account
- **PLAYER** - Game avatar/profile (e.g., "Alice 👧", "Bob 👦"), stored in `players` table
- **PLAYER ROSTER** - All PLAYERS belonging to a USER (can have many)
- **ACTIVE PLAYERS** - PLAYERS where `isActive = true` - these are the ones that actually participate in games
- **ROOM MEMBER** - A USER's participation in a multiplayer room (tracked in `room_members` table)
**Important:** A USER can have many PLAYERS in their roster, but only the ACTIVE PLAYERS (where `isActive = true`) participate in games. This enables "hot-potato" style local multiplayer where multiple people share the same device. This is LOCAL play (not networked), even though multiple PLAYERS participate.
In arcade sessions:
- `arcade_sessions.userId` - The USER who owns the session
- `arcade_sessions.activePlayers` - Array of PLAYER IDs (only active players with `isActive = true`)
- `arcade_sessions.roomId` - If present, the room ID for networked play (references `arcade_rooms.id`)
## Critical Architectural Requirements
### 1. Mode Isolation (MUST ENFORCE)
**Local Play** (`/arcade/[game-name]`)
- MUST NOT sync game state across the network
- MUST NOT use room data, even if the USER is currently a member of an active room
- MUST create isolated, per-USER game sessions
- Game state lives only in the current browser tab/session
- CAN have multiple ACTIVE PLAYERS from the same USER (local multiplayer / hot-potato)
- State is NOT shared across the network, only within the browser session
**Room-Based Play** (`/arcade/room`)
- MUST sync game state across all room members via network
- MUST use the USER's current active room
- MUST coordinate moves via server WebSocket
- Game state is shared across all ACTIVE PLAYERS from all USERS in the room
- When a PLAYER makes a move, all room members see it in real-time
- CAN ALSO have multiple ACTIVE PLAYERS per USER (networked + local multiplayer combined)
### 2. Room ID Usage Rules
```typescript
// ❌ WRONG: Always checking for room data
const { roomData } = useRoomData()
useArcadeSession({ roomId: roomData?.id }) // This causes the bug!
// ✅ CORRECT: Explicit mode control via separate providers
<LocalMemoryPairsProvider> {/* Never passes roomId */}
<RoomMemoryPairsProvider> {/* Always passes roomId */}
```
**Key principle:** The presence of a `roomId` parameter in `useArcadeSession` determines synchronization behavior:
- `roomId` present → room-wide network sync enabled (room-based play)
- `roomId` undefined → local play only (no network sync)
### 3. Composition Over Flags (PREFERRED APPROACH)
**✅ Option 1: Separate Providers (CLEAREST - USE THIS)**
Create two distinct provider components:
```typescript
// context/LocalMemoryPairsProvider.tsx
export function LocalMemoryPairsProvider({ children }) {
const { data: viewerId } = useViewerId()
const { activePlayers } = useGameMode() // Gets active players (isActive = true)
// NEVER fetch room data for local play
const { state, sendMove } = useArcadeSession<MemoryPairsState>({
userId: viewerId || '',
roomId: undefined, // Explicitly undefined - no network sync
initialState,
applyMove: applyMoveOptimistically,
})
// ... rest of provider logic
// Note: activePlayers contains only PLAYERS with isActive = true
}
// context/RoomMemoryPairsProvider.tsx
export function RoomMemoryPairsProvider({ children }) {
const { data: viewerId } = useViewerId()
const { roomData } = useRoomData() // OK to fetch for room-based play
const { activePlayers } = useGameMode() // Gets active players (isActive = true)
const { state, sendMove } = useArcadeSession<MemoryPairsState>({
userId: viewerId || '',
roomId: roomData?.id, // Pass roomId for network sync
initialState,
applyMove: applyMoveOptimistically,
})
// ... rest of provider logic
}
```
Then use them explicitly:
```typescript
// /arcade/matching/page.tsx (Local Play)
export default function MatchingPage() {
return (
<ArcadeGuardedPage>
<LocalMemoryPairsProvider>
<MemoryPairsGame />
</LocalMemoryPairsProvider>
</ArcadeGuardedPage>
)
}
// /arcade/room/page.tsx (Room-Based Play)
export default function RoomPage() {
// ... room validation logic
if (roomData.gameName === 'matching') {
return (
<RoomMemoryPairsProvider>
<MemoryPairsGame />
</RoomMemoryPairsProvider>
)
}
}
```
**Benefits of separate providers:**
- Compile-time safety - impossible to mix modes
- Clear intent - any developer can see which mode at a glance
- No runtime conditionals needed
- Easier to test - each provider tests separately
**❌ Avoid:** Runtime flag checking scattered throughout code
```typescript
// Anti-pattern: Too many conditionals
if (isRoomBased) { ... } else { ... }
```
### 4. How Synchronization Works
#### Local Play Flow
```
USER Action → useArcadeSession (roomId: undefined)
→ WebSocket emit('join-arcade-session', { userId })
→ Server creates isolated session for userId
→ Session key = userId
→ session.activePlayers = USER's active player IDs (isActive = true)
→ State changes only affect this USER's browser tabs
Note: Multiple ACTIVE PLAYERS from same USER can participate (local multiplayer),
but state is NEVER synced across network
```
#### Room-Based Play Flow
```
USER Action (on behalf of PLAYER)
→ useArcadeSession (roomId: 'room_xyz')
→ WebSocket emit('join-arcade-session', { userId, roomId })
→ Server creates/joins shared session for roomId
→ session.activePlayers = ALL active players from ALL room members
→ Socket joins TWO rooms: `arcade:${userId}` AND `game:${roomId}`
→ PLAYER makes move
→ Server validates PLAYER ownership (is this PLAYER owned by this USER?)
→ State changes broadcast to:
- arcade:${userId} - All tabs of this USER (for optimistic reconciliation)
- game:${roomId} - All USERS in the room (for network sync)
Note: Each USER can still have multiple ACTIVE PLAYERS (local + networked multiplayer)
```
The server-side logic uses `roomId` to determine session scope:
- No `roomId`: Session key = `userId` (isolated to USER's browser)
- With `roomId`: Session key = `roomId` (shared across all room members)
See `docs/MULTIPLAYER_SYNC_ARCHITECTURE.md` for detailed socket room mechanics.
### 5. USER vs PLAYER in Game Logic
**Important distinction:**
- **Session ownership**: Tracked by USER ID (`useViewerId()`)
- **Player roster**: All PLAYERS for a USER (can be many)
- **Active players**: PLAYERS with `isActive = true` (these join the game)
- **Game actions**: Performed by PLAYER ID (from `players` table)
- **Move validation**: Server checks that PLAYER ID belongs to the requesting USER
- **Local multiplayer**: One USER with multiple ACTIVE PLAYERS (hot-potato style, same device)
- **Networked multiplayer**: Multiple USERS, each with their own ACTIVE PLAYERS, in a room
```typescript
// ✅ Correct: USER owns session, ACTIVE PLAYERS participate
const { data: viewerId } = useViewerId() // USER ID
const { activePlayers } = useGameMode() // ACTIVE PLAYER IDs (isActive = true)
// activePlayers might be [player_001, player_002]
// even though USER has 5 total PLAYERS in their roster
const { state, sendMove } = useArcadeSession({
userId: viewerId, // Session owned by USER
roomId: undefined, // Local play (or roomData?.id for room-based)
// ...
})
// When PLAYER flips card:
sendMove({
type: 'FLIP_CARD',
playerId: currentPlayerId, // PLAYER ID from activePlayers
data: { cardId: '...' }
})
```
**Example Scenarios:**
1. **Single-player local game:**
- USER: "guest_abc"
- Player roster: ["player_001" (active), "player_002" (inactive), "player_003" (inactive)]
- Active PLAYERS in game: ["player_001"]
- Mode: Local play (no roomId)
2. **Local multiplayer (hot-potato):**
- USER: "guest_abc"
- Player roster: ["player_001" (active), "player_002" (active), "player_003" (active), "player_004" (inactive)]
- Active PLAYERS in game: ["player_001", "player_002", "player_003"] (3 kids sharing device)
- Mode: Local play (no roomId)
- Game rotates turns between the 3 active PLAYERS, but NO network sync
3. **Room-based networked play:**
- USER A: "guest_abc"
- Player roster: 5 total PLAYERS
- Active PLAYERS: ["player_001", "player_002"]
- USER B: "guest_def"
- Player roster: 3 total PLAYERS
- Active PLAYERS: ["player_003"]
- Mode: Room-based play (roomId: "room_xyz")
- Total PLAYERS in game: 3 (player_001, player_002, player_003)
- All 3 synced across network
4. **Room-based + local multiplayer combined:**
- USER A: "guest_abc" with 3 active PLAYERS (3 kids at Device A)
- USER B: "guest_def" with 2 active PLAYERS (2 kids at Device B)
- Mode: Room-based play (roomId: "room_xyz")
- 5 total active PLAYERS across 2 devices, all synced over network
## Common Mistakes to Avoid
### Mistake 1: Conditional Room Usage
```typescript
// ❌ BAD: Room sync leaks into local play
const { roomData } = useRoomData()
useArcadeSession({
roomId: roomData?.id // Local play will sync if USER is in a room!
})
```
### Mistake 2: Shared Components Without Mode Context
```typescript
// ❌ BAD: Same provider used for both modes
export default function LocalGamePage() {
return <GameProvider><Game /></GameProvider> // Which mode?
}
```
### Mistake 3: Confusing "multiplayer" with "networked"
```typescript
// ❌ BAD: Thinking multiple PLAYERS means room-based
if (activePlayers.length > 1) {
// Must be room-based! WRONG!
// Could be local multiplayer (hot-potato style)
}
// ✅ CORRECT: Check for roomId to determine network sync
const isNetworked = !!roomId
const isLocalMultiplayer = activePlayers.length > 1 && !roomId
```
### Mistake 4: Using all PLAYERS instead of only active ones
```typescript
// ❌ BAD: Including inactive players
const allPlayers = await db.query.players.findMany({
where: eq(players.userId, userId)
})
// ✅ CORRECT: Only active players join the game
const activePlayers = await db.query.players.findMany({
where: and(
eq(players.userId, userId),
eq(players.isActive, true)
)
})
```
### Mistake 5: Mixing USER ID and PLAYER ID
```typescript
// ❌ BAD: Using USER ID for game actions
sendMove({
type: 'FLIP_CARD',
playerId: viewerId, // WRONG! viewerId is USER ID, not PLAYER ID
data: { cardId: '...' }
})
// ✅ CORRECT: Use PLAYER ID from game state
sendMove({
type: 'FLIP_CARD',
playerId: state.currentPlayer, // PLAYER ID from activePlayers
data: { cardId: '...' }
})
```
### Mistake 6: Server-Side Ambiguity
```typescript
// ❌ BAD: Server can't distinguish intent
socket.on('join-arcade-session', ({ userId, roomId }) => {
// If roomId exists, did USER want local or room-based play?
// This happens when provider always passes roomData?.id
})
```
## Testing Requirements
Tests MUST verify mode isolation:
### Local Play Tests
```typescript
it('should NOT sync state when USER is in a room but playing locally', async () => {
// Setup: USER is a member of an active room
// Action: USER navigates to /arcade/matching
// Assert: Game state is NOT shared with other room members
// Assert: Other room members' actions do NOT affect this game
})
it('should create isolated sessions for concurrent local games', () => {
// Setup: Two USERS who are members of the same room
// Action: Both navigate to /arcade/matching separately
// Assert: Each has independent game state
// Assert: USER A's moves do NOT appear in USER B's game
})
it('should support local multiplayer without network sync', () => {
// Setup: USER with 3 active PLAYERS in roster (hot-potato style)
// Action: USER plays at /arcade/matching with the 3 active PLAYERS
// Assert: All 3 active PLAYERS participate in the same session
// Assert: Inactive PLAYERS do NOT participate
// Assert: State is NOT synced across network
// Assert: Game rotates turns between active PLAYERS locally
})
it('should only include active players in game', () => {
// Setup: USER has 5 PLAYERS in roster, but only 2 are active
// Action: USER starts a local game
// Assert: Only the 2 active PLAYERS are in activePlayers array
// Assert: Inactive PLAYERS are not included
})
it('should sync across USER tabs but not across network', () => {
// Setup: USER opens /arcade/matching in 2 browser tabs
// Action: PLAYER makes move in Tab 1
// Assert: Tab 2 sees the move (multi-tab sync)
// Assert: Other USERS do NOT see the move (no network sync)
})
```
### Room-Based Play Tests
```typescript
it('should sync state across all room members', async () => {
// Setup: Two USERS are members of the same room
// Action: USER A's PLAYER flips card at /arcade/room
// Assert: USER B sees the card flip in real-time
})
it('should sync across multiple active PLAYERS from multiple USERS', () => {
// Setup: USER A has 2 active PLAYERS, USER B has 1 active PLAYER in same room
// Action: USER A's PLAYER 1 makes move
// Assert: All 3 PLAYERS see the move (networked)
})
it('should only include active players in room games', () => {
// Setup: USER A (5 PLAYERS, 2 active), USER B (3 PLAYERS, 1 active) join room
// Action: Game starts
// Assert: session.activePlayers = [userA_player1, userA_player2, userB_player1]
// Assert: Inactive PLAYERS are NOT included
})
it('should handle combined local + networked multiplayer', () => {
// Setup: USER A (3 active PLAYERS), USER B (2 active PLAYERS) in same room
// Action: Any PLAYER makes a move
// Assert: All 5 active PLAYERS see the move across both devices
})
it('should fail gracefully when no room exists', () => {
// Setup: USER is not a member of any room
// Action: Navigate to /arcade/room
// Assert: Shows "No active room" message
// Assert: Does not create a session
})
it('should validate PLAYER ownership', async () => {
// Setup: USER A in room with active PLAYER 'alice'
// Action: USER A attempts move for PLAYER 'bob' (owned by USER B)
// Assert: Server rejects the move
// Assert: Error indicates unauthorized PLAYER
})
```
## Implementation Checklist
When adding a new game or modifying existing ones:
- [ ] Create separate `LocalGameProvider` and `RoomGameProvider` components
- [ ] Local provider never calls `useRoomData()`
- [ ] Local provider passes `roomId: undefined` to `useArcadeSession`
- [ ] Room provider calls `useRoomData()` and passes `roomId: roomData?.id`
- [ ] Both providers use `useGameMode()` to get active players
- [ ] Local play page uses `LocalGameProvider`
- [ ] `/arcade/room` page uses `RoomGameProvider`
- [ ] Game components correctly use PLAYER IDs (not USER IDs) for moves
- [ ] Game supports multiple active PLAYERS from same USER (local multiplayer)
- [ ] Inactive PLAYERS are never included in game sessions
- [ ] Tests verify mode isolation (local doesn't network sync, room-based does)
- [ ] Tests verify PLAYER ownership validation
- [ ] Tests verify only active PLAYERS participate
- [ ] Tests verify local multiplayer works (multiple active PLAYERS, one USER)
- [ ] Documentation updated if behavior changes
## File Organization
```
src/app/arcade/
├── [game-name]/ # Local play games
│ ├── page.tsx # Uses LocalGameProvider
│ └── context/
│ ├── LocalGameProvider.tsx # roomId: undefined
│ └── RoomGameProvider.tsx # roomId: roomData?.id
├── room/ # Room-based play
│ └── page.tsx # Uses RoomGameProvider
└── ...
```
## Architecture Decision Records
### Why separate providers instead of auto-detect from route?
While we could detect mode based on the route (`/arcade/room` vs `/arcade/matching`), separate providers are clearer and prevent accidental misuse. Future developers can immediately see the intent, and the type system can enforce correctness.
### Why being in a room doesn't mean all games sync?
A USER being a room member does NOT mean all their games should network sync. They should be able to play local games while remaining in a room for future room-based sessions. Mode is determined by the page they're on, not their room membership status.
### Why not use a single shared provider with mode props?
We tried that. It led to the current bug where local play accidentally synced with rooms. Separate providers make the distinction compile-time safe rather than runtime conditional, and eliminate the possibility of accidentally passing `roomId` when we shouldn't.
### Why do we track sessions by USER but moves by PLAYER?
- **Sessions** are per-USER because each USER can have their own game session
- **Moves** are per-PLAYER because PLAYERS are the game avatars that score points
- **Only active PLAYERS** (isActive = true) participate in games
- This allows:
- One USER with multiple active PLAYERS (local multiplayer / hot-potato)
- Multiple USERS in one room (networked play)
- Combined: Multiple USERS each with multiple active PLAYERS (local + networked)
- Proper ownership validation (server checks USER owns PLAYER)
- PLAYERS can be toggled active/inactive without deleting them
### Why use "local" vs "room-based" instead of "solo" vs "multiplayer"?
- **"Solo"** is misleading - a USER can have multiple active PLAYERS in local play (hot-potato style)
- **"Multiplayer"** is ambiguous - it could mean local multiplayer OR networked multiplayer
- **"Local play"** clearly means: no network sync (but can have multiple active PLAYERS)
- **"Room-based play"** clearly means: network sync across room members
## Related Files
- `src/hooks/useArcadeSession.ts` - Session management with optional roomId
- `src/hooks/useArcadeSocket.ts` - WebSocket connection with sync logic (socket rooms: `arcade:${userId}` and `game:${roomId}`)
- `src/hooks/useRoomData.ts` - Fetches USER's current room membership
- `src/hooks/useViewerId.ts` - Retrieves current USER ID
- `src/contexts/GameModeContext.tsx` - Provides active PLAYER information
- `src/app/arcade/matching/context/ArcadeMemoryPairsContext.tsx` - Game context (needs refactoring to separate providers)
- `src/app/arcade/matching/page.tsx` - Local play entry point
- `src/app/arcade/room/page.tsx` - Room-based play entry point
- `docs/terminology-user-player-room.md` - Terminology guide (USER/PLAYER/MEMBER)
- `docs/MULTIPLAYER_SYNC_ARCHITECTURE.md` - Technical details of room-based sync
## Version History
- **2025-10-09**: Initial documentation
- Issue identified: Local play was syncing with rooms over network
- Root cause: Same provider always fetched `roomData` and passed `roomId` to `useArcadeSession`
- Solution: Separate providers for local vs room-based play
- Terminology clarification: "local" vs "room-based" (not "solo" vs "multiplayer")
- Active players: Only PLAYERS with `isActive = true` participate in games

View File

@@ -0,0 +1,416 @@
# Arcade Setup Pattern
## Overview
This document describes the **standard synchronized setup pattern** for arcade games. Following this pattern ensures that:
1.**Setup is synchronized** - All room members see the same setup screen and config changes in real-time
2.**No local state hacks** - Configuration lives entirely in session state, no React state merging
3.**Optimistic updates** - Config changes feel instant with client-side prediction
4.**Consistent pattern** - All games follow the same architecture
**Reference Implementation**: `src/app/arcade/matching/*` (Matching game)
---
## Core Concept
Setup configuration is **game state**, not UI state. Configuration changes are **moves** that are validated, synchronized, and can be made by any room member.
### Key Principles
1. **Game state includes configuration** during ALL phases (setup, playing, results)
2. **No local React state** for configuration - use session state directly
3. **Standard move types** that all games should implement
4. **Setup phase is collaborative** - any room member can configure the game
---
## Required Move Types
Every arcade game must support these standard moves:
### 1. `GO_TO_SETUP`
Transitions game to setup phase, allowing reconfiguration.
```typescript
{
type: 'GO_TO_SETUP',
playerId: string,
data: {}
}
```
**Behavior:**
- Can be called from any phase (setup, playing, results)
- Sets `gamePhase: 'setup'`
- Resets game progression (scores, cards, etc.)
- Preserves configuration (players can modify it)
- Synchronized across all room members
### 2. `SET_CONFIG`
Updates a configuration field during setup phase.
```typescript
{
type: 'SET_CONFIG',
playerId: string,
data: {
field: string, // Config field name
value: any // New value
}
}
```
**Behavior:**
- Only allowed during setup phase
- Validates field name and value
- Updates immediately with optimistic update
- Synchronized across all room members
### 3. `START_GAME`
Starts the game with current configuration.
```typescript
{
type: 'START_GAME',
playerId: string,
data: {
activePlayers: string[],
playerMetadata: { [playerId: string]: PlayerMetadata },
// ... game-specific initial data
}
}
```
**Behavior:**
- Only allowed during setup phase
- Uses current session state configuration
- Initializes game-specific state
- Sets `gamePhase: 'playing'`
---
## Implementation Checklist
### 1. Update Validation Types
Add move types to your game's validation types:
```typescript
// src/lib/arcade/validation/types.ts
export interface YourGameGoToSetupMove extends GameMove {
type: 'GO_TO_SETUP'
data: Record<string, never>
}
export interface YourGameSetConfigMove extends GameMove {
type: 'SET_CONFIG'
data: {
field: 'configField1' | 'configField2' | 'configField3'
value: any
}
}
export type YourGameMove =
| YourGameStartGameMove
| YourGameGoToSetupMove
| YourGameSetConfigMove
| ... // other game-specific moves
```
### 2. Implement Validators
Add validators for setup moves in your game's validator class:
```typescript
// src/lib/arcade/validation/YourGameValidator.ts
export class YourGameValidator implements GameValidator<YourGameState, YourGameMove> {
validateMove(state, move, context) {
switch (move.type) {
case 'GO_TO_SETUP':
return this.validateGoToSetup(state)
case 'SET_CONFIG':
return this.validateSetConfig(state, move.data.field, move.data.value)
case 'START_GAME':
return this.validateStartGame(state, move.data)
// ... other moves
}
}
private validateGoToSetup(state: YourGameState): ValidationResult {
return {
valid: true,
newState: {
...state,
gamePhase: 'setup',
// Reset game progression, preserve configuration
// ... reset scores, game data, etc.
},
}
}
private validateSetConfig(
state: YourGameState,
field: string,
value: any
): ValidationResult {
// Only during setup
if (state.gamePhase !== 'setup') {
return { valid: false, error: 'Cannot change config outside setup' }
}
// Validate field-specific values
switch (field) {
case 'configField1':
if (!isValidValue(value)) {
return { valid: false, error: 'Invalid value' }
}
break
// ... validate other fields
}
return {
valid: true,
newState: { ...state, [field]: value },
}
}
private validateStartGame(state: YourGameState, data: any): ValidationResult {
if (state.gamePhase !== 'setup') {
return { valid: false, error: 'Can only start from setup' }
}
// Use current state configuration to initialize game
const initialGameData = initializeYourGame(state.configField1, state.configField2)
return {
valid: true,
newState: {
...state,
gamePhase: 'playing',
...initialGameData,
},
}
}
}
```
### 3. Add Optimistic Updates
Update `applyMoveOptimistically` in your providers:
```typescript
// src/app/arcade/your-game/context/YourGameProvider.tsx
function applyMoveOptimistically(state: YourGameState, move: GameMove): YourGameState {
switch (move.type) {
case 'GO_TO_SETUP':
return {
...state,
gamePhase: 'setup',
// Reset game state, preserve config
}
case 'SET_CONFIG':
const { field, value } = move.data
return {
...state,
[field]: value,
}
case 'START_GAME':
return {
...state,
gamePhase: 'playing',
// ... initialize game data from move
}
// ... other moves
}
}
```
### 4. Remove Local State from Providers
**❌ OLD PATTERN (Don't do this):**
```typescript
// DON'T: Local React state for configuration
const [localDifficulty, setLocalDifficulty] = useState(6)
// DON'T: Merge hack
const effectiveState = state.gamePhase === 'setup'
? { ...state, difficulty: localDifficulty }
: state
// DON'T: Direct setter
const setDifficulty = (value) => setLocalDifficulty(value)
```
**✅ NEW PATTERN (Do this):**
```typescript
// DO: Use session state directly
const { state, sendMove } = useArcadeSession(...)
// DO: Send move for config changes
const setDifficulty = useCallback((value) => {
const playerId = activePlayers[0] || ''
sendMove({
type: 'SET_CONFIG',
playerId,
data: { field: 'difficulty', value },
})
}, [activePlayers, sendMove])
// DO: Use state directly (no merging!)
const contextValue = { state: { ...state, gameMode }, ... }
```
### 5. Update Action Creators
All configuration actions should send moves:
```typescript
export function YourGameProvider({ children }) {
const { state, sendMove } = useArcadeSession(...)
const setConfigField = useCallback((value) => {
const playerId = activePlayers[0] || ''
sendMove({
type: 'SET_CONFIG',
playerId,
data: { field: 'configField', value },
})
}, [activePlayers, sendMove])
const goToSetup = useCallback(() => {
const playerId = activePlayers[0] || state.currentPlayer || ''
sendMove({
type: 'GO_TO_SETUP',
playerId,
data: {},
})
}, [activePlayers, state.currentPlayer, sendMove])
const startGame = useCallback(() => {
// Use current session state config (not local state!)
const initialData = initializeGame(state.config1, state.config2)
const playerId = activePlayers[0]
sendMove({
type: 'START_GAME',
playerId,
data: {
...initialData,
activePlayers,
playerMetadata: capturePlayerMetadata(players, activePlayers),
},
})
}, [state.config1, state.config2, activePlayers, sendMove])
return <YourGameContext.Provider value={...} />
}
```
---
## Benefits of This Pattern
### 1. **Synchronized Setup**
- User A clicks "Setup" → All room members see setup screen
- User B changes difficulty → All room members see the change
- User A clicks "Start" → All room members start playing
### 2. **No Special Cases**
- Setup works like gameplay (moves + validation)
- No conditional logic based on phase
- No React state merging hacks
### 3. **Easy to Extend**
- New games copy the same pattern
- Well-documented and tested
- Consistent developer experience
### 4. **Optimistic Updates**
- Config changes feel instant
- Client-side prediction + server validation
- Rollback on validation failure
---
## Testing Checklist
When implementing this pattern, test these scenarios:
### Local Mode
- [ ] Click setup button during game → returns to setup
- [ ] Change config fields → updates immediately
- [ ] Start game → uses current config
### Room Mode (Multi-User)
- [ ] User A clicks setup → User B sees setup screen
- [ ] User A changes difficulty → User B sees change in real-time
- [ ] User B changes game type → User A sees change in real-time
- [ ] User A starts game → Both users see game with same config
### Edge Cases
- [ ] Change config rapidly → no race conditions
- [ ] User with 0 players can see/modify setup
- [ ] Setup → Play → Setup preserves last config
- [ ] Invalid config values are rejected by validator
---
## Migration Guide
If you have an existing game using local state, follow these steps:
### Step 1: Add Move Types
Add `GO_TO_SETUP` and `SET_CONFIG` to your validation types.
### Step 2: Implement Validators
Add validators for the new moves in your game validator class.
### Step 3: Add Optimistic Updates
Update `applyMoveOptimistically` to handle the new moves.
### Step 4: Remove Local State
1. Delete all `useState` calls for configuration
2. Delete the `effectiveState` merging logic
3. Update action creators to send moves instead
### Step 5: Test
Run through the testing checklist above.
---
## Reference Implementation
See the Matching game for a complete reference implementation:
- **Types**: `src/lib/arcade/validation/types.ts`
- **Validator**: `src/lib/arcade/validation/MatchingGameValidator.ts`
- **Provider**: `src/app/arcade/matching/context/RoomMemoryPairsProvider.tsx`
- **Optimistic Updates**: `applyMoveOptimistically` function in provider
Look for comments marked with:
- `// STANDARD ARCADE PATTERN: GO_TO_SETUP`
- `// STANDARD ARCADE PATTERN: SET_CONFIG`
- `// NO LOCAL STATE`
- `// NO MORE effectiveState merging!`
---
## Questions?
If something is unclear or you encounter issues implementing this pattern, refer to the Matching game implementation or update this document with clarifications.

View File

@@ -1,6 +1,18 @@
{
"permissions": {
"allow": ["Bash(npm test:*)"],
"allow": [
"Bash(npm test:*)",
"Read(//Users/antialias/projects/**)",
"Bash(npm run lint:*)",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git push:*)",
"Bash(git pull:*)",
"Bash(git stash:*)",
"Bash(npm run format:*)",
"Bash(npm run pre-commit:*)",
"Bash(npm run type-check:*)"
],
"deny": [],
"ask": []
}

445
apps/web/pnpm-lock.yaml generated Normal file
View File

@@ -0,0 +1,445 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
'@radix-ui/react-tooltip':
specifier: ^1.2.8
version: 1.2.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@soroban/abacus-react':
specifier: workspace:*
version: link:../../packages/abacus-react
'@soroban/client':
specifier: workspace:*
version: link:../../packages/core/client/typescript
'@soroban/core':
specifier: workspace:*
version: link:../../packages/core/client/node
'@soroban/templates':
specifier: workspace:*
version: link:../../packages/templates
react:
specifier: ^18.2.0
version: 18.3.1
react-dom:
specifier: ^18.2.0
version: 18.3.1(react@18.3.1)
packages:
'@floating-ui/core@1.7.3':
resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==}
'@floating-ui/dom@1.7.4':
resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==}
'@floating-ui/react-dom@2.1.6':
resolution: {integrity: sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@floating-ui/utils@0.2.10':
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
'@radix-ui/primitive@1.1.3':
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
'@radix-ui/react-arrow@1.1.7':
resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-compose-refs@1.1.2':
resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-context@1.1.2':
resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-dismissable-layer@1.1.11':
resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-id@1.1.1':
resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-popper@1.2.8':
resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-portal@1.1.9':
resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-presence@1.1.5':
resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-primitive@2.1.3':
resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-slot@1.2.3':
resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-tooltip@1.2.8':
resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-use-callback-ref@1.1.1':
resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-controllable-state@1.2.2':
resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-effect-event@0.0.2':
resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-escape-keydown@1.1.1':
resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-layout-effect@1.1.1':
resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-rect@1.1.1':
resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-size@1.1.1':
resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-visually-hidden@1.2.3':
resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/rect@1.1.1':
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
loose-envify@1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
react-dom@18.3.1:
resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==}
peerDependencies:
react: ^18.3.1
react@18.3.1:
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
engines: {node: '>=0.10.0'}
scheduler@0.23.2:
resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==}
snapshots:
'@floating-ui/core@1.7.3':
dependencies:
'@floating-ui/utils': 0.2.10
'@floating-ui/dom@1.7.4':
dependencies:
'@floating-ui/core': 1.7.3
'@floating-ui/utils': 0.2.10
'@floating-ui/react-dom@2.1.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@floating-ui/dom': 1.7.4
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@floating-ui/utils@0.2.10': {}
'@radix-ui/primitive@1.1.3': {}
'@radix-ui/react-arrow@1.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/react-primitive': 2.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@radix-ui/react-compose-refs@1.1.2(react@18.3.1)':
dependencies:
react: 18.3.1
'@radix-ui/react-context@1.1.2(react@18.3.1)':
dependencies:
react: 18.3.1
'@radix-ui/react-dismissable-layer@1.1.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(react@18.3.1)
'@radix-ui/react-primitive': 2.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-use-callback-ref': 1.1.1(react@18.3.1)
'@radix-ui/react-use-escape-keydown': 1.1.1(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@radix-ui/react-id@1.1.1(react@18.3.1)':
dependencies:
'@radix-ui/react-use-layout-effect': 1.1.1(react@18.3.1)
react: 18.3.1
'@radix-ui/react-popper@1.2.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@floating-ui/react-dom': 2.1.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-arrow': 1.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-compose-refs': 1.1.2(react@18.3.1)
'@radix-ui/react-context': 1.1.2(react@18.3.1)
'@radix-ui/react-primitive': 2.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-use-callback-ref': 1.1.1(react@18.3.1)
'@radix-ui/react-use-layout-effect': 1.1.1(react@18.3.1)
'@radix-ui/react-use-rect': 1.1.1(react@18.3.1)
'@radix-ui/react-use-size': 1.1.1(react@18.3.1)
'@radix-ui/rect': 1.1.1
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@radix-ui/react-portal@1.1.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/react-primitive': 2.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-use-layout-effect': 1.1.1(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@radix-ui/react-presence@1.1.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(react@18.3.1)
'@radix-ui/react-use-layout-effect': 1.1.1(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@radix-ui/react-primitive@2.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/react-slot': 1.2.3(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@radix-ui/react-slot@1.2.3(react@18.3.1)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(react@18.3.1)
react: 18.3.1
'@radix-ui/react-tooltip@1.2.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(react@18.3.1)
'@radix-ui/react-context': 1.1.2(react@18.3.1)
'@radix-ui/react-dismissable-layer': 1.1.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-id': 1.1.1(react@18.3.1)
'@radix-ui/react-popper': 1.2.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-portal': 1.1.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-presence': 1.1.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-primitive': 2.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-slot': 1.2.3(react@18.3.1)
'@radix-ui/react-use-controllable-state': 1.2.2(react@18.3.1)
'@radix-ui/react-visually-hidden': 1.2.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@radix-ui/react-use-callback-ref@1.1.1(react@18.3.1)':
dependencies:
react: 18.3.1
'@radix-ui/react-use-controllable-state@1.2.2(react@18.3.1)':
dependencies:
'@radix-ui/react-use-effect-event': 0.0.2(react@18.3.1)
'@radix-ui/react-use-layout-effect': 1.1.1(react@18.3.1)
react: 18.3.1
'@radix-ui/react-use-effect-event@0.0.2(react@18.3.1)':
dependencies:
'@radix-ui/react-use-layout-effect': 1.1.1(react@18.3.1)
react: 18.3.1
'@radix-ui/react-use-escape-keydown@1.1.1(react@18.3.1)':
dependencies:
'@radix-ui/react-use-callback-ref': 1.1.1(react@18.3.1)
react: 18.3.1
'@radix-ui/react-use-layout-effect@1.1.1(react@18.3.1)':
dependencies:
react: 18.3.1
'@radix-ui/react-use-rect@1.1.1(react@18.3.1)':
dependencies:
'@radix-ui/rect': 1.1.1
react: 18.3.1
'@radix-ui/react-use-size@1.1.1(react@18.3.1)':
dependencies:
'@radix-ui/react-use-layout-effect': 1.1.1(react@18.3.1)
react: 18.3.1
'@radix-ui/react-visually-hidden@1.2.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/react-primitive': 2.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@radix-ui/rect@1.1.1': {}
js-tokens@4.0.0: {}
loose-envify@1.4.0:
dependencies:
js-tokens: 4.0.0
react-dom@18.3.1(react@18.3.1):
dependencies:
loose-envify: 1.4.0
react: 18.3.1
scheduler: 0.23.2
react@18.3.1:
dependencies:
loose-envify: 1.4.0
scheduler@0.23.2:
dependencies:
loose-envify: 1.4.0

View File

@@ -6,6 +6,7 @@ import {
createArcadeSession,
deleteArcadeSession,
getArcadeSession,
getArcadeSessionByRoom,
updateSessionActivity,
} from './src/lib/arcade/session-manager'
import { createRoom, getRoomById } from './src/lib/arcade/room-manager'
@@ -56,9 +57,19 @@ export function initializeSocketServer(httpServer: HTTPServer) {
}
// Send current session state if exists
// For room-based games, look up shared room session
try {
const session = await getArcadeSession(userId)
const session = roomId
? await getArcadeSessionByRoom(roomId)
: await getArcadeSession(userId)
if (session) {
console.log('[join-arcade-session] Found session:', {
userId,
roomId,
version: session.version,
sessionUserId: session.userId,
})
socket.emit('session-state', {
gameState: session.gameState,
currentGame: session.currentGame,
@@ -67,6 +78,10 @@ export function initializeSocketServer(httpServer: HTTPServer) {
version: session.version,
})
} else {
console.log('[join-arcade-session] No active session found for:', {
userId,
roomId,
})
socket.emit('no-active-session')
}
} catch (error) {
@@ -77,19 +92,23 @@ export function initializeSocketServer(httpServer: HTTPServer) {
)
// Handle game moves
socket.on('game-move', async (data: { userId: string; move: GameMove }) => {
socket.on('game-move', async (data: { userId: string; move: GameMove; roomId?: string }) => {
console.log('🎮 Game move received:', {
userId: data.userId,
moveType: data.move.type,
playerId: data.move.playerId,
timestamp: data.move.timestamp,
roomId: data.roomId,
fullMove: JSON.stringify(data.move, null, 2),
})
try {
// Special handling for START_GAME - create session if it doesn't exist
if (data.move.type === 'START_GAME') {
const existingSession = await getArcadeSession(data.userId)
// For room-based games, check if room session exists
const existingSession = data.roomId
? await getArcadeSessionByRoom(data.roomId)
: await getArcadeSession(data.userId)
if (!existingSession) {
console.log('🎯 Creating new session for START_GAME')
@@ -174,7 +193,8 @@ export function initializeSocketServer(httpServer: HTTPServer) {
}
}
const result = await applyGameMove(data.userId, data.move)
// Apply game move - use roomId for room-based games to access shared session
const result = await applyGameMove(data.userId, data.move, data.roomId)
if (result.success && result.session) {
const moveAcceptedData = {

View File

@@ -3,12 +3,12 @@
import { css } from '../../../../../styled-system/css'
import { useGameMode } from '../../../../contexts/GameModeContext'
import { pluralizeWord } from '../../../../utils/pluralization'
import { useArcadeMemoryPairs } from '../context/ArcadeMemoryPairsContext'
import { useMemoryPairs } from '../context/MemoryPairsContext'
import { MemoryGrid } from './MemoryGrid'
import { PlayerStatusBar } from './PlayerStatusBar'
export function GamePhase() {
const { state, resetGame: _resetGame, activePlayers } = useArcadeMemoryPairs()
const { state, resetGame: _resetGame, activePlayers } = useMemoryPairs()
const { players: playerMap, activePlayers: activePlayerIds } = useGameMode()
// Convert Map to array and create mapping from numeric index to player

View File

@@ -1,8 +1,10 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { useSpring, animated } from '@react-spring/web'
import { useEffect, useMemo, useRef, useState } from 'react'
import { css } from '../../../../../styled-system/css'
import { useArcadeMemoryPairs } from '../context/ArcadeMemoryPairsContext'
import { useGameMode } from '../../../../contexts/GameModeContext'
import { useMemoryPairs } from '../context/MemoryPairsContext'
import { getGridConfiguration } from '../utils/cardGeneration'
import { GameCard } from './GameCard'
@@ -80,8 +82,109 @@ function useGridDimensions(gridConfig: any, totalCards: number) {
return gridDimensions
}
// Animated hover avatar component
function HoverAvatar({
playerId,
playerInfo,
cardElement,
isPlayersTurn,
}: {
playerId: string
playerInfo: { emoji: string; name: string; color?: string }
cardElement: HTMLElement | null
isPlayersTurn: 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,
opacity: position && isPlayersTurn && cardElement ? 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 } = useArcadeMemoryPairs()
const { state, flipCard, hoverCard, gameMode } = useMemoryPairs()
const { players: playerMap } = useGameMode()
// 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
const currentPlayerData = playerMap.get(state.currentPlayer)
return currentPlayerData?.isLocal === true
}, [state.currentPlayer, playerMap, gameMode])
// Hooks must be called before early return
const gridConfig = useMemo(() => getGridConfiguration(state.difficulty), [state.difficulty])
@@ -95,6 +198,36 @@ export function MemoryGrid() {
flipCard(cardId)
}
// Get player metadata for hover avatars
const getPlayerHoverInfo = (playerId: string) => {
// Check playerMetadata first (from room members)
if (state.playerMetadata && state.playerMetadata[playerId]) {
return {
emoji: state.playerMetadata[playerId].emoji,
name: state.playerMetadata[playerId].name,
color: state.playerMetadata[playerId].color,
}
}
// Fall back to local player map
const player = playerMap.get(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 +295,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 +306,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 +380,62 @@ export function MemoryGrid() {
})}
/>
)}
{/* Animated Hover Avatars - Rendered as fixed positioned elements that smoothly transition */}
{/* Render one avatar per remote player - key by playerId to keep component alive */}
{state.playerHovers &&
Object.entries(state.playerHovers)
.filter(([playerId]) => {
// Don't show your own hover avatar (only show remote players)
const player = playerMap.get(playerId)
return player?.isLocal !== true
})
.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
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}
/>
)
})}
</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,20 +3,18 @@
import { useRouter } from 'next/navigation'
import { useEffect, useRef } from 'react'
import { PageWithNav } from '@/components/PageWithNav'
import { useArcadeRedirect } from '@/hooks/useArcadeRedirect'
import { css } from '../../../../../styled-system/css'
import { StandardGameLayout } from '../../../../components/StandardGameLayout'
import { useFullscreen } from '../../../../contexts/FullscreenContext'
import { useArcadeMemoryPairs } from '../context/ArcadeMemoryPairsContext'
import { useMemoryPairs } from '../context/MemoryPairsContext'
import { GamePhase } from './GamePhase'
import { ResultsPhase } from './ResultsPhase'
import { SetupPhase } from './SetupPhase'
export function MemoryPairsGame() {
const router = useRouter()
const { state, exitSession, resetGame } = useArcadeMemoryPairs()
const { state, exitSession, resetGame, goToSetup, canModifyPlayers } = useMemoryPairs()
const { setFullscreenElement } = useFullscreen()
const { canModifyPlayers } = useArcadeRedirect({ currentGame: 'matching' })
const gameRef = useRef<HTMLDivElement>(null)
useEffect(() => {
@@ -37,11 +35,14 @@ export function MemoryPairsGame() {
exitSession()
router.push('/arcade')
}}
onSetup={() => {
// Exit current session and return to arcade (which will redirect to setup)
exitSession()
router.push('/arcade/matching')
}}
onSetup={
goToSetup
? () => {
// Transition to setup phase (will pause game if active)
goToSetup()
}
: undefined
}
onNewGame={() => {
resetGame()
}}

View File

@@ -3,7 +3,7 @@
import { css } from '../../../../../styled-system/css'
import { useGameMode } from '../../../../contexts/GameModeContext'
import { gamePlurals } from '../../../../utils/pluralization'
import { useArcadeMemoryPairs } from '../context/ArcadeMemoryPairsContext'
import { useMemoryPairs } from '../context/MemoryPairsContext'
interface PlayerStatusBarProps {
className?: string
@@ -11,7 +11,7 @@ interface PlayerStatusBarProps {
export function PlayerStatusBar({ className }: PlayerStatusBarProps) {
const { players: playerMap, activePlayers: activePlayerIds } = useGameMode()
const { state } = useArcadeMemoryPairs()
const { state } = useMemoryPairs()
// Get active players array
const activePlayersData = Array.from(activePlayerIds)
@@ -26,8 +26,13 @@ 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 current player is local (your turn) or remote (waiting)
const currentPlayer = activePlayers.find((p) => p.id === state.currentPlayer)
const isYourTurn = currentPlayer?.isLocalPlayer === true
// Get celebration level based on consecutive matches
const getCelebrationLevel = (consecutiveMatches: number) => {
if (consecutiveMatches >= 5) return 'legendary'
@@ -250,14 +255,16 @@ export function PlayerStatusBar({ className }: PlayerStatusBarProps) {
{isCurrentPlayer && (
<span
className={css({
color: 'red.600',
color: player.isLocalPlayer ? 'red.600' : 'blue.600',
fontWeight: 'black',
fontSize: isCurrentPlayer ? { base: 'sm', md: 'lg' } : 'inherit',
animation: 'none',
textShadow: '0 0 15px currentColor',
animation: player.isLocalPlayer
? 'none'
: 'gentle-pulse 2s ease-in-out infinite',
textShadow: player.isLocalPlayer ? '0 0 15px currentColor' : 'none',
})}
>
{' • Your turn'}
{player.isLocalPlayer ? ' • Your turn' : ' • Their turn'}
</span>
)}
{player.consecutiveMatches > 1 && (

View File

@@ -3,12 +3,12 @@
import { useRouter } from 'next/navigation'
import { css } from '../../../../../styled-system/css'
import { useGameMode } from '../../../../contexts/GameModeContext'
import { useArcadeMemoryPairs } from '../context/ArcadeMemoryPairsContext'
import { useMemoryPairs } from '../context/MemoryPairsContext'
import { formatGameTime, getMultiplayerWinner, getPerformanceAnalysis } from '../utils/gameScoring'
export function ResultsPhase() {
const router = useRouter()
const { state, resetGame, activePlayers, gameMode } = useArcadeMemoryPairs()
const { state, resetGame, activePlayers, gameMode, exitSession } = useMemoryPairs()
const { players: playerMap, activePlayers: activePlayerIds } = useGameMode()
// Get active player data array

View File

@@ -1,8 +1,9 @@
'use client'
import { useState } from 'react'
import { css } from '../../../../../styled-system/css'
import { useGameMode } from '../../../../contexts/GameModeContext'
import { useArcadeMemoryPairs } from '../context/ArcadeMemoryPairsContext'
import { useMemoryPairs } from '../context/MemoryPairsContext'
// Add bounce animation for the start button
const bounceAnimation = `
@@ -32,14 +33,87 @@ export function SetupPhase() {
state,
setGameType,
setDifficulty,
setTurnTimer,
startGame,
resumeGame,
canResumeGame,
hasConfigChanged,
activePlayers: _activePlayers,
} = useArcadeMemoryPairs()
} = useMemoryPairs()
const { activePlayerCount, gameMode: _globalGameMode } = useGameMode()
const handleStartGame = () => {
startGame()
// PAUSE/RESUME: Warning dialog state
const [showConfigWarning, setShowConfigWarning] = useState(false)
const [hasSeenWarning, setHasSeenWarning] = useState(false)
const [pendingConfigChange, setPendingConfigChange] = useState<{
type: 'gameType' | 'difficulty' | 'turnTimer'
value: any
} | null>(null)
// Check if we should show warning when changing config
const shouldShowWarning = state.pausedGamePhase && !hasSeenWarning && !hasConfigChanged
// Config change handlers that check for paused game
const handleSetGameType = (value: typeof state.gameType) => {
if (shouldShowWarning) {
setPendingConfigChange({ type: 'gameType', value })
setShowConfigWarning(true)
} else {
setGameType(value)
}
}
const handleSetDifficulty = (value: typeof state.difficulty) => {
if (shouldShowWarning) {
setPendingConfigChange({ type: 'difficulty', value })
setShowConfigWarning(true)
} else {
setDifficulty(value)
}
}
const handleSetTurnTimer = (value: typeof state.turnTimer) => {
if (shouldShowWarning) {
setPendingConfigChange({ type: 'turnTimer', value })
setShowConfigWarning(true)
} else {
setTurnTimer(value)
}
}
// Apply pending config change after warning
const applyPendingChange = () => {
if (pendingConfigChange) {
switch (pendingConfigChange.type) {
case 'gameType':
setGameType(pendingConfigChange.value)
break
case 'difficulty':
setDifficulty(pendingConfigChange.value)
break
case 'turnTimer':
setTurnTimer(pendingConfigChange.value)
break
}
setHasSeenWarning(true)
setPendingConfigChange(null)
setShowConfigWarning(false)
}
}
// Cancel config change
const cancelConfigChange = () => {
setPendingConfigChange(null)
setShowConfigWarning(false)
}
const handleStartOrResumeGame = () => {
if (canResumeGame) {
resumeGame()
} else {
startGame()
}
}
const getButtonStyles = (
@@ -150,6 +224,94 @@ export function SetupPhase() {
minHeight: 0, // Allow shrinking
})}
>
{/* PAUSE/RESUME: Config change warning */}
{showConfigWarning && (
<div
className={css({
p: '4',
background:
'linear-gradient(135deg, rgba(251, 191, 36, 0.15), rgba(245, 158, 11, 0.15))',
border: '2px solid',
borderColor: 'yellow.400',
rounded: 'xl',
textAlign: 'center',
boxShadow: '0 4px 12px rgba(251, 191, 36, 0.2)',
})}
>
<p
className={css({
color: 'yellow.700',
fontSize: { base: '15px', md: '17px' },
fontWeight: 'bold',
marginBottom: '8px',
})}
>
Warning: Changing Settings Will End Current Game
</p>
<p
className={css({
color: 'gray.600',
fontSize: { base: '13px', md: '14px' },
marginBottom: '12px',
})}
>
You have a paused game in progress. Changing any setting will end it and you won't be
able to resume.
</p>
<div
className={css({
display: 'flex',
gap: '8px',
justifyContent: 'center',
flexWrap: 'wrap',
})}
>
<button
className={css({
background: 'linear-gradient(135deg, #10b981, #059669)',
color: 'white',
border: 'none',
borderRadius: '8px',
padding: '8px 16px',
fontSize: '14px',
fontWeight: 'bold',
cursor: 'pointer',
transition: 'all 0.2s',
boxShadow: '0 2px 8px rgba(16, 185, 129, 0.3)',
_hover: {
transform: 'translateY(-2px)',
boxShadow: '0 4px 12px rgba(16, 185, 129, 0.4)',
},
})}
onClick={cancelConfigChange}
>
✓ Keep Game & Cancel Change
</button>
<button
className={css({
background: 'linear-gradient(135deg, #ef4444, #dc2626)',
color: 'white',
border: 'none',
borderRadius: '8px',
padding: '8px 16px',
fontSize: '14px',
fontWeight: 'bold',
cursor: 'pointer',
transition: 'all 0.2s',
boxShadow: '0 2px 8px rgba(239, 68, 68, 0.3)',
_hover: {
transform: 'translateY(-2px)',
boxShadow: '0 4px 12px rgba(239, 68, 68, 0.4)',
},
})}
onClick={applyPendingChange}
>
✗ End Game & Apply Change
</button>
</div>
</div>
)}
{/* Warning if no players */}
{activePlayerCount === 0 && (
<div
@@ -200,7 +362,7 @@ export function SetupPhase() {
>
<button
className={getButtonStyles(state.gameType === 'abacus-numeral', 'secondary')}
onClick={() => setGameType('abacus-numeral')}
onClick={() => handleSetGameType('abacus-numeral')}
>
<div
className={css({
@@ -246,7 +408,7 @@ export function SetupPhase() {
</button>
<button
className={getButtonStyles(state.gameType === 'complement-pairs', 'secondary')}
onClick={() => setGameType('complement-pairs')}
onClick={() => handleSetGameType('complement-pairs')}
>
<div
className={css({
@@ -342,7 +504,7 @@ export function SetupPhase() {
<button
key={difficulty}
className={getButtonStyles(state.difficulty === difficulty, 'difficulty')}
onClick={() => setDifficulty(difficulty)}
onClick={() => handleSetDifficulty(difficulty)}
>
<div
className={css({
@@ -414,7 +576,7 @@ export function SetupPhase() {
<button
key={timer}
className={getButtonStyles(state.turnTimer === timer, 'secondary')}
onClick={() => dispatch({ type: 'SET_TURN_TIMER', timer })}
onClick={() => handleSetTurnTimer(timer)}
>
<div
className={css({
@@ -464,7 +626,9 @@ export function SetupPhase() {
>
<button
className={css({
background: 'linear-gradient(135deg, #ff6b6b 0%, #ee5a24 50%, #ff9ff3 100%)',
background: canResumeGame
? 'linear-gradient(135deg, #10b981 0%, #059669 50%, #34d399 100%)'
: 'linear-gradient(135deg, #ff6b6b 0%, #ee5a24 50%, #ff9ff3 100%)',
color: 'white',
border: 'none',
borderRadius: { base: '16px', sm: '20px', md: '24px' },
@@ -473,7 +637,9 @@ export function SetupPhase() {
fontWeight: 'black',
cursor: 'pointer',
transition: 'all 0.4s cubic-bezier(0.4, 0, 0.2, 1)',
boxShadow: '0 8px 20px rgba(255, 107, 107, 0.4), inset 0 2px 0 rgba(255,255,255,0.3)',
boxShadow: canResumeGame
? '0 8px 20px rgba(16, 185, 129, 0.4), inset 0 2px 0 rgba(255,255,255,0.3)'
: '0 8px 20px rgba(255, 107, 107, 0.4), inset 0 2px 0 rgba(255,255,255,0.3)',
textShadow: '0 2px 4px rgba(0,0,0,0.3)',
position: 'relative',
overflow: 'hidden',
@@ -491,9 +657,12 @@ export function SetupPhase() {
},
_hover: {
transform: { base: 'translateY(-2px)', md: 'translateY(-3px) scale(1.02)' },
boxShadow:
'0 12px 30px rgba(255, 107, 107, 0.6), inset 0 2px 0 rgba(255,255,255,0.3)',
background: 'linear-gradient(135deg, #ff5252 0%, #dd2c00 50%, #e91e63 100%)',
boxShadow: canResumeGame
? '0 12px 30px rgba(16, 185, 129, 0.6), inset 0 2px 0 rgba(255,255,255,0.3)'
: '0 12px 30px rgba(255, 107, 107, 0.6), inset 0 2px 0 rgba(255,255,255,0.3)',
background: canResumeGame
? 'linear-gradient(135deg, #059669 0%, #047857 50%, #10b981 100%)'
: 'linear-gradient(135deg, #ff5252 0%, #dd2c00 50%, #e91e63 100%)',
_before: {
left: '100%',
},
@@ -502,7 +671,7 @@ export function SetupPhase() {
transform: 'translateY(-1px) scale(1.01)',
},
})}
onClick={handleStartGame}
onClick={handleStartOrResumeGame}
>
<div
className={css({
@@ -518,9 +687,9 @@ export function SetupPhase() {
animation: 'bounce 2s infinite',
})}
>
🚀
{canResumeGame ? '▶️' : '🚀'}
</span>
<span>START GAME</span>
<span>{canResumeGame ? 'RESUME GAME' : 'START GAME'}</span>
<span
className={css({
fontSize: { base: '18px', sm: '20px', md: '24px' },
@@ -528,7 +697,7 @@ export function SetupPhase() {
animationDelay: '0.5s',
})}
>
🎮
{canResumeGame ? '🎮' : '🎮'}
</span>
</div>
</button>

View File

@@ -146,22 +146,77 @@ export function ArcadeMemoryPairsProvider({ children }: { children: ReactNode })
// Computed values
const isGameActive = state.gamePhase === 'playing'
const { players } = useGameMode()
const canFlipCard = useCallback(
(cardId: string): boolean => {
if (!isGameActive || state.isProcessingMove) return false
console.log('[canFlipCard] Checking card:', {
cardId,
isGameActive,
isProcessingMove: state.isProcessingMove,
currentPlayer: state.currentPlayer,
hasRoomData: !!roomData,
flippedCardsCount: state.flippedCards.length,
})
if (!isGameActive || state.isProcessingMove) {
console.log('[canFlipCard] Blocked: game not active or processing')
return false
}
const card = state.gameCards.find((c) => c.id === cardId)
if (!card || card.matched) return false
if (!card || card.matched) {
console.log('[canFlipCard] Blocked: card not found or already matched')
return false
}
// Can't flip if already flipped
if (state.flippedCards.some((c) => c.id === cardId)) return false
if (state.flippedCards.some((c) => c.id === cardId)) {
console.log('[canFlipCard] Blocked: card already flipped')
return false
}
// Can't flip more than 2 cards
if (state.flippedCards.length >= 2) return false
if (state.flippedCards.length >= 2) {
console.log('[canFlipCard] Blocked: 2 cards already flipped')
return false
}
// Authorization check: Only allow flipping if it's your player's turn
if (roomData && state.currentPlayer) {
const currentPlayerData = players.get(state.currentPlayer)
console.log('[canFlipCard] Authorization check:', {
currentPlayerId: state.currentPlayer,
currentPlayerFound: !!currentPlayerData,
currentPlayerIsLocal: currentPlayerData?.isLocal,
})
// Block if current player is explicitly marked as remote (isLocal === false)
if (currentPlayerData && currentPlayerData.isLocal === false) {
console.log('[canFlipCard] BLOCKED: Current player is remote (not your turn)')
return false
}
// If player data not found in map, this might be an issue - allow for now but warn
if (!currentPlayerData) {
console.warn(
'[canFlipCard] WARNING: Current player not found in players map, allowing move'
)
}
}
console.log('[canFlipCard] ALLOWED: All checks passed')
return true
},
[isGameActive, state.isProcessingMove, state.gameCards, state.flippedCards]
[
isGameActive,
state.isProcessingMove,
state.gameCards,
state.flippedCards,
state.currentPlayer,
roomData,
players,
]
)
const currentGameStatistics: GameStatistics = useMemo(

View File

@@ -0,0 +1,560 @@
'use client'
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 { 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 for local-only games
const initialState: MemoryPairsState = {
cards: [],
gameCards: [],
flippedCards: [],
gameType: 'abacus-numeral',
difficulty: 6,
turnTimer: 30,
gamePhase: 'setup',
currentPlayer: '',
matchedPairs: 0,
totalPairs: 6,
moves: 0,
scores: {},
activePlayers: [],
playerMetadata: {},
consecutiveMatches: {},
gameStartTime: null,
gameEndTime: null,
currentMoveStartTime: null,
timerInterval: null,
celebrationAnimations: [],
isProcessingMove: false,
showMismatchFeedback: false,
lastMatchedPair: null,
originalConfig: undefined,
pausedGamePhase: undefined,
pausedGameState: undefined,
playerHovers: {},
}
// 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':
return {
...state,
gamePhase: 'playing',
gameCards: action.cards,
cards: action.cards,
flippedCards: [],
matchedPairs: 0,
moves: 0,
scores: action.activePlayers.reduce((acc: any, p: string) => ({ ...acc, [p]: 0 }), {}),
consecutiveMatches: action.activePlayers.reduce((acc: any, p: string) => ({ ...acc, [p]: 0 }), {}),
activePlayers: action.activePlayers,
playerMetadata: action.playerMetadata,
currentPlayer: action.activePlayers[0] || '',
gameStartTime: Date.now(),
gameEndTime: null,
currentMoveStartTime: Date.now(),
celebrationAnimations: [],
isProcessingMove: false,
showMismatchFeedback: false,
lastMatchedPair: null,
originalConfig: {
gameType: state.gameType,
difficulty: state.difficulty,
turnTimer: state.turnTimer,
},
pausedGamePhase: undefined,
pausedGameState: undefined,
}
case 'FLIP_CARD': {
const card = state.gameCards.find((c) => c.id === action.cardId)
if (!card) return state
const newFlippedCards = [...state.flippedCards, card]
return {
...state,
flippedCards: newFlippedCards,
currentMoveStartTime: state.flippedCards.length === 0 ? Date.now() : state.currentMoveStartTime,
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': {
return {
...state,
flippedCards: [],
showMismatchFeedback: false,
isProcessingMove: false,
}
}
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': {
const isPausingGame = state.gamePhase === 'playing' || state.gamePhase === 'results'
return {
...state,
gamePhase: 'setup',
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,
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,
}
}
case 'SET_CONFIG': {
const clearPausedGame = !!state.pausedGamePhase
return {
...state,
[action.field]: action.value,
...(action.field === 'difficulty' ? { totalPairs: action.value } : {}),
...(clearPausedGame
? { pausedGamePhase: undefined, pausedGameState: undefined, originalConfig: undefined }
: {}),
}
}
case 'RESUME_GAME': {
if (!state.pausedGamePhase || !state.pausedGameState) {
return state
}
return {
...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,
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-ONLY play (no network, no arcade session)
export function LocalMemoryPairsProvider({ children }: { children: ReactNode }) {
const router = useRouter()
const { data: viewerId } = useViewerId()
// 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 = activePlayers.length > 1 ? 'multiplayer' : 'single'
// Pure client-side state with useReducer
const [state, dispatch] = useReducer(localMemoryPairsReducer, initialState)
// Handle mismatch feedback timeout and player switching
useEffect(() => {
if (state.showMismatchFeedback && state.flippedCards.length === 2) {
const timeout = setTimeout(() => {
dispatch({ type: 'CLEAR_MISMATCH' })
// Switch to next player after mismatch
dispatch({ type: 'SWITCH_PLAYER' })
}, 1500)
return () => clearTimeout(timeout)
}
}, [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 => {
if (!isGameActive || state.isProcessingMove) {
return false
}
const card = state.gameCards.find((c) => c.id === cardId)
if (!card || card.matched) {
return false
}
if (state.flippedCards.some((c) => c.id === cardId)) {
return false
}
if (state.flippedCards.length >= 2) {
return false
}
// In local play, all local players can flip during their turn
const currentPlayerData = players.get(state.currentPlayer)
if (currentPlayerData && currentPlayerData.isLocal === false) {
return false
}
return true
},
[isGameActive, state.isProcessingMove, state.gameCards, state.flippedCards, state.currentPlayer, players]
)
const currentGameStatistics: GameStatistics = useMemo(
() => ({
totalMoves: state.moves,
matchedPairs: state.matchedPairs,
totalPairs: state.totalPairs,
gameTime: state.gameStartTime ? (state.gameEndTime || Date.now()) - state.gameStartTime : 0,
accuracy: state.moves > 0 ? (state.matchedPairs / state.moves) * 100 : 0,
averageTimePerMove:
state.moves > 0 && state.gameStartTime
? ((state.gameEndTime || Date.now()) - state.gameStartTime) / state.moves
: 0,
}),
[state.moves, state.matchedPairs, state.totalPairs, state.gameStartTime, state.gameEndTime]
)
const hasConfigChanged = useMemo(() => {
if (!state.originalConfig) return false
return (
state.gameType !== state.originalConfig.gameType ||
state.difficulty !== state.originalConfig.difficulty ||
state.turnTimer !== state.originalConfig.turnTimer
)
}, [state.gameType, state.difficulty, state.turnTimer, state.originalConfig])
const canResumeGame = useMemo(() => {
return !!state.pausedGamePhase && !!state.pausedGameState && !hasConfigChanged
}, [state.pausedGamePhase, state.pausedGameState, hasConfigChanged])
// Action creators
const startGame = useCallback(() => {
if (activePlayers.length === 0) {
console.error('[LocalMemoryPairs] Cannot start game without active players')
return
}
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 cards = generateGameCards(state.gameType, state.difficulty)
dispatch({
type: 'START_GAME',
cards,
activePlayers,
playerMetadata,
})
}, [state.gameType, state.difficulty, activePlayers, players, viewerId])
const flipCard = useCallback(
(cardId: string) => {
if (!canFlipCard(cardId)) {
return
}
dispatch({ type: 'FLIP_CARD', cardId })
},
[canFlipCard]
)
const resetGame = useCallback(() => {
if (activePlayers.length === 0) {
console.error('[LocalMemoryPairs] Cannot reset game without active players')
return
}
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 cards = generateGameCards(state.gameType, state.difficulty)
dispatch({
type: 'START_GAME',
cards,
activePlayers,
playerMetadata,
})
}, [state.gameType, state.difficulty, activePlayers, players, viewerId])
const setGameType = useCallback((gameType: typeof state.gameType) => {
dispatch({ type: 'SET_CONFIG', field: 'gameType', value: gameType })
}, [])
const setDifficulty = useCallback((difficulty: typeof state.difficulty) => {
dispatch({ type: 'SET_CONFIG', field: 'difficulty', value: difficulty })
}, [])
const setTurnTimer = useCallback((turnTimer: typeof state.turnTimer) => {
dispatch({ type: 'SET_CONFIG', field: 'turnTimer', value: turnTimer })
}, [])
const resumeGame = useCallback(() => {
if (!canResumeGame) {
console.warn('[LocalMemoryPairs] Cannot resume - no paused game or config changed')
return
}
dispatch({ type: 'RESUME_GAME' })
}, [canResumeGame])
const goToSetup = useCallback(() => {
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])
const effectiveState = { ...state, gameMode } as MemoryPairsState & { gameMode: GameMode }
const contextValue: MemoryPairsContextValue = {
state: effectiveState,
dispatch: () => {
// 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,
resetGame,
goToSetup,
setGameType,
setDifficulty,
setTurnTimer,
hoverCard,
exitSession,
gameMode,
activePlayers,
}
return <MemoryPairsContext.Provider value={contextValue}>{children}</MemoryPairsContext.Provider>
}

View File

@@ -251,7 +251,7 @@ function memoryPairsReducer(state: MemoryPairsState, action: MemoryPairsAction):
}
// Create context
const MemoryPairsContext = createContext<MemoryPairsContextValue | null>(null)
export const MemoryPairsContext = createContext<MemoryPairsContextValue | null>(null)
// Provider component
export function MemoryPairsProvider({ children }: { children: ReactNode }) {

View File

@@ -0,0 +1,582 @@
'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'
import type { GameMove } from '@/lib/arcade/validation'
import { useGameMode } from '../../../../contexts/GameModeContext'
import { generateGameCards } from '../utils/cardGeneration'
import { MemoryPairsContext } from './MemoryPairsContext'
import type { GameMode, GameStatistics, MemoryPairsContextValue, MemoryPairsState } from './types'
// Initial state
const initialState: MemoryPairsState = {
cards: [],
gameCards: [],
flippedCards: [],
gameType: 'abacus-numeral',
difficulty: 6,
turnTimer: 30,
gamePhase: 'setup',
currentPlayer: '', // Will be set to first player ID on START_GAME
matchedPairs: 0,
totalPairs: 6,
moves: 0,
scores: {},
activePlayers: [],
playerMetadata: {}, // Player metadata for cross-user visibility
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: {},
}
/**
* 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) {
case 'START_GAME':
// Generate cards and initialize game
return {
...state,
gamePhase: 'playing',
gameCards: move.data.cards,
cards: move.data.cards,
flippedCards: [],
matchedPairs: 0,
moves: 0,
scores: move.data.activePlayers.reduce((acc: any, p: string) => ({ ...acc, [p]: 0 }), {}),
consecutiveMatches: move.data.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] || '',
gameStartTime: Date.now(),
gameEndTime: null,
currentMoveStartTime: Date.now(),
celebrationAnimations: [],
isProcessingMove: false,
showMismatchFeedback: false,
lastMatchedPair: null,
// PAUSE/RESUME: Save original config and clear paused state
originalConfig: {
gameType: state.gameType,
difficulty: state.difficulty,
turnTimer: state.turnTimer,
},
pausedGamePhase: undefined,
pausedGameState: undefined,
}
case 'FLIP_CARD': {
// Optimistically flip the card
const card = state.gameCards.find((c) => c.id === move.data.cardId)
if (!card) return state
const newFlippedCards = [...state.flippedCards, card]
return {
...state,
flippedCards: newFlippedCards,
currentMoveStartTime:
state.flippedCards.length === 0 ? Date.now() : state.currentMoveStartTime,
isProcessingMove: newFlippedCards.length === 2, // Processing if 2 cards flipped
showMismatchFeedback: false,
}
}
case 'CLEAR_MISMATCH': {
// Clear mismatched cards and feedback
return {
...state,
flippedCards: [],
showMismatchFeedback: false,
isProcessingMove: false,
}
}
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
? {
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,
// Reset visible game state
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,
}
}
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
...(clearPausedGame
? { pausedGamePhase: undefined, pausedGameState: undefined, originalConfig: undefined }
: {}),
}
}
case 'RESUME_GAME': {
// Resume paused game
if (!state.pausedGamePhase || !state.pausedGameState) {
return state // No paused game, no-op
}
return {
...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,
}
}
case 'HOVER_CARD': {
// Update player hover state for networked presence
return {
...state,
playerHovers: {
...state.playerHovers,
[move.playerId]: move.data.cardId,
},
}
}
default:
return state
}
}
// 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
const { activePlayerCount, activePlayers: activePlayerIds, players } = useGameMode()
// Get active player IDs directly as strings (UUIDs)
const activePlayers = Array.from(activePlayerIds)
// Derive game mode from active player count
const gameMode = activePlayerCount > 1 ? 'multiplayer' : 'single'
// NO LOCAL STATE - Configuration lives in session state
// Changes are sent as moves and synchronized across all room members
// Arcade session integration WITH room sync
const {
state,
sendMove,
connected: _connected,
exitSession,
} = useArcadeSession<MemoryPairsState>({
userId: viewerId || '',
roomId: roomData?.id, // CRITICAL: Pass roomId for network sync across room members
initialState,
applyMove: applyMoveOptimistically,
})
// Handle mismatch feedback timeout
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: {},
})
}, 1500)
return () => clearTimeout(timeout)
}
}, [state.showMismatchFeedback, state.flippedCards.length, sendMove, state.currentPlayer])
// Computed values
const isGameActive = state.gamePhase === 'playing'
const canFlipCard = useCallback(
(cardId: string): boolean => {
console.log('[RoomProvider][canFlipCard] Checking card:', {
cardId,
isGameActive,
isProcessingMove: state.isProcessingMove,
currentPlayer: state.currentPlayer,
hasRoomData: !!roomData,
flippedCardsCount: state.flippedCards.length,
})
if (!isGameActive || state.isProcessingMove) {
console.log('[RoomProvider][canFlipCard] Blocked: game not active or processing')
return false
}
const card = state.gameCards.find((c) => c.id === cardId)
if (!card || card.matched) {
console.log('[RoomProvider][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('[RoomProvider][canFlipCard] Blocked: card already flipped')
return false
}
// Can't flip more than 2 cards
if (state.flippedCards.length >= 2) {
console.log('[RoomProvider][canFlipCard] Blocked: 2 cards already flipped')
return false
}
// Authorization check: Only allow flipping if it's your player's turn
if (roomData && state.currentPlayer) {
const currentPlayerData = players.get(state.currentPlayer)
console.log('[RoomProvider][canFlipCard] Authorization check:', {
currentPlayerId: state.currentPlayer,
currentPlayerFound: !!currentPlayerData,
currentPlayerIsLocal: currentPlayerData?.isLocal,
})
// Block if current player is explicitly marked as remote (isLocal === false)
if (currentPlayerData && currentPlayerData.isLocal === false) {
console.log(
'[RoomProvider][canFlipCard] BLOCKED: Current player is remote (not your turn)'
)
return false
}
// If player data not found in map, this might be an issue - allow for now but warn
if (!currentPlayerData) {
console.warn(
'[RoomProvider][canFlipCard] WARNING: Current player not found in players map, allowing move'
)
}
}
console.log('[RoomProvider][canFlipCard] ALLOWED: All checks passed')
return true
},
[
isGameActive,
state.isProcessingMove,
state.gameCards,
state.flippedCards,
state.currentPlayer,
roomData,
players,
]
)
const currentGameStatistics: GameStatistics = useMemo(
() => ({
totalMoves: state.moves,
matchedPairs: state.matchedPairs,
totalPairs: state.totalPairs,
gameTime: state.gameStartTime ? (state.gameEndTime || Date.now()) - state.gameStartTime : 0,
accuracy: state.moves > 0 ? (state.matchedPairs / state.moves) * 100 : 0,
averageTimePerMove:
state.moves > 0 && state.gameStartTime
? ((state.gameEndTime || Date.now()) - state.gameStartTime) / state.moves
: 0,
}),
[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 (
state.gameType !== state.originalConfig.gameType ||
state.difficulty !== state.originalConfig.difficulty ||
state.turnTimer !== state.originalConfig.turnTimer
)
}, [state.gameType, state.difficulty, state.turnTimer, state.originalConfig])
const canResumeGame = useMemo(() => {
return !!state.pausedGamePhase && !!state.pausedGameState && !hasConfigChanged
}, [state.pausedGamePhase, state.pausedGameState, hasConfigChanged])
// Action creators - send moves to arcade session
const startGame = useCallback(() => {
// Must have at least one active player
if (activePlayers.length === 0) {
console.error('[RoomMemoryPairs] 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)
if (playerData) {
playerMetadata[playerId] = {
id: playerId,
name: playerData.name,
emoji: playerData.emoji,
userId: viewerId || '',
color: playerData.color,
}
}
}
// 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({
type: 'START_GAME',
playerId: firstPlayer,
data: {
cards,
activePlayers,
playerMetadata,
},
})
}, [state.gameType, state.difficulty, activePlayers, players, viewerId, sendMove])
const flipCard = useCallback(
(cardId: string) => {
console.log('[RoomProvider] flipCard called:', {
cardId,
viewerId,
currentPlayer: state.currentPlayer,
activePlayers: state.activePlayers,
gamePhase: state.gamePhase,
canFlip: canFlipCard(cardId),
})
if (!canFlipCard(cardId)) {
console.log('[RoomProvider] 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('[RoomProvider] Sending FLIP_CARD move via sendMove:', move)
sendMove(move)
},
[canFlipCard, sendMove, viewerId, state.currentPlayer, state.activePlayers, state.gamePhase]
)
const resetGame = useCallback(() => {
// Must have at least one active player
if (activePlayers.length === 0) {
console.error('[RoomMemoryPairs] 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)
if (playerData) {
playerMetadata[playerId] = {
id: playerId,
name: playerData.name,
emoji: playerData.emoji,
userId: viewerId || '',
color: playerData.color,
}
}
}
// 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({
type: 'START_GAME',
playerId: firstPlayer,
data: {
cards,
activePlayers,
playerMetadata,
},
})
}, [state.gameType, state.difficulty, activePlayers, players, viewerId, sendMove])
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 setDifficulty = useCallback(
(difficulty: typeof state.difficulty) => {
const playerId = activePlayers[0] || ''
sendMove({
type: 'SET_CONFIG',
playerId,
data: { field: 'difficulty', value: difficulty },
})
},
[activePlayers, sendMove]
)
const setTurnTimer = useCallback(
(turnTimer: typeof state.turnTimer) => {
const playerId = activePlayers[0] || ''
sendMove({
type: 'SET_CONFIG',
playerId,
data: { field: 'turnTimer', value: turnTimer },
})
},
[activePlayers, sendMove]
)
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])
const resumeGame = useCallback(() => {
// PAUSE/RESUME: Resume paused game if config unchanged
if (!canResumeGame) {
console.warn('[RoomMemoryPairs] 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])
const hoverCard = useCallback(
(cardId: string | null) => {
// HOVER: Send hover state for networked presence
// Use current player as the one hovering
const playerId = state.currentPlayer || activePlayers[0] || ''
if (!playerId) return // No active player to send hover for
sendMove({
type: 'HOVER_CARD',
playerId,
data: { cardId },
})
},
[state.currentPlayer, activePlayers, sendMove]
)
// 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')
},
isGameActive,
canFlipCard,
currentGameStatistics,
hasConfigChanged,
canResumeGame,
canModifyPlayers: false, // Room-based games: always show buttons (false = show buttons)
startGame,
resumeGame,
flipCard,
resetGame,
goToSetup,
setGameType,
setDifficulty,
setTurnTimer,
hoverCard,
exitSession,
gameMode,
activePlayers,
}
return <MemoryPairsContext.Provider value={contextValue}>{children}</MemoryPairsContext.Provider>
}
// Export the hook for this provider
export { useMemoryPairs } from './MemoryPairsContext'

View File

@@ -0,0 +1,20 @@
/**
* Central export point for arcade matching game context
* Re-exports the hook from the appropriate provider
*/
// Export the hook (works with both local and room providers)
export { useMemoryPairs } from './MemoryPairsContext'
// Export the room provider (networked multiplayer)
export { RoomMemoryPairsProvider } from './RoomMemoryPairsProvider'
// Export types
export type {
GameCard,
GameMode,
GamePhase,
GameType,
MemoryPairsState,
MemoryPairsContextValue,
} from './types'

View File

@@ -59,6 +59,7 @@ export interface MemoryPairsState {
moves: number
scores: PlayerScore
activePlayers: Player[] // Track active player IDs
playerMetadata?: { [playerId: string]: any } // Player metadata for cross-user visibility
consecutiveMatches: { [playerId: string]: number } // Track consecutive matches per player
// Timing
@@ -72,6 +73,24 @@ export interface MemoryPairsState {
isProcessingMove: boolean
showMismatchFeedback: boolean
lastMatchedPair: [string, string] | null
// PAUSE/RESUME: Paused game state
originalConfig?: { gameType: GameType; difficulty: Difficulty; turnTimer: number }
pausedGamePhase?: GamePhase
pausedGameState?: {
gameCards: GameCard[]
currentPlayer: Player
matchedPairs: number
moves: number
scores: PlayerScore
activePlayers: Player[]
playerMetadata: { [playerId: string]: any }
consecutiveMatches: { [playerId: string]: number }
gameStartTime: number | null
}
// HOVER: Networked hover state
playerHovers?: { [playerId: string]: string | null }
}
export type MemoryPairsAction =
@@ -101,6 +120,11 @@ 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
canResumeGame?: boolean
// Actions
startGame: () => void
@@ -108,6 +132,10 @@ export interface MemoryPairsContextValue {
resetGame: () => void
setGameType: (type: GameType) => void
setDifficulty: (difficulty: Difficulty) => void
setTurnTimer?: (timer: number) => void
goToSetup?: () => void
resumeGame?: () => void
hoverCard?: (cardId: string | null) => void
exitSession: () => void
}

View File

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

View File

@@ -2,7 +2,7 @@
import { useRoomData } from '@/hooks/useRoomData'
import { MemoryPairsGame } from '../matching/components/MemoryPairsGame'
import { ArcadeMemoryPairsProvider } from '../matching/context/ArcadeMemoryPairsContext'
import { RoomMemoryPairsProvider } from '../matching/context/RoomMemoryPairsProvider'
/**
* /arcade/room - Renders the game for the user's current room
@@ -68,9 +68,9 @@ export default function RoomPage() {
switch (roomData.gameName) {
case 'matching':
return (
<ArcadeMemoryPairsProvider>
<RoomMemoryPairsProvider>
<MemoryPairsGame />
</ArcadeMemoryPairsProvider>
</RoomMemoryPairsProvider>
)
// TODO: Add other games (complement-race, memory-quiz, etc.)

View File

@@ -40,6 +40,20 @@ export interface GameStatistics {
averageTimePerMove: number
}
export interface PlayerMetadata {
id: string // Player ID
name: string
emoji: string
userId: string // Which user owns this player
color?: string
}
export interface GameConfiguration {
gameType: GameType
difficulty: Difficulty
turnTimer: number
}
export interface MemoryPairsState {
// Core game data
cards: GameCard[]
@@ -51,6 +65,22 @@ export interface MemoryPairsState {
difficulty: Difficulty
turnTimer: number // Seconds for two-player mode
// Paused game state - for Resume functionality
originalConfig?: GameConfiguration // Config when game started - used to detect changes
pausedGamePhase?: 'playing' | 'results' // Set when GO_TO_SETUP called from active game
pausedGameState?: {
// Snapshot of game state when paused
gameCards: GameCard[]
currentPlayer: Player
matchedPairs: number
moves: number
scores: PlayerScore
activePlayers: Player[]
playerMetadata: { [playerId: string]: PlayerMetadata }
consecutiveMatches: { [playerId: string]: number }
gameStartTime: number | null
}
// Game progression
gamePhase: GamePhase
currentPlayer: Player
@@ -59,6 +89,7 @@ export interface MemoryPairsState {
moves: number
scores: PlayerScore
activePlayers: Player[] // Track active player IDs
playerMetadata: { [playerId: string]: PlayerMetadata } // Player metadata snapshot for cross-user visibility
consecutiveMatches: { [playerId: string]: number } // Track consecutive matches per player
// Timing
@@ -72,6 +103,9 @@ export interface MemoryPairsState {
isProcessingMove: boolean
showMismatchFeedback: boolean
lastMatchedPair: [string, string] | null
// Hover state for networked presence
playerHovers: { [playerId: string]: string | null } // playerId -> cardId (or null if not hovering)
}
export type MemoryPairsAction =
@@ -101,13 +135,19 @@ export interface MemoryPairsContextValue {
currentGameStatistics: GameStatistics
gameMode: GameMode // Derived from global context
activePlayers: Player[] // Active player IDs from arena
hasConfigChanged: boolean // True if current config differs from originalConfig
canResumeGame: boolean // True if there's a paused game and config hasn't changed
// Actions
startGame: () => void
resumeGame: () => void
flipCard: (cardId: string) => void
resetGame: () => void
setGameType: (type: GameType) => void
setDifficulty: (difficulty: Difficulty) => void
setTurnTimer: (timer: number) => void
hoverCard: (cardId: string | null) => void // Send hover state for networked presence
goToSetup: () => void
exitSession: () => void // Exit arcade session (no-op for non-arcade mode)
}
@@ -133,14 +173,6 @@ export interface GameGridProps {
disabled?: boolean
}
// Configuration interfaces
export interface GameConfiguration {
gameMode: GameMode
gameType: GameType
difficulty: Difficulty
turnTimer: number
}
export interface MatchValidationResult {
isValid: boolean
reason?: string

View File

@@ -62,11 +62,10 @@ export function PageWithNav({
const activePlayerList = Array.from(activePlayers)
.map((id) => players.get(id))
.filter((p): p is NonNullable<typeof p> => p !== undefined && p.isLocal !== false) // Filter out remote players
.map((p) => ({ id: p.id, name: p.name, emoji: p.emoji }))
const inactivePlayerList = Array.from(players.values())
.filter((p) => !activePlayers.has(p.id) && p.isLocal !== false) // Filter out remote players
.map((p) => ({ id: p.id, name: p.name, emoji: p.emoji }))
const inactivePlayerList = Array.from(players.values()).filter(
(p) => !activePlayers.has(p.id) && p.isLocal !== false
) // Filter out remote players
// Compute game mode from active player count
const gameMode =
@@ -99,7 +98,13 @@ export function PageWithNav({
: undefined
// Compute network players (other players in the room, excluding current user)
const networkPlayers: Array<{ id: string; emoji?: string; name?: string }> =
const networkPlayers: Array<{
id: string
emoji?: string
name?: string
color?: string
memberName?: string
}> =
isInRoom && roomData
? roomData.members
.filter((member) => member.userId !== viewerId)
@@ -108,7 +113,9 @@ export function PageWithNav({
return memberPlayerList.map((player) => ({
id: player.id,
emoji: player.emoji,
name: `${player.name} (${member.displayName})`,
name: player.name,
color: player.color,
memberName: member.displayName,
}))
})
: []

View File

@@ -1,9 +1,13 @@
import React from 'react'
import { PlayerTooltip } from './PlayerTooltip'
interface Player {
id: string
name: string
emoji: string
color?: string
createdAt?: Date | number
isLocal?: boolean
}
interface ActivePlayersListProps {
@@ -24,105 +28,111 @@ export function ActivePlayersList({
return (
<>
{activePlayers.map((player) => (
<div
<PlayerTooltip
key={player.id}
style={{
position: 'relative',
fontSize: shouldEmphasize ? '48px' : '20px',
lineHeight: 1,
transition: 'font-size 0.4s cubic-bezier(0.4, 0, 0.2, 1), filter 0.4s ease',
filter: shouldEmphasize ? 'drop-shadow(0 4px 8px rgba(0,0,0,0.25))' : 'none',
cursor: shouldEmphasize ? 'pointer' : 'default',
}}
title={player.name}
onClick={() => shouldEmphasize && onConfigurePlayer(player.id)}
onMouseEnter={() => shouldEmphasize && setHoveredPlayerId(player.id)}
onMouseLeave={() => shouldEmphasize && setHoveredPlayerId(null)}
playerName={player.name}
playerColor={player.color}
isLocal={player.isLocal !== false}
createdAt={player.createdAt}
>
{player.emoji}
{shouldEmphasize && hoveredPlayerId === player.id && (
<>
{/* Configure button - bottom left */}
<button
onClick={(e) => {
e.stopPropagation()
onConfigurePlayer(player.id)
}}
style={{
position: 'absolute',
bottom: '-4px',
left: '-4px',
width: '18px',
height: '18px',
borderRadius: '50%',
border: '2px solid white',
background: '#6b7280',
color: 'white',
fontSize: '10px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
transition: 'all 0.2s ease',
padding: 0,
lineHeight: 1,
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#3b82f6'
e.currentTarget.style.transform = 'scale(1.15)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#6b7280'
e.currentTarget.style.transform = 'scale(1)'
}}
aria-label={`Configure ${player.name}`}
>
</button>
<div
style={{
position: 'relative',
fontSize: shouldEmphasize ? '48px' : '20px',
lineHeight: 1,
transition: 'font-size 0.4s cubic-bezier(0.4, 0, 0.2, 1), filter 0.4s ease',
filter: shouldEmphasize ? 'drop-shadow(0 4px 8px rgba(0,0,0,0.25))' : 'none',
cursor: shouldEmphasize ? 'pointer' : 'default',
}}
onClick={() => shouldEmphasize && onConfigurePlayer(player.id)}
onMouseEnter={() => shouldEmphasize && setHoveredPlayerId(player.id)}
onMouseLeave={() => shouldEmphasize && setHoveredPlayerId(null)}
>
{player.emoji}
{shouldEmphasize && hoveredPlayerId === player.id && (
<>
{/* Configure button - bottom left */}
<button
onClick={(e) => {
e.stopPropagation()
onConfigurePlayer(player.id)
}}
style={{
position: 'absolute',
bottom: '-4px',
left: '-4px',
width: '18px',
height: '18px',
borderRadius: '50%',
border: '2px solid white',
background: '#6b7280',
color: 'white',
fontSize: '10px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
transition: 'all 0.2s ease',
padding: 0,
lineHeight: 1,
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#3b82f6'
e.currentTarget.style.transform = 'scale(1.15)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#6b7280'
e.currentTarget.style.transform = 'scale(1)'
}}
aria-label={`Configure ${player.name}`}
>
</button>
{/* Remove button - top right */}
<button
onClick={(e) => {
e.stopPropagation()
onRemovePlayer(player.id)
}}
style={{
position: 'absolute',
top: '-4px',
right: '-4px',
width: '20px',
height: '20px',
borderRadius: '50%',
border: '2px solid white',
background: '#ef4444',
color: 'white',
fontSize: '12px',
fontWeight: 'bold',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
transition: 'all 0.2s ease',
padding: 0,
lineHeight: 1,
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#dc2626'
e.currentTarget.style.transform = 'scale(1.1)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#ef4444'
e.currentTarget.style.transform = 'scale(1)'
}}
aria-label={`Remove ${player.name}`}
>
×
</button>
</>
)}
</div>
{/* Remove button - top right */}
<button
onClick={(e) => {
e.stopPropagation()
onRemovePlayer(player.id)
}}
style={{
position: 'absolute',
top: '-4px',
right: '-4px',
width: '20px',
height: '20px',
borderRadius: '50%',
border: '2px solid white',
background: '#ef4444',
color: 'white',
fontSize: '12px',
fontWeight: 'bold',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
transition: 'all 0.2s ease',
padding: 0,
lineHeight: 1,
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#dc2626'
e.currentTarget.style.transform = 'scale(1.1)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#ef4444'
e.currentTarget.style.transform = 'scale(1)'
}}
aria-label={`Remove ${player.name}`}
>
×
</button>
</>
)}
</div>
</PlayerTooltip>
))}
</>
)

View File

@@ -1,9 +1,12 @@
import React from 'react'
import { PlayerTooltip } from './PlayerTooltip'
interface NetworkPlayer {
id: string
emoji?: string
name?: string
color?: string
memberName?: string
}
interface NetworkPlayerIndicatorProps {
@@ -17,92 +20,97 @@ interface NetworkPlayerIndicatorProps {
*/
export function NetworkPlayerIndicator({ player, shouldEmphasize }: NetworkPlayerIndicatorProps) {
const [isHovered, setIsHovered] = React.useState(false)
const playerName = player.name || `Network Player ${player.id.slice(0, 8)}`
const extraInfo = player.memberName ? `Controlled by ${player.memberName}` : undefined
return (
<div
style={{
position: 'relative',
fontSize: shouldEmphasize ? '48px' : '20px',
lineHeight: 1,
transition: 'all 0.4s cubic-bezier(0.4, 0, 0.2, 1)',
cursor: 'default',
}}
title={player.name || `Network Player ${player.id.slice(0, 8)}`}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
<PlayerTooltip
playerName={playerName}
playerColor={player.color}
isLocal={false}
extraInfo={extraInfo}
>
{/* Network frame border */}
<div
style={{
position: 'absolute',
inset: '-6px',
borderRadius: '8px',
background: `
position: 'relative',
fontSize: shouldEmphasize ? '48px' : '20px',
lineHeight: 1,
transition: 'all 0.4s cubic-bezier(0.4, 0, 0.2, 1)',
cursor: 'default',
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* Network frame border */}
<div
style={{
position: 'absolute',
inset: '-6px',
borderRadius: '8px',
background: `
linear-gradient(135deg,
rgba(59, 130, 246, 0.4),
rgba(147, 51, 234, 0.4),
rgba(236, 72, 153, 0.4))
`,
opacity: isHovered ? 1 : 0.7,
transition: 'opacity 0.2s ease',
zIndex: -1,
}}
/>
opacity: isHovered ? 1 : 0.7,
transition: 'opacity 0.2s ease',
zIndex: -1,
}}
/>
{/* Animated network signal indicator */}
<div
style={{
position: 'absolute',
top: '-8px',
right: '-8px',
width: '12px',
height: '12px',
borderRadius: '50%',
background: 'rgba(34, 197, 94, 0.9)',
boxShadow: '0 0 8px rgba(34, 197, 94, 0.6)',
animation: 'networkPulse 2s ease-in-out infinite',
zIndex: 1,
}}
title="Connected"
/>
{/* Animated network signal indicator */}
<div
style={{
position: 'absolute',
top: '-8px',
right: '-8px',
width: '12px',
height: '12px',
borderRadius: '50%',
background: 'rgba(34, 197, 94, 0.9)',
boxShadow: '0 0 8px rgba(34, 197, 94, 0.6)',
animation: 'networkPulse 2s ease-in-out infinite',
zIndex: 1,
}}
/>
{/* Player emoji or fallback */}
<div
style={{
position: 'relative',
filter: shouldEmphasize ? 'drop-shadow(0 4px 8px rgba(0,0,0,0.25))' : 'none',
}}
>
{player.emoji || '🌐'}
</div>
{/* Player emoji or fallback */}
<div
style={{
position: 'relative',
filter: shouldEmphasize ? 'drop-shadow(0 4px 8px rgba(0,0,0,0.25))' : 'none',
}}
>
{player.emoji || '🌐'}
</div>
{/* Network icon badge */}
<div
style={{
position: 'absolute',
bottom: '-4px',
left: '-4px',
width: '16px',
height: '16px',
borderRadius: '50%',
border: '2px solid white',
background: 'linear-gradient(135deg, #3b82f6, #8b5cf6)',
color: 'white',
fontSize: '8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 2px 6px rgba(0,0,0,0.3)',
zIndex: 1,
}}
title="Network Player"
>
📡
</div>
{/* Network icon badge */}
<div
style={{
position: 'absolute',
bottom: '-4px',
left: '-4px',
width: '16px',
height: '16px',
borderRadius: '50%',
border: '2px solid white',
background: 'linear-gradient(135deg, #3b82f6, #8b5cf6)',
color: 'white',
fontSize: '8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 2px 6px rgba(0,0,0,0.3)',
zIndex: 1,
}}
>
📡
</div>
<style
dangerouslySetInnerHTML={{
__html: `
<style
dangerouslySetInnerHTML={{
__html: `
@keyframes networkPulse {
0%, 100% {
opacity: 1;
@@ -114,8 +122,9 @@ export function NetworkPlayerIndicator({ player, shouldEmphasize }: NetworkPlaye
}
}
`,
}}
/>
</div>
}}
/>
</div>
</PlayerTooltip>
)
}

View File

@@ -50,10 +50,18 @@ export function PlayerConfigDialog({ playerId, onClose }: PlayerConfigDialogProp
// Get player number for UI theming (first 4 players get special colors)
const allPlayers = Array.from(players.values()).sort((a, b) => {
const aTime = typeof a.createdAt === 'number' ? a.createdAt :
a.createdAt instanceof Date ? a.createdAt.getTime() : 0
const bTime = typeof b.createdAt === 'number' ? b.createdAt :
b.createdAt instanceof Date ? b.createdAt.getTime() : 0
const aTime =
typeof a.createdAt === 'number'
? a.createdAt
: a.createdAt instanceof Date
? a.createdAt.getTime()
: 0
const bTime =
typeof b.createdAt === 'number'
? b.createdAt
: b.createdAt instanceof Date
? b.createdAt.getTime()
: 0
return aTime - bTime
})
const playerIndex = allPlayers.findIndex((p) => p.id === playerId)

View File

@@ -0,0 +1,164 @@
import * as Tooltip from '@radix-ui/react-tooltip'
import type React from 'react'
interface PlayerTooltipProps {
children: React.ReactNode
playerName: string
playerColor?: string
isLocal?: boolean
createdAt?: Date | number
extraInfo?: string
}
/**
* Radix-based tooltip for displaying rich player information
* Shows player name, type (local/network), color, and other details
*/
export function PlayerTooltip({
children,
playerName,
playerColor,
isLocal = true,
createdAt,
extraInfo,
}: PlayerTooltipProps) {
// Format creation time
const getCreatedTimeAgo = () => {
if (!createdAt) return null
const now = Date.now()
const created =
typeof createdAt === 'number'
? createdAt
: createdAt instanceof Date
? createdAt.getTime()
: 0
const diff = now - created
const minutes = Math.floor(diff / 60000)
const hours = Math.floor(diff / 3600000)
const days = Math.floor(diff / 86400000)
if (days > 0) return `${days}d ago`
if (hours > 0) return `${hours}h ago`
if (minutes > 0) return `${minutes}m ago`
return 'just now'
}
return (
<Tooltip.Provider delayDuration={200}>
<Tooltip.Root>
<Tooltip.Trigger asChild>{children}</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
side="bottom"
sideOffset={8}
style={{
background: 'linear-gradient(135deg, rgba(17, 24, 39, 0.97), rgba(31, 41, 55, 0.97))',
backdropFilter: 'blur(8px)',
borderRadius: '12px',
padding: '12px 16px',
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.1)',
maxWidth: '280px',
zIndex: 9999,
animation: 'tooltipFadeIn 0.2s ease-out',
}}
>
{/* Player name with color accent */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '8px',
}}
>
{playerColor && (
<div
style={{
width: '12px',
height: '12px',
borderRadius: '50%',
background: playerColor,
boxShadow: `0 0 8px ${playerColor}50`,
flexShrink: 0,
}}
/>
)}
<div
style={{
fontSize: '15px',
fontWeight: '600',
color: 'white',
lineHeight: 1.3,
}}
>
{playerName}
</div>
</div>
{/* Player type badge */}
<div
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
padding: '4px 8px',
borderRadius: '6px',
background: isLocal
? 'rgba(16, 185, 129, 0.15)'
: 'linear-gradient(135deg, rgba(59, 130, 246, 0.15), rgba(147, 51, 234, 0.15))',
border: `1px solid ${isLocal ? 'rgba(16, 185, 129, 0.3)' : 'rgba(147, 51, 234, 0.3)'}`,
fontSize: '11px',
fontWeight: '600',
color: isLocal ? 'rgba(167, 243, 208, 1)' : 'rgba(196, 181, 253, 1)',
marginBottom: extraInfo || createdAt ? '8px' : 0,
}}
>
<span style={{ fontSize: '10px' }}>{isLocal ? '●' : '📡'}</span>
{isLocal ? 'Your Player' : 'Network Player'}
</div>
{/* Additional info */}
{(extraInfo || createdAt) && (
<div
style={{
fontSize: '12px',
color: 'rgba(209, 213, 219, 0.9)',
lineHeight: 1.4,
marginTop: '4px',
}}
>
{extraInfo && <div>{extraInfo}</div>}
{createdAt && <div style={{ opacity: 0.7 }}>Joined {getCreatedTimeAgo()}</div>}
</div>
)}
<Tooltip.Arrow
style={{
fill: 'rgba(17, 24, 39, 0.97)',
}}
/>
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
<style
dangerouslySetInnerHTML={{
__html: `
@keyframes tooltipFadeIn {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
`,
}}
/>
</Tooltip.Provider>
)
}

View File

@@ -48,8 +48,9 @@ export interface UseArcadeSessionReturn<TState> {
/**
* Send a game move (applies optimistically and sends to server)
* Note: playerId must be provided by caller (not omitted)
*/
sendMove: (move: Omit<GameMove, 'playerId' | 'timestamp'>) => void
sendMove: (move: Omit<GameMove, 'timestamp'>) => void
/**
* Exit the arcade session
@@ -149,10 +150,10 @@ export function useArcadeSession<TState>(
// Apply optimistically
optimistic.applyOptimisticMove(fullMove)
// Send to server
socketSendMove(userId, fullMove)
// Send to server with roomId for room-based games
socketSendMove(userId, fullMove, roomId)
},
[userId, optimistic, socketSendMove]
[userId, roomId, optimistic, socketSendMove]
)
const exitSession = useCallback(() => {

View File

@@ -21,7 +21,7 @@ export interface UseArcadeSocketReturn {
socket: Socket | null
connected: boolean
joinSession: (userId: string, roomId?: string) => void
sendMove: (userId: string, move: GameMove) => void
sendMove: (userId: string, move: GameMove, roomId?: string) => void
exitSession: (userId: string) => void
pingSession: (userId: string) => void
}
@@ -119,12 +119,12 @@ export function useArcadeSocket(events: ArcadeSocketEvents = {}): UseArcadeSocke
)
const sendMove = useCallback(
(userId: string, move: GameMove) => {
(userId: string, move: GameMove, roomId?: string) => {
if (!socket) {
console.warn('[ArcadeSocket] Cannot send move - socket not connected')
return
}
const payload = { userId, move }
const payload = { userId, move, roomId }
console.log(
'[ArcadeSocket] Sending game-move event with payload:',
JSON.stringify(payload, null, 2)

View File

@@ -37,8 +37,35 @@ async function getUserIdFromGuestId(guestId: string): Promise<string | undefined
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
*/
export async function getArcadeSessionByRoom(
roomId: string
): Promise<schema.ArcadeSession | undefined> {
const [session] = await db
.select()
.from(schema.arcadeSessions)
.where(eq(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.delete(schema.arcadeSessions).where(eq(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
*/
export async function createArcadeSession(
options: CreateSessionOptions
@@ -46,6 +73,19 @@ export async function createArcadeSession(
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.query.users.findFirst({
where: eq(schema.users.guestId, options.userId),
@@ -80,6 +120,12 @@ export async function createArcadeSession(
version: 1,
}
console.log('[Session Manager] Creating new session:', {
userId: user.id,
roomId: options.roomId,
gameName: options.gameName,
})
const [session] = await db.insert(schema.arcadeSessions).values(newSession).returning()
return session
}
@@ -130,9 +176,18 @@ export async function getArcadeSession(guestId: string): Promise<schema.ArcadeSe
/**
* 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)
*/
export async function applyGameMove(userId: string, move: GameMove): Promise<SessionUpdateResult> {
const session = await getArcadeSession(userId)
export async function applyGameMove(
userId: string,
move: GameMove,
roomId?: string
): Promise<SessionUpdateResult> {
// 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 {
@@ -159,8 +214,40 @@ export async function applyGameMove(userId: string, move: GameMove): Promise<Ses
gameStatePhase: (session.gameState as any)?.gamePhase,
})
// Validate the move
const validationResult = validator.validateMove(session.gameState, move)
// Fetch player ownership for authorization checks (room-based games)
let playerOwnership: Record<string, string> | undefined
let internalUserId: string | undefined
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.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,

View File

@@ -15,17 +15,38 @@ import { canFlipCard, validateMatch } from '@/app/games/matching/utils/matchVali
import type { GameValidator, MatchingGameMove, ValidationResult } from './types'
export class MatchingGameValidator implements GameValidator<MemoryPairsState, MatchingGameMove> {
validateMove(state: MemoryPairsState, move: MatchingGameMove): ValidationResult {
validateMove(
state: MemoryPairsState,
move: MatchingGameMove,
context?: { userId?: string; playerOwnership?: Record<string, string> }
): ValidationResult {
switch (move.type) {
case 'FLIP_CARD':
return this.validateFlipCard(state, move.data.cardId, move.playerId)
return this.validateFlipCard(state, move.data.cardId, move.playerId, context)
case 'START_GAME':
return this.validateStartGame(state, move.data.activePlayers, move.data.cards)
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,
@@ -37,7 +58,8 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
private validateFlipCard(
state: MemoryPairsState,
cardId: string,
playerId: string
playerId: string,
context?: { userId?: string; playerOwnership?: Record<string, string> }
): ValidationResult {
// Game must be in playing phase
if (state.gamePhase !== 'playing') {
@@ -63,6 +85,22 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
}
}
// 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) {
@@ -162,7 +200,8 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
private validateStartGame(
state: MemoryPairsState,
activePlayers: Player[],
cards?: GameCard[]
cards?: GameCard[],
playerMetadata?: { [playerId: string]: any }
): ValidationResult {
// Allow starting a new game from any phase (for "New Game" button)
@@ -182,6 +221,7 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
gameCards,
cards: gameCards,
activePlayers,
playerMetadata: playerMetadata || {}, // Store player metadata for cross-user visibility
gamePhase: 'playing',
gameStartTime: Date.now(),
currentPlayer: activePlayers[0],
@@ -190,6 +230,15 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
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 {
@@ -199,6 +248,16 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
}
private validateClearMismatch(state: MemoryPairsState): ValidationResult {
// 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,
@@ -211,6 +270,243 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
}
}
/**
* 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.)
*/
private validateGoToSetup(state: MemoryPairsState): ValidationResult {
// 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
*/
private validateSetConfig(
state: MemoryPairsState,
field: 'gameType' | 'difficulty' | 'turnTimer',
value: any
): ValidationResult {
// 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
*/
private validateResumeGame(state: MemoryPairsState): ValidationResult {
// 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.
*/
private validateHoverCard(
state: MemoryPairsState,
cardId: string | null,
playerId: string
): ValidationResult {
// 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: MemoryPairsState): boolean {
return state.gamePhase === 'results' || state.matchedPairs === state.totalPairs
}
@@ -234,6 +530,7 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
moves: 0,
scores: {},
activePlayers: [],
playerMetadata: {}, // Initialize empty player metadata
consecutiveMatches: {},
gameStartTime: null,
gameEndTime: null,
@@ -243,6 +540,12 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
isProcessingMove: false,
showMismatchFeedback: false,
lastMatchedPair: null,
// PAUSE/RESUME: Initialize paused game fields
originalConfig: undefined,
pausedGamePhase: undefined,
pausedGameState: undefined,
// HOVER: Initialize hover state
playerHovers: {},
}
}
}

View File

@@ -33,6 +33,7 @@ export interface MatchingStartGameMove extends GameMove {
data: {
activePlayers: string[] // Player IDs (UUIDs)
cards?: any[] // GameCard type from context
playerMetadata?: { [playerId: string]: any } // Player metadata for cross-user visibility
}
}
@@ -41,22 +42,63 @@ export interface MatchingClearMismatchMove extends GameMove {
data: Record<string, never>
}
// Standard setup moves - pattern for all arcade games
export interface MatchingGoToSetupMove extends GameMove {
type: 'GO_TO_SETUP'
data: Record<string, never>
}
export interface MatchingSetConfigMove extends GameMove {
type: 'SET_CONFIG'
data: {
field: 'gameType' | 'difficulty' | 'turnTimer'
value: any
}
}
export interface MatchingResumeGameMove extends GameMove {
type: 'RESUME_GAME'
data: Record<string, never>
}
export interface MatchingHoverCardMove extends GameMove {
type: 'HOVER_CARD'
data: {
cardId: string | null // null when mouse leaves card
}
}
export type MatchingGameMove =
| MatchingFlipCardMove
| MatchingStartGameMove
| MatchingClearMismatchMove
| MatchingGoToSetupMove
| MatchingSetConfigMove
| MatchingResumeGameMove
| MatchingHoverCardMove
// Generic game state union
export type GameState = MemoryPairsState // Add other game states as union later
/**
* Validation context for authorization checks
*/
export interface ValidationContext {
userId?: string
playerOwnership?: Record<string, string> // playerId -> userId mapping
}
/**
* Base validator interface that all games must implement
*/
export interface GameValidator<TState = unknown, TMove extends GameMove = GameMove> {
/**
* Validate a game move and return the new state if valid
* @param state Current game state
* @param move The move to validate
* @param context Optional validation context for authorization checks
*/
validateMove(state: TState, move: TMove): ValidationResult
validateMove(state: TState, move: TMove, context?: ValidationContext): ValidationResult
/**
* Check if the game is in a terminal state (completed)

View File

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