Compare commits

..

1 Commits

Author SHA1 Message Date
semantic-release-bot
963d9ec618 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))
2025-10-09 13:21:14 +00:00
25 changed files with 59 additions and 2763 deletions

View File

@@ -1,46 +1,9 @@
## [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)

View File

@@ -1,492 +0,0 @@
# 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

@@ -1,416 +0,0 @@
# 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,16 +1,6 @@
{
"permissions": {
"allow": [
"Bash(npm test:*)",
"Read(//Users/antialias/projects/**)",
"Bash(npm run lint:*)",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git push:*)",
"Bash(git pull:*)",
"Bash(git stash:*)",
"Bash(npm run format:*)"
],
"allow": ["Bash(npm test:*)", "Read(//Users/antialias/projects/**)"],
"deny": [],
"ask": []
}

View File

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

View File

@@ -3,12 +3,12 @@
import { css } from '../../../../../styled-system/css'
import { useGameMode } from '../../../../contexts/GameModeContext'
import { pluralizeWord } from '../../../../utils/pluralization'
import { useMemoryPairs } from '../context/MemoryPairsContext'
import { useArcadeMemoryPairs } from '../context/ArcadeMemoryPairsContext'
import { MemoryGrid } from './MemoryGrid'
import { PlayerStatusBar } from './PlayerStatusBar'
export function GamePhase() {
const { state, resetGame: _resetGame, activePlayers } = useMemoryPairs()
const { state, resetGame: _resetGame, activePlayers } = useArcadeMemoryPairs()
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 { useMemoryPairs } from '../context/MemoryPairsContext'
import { useArcadeMemoryPairs } from '../context/ArcadeMemoryPairsContext'
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 } = useMemoryPairs()
const { state, flipCard } = useArcadeMemoryPairs()
// 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 { useMemoryPairs } from '../context/MemoryPairsContext'
import { useArcadeMemoryPairs } from '../context/ArcadeMemoryPairsContext'
import { GamePhase } from './GamePhase'
import { ResultsPhase } from './ResultsPhase'
import { SetupPhase } from './SetupPhase'
export function MemoryPairsGame() {
const router = useRouter()
const { state, exitSession, resetGame } = useMemoryPairs()
const { state, exitSession, resetGame } = useArcadeMemoryPairs()
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 { useMemoryPairs } from '../context/MemoryPairsContext'
import { useArcadeMemoryPairs } from '../context/ArcadeMemoryPairsContext'
interface PlayerStatusBarProps {
className?: string
@@ -11,7 +11,7 @@ interface PlayerStatusBarProps {
export function PlayerStatusBar({ className }: PlayerStatusBarProps) {
const { players: playerMap, activePlayers: activePlayerIds } = useGameMode()
const { state } = useMemoryPairs()
const { state } = useArcadeMemoryPairs()
// Get active players array
const activePlayersData = Array.from(activePlayerIds)

View File

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

View File

@@ -1,9 +1,8 @@
'use client'
import { useState } from 'react'
import { css } from '../../../../../styled-system/css'
import { useGameMode } from '../../../../contexts/GameModeContext'
import { useMemoryPairs } from '../context/MemoryPairsContext'
import { useArcadeMemoryPairs } from '../context/ArcadeMemoryPairsContext'
// Add bounce animation for the start button
const bounceAnimation = `
@@ -33,87 +32,14 @@ export function SetupPhase() {
state,
setGameType,
setDifficulty,
setTurnTimer,
startGame,
resumeGame,
canResumeGame,
hasConfigChanged,
activePlayers: _activePlayers,
} = useMemoryPairs()
} = useArcadeMemoryPairs()
const { activePlayerCount, gameMode: _globalGameMode } = useGameMode()
// 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 handleStartGame = () => {
startGame()
}
const getButtonStyles = (
@@ -224,94 +150,6 @@ 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
@@ -362,7 +200,7 @@ export function SetupPhase() {
>
<button
className={getButtonStyles(state.gameType === 'abacus-numeral', 'secondary')}
onClick={() => handleSetGameType('abacus-numeral')}
onClick={() => setGameType('abacus-numeral')}
>
<div
className={css({
@@ -408,7 +246,7 @@ export function SetupPhase() {
</button>
<button
className={getButtonStyles(state.gameType === 'complement-pairs', 'secondary')}
onClick={() => handleSetGameType('complement-pairs')}
onClick={() => setGameType('complement-pairs')}
>
<div
className={css({
@@ -504,7 +342,7 @@ export function SetupPhase() {
<button
key={difficulty}
className={getButtonStyles(state.difficulty === difficulty, 'difficulty')}
onClick={() => handleSetDifficulty(difficulty)}
onClick={() => setDifficulty(difficulty)}
>
<div
className={css({
@@ -576,7 +414,7 @@ export function SetupPhase() {
<button
key={timer}
className={getButtonStyles(state.turnTimer === timer, 'secondary')}
onClick={() => handleSetTurnTimer(timer)}
onClick={() => dispatch({ type: 'SET_TURN_TIMER', timer })}
>
<div
className={css({
@@ -626,9 +464,7 @@ export function SetupPhase() {
>
<button
className={css({
background: canResumeGame
? 'linear-gradient(135deg, #10b981 0%, #059669 50%, #34d399 100%)'
: 'linear-gradient(135deg, #ff6b6b 0%, #ee5a24 50%, #ff9ff3 100%)',
background: 'linear-gradient(135deg, #ff6b6b 0%, #ee5a24 50%, #ff9ff3 100%)',
color: 'white',
border: 'none',
borderRadius: { base: '16px', sm: '20px', md: '24px' },
@@ -637,9 +473,7 @@ export function SetupPhase() {
fontWeight: 'black',
cursor: 'pointer',
transition: 'all 0.4s cubic-bezier(0.4, 0, 0.2, 1)',
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)',
boxShadow: '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',
@@ -657,12 +491,9 @@ export function SetupPhase() {
},
_hover: {
transform: { base: 'translateY(-2px)', md: 'translateY(-3px) scale(1.02)' },
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%)',
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%)',
_before: {
left: '100%',
},
@@ -671,7 +502,7 @@ export function SetupPhase() {
transform: 'translateY(-1px) scale(1.01)',
},
})}
onClick={handleStartOrResumeGame}
onClick={handleStartGame}
>
<div
className={css({
@@ -687,9 +518,9 @@ export function SetupPhase() {
animation: 'bounce 2s infinite',
})}
>
{canResumeGame ? '▶️' : '🚀'}
🚀
</span>
<span>{canResumeGame ? 'RESUME GAME' : 'START GAME'}</span>
<span>START GAME</span>
<span
className={css({
fontSize: { base: '18px', sm: '20px', md: '24px' },
@@ -697,7 +528,7 @@ export function SetupPhase() {
animationDelay: '0.5s',
})}
>
{canResumeGame ? '🎮' : '🎮'}
🎮
</span>
</div>
</button>

View File

@@ -1,534 +0,0 @@
'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
export const MemoryPairsContext = createContext<MemoryPairsContextValue | null>(null)
const MemoryPairsContext = createContext<MemoryPairsContextValue | null>(null)
// Provider component
export function MemoryPairsProvider({ children }: { children: ReactNode }) {

View File

@@ -1,578 +0,0 @@
'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

@@ -1,20 +0,0 @@
/**
* 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,7 +59,6 @@ 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
@@ -73,24 +72,6 @@ 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 =
@@ -121,20 +102,12 @@ 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 { RoomMemoryPairsProvider } from './context/RoomMemoryPairsProvider'
import { ArcadeMemoryPairsProvider } from './context/ArcadeMemoryPairsContext'
export default function MatchingPage() {
return (
<ArcadeGuardedPage>
<RoomMemoryPairsProvider>
<ArcadeMemoryPairsProvider>
<MemoryPairsGame />
</RoomMemoryPairsProvider>
</ArcadeMemoryPairsProvider>
</ArcadeGuardedPage>
)
}

View File

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

View File

@@ -40,20 +40,6 @@ 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[]
@@ -65,22 +51,6 @@ 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
@@ -89,7 +59,6 @@ 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
@@ -103,9 +72,6 @@ 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 =
@@ -135,19 +101,13 @@ 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)
}
@@ -173,6 +133,14 @@ 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

@@ -48,9 +48,8 @@ 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, 'timestamp'>) => void
sendMove: (move: Omit<GameMove, 'playerId' | 'timestamp'>) => void
/**
* Exit the arcade session
@@ -150,10 +149,10 @@ export function useArcadeSession<TState>(
// Apply optimistically
optimistic.applyOptimisticMove(fullMove)
// Send to server with roomId for room-based games
socketSendMove(userId, fullMove, roomId)
// Send to server
socketSendMove(userId, fullMove)
},
[userId, roomId, optimistic, socketSendMove]
[userId, 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, roomId?: string) => void
sendMove: (userId: string, move: GameMove) => 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, roomId?: string) => {
(userId: string, move: GameMove) => {
if (!socket) {
console.warn('[ArcadeSocket] Cannot send move - socket not connected')
return
}
const payload = { userId, move, roomId }
const payload = { userId, move }
console.log(
'[ArcadeSocket] Sending game-move event with payload:',
JSON.stringify(payload, null, 2)

View File

@@ -37,35 +37,8 @@ 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
@@ -73,19 +46,6 @@ 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),
@@ -120,12 +80,6 @@ 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
}
@@ -176,18 +130,9 @@ 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,
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)
export async function applyGameMove(userId: string, move: GameMove): Promise<SessionUpdateResult> {
const session = await getArcadeSession(userId)
if (!session) {
return {

View File

@@ -25,28 +25,11 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
return this.validateFlipCard(state, move.data.cardId, move.playerId, context)
case 'START_GAME':
return this.validateStartGame(
state,
move.data.activePlayers,
move.data.cards,
move.data.playerMetadata
)
return this.validateStartGame(state, move.data.activePlayers, move.data.cards)
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,
@@ -200,8 +183,7 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
private validateStartGame(
state: MemoryPairsState,
activePlayers: Player[],
cards?: GameCard[],
playerMetadata?: { [playerId: string]: any }
cards?: GameCard[]
): ValidationResult {
// Allow starting a new game from any phase (for "New Game" button)
@@ -221,7 +203,6 @@ 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],
@@ -230,15 +211,6 @@ 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 {
@@ -248,16 +220,6 @@ 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,
@@ -270,243 +232,6 @@ 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
}
@@ -530,7 +255,6 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
moves: 0,
scores: {},
activePlayers: [],
playerMetadata: {}, // Initialize empty player metadata
consecutiveMatches: {},
gameStartTime: null,
gameEndTime: null,
@@ -540,12 +264,6 @@ 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,7 +33,6 @@ 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
}
}
@@ -42,40 +41,10 @@ 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

View File

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