Compare commits

...

28 Commits

Author SHA1 Message Date
semantic-release-bot
3541466630 chore(release): 2.12.2 [skip ci]
## [2.12.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.12.1...v2.12.2) (2025-10-09)

### Bug Fixes

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

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

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

Note: ArcadeMemoryPairsProvider can be deprecated in future cleanup.

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

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

### Bug Fixes

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

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

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

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

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

### Features

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

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

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

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

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

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

### Features

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

### Bug Fixes

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

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

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

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

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

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

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

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

Server validates all moves and sends authoritative state back.

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

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

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

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

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

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

### Bug Fixes

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

### Bug Fixes

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

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

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

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

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

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

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

### Features

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

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

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

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

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

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

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

### Features

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

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

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

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

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

### Bug Fixes

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

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

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

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

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

### Bug Fixes

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

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

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

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

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

### Bug Fixes

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

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

This prevents the redirect conflict and allows proper navigation.

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

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

### Bug Fixes

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 20:10:40 -05:00
37 changed files with 3935 additions and 355 deletions

View File

@@ -1,3 +1,96 @@
## [2.12.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.12.1...v2.12.2) (2025-10-09)
### Bug Fixes
* use RoomMemoryPairsProvider in room page ([c279731](https://github.com/antialias/soroban-abacus-flashcards/commit/c27973191f0144604e17a8a14adf0a88df476e27))
## [2.12.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.12.0...v2.12.1) (2025-10-09)
### Bug Fixes
* export MemoryPairsContext to fix provider hook error ([80ad33e](https://github.com/antialias/soroban-abacus-flashcards/commit/80ad33eec0b6946702eaa9cf1b1c246852864b00))
## [2.12.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.11.0...v2.12.0) (2025-10-09)
### Features
* add networked hover state infrastructure for multiplayer presence ([d149799](https://github.com/antialias/soroban-abacus-flashcards/commit/d14979907c5df9b793a1c110028fc5b54457f507))
## [2.11.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.10.1...v2.11.0) (2025-10-09)
### Features
* add pause/resume game state architecture ([05eacac](https://github.com/antialias/soroban-abacus-flashcards/commit/05eacac438dbaf405ce91e188c53dbbe2e9f9507))
* add Resume button and config change warning to setup UI ([b5ee04f](https://github.com/antialias/soroban-abacus-flashcards/commit/b5ee04f57651f53517468fcc4c456f0ccb65a8e2))
* implement pause/resume in game providers with optimistic updates ([ce30fca](https://github.com/antialias/soroban-abacus-flashcards/commit/ce30fcaf55270f9089249bd13ba73a25fbfa5ab4))
### Bug Fixes
* convert guestId to internal userId for player ownership check ([3a01f46](https://github.com/antialias/soroban-abacus-flashcards/commit/3a01f4637d2081c66fe37c7f8cfee229442ec744))
* implement shared session architecture for room-based multiplayer ([2856f4b](https://github.com/antialias/soroban-abacus-flashcards/commit/2856f4b83fbcc6483d96cc6e7da2fe5bc911625d))
## [2.10.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.10.1...v2.10.2) (2025-10-09)
### Bug Fixes
* convert guestId to internal userId for player ownership check ([3a01f46](https://github.com/antialias/soroban-abacus-flashcards/commit/3a01f4637d2081c66fe37c7f8cfee229442ec744))
* implement shared session architecture for room-based multiplayer ([2856f4b](https://github.com/antialias/soroban-abacus-flashcards/commit/2856f4b83fbcc6483d96cc6e7da2fe5bc911625d))
## [2.10.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.10.0...v2.10.1) (2025-10-09)
### Bug Fixes
* enforce player ownership authorization for multiplayer games ([71b0aac](https://github.com/antialias/soroban-abacus-flashcards/commit/71b0aac13c970c03fe8d296d41e9472ad72a00fa))
## [2.10.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.9.0...v2.10.0) (2025-10-09)
### Features
* implement rich Radix UI tooltips for player avatars ([d03c789](https://github.com/antialias/soroban-abacus-flashcards/commit/d03c7898799b378f912f47d7267a00bc7ce3d580))
## [2.9.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.7...v2.9.0) (2025-10-09)
### Features
* implement auto-save for player settings modal ([a83dc09](https://github.com/antialias/soroban-abacus-flashcards/commit/a83dc097e43c265a297281da54754f58ac831754))
## [2.8.7](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.6...v2.8.7) (2025-10-09)
### Bug Fixes
* enable real-time player name updates across room members ([5171be3](https://github.com/antialias/soroban-abacus-flashcards/commit/5171be3d37980eb1c98aa0d1e1d6e06f589763d1))
## [2.8.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.5...v2.8.6) (2025-10-09)
### Bug Fixes
* prevent duplicate display of network avatars in nav ([d474ef0](https://github.com/antialias/soroban-abacus-flashcards/commit/d474ef07d69cf0b4f5dedd404616e3bbee7289fe))
## [2.8.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.4...v2.8.5) (2025-10-09)
### Bug Fixes
* remove redirect loop by not redirecting from room page ([10cf715](https://github.com/antialias/soroban-abacus-flashcards/commit/10cf71527f7cede7fd93e502dbfc59df99b5a524))
## [2.8.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.3...v2.8.4) (2025-10-09)
### Bug Fixes
* prevent redirect loops by checking if already at target URL ([c5268b7](https://github.com/antialias/soroban-abacus-flashcards/commit/c5268b79dee66aa02e14e2024fe1c6242a172ed3))
## [2.8.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.2...v2.8.3) (2025-10-08)

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,16 @@
{
"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:*)"
],
"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

@@ -90,6 +90,7 @@ describe('Room Navigation with Active Sessions', () => {
},
isLoading: false,
isInRoom: true,
notifyRoomOfPlayerUpdate: vi.fn(),
})
// Mock rooms API
@@ -140,6 +141,7 @@ describe('Room Navigation with Active Sessions', () => {
roomData: null,
isLoading: false,
isInRoom: false,
notifyRoomOfPlayerUpdate: vi.fn(),
})
;(global.fetch as any).mockResolvedValue({
@@ -172,6 +174,7 @@ describe('Room Navigation with Active Sessions', () => {
roomData: null,
isLoading: false,
isInRoom: false,
notifyRoomOfPlayerUpdate: vi.fn(),
})
;(global.fetch as any).mockResolvedValue({
@@ -225,6 +228,7 @@ describe('Room Navigation with Active Sessions', () => {
roomData: null,
isLoading: false,
isInRoom: false,
notifyRoomOfPlayerUpdate: vi.fn(),
})
;(global.fetch as any).mockResolvedValue({
@@ -271,6 +275,7 @@ describe('Room Navigation with Active Sessions', () => {
},
isLoading: false,
isInRoom: true,
notifyRoomOfPlayerUpdate: vi.fn(),
})
;(global.fetch as any).mockResolvedValue({

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

@@ -2,7 +2,7 @@
import { useEffect, useMemo, useState } from 'react'
import { css } from '../../../../../styled-system/css'
import { useArcadeMemoryPairs } from '../context/ArcadeMemoryPairsContext'
import { useMemoryPairs } from '../context/MemoryPairsContext'
import { getGridConfiguration } from '../utils/cardGeneration'
import { GameCard } from './GameCard'
@@ -81,7 +81,7 @@ function useGridDimensions(gridConfig: any, totalCards: number) {
}
export function MemoryGrid() {
const { state, flipCard } = useArcadeMemoryPairs()
const { state, flipCard } = useMemoryPairs()
// Hooks must be called before early return
const gridConfig = useMemo(() => getGridConfiguration(state.difficulty), [state.difficulty])

View File

@@ -7,14 +7,14 @@ 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 } = useMemoryPairs()
const { setFullscreenElement } = useFullscreen()
const { canModifyPlayers } = useArcadeRedirect({ currentGame: 'matching' })
const gameRef = useRef<HTMLDivElement>(null)

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,534 @@
'use client'
import { type ReactNode, useCallback, useEffect, useMemo } from 'react'
import { useArcadeSession } from '@/hooks/useArcadeSession'
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,
}
/**
* 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,
}
}
default:
return state
}
}
// Provider component for LOCAL play (no network sync)
export function LocalMemoryPairsProvider({ children }: { children: ReactNode }) {
const { data: viewerId } = useViewerId()
// NOTE: We deliberately do NOT call useRoomData() for local play
const { activePlayerCount, activePlayers: activePlayerIds, players } = useGameMode()
// Get active player IDs directly as strings (UUIDs)
const activePlayers = Array.from(activePlayerIds)
// 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 (even in local mode for consistency)
// Arcade session integration WITHOUT room sync
const {
state,
sendMove,
connected: _connected,
exitSession,
} = useArcadeSession<MemoryPairsState>({
userId: viewerId || '',
roomId: undefined, // CRITICAL: No roomId means no network sync
initialState,
applyMove: applyMoveOptimistically,
})
// Handle mismatch feedback timeout
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('[LocalProvider][canFlipCard] Checking card:', {
cardId,
isGameActive,
isProcessingMove: state.isProcessingMove,
currentPlayer: state.currentPlayer,
flippedCardsCount: state.flippedCards.length,
})
if (!isGameActive || state.isProcessingMove) {
console.log('[LocalProvider][canFlipCard] Blocked: game not active or processing')
return false
}
const card = state.gameCards.find((c) => c.id === cardId)
if (!card || card.matched) {
console.log('[LocalProvider][canFlipCard] Blocked: card not found or already matched')
return false
}
// Can't flip if already flipped
if (state.flippedCards.some((c) => c.id === cardId)) {
console.log('[LocalProvider][canFlipCard] Blocked: card already flipped')
return false
}
// Can't flip more than 2 cards
if (state.flippedCards.length >= 2) {
console.log('[LocalProvider][canFlipCard] Blocked: 2 cards already flipped')
return false
}
// In local play, we allow the current player to flip
// Authorization is simpler - just check if it's this player's turn
const currentPlayerData = players.get(state.currentPlayer)
console.log('[LocalProvider][canFlipCard] Authorization check:', {
currentPlayerId: state.currentPlayer,
currentPlayerFound: !!currentPlayerData,
currentPlayerIsLocal: currentPlayerData?.isLocal,
})
// Block if current player is explicitly marked as remote (shouldn't happen in local play)
if (currentPlayerData && currentPlayerData.isLocal === false) {
console.log(
'[LocalProvider][canFlipCard] BLOCKED: Current player is remote (unexpected in local play)'
)
return false
}
console.log('[LocalProvider][canFlipCard] ALLOWED: All checks passed')
return true
},
[
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]
)
// 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('[LocalMemoryPairs] Cannot start game without active players')
return
}
// Capture player metadata from local players map
// This ensures all room members can display player info even if they don't own the players
const playerMetadata: { [playerId: string]: any } = {}
for (const playerId of activePlayers) {
const playerData = players.get(playerId)
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('[LocalProvider] flipCard called:', {
cardId,
viewerId,
currentPlayer: state.currentPlayer,
activePlayers: state.activePlayers,
gamePhase: state.gamePhase,
canFlip: canFlipCard(cardId),
})
if (!canFlipCard(cardId)) {
console.log('[LocalProvider] Cannot flip card - canFlipCard returned false')
return
}
const move = {
type: 'FLIP_CARD' as const,
playerId: state.currentPlayer, // Use the current player ID from game state (database player ID)
data: { cardId },
}
console.log('[LocalProvider] Sending FLIP_CARD move via sendMove:', move)
sendMove(move)
},
[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('[LocalMemoryPairs] Cannot reset game without active players')
return
}
// Capture player metadata from local players map
const playerMetadata: { [playerId: string]: any } = {}
for (const playerId of activePlayers) {
const playerData = players.get(playerId)
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 resumeGame = useCallback(() => {
// PAUSE/RESUME: Resume paused game if config unchanged
if (!canResumeGame) {
console.warn('[LocalMemoryPairs] Cannot resume - no paused game or config changed')
return
}
const playerId = activePlayers[0] || state.currentPlayer || ''
sendMove({
type: 'RESUME_GAME',
playerId,
data: {},
})
}, [canResumeGame, activePlayers, state.currentPlayer, sendMove])
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])
// 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,
startGame,
resumeGame,
flipCard,
resetGame,
goToSetup,
setGameType,
setDifficulty,
setTurnTimer,
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,578 @@
'use client'
import { type ReactNode, useCallback, useEffect, useMemo } from 'react'
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)
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,
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 =
@@ -102,12 +121,20 @@ export interface MemoryPairsContextValue {
gameMode: GameMode // Derived from global context
activePlayers: Player[] // Active player IDs from arena
// PAUSE/RESUME: Computed pause/resume values
hasConfigChanged?: boolean
canResumeGame?: boolean
// Actions
startGame: () => void
flipCard: (cardId: string) => void
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 { RoomMemoryPairsProvider } from './context/RoomMemoryPairsProvider'
export default function MatchingPage() {
return (
<ArcadeGuardedPage>
<ArcadeMemoryPairsProvider>
<RoomMemoryPairsProvider>
<MemoryPairsGame />
</ArcadeMemoryPairsProvider>
</RoomMemoryPairsProvider>
</ArcadeGuardedPage>
)
}

View File

@@ -1,26 +1,20 @@
'use client'
import { useRouter } from 'next/navigation'
import { useEffect } from 'react'
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
* Since users can only be in one room at a time, this is a simple singular route
*
* Note: We don't redirect to /arcade if no room exists because:
* - It would conflict with arcade session redirects and create loops
* - useArcadeRedirect on /arcade page handles redirecting to active sessions
*/
export default function RoomPage() {
const router = useRouter()
const { roomData, isLoading } = useRoomData()
// Redirect to arcade if no room
useEffect(() => {
if (!isLoading && !roomData) {
router.push('/arcade')
}
}, [isLoading, roomData, router])
// Show loading state
if (isLoading) {
return (
@@ -39,9 +33,33 @@ export default function RoomPage() {
)
}
// Show nothing while redirecting
// Show error if no room (instead of redirecting)
if (!roomData) {
return null
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
fontSize: '18px',
color: '#666',
gap: '1rem',
}}
>
<div>No active room found</div>
<a
href="/arcade"
style={{
color: '#3b82f6',
textDecoration: 'underline',
}}
>
Go to Champion Arena
</a>
</div>
)
}
// Render the appropriate game based on room's gameName
@@ -50,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

@@ -58,14 +58,14 @@ export function PageWithNav({
}
// Get active and inactive players as arrays
// Only show LOCAL players in the active/inactive lists (remote players shown separately in networkPlayers)
const activePlayerList = Array.from(activePlayers)
.map((id) => players.get(id))
.filter((p) => p !== undefined)
.map((p) => ({ id: p.id, name: p.name, emoji: p.emoji }))
.filter((p): p is NonNullable<typeof p> => p !== undefined && p.isLocal !== false) // Filter out remote players
const inactivePlayerList = Array.from(players.values())
.filter((p) => !activePlayers.has(p.id))
.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 =
@@ -98,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)
@@ -107,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

@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { EmojiPicker } from '../../app/games/matching/components/EmojiPicker'
import { useGameMode } from '../../contexts/GameModeContext'
@@ -11,17 +11,36 @@ export function PlayerConfigDialog({ playerId, onClose }: PlayerConfigDialogProp
// All hooks must be called before early return
const { getPlayer, updatePlayer, players } = useGameMode()
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
const [localName, setLocalName] = useState('')
const [isSaving, setIsSaving] = useState(false)
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null)
const player = getPlayer(playerId)
const [tempName, setTempName] = useState(player?.name || '')
// Initialize local name from player
useEffect(() => {
if (player) {
setLocalName(player.name)
}
}, [player])
if (!player) {
return null
}
const handleSave = () => {
updatePlayer(playerId, { name: tempName })
onClose()
const handleNameChange = (newName: string) => {
setLocalName(newName)
// Debounce the update to avoid too many API calls
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current)
}
setIsSaving(true)
debounceTimerRef.current = setTimeout(() => {
updatePlayer(playerId, { name: newName })
setIsSaving(false)
}, 500) // Wait 500ms after user stops typing
}
const handleEmojiSelect = (emoji: string) => {
@@ -30,7 +49,21 @@ 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) => a.createdAt - b.createdAt)
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
return aTime - bTime
})
const playerIndex = allPlayers.findIndex((p) => p.id === playerId)
const displayNumber = playerIndex + 1
@@ -81,22 +114,35 @@ export function PlayerConfigDialog({ playerId, onClose }: PlayerConfigDialogProp
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
alignItems: 'flex-start',
marginBottom: '24px',
}}
>
<h2
style={{
fontSize: '24px',
fontWeight: 'bold',
background: `linear-gradient(135deg, ${gradientColor}, ${gradientColor}dd)`,
backgroundClip: 'text',
color: 'transparent',
margin: 0,
}}
>
Configure Player
</h2>
<div>
<h2
style={{
fontSize: '24px',
fontWeight: 'bold',
background: `linear-gradient(135deg, ${gradientColor}, ${gradientColor}dd)`,
backgroundClip: 'text',
color: 'transparent',
margin: 0,
marginBottom: '4px',
}}
>
Player Settings
</h2>
<div
style={{
fontSize: '12px',
color: isSaving ? '#f59e0b' : '#10b981',
fontWeight: '500',
opacity: 0.8,
}}
>
{isSaving ? '💾 Saving...' : '✓ Changes saved automatically'}
</div>
</div>
<button
onClick={onClose}
style={{
@@ -198,7 +244,7 @@ export function PlayerConfigDialog({ playerId, onClose }: PlayerConfigDialogProp
</div>
{/* Name Input */}
<div style={{ marginBottom: '24px' }}>
<div>
<label
style={{
display: 'block',
@@ -212,8 +258,8 @@ export function PlayerConfigDialog({ playerId, onClose }: PlayerConfigDialogProp
</label>
<input
type="text"
value={tempName}
onChange={(e) => setTempName(e.target.value)}
value={localName}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="Player Name"
maxLength={20}
style={{
@@ -243,69 +289,9 @@ export function PlayerConfigDialog({ playerId, onClose }: PlayerConfigDialogProp
textAlign: 'right',
}}
>
{tempName.length}/20 characters
{localName.length}/20 characters
</div>
</div>
{/* Action Buttons */}
<div
style={{
display: 'flex',
gap: '12px',
}}
>
<button
onClick={onClose}
style={{
flex: 1,
padding: '12px',
background: 'white',
border: '2px solid #e5e7eb',
borderRadius: '12px',
fontSize: '14px',
fontWeight: '600',
color: '#6b7280',
cursor: 'pointer',
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#f9fafb'
e.currentTarget.style.borderColor = '#d1d5db'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'white'
e.currentTarget.style.borderColor = '#e5e7eb'
}}
>
Cancel
</button>
<button
onClick={handleSave}
style={{
flex: 1,
padding: '12px',
background: `linear-gradient(135deg, ${gradientColor}, ${gradientColor}dd)`,
border: 'none',
borderRadius: '12px',
fontSize: '14px',
fontWeight: '600',
color: 'white',
cursor: 'pointer',
transition: 'all 0.2s ease',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-2px)'
e.currentTarget.style.boxShadow = '0 6px 16px rgba(0,0,0,0.2)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)'
e.currentTarget.style.boxShadow = '0 4px 12px rgba(0,0,0,0.15)'
}}
>
Save Changes
</button>
</div>
</div>
</div>
)

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

@@ -68,7 +68,7 @@ export function GameModeProvider({ children }: { children: ReactNode }) {
const { mutate: createPlayer } = useCreatePlayer()
const { mutate: updatePlayerMutation } = useUpdatePlayer()
const { mutate: deletePlayer } = useDeletePlayer()
const { roomData } = useRoomData()
const { roomData, notifyRoomOfPlayerUpdate } = useRoomData()
const { data: viewerId } = useViewerId()
const [isInitialized, setIsInitialized] = useState(false)
@@ -167,14 +167,27 @@ export function GameModeProvider({ children }: { children: ReactNode }) {
isActive: playerData?.isActive ?? false,
}
createPlayer(newPlayer)
createPlayer(newPlayer, {
onSuccess: () => {
// Notify room members if in a room
notifyRoomOfPlayerUpdate()
},
})
}
const updatePlayer = (id: string, updates: Partial<Player>) => {
const player = players.get(id)
// Only allow updating local players
if (player?.isLocal) {
updatePlayerMutation({ id, updates })
updatePlayerMutation(
{ id, updates },
{
onSuccess: () => {
// Notify room members if in a room
notifyRoomOfPlayerUpdate()
},
}
)
} else {
console.warn('[GameModeContext] Cannot update remote player:', id)
}
@@ -184,7 +197,12 @@ export function GameModeProvider({ children }: { children: ReactNode }) {
const player = players.get(id)
// Only allow removing local players
if (player?.isLocal) {
deletePlayer(id)
deletePlayer(id, {
onSuccess: () => {
// Notify room members if in a room
notifyRoomOfPlayerUpdate()
},
})
} else {
console.warn('[GameModeContext] Cannot remove remote player:', id)
}
@@ -194,7 +212,15 @@ export function GameModeProvider({ children }: { children: ReactNode }) {
const player = players.get(id)
// Only allow changing active status of local players
if (player?.isLocal) {
updatePlayerMutation({ id, updates: { isActive: active } })
updatePlayerMutation(
{ id, updates: { isActive: active } },
{
onSuccess: () => {
// Notify room members if in a room
notifyRoomOfPlayerUpdate()
},
}
)
} else {
console.warn('[GameModeContext] Cannot change active status of remote player:', id)
}
@@ -227,6 +253,11 @@ export function GameModeProvider({ children }: { children: ReactNode }) {
isActive: index === 0,
})
})
// Notify room members after reset (slight delay to ensure mutations complete)
setTimeout(() => {
notifyRoomOfPlayerUpdate()
}, 100)
}
const activePlayerCount = activePlayers.size

View File

@@ -74,10 +74,13 @@ export function useArcadeGuard(options: UseArcadeGuardOptions = {}): UseArcadeGu
})
// Redirect if we're not already on the active game page (only if enabled)
if (enabled && pathname !== data.gameUrl) {
const isAlreadyAtTarget = pathname === data.gameUrl
if (enabled && !isAlreadyAtTarget) {
console.log('[ArcadeGuard] Redirecting to active session:', data.gameUrl)
onRedirect?.(data.gameUrl)
router.push(data.gameUrl)
} else if (isAlreadyAtTarget) {
console.log('[ArcadeGuard] Already at target URL, no redirect needed')
}
},
@@ -127,10 +130,13 @@ export function useArcadeGuard(options: UseArcadeGuardOptions = {}): UseArcadeGu
})
// Redirect if we're not already on the active game page (only if enabled)
if (enabled && pathname !== session.gameUrl) {
const isAlreadyAtTarget = pathname === session.gameUrl
if (enabled && !isAlreadyAtTarget) {
console.log('[ArcadeGuard] Redirecting to active session:', session.gameUrl)
onRedirect?.(session.gameUrl)
router.push(session.gameUrl)
} else if (isAlreadyAtTarget) {
console.log('[ArcadeGuard] Already at target URL, no redirect needed')
}
} else if (response.status === 404) {
// No active session

View File

@@ -71,10 +71,13 @@ export function useArcadeRedirect(options: UseArcadeRedirectOptions = {}): UseAr
// Determine if we need to redirect
const isArcadeLobby = currentGame === null || currentGame === undefined
const isWrongGame = currentGame && currentGame !== data.currentGame
const isAlreadyAtTarget = _pathname === data.gameUrl
if (isArcadeLobby || isWrongGame) {
if ((isArcadeLobby || isWrongGame) && !isAlreadyAtTarget) {
console.log('[ArcadeRedirect] Redirecting to active game:', data.gameUrl)
router.push(data.gameUrl)
} else if (isAlreadyAtTarget) {
console.log('[ArcadeRedirect] Already at target URL, no redirect needed')
}
},

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

@@ -196,10 +196,19 @@ export function useRoomData() {
}
}, [socket, roomData?.id])
// Function to notify room members of player updates
const notifyRoomOfPlayerUpdate = () => {
if (socket && roomData?.id && userId) {
console.log('[useRoomData] Notifying room of player update')
socket.emit('players-updated', { roomId: roomData.id, userId })
}
}
return {
roomData,
// Loading if: userId is pending, currently fetching, or have userId but haven't tried fetching yet
isLoading: isUserIdPending || isLoading || (!!userId && !hasAttemptedFetch),
isInRoom: !!roomData,
notifyRoomOfPlayerUpdate,
}
}

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.8.3",
"version": "2.12.2",
"private": true,
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
"workspaces": [