Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41aa205d04 | ||
|
|
388c25451d | ||
|
|
fa827ac792 | ||
|
|
c26138ffb5 | ||
|
|
168b98b888 | ||
|
|
b128db1783 | ||
|
|
df50239079 | ||
|
|
820eeb4fb0 | ||
|
|
90be7c053c | ||
|
|
442c6b4529 | ||
|
|
75b193e1d2 | ||
|
|
8d53b589aa | ||
|
|
af85b3e481 | ||
|
|
573d0df20d | ||
|
|
d312969747 | ||
|
|
7f65a67cef | ||
|
|
4d7f6f469f | ||
|
|
71b11f4ef0 | ||
|
|
e0d08a1aa2 | ||
|
|
62f3730542 | ||
|
|
98822ecda5 | ||
|
|
db9f9096b4 | ||
|
|
1fe507bc12 | ||
|
|
14ba422919 | ||
|
|
3541466630 | ||
|
|
c27973191f | ||
|
|
d423ff7612 | ||
|
|
80ad33eec0 |
97
CHANGELOG.md
97
CHANGELOG.md
@@ -1,3 +1,100 @@
|
||||
## [2.16.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.16.2...v2.16.3) (2025-10-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* use game state playerMetadata instead of GameModeContext in UI components ([388c254](https://github.com/antialias/soroban-abacus-flashcards/commit/388c25451d11b85236c1f7682fe2f7a62a15d5eb))
|
||||
|
||||
## [2.16.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.16.1...v2.16.2) (2025-10-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* use only local user's players in LocalMemoryPairsProvider ([c26138f](https://github.com/antialias/soroban-abacus-flashcards/commit/c26138ffb55a237a99cb6ff399c8a2ac54a22b51))
|
||||
|
||||
## [2.16.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.16.0...v2.16.1) (2025-10-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* convert LocalMemoryPairsProvider to pure client-side with useReducer ([b128db1](https://github.com/antialias/soroban-abacus-flashcards/commit/b128db1783a8dcffe7879745c3342add2f9ffe29))
|
||||
|
||||
## [2.16.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.15.0...v2.16.0) (2025-10-09)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* fade out hover avatar when player stops hovering ([820eeb4](https://github.com/antialias/soroban-abacus-flashcards/commit/820eeb4fb03ad8be6a86dd0a26e089052224f427))
|
||||
|
||||
## [2.15.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.14.3...v2.15.0) (2025-10-09)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* implement smooth hover avatar animations with react-spring ([442c6b4](https://github.com/antialias/soroban-abacus-flashcards/commit/442c6b4529ba5c820b1fe8a64805a3d85489a8ea))
|
||||
|
||||
## [2.14.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.14.2...v2.14.3) (2025-10-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* enable smooth spring animations between card hovers ([8d53b58](https://github.com/antialias/soroban-abacus-flashcards/commit/8d53b589aa17ebc6d0a9251b3006fd8a90f90a61))
|
||||
|
||||
## [2.14.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.14.1...v2.14.2) (2025-10-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* correct avatar positioning to prevent fly-in animation ([573d0df](https://github.com/antialias/soroban-abacus-flashcards/commit/573d0df20dcdac41021c46feb423dbf3782728f6))
|
||||
|
||||
## [2.14.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.14.0...v2.14.1) (2025-10-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* prevent avatar fly-in and hide local player's own hover ([7f65a67](https://github.com/antialias/soroban-abacus-flashcards/commit/7f65a67cef3d7f0ebce1bd7417972a6138acfc46))
|
||||
|
||||
## [2.14.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.13.0...v2.14.0) (2025-10-09)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* improve hover avatars with smooth animation and 3D elevation ([71b11f4](https://github.com/antialias/soroban-abacus-flashcards/commit/71b11f4ef08a5f9c3f1c1aaabca21ef023d5c0ce))
|
||||
|
||||
## [2.13.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.12.3...v2.13.0) (2025-10-09)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* implement networked hover presence for multiplayer gameplay ([62f3730](https://github.com/antialias/soroban-abacus-flashcards/commit/62f3730542334a0580f5dad1c73adc333614ee58))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* move canModifyPlayers logic into provider layer ([db9f909](https://github.com/antialias/soroban-abacus-flashcards/commit/db9f9096b446b078e1b4dfe970723bef54a6f4ae))
|
||||
* properly separate LocalMemoryPairsProvider and RoomMemoryPairsProvider ([98822ec](https://github.com/antialias/soroban-abacus-flashcards/commit/98822ecda52bf004d9950e3f4c92c834fd820e49))
|
||||
|
||||
## [2.12.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.12.2...v2.12.3) (2025-10-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* always show game control buttons in room-based sessions ([14ba422](https://github.com/antialias/soroban-abacus-flashcards/commit/14ba422919abd648e2a134ce167a5e6fd9f84e73))
|
||||
|
||||
## [2.12.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.12.1...v2.12.2) (2025-10-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* use RoomMemoryPairsProvider in room page ([c279731](https://github.com/antialias/soroban-abacus-flashcards/commit/c27973191f0144604e17a8a14adf0a88df476e27))
|
||||
|
||||
## [2.12.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.12.0...v2.12.1) (2025-10-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* export MemoryPairsContext to fix provider hook error ([80ad33e](https://github.com/antialias/soroban-abacus-flashcards/commit/80ad33eec0b6946702eaa9cf1b1c246852864b00))
|
||||
|
||||
## [2.12.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.11.0...v2.12.0) (2025-10-09)
|
||||
|
||||
|
||||
|
||||
492
apps/web/.claude/ARCADE_ARCHITECTURE.md
Normal file
492
apps/web/.claude/ARCADE_ARCHITECTURE.md
Normal file
@@ -0,0 +1,492 @@
|
||||
# Arcade Game Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
The arcade system supports two distinct game modes that must remain completely isolated from each other:
|
||||
|
||||
1. **Local Play** - Games without network synchronization (can be single-player OR local multiplayer)
|
||||
2. **Room-Based Play** - Networked games with real-time synchronization across room members
|
||||
|
||||
## Core Terminology
|
||||
|
||||
Following `docs/terminology-user-player-room.md`:
|
||||
|
||||
- **USER** - Identity (guest or authenticated account), retrieved via `useViewerId()`, one per browser/account
|
||||
- **PLAYER** - Game avatar/profile (e.g., "Alice 👧", "Bob 👦"), stored in `players` table
|
||||
- **PLAYER ROSTER** - All PLAYERS belonging to a USER (can have many)
|
||||
- **ACTIVE PLAYERS** - PLAYERS where `isActive = true` - these are the ones that actually participate in games
|
||||
- **ROOM MEMBER** - A USER's participation in a multiplayer room (tracked in `room_members` table)
|
||||
|
||||
**Important:** A USER can have many PLAYERS in their roster, but only the ACTIVE PLAYERS (where `isActive = true`) participate in games. This enables "hot-potato" style local multiplayer where multiple people share the same device. This is LOCAL play (not networked), even though multiple PLAYERS participate.
|
||||
|
||||
In arcade sessions:
|
||||
- `arcade_sessions.userId` - The USER who owns the session
|
||||
- `arcade_sessions.activePlayers` - Array of PLAYER IDs (only active players with `isActive = true`)
|
||||
- `arcade_sessions.roomId` - If present, the room ID for networked play (references `arcade_rooms.id`)
|
||||
|
||||
## Critical Architectural Requirements
|
||||
|
||||
### 1. Mode Isolation (MUST ENFORCE)
|
||||
|
||||
**Local Play** (`/arcade/[game-name]`)
|
||||
- MUST NOT sync game state across the network
|
||||
- MUST NOT use room data, even if the USER is currently a member of an active room
|
||||
- MUST create isolated, per-USER game sessions
|
||||
- Game state lives only in the current browser tab/session
|
||||
- CAN have multiple ACTIVE PLAYERS from the same USER (local multiplayer / hot-potato)
|
||||
- State is NOT shared across the network, only within the browser session
|
||||
|
||||
**Room-Based Play** (`/arcade/room`)
|
||||
- MUST sync game state across all room members via network
|
||||
- MUST use the USER's current active room
|
||||
- MUST coordinate moves via server WebSocket
|
||||
- Game state is shared across all ACTIVE PLAYERS from all USERS in the room
|
||||
- When a PLAYER makes a move, all room members see it in real-time
|
||||
- CAN ALSO have multiple ACTIVE PLAYERS per USER (networked + local multiplayer combined)
|
||||
|
||||
### 2. Room ID Usage Rules
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG: Always checking for room data
|
||||
const { roomData } = useRoomData()
|
||||
useArcadeSession({ roomId: roomData?.id }) // This causes the bug!
|
||||
|
||||
// ✅ CORRECT: Explicit mode control via separate providers
|
||||
<LocalMemoryPairsProvider> {/* Never passes roomId */}
|
||||
<RoomMemoryPairsProvider> {/* Always passes roomId */}
|
||||
```
|
||||
|
||||
**Key principle:** The presence of a `roomId` parameter in `useArcadeSession` determines synchronization behavior:
|
||||
- `roomId` present → room-wide network sync enabled (room-based play)
|
||||
- `roomId` undefined → local play only (no network sync)
|
||||
|
||||
### 3. Composition Over Flags (PREFERRED APPROACH)
|
||||
|
||||
**✅ Option 1: Separate Providers (CLEAREST - USE THIS)**
|
||||
|
||||
Create two distinct provider components:
|
||||
|
||||
```typescript
|
||||
// context/LocalMemoryPairsProvider.tsx
|
||||
export function LocalMemoryPairsProvider({ children }) {
|
||||
const { data: viewerId } = useViewerId()
|
||||
const { activePlayers } = useGameMode() // Gets active players (isActive = true)
|
||||
// NEVER fetch room data for local play
|
||||
|
||||
const { state, sendMove } = useArcadeSession<MemoryPairsState>({
|
||||
userId: viewerId || '',
|
||||
roomId: undefined, // Explicitly undefined - no network sync
|
||||
initialState,
|
||||
applyMove: applyMoveOptimistically,
|
||||
})
|
||||
|
||||
// ... rest of provider logic
|
||||
// Note: activePlayers contains only PLAYERS with isActive = true
|
||||
}
|
||||
|
||||
// context/RoomMemoryPairsProvider.tsx
|
||||
export function RoomMemoryPairsProvider({ children }) {
|
||||
const { data: viewerId } = useViewerId()
|
||||
const { roomData } = useRoomData() // OK to fetch for room-based play
|
||||
const { activePlayers } = useGameMode() // Gets active players (isActive = true)
|
||||
|
||||
const { state, sendMove } = useArcadeSession<MemoryPairsState>({
|
||||
userId: viewerId || '',
|
||||
roomId: roomData?.id, // Pass roomId for network sync
|
||||
initialState,
|
||||
applyMove: applyMoveOptimistically,
|
||||
})
|
||||
|
||||
// ... rest of provider logic
|
||||
}
|
||||
```
|
||||
|
||||
Then use them explicitly:
|
||||
```typescript
|
||||
// /arcade/matching/page.tsx (Local Play)
|
||||
export default function MatchingPage() {
|
||||
return (
|
||||
<ArcadeGuardedPage>
|
||||
<LocalMemoryPairsProvider>
|
||||
<MemoryPairsGame />
|
||||
</LocalMemoryPairsProvider>
|
||||
</ArcadeGuardedPage>
|
||||
)
|
||||
}
|
||||
|
||||
// /arcade/room/page.tsx (Room-Based Play)
|
||||
export default function RoomPage() {
|
||||
// ... room validation logic
|
||||
if (roomData.gameName === 'matching') {
|
||||
return (
|
||||
<RoomMemoryPairsProvider>
|
||||
<MemoryPairsGame />
|
||||
</RoomMemoryPairsProvider>
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits of separate providers:**
|
||||
- Compile-time safety - impossible to mix modes
|
||||
- Clear intent - any developer can see which mode at a glance
|
||||
- No runtime conditionals needed
|
||||
- Easier to test - each provider tests separately
|
||||
|
||||
**❌ Avoid:** Runtime flag checking scattered throughout code
|
||||
```typescript
|
||||
// Anti-pattern: Too many conditionals
|
||||
if (isRoomBased) { ... } else { ... }
|
||||
```
|
||||
|
||||
### 4. How Synchronization Works
|
||||
|
||||
#### Local Play Flow
|
||||
```
|
||||
USER Action → useArcadeSession (roomId: undefined)
|
||||
→ WebSocket emit('join-arcade-session', { userId })
|
||||
→ Server creates isolated session for userId
|
||||
→ Session key = userId
|
||||
→ session.activePlayers = USER's active player IDs (isActive = true)
|
||||
→ State changes only affect this USER's browser tabs
|
||||
|
||||
Note: Multiple ACTIVE PLAYERS from same USER can participate (local multiplayer),
|
||||
but state is NEVER synced across network
|
||||
```
|
||||
|
||||
#### Room-Based Play Flow
|
||||
```
|
||||
USER Action (on behalf of PLAYER)
|
||||
→ useArcadeSession (roomId: 'room_xyz')
|
||||
→ WebSocket emit('join-arcade-session', { userId, roomId })
|
||||
→ Server creates/joins shared session for roomId
|
||||
→ session.activePlayers = ALL active players from ALL room members
|
||||
→ Socket joins TWO rooms: `arcade:${userId}` AND `game:${roomId}`
|
||||
→ PLAYER makes move
|
||||
→ Server validates PLAYER ownership (is this PLAYER owned by this USER?)
|
||||
→ State changes broadcast to:
|
||||
- arcade:${userId} - All tabs of this USER (for optimistic reconciliation)
|
||||
- game:${roomId} - All USERS in the room (for network sync)
|
||||
|
||||
Note: Each USER can still have multiple ACTIVE PLAYERS (local + networked multiplayer)
|
||||
```
|
||||
|
||||
The server-side logic uses `roomId` to determine session scope:
|
||||
- No `roomId`: Session key = `userId` (isolated to USER's browser)
|
||||
- With `roomId`: Session key = `roomId` (shared across all room members)
|
||||
|
||||
See `docs/MULTIPLAYER_SYNC_ARCHITECTURE.md` for detailed socket room mechanics.
|
||||
|
||||
### 5. USER vs PLAYER in Game Logic
|
||||
|
||||
**Important distinction:**
|
||||
- **Session ownership**: Tracked by USER ID (`useViewerId()`)
|
||||
- **Player roster**: All PLAYERS for a USER (can be many)
|
||||
- **Active players**: PLAYERS with `isActive = true` (these join the game)
|
||||
- **Game actions**: Performed by PLAYER ID (from `players` table)
|
||||
- **Move validation**: Server checks that PLAYER ID belongs to the requesting USER
|
||||
- **Local multiplayer**: One USER with multiple ACTIVE PLAYERS (hot-potato style, same device)
|
||||
- **Networked multiplayer**: Multiple USERS, each with their own ACTIVE PLAYERS, in a room
|
||||
|
||||
```typescript
|
||||
// ✅ Correct: USER owns session, ACTIVE PLAYERS participate
|
||||
const { data: viewerId } = useViewerId() // USER ID
|
||||
const { activePlayers } = useGameMode() // ACTIVE PLAYER IDs (isActive = true)
|
||||
|
||||
// activePlayers might be [player_001, player_002]
|
||||
// even though USER has 5 total PLAYERS in their roster
|
||||
|
||||
const { state, sendMove } = useArcadeSession({
|
||||
userId: viewerId, // Session owned by USER
|
||||
roomId: undefined, // Local play (or roomData?.id for room-based)
|
||||
// ...
|
||||
})
|
||||
|
||||
// When PLAYER flips card:
|
||||
sendMove({
|
||||
type: 'FLIP_CARD',
|
||||
playerId: currentPlayerId, // PLAYER ID from activePlayers
|
||||
data: { cardId: '...' }
|
||||
})
|
||||
```
|
||||
|
||||
**Example Scenarios:**
|
||||
|
||||
1. **Single-player local game:**
|
||||
- USER: "guest_abc"
|
||||
- Player roster: ["player_001" (active), "player_002" (inactive), "player_003" (inactive)]
|
||||
- Active PLAYERS in game: ["player_001"]
|
||||
- Mode: Local play (no roomId)
|
||||
|
||||
2. **Local multiplayer (hot-potato):**
|
||||
- USER: "guest_abc"
|
||||
- Player roster: ["player_001" (active), "player_002" (active), "player_003" (active), "player_004" (inactive)]
|
||||
- Active PLAYERS in game: ["player_001", "player_002", "player_003"] (3 kids sharing device)
|
||||
- Mode: Local play (no roomId)
|
||||
- Game rotates turns between the 3 active PLAYERS, but NO network sync
|
||||
|
||||
3. **Room-based networked play:**
|
||||
- USER A: "guest_abc"
|
||||
- Player roster: 5 total PLAYERS
|
||||
- Active PLAYERS: ["player_001", "player_002"]
|
||||
- USER B: "guest_def"
|
||||
- Player roster: 3 total PLAYERS
|
||||
- Active PLAYERS: ["player_003"]
|
||||
- Mode: Room-based play (roomId: "room_xyz")
|
||||
- Total PLAYERS in game: 3 (player_001, player_002, player_003)
|
||||
- All 3 synced across network
|
||||
|
||||
4. **Room-based + local multiplayer combined:**
|
||||
- USER A: "guest_abc" with 3 active PLAYERS (3 kids at Device A)
|
||||
- USER B: "guest_def" with 2 active PLAYERS (2 kids at Device B)
|
||||
- Mode: Room-based play (roomId: "room_xyz")
|
||||
- 5 total active PLAYERS across 2 devices, all synced over network
|
||||
|
||||
## Common Mistakes to Avoid
|
||||
|
||||
### Mistake 1: Conditional Room Usage
|
||||
```typescript
|
||||
// ❌ BAD: Room sync leaks into local play
|
||||
const { roomData } = useRoomData()
|
||||
useArcadeSession({
|
||||
roomId: roomData?.id // Local play will sync if USER is in a room!
|
||||
})
|
||||
```
|
||||
|
||||
### Mistake 2: Shared Components Without Mode Context
|
||||
```typescript
|
||||
// ❌ BAD: Same provider used for both modes
|
||||
export default function LocalGamePage() {
|
||||
return <GameProvider><Game /></GameProvider> // Which mode?
|
||||
}
|
||||
```
|
||||
|
||||
### Mistake 3: Confusing "multiplayer" with "networked"
|
||||
```typescript
|
||||
// ❌ BAD: Thinking multiple PLAYERS means room-based
|
||||
if (activePlayers.length > 1) {
|
||||
// Must be room-based! WRONG!
|
||||
// Could be local multiplayer (hot-potato style)
|
||||
}
|
||||
|
||||
// ✅ CORRECT: Check for roomId to determine network sync
|
||||
const isNetworked = !!roomId
|
||||
const isLocalMultiplayer = activePlayers.length > 1 && !roomId
|
||||
```
|
||||
|
||||
### Mistake 4: Using all PLAYERS instead of only active ones
|
||||
```typescript
|
||||
// ❌ BAD: Including inactive players
|
||||
const allPlayers = await db.query.players.findMany({
|
||||
where: eq(players.userId, userId)
|
||||
})
|
||||
|
||||
// ✅ CORRECT: Only active players join the game
|
||||
const activePlayers = await db.query.players.findMany({
|
||||
where: and(
|
||||
eq(players.userId, userId),
|
||||
eq(players.isActive, true)
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
### Mistake 5: Mixing USER ID and PLAYER ID
|
||||
```typescript
|
||||
// ❌ BAD: Using USER ID for game actions
|
||||
sendMove({
|
||||
type: 'FLIP_CARD',
|
||||
playerId: viewerId, // WRONG! viewerId is USER ID, not PLAYER ID
|
||||
data: { cardId: '...' }
|
||||
})
|
||||
|
||||
// ✅ CORRECT: Use PLAYER ID from game state
|
||||
sendMove({
|
||||
type: 'FLIP_CARD',
|
||||
playerId: state.currentPlayer, // PLAYER ID from activePlayers
|
||||
data: { cardId: '...' }
|
||||
})
|
||||
```
|
||||
|
||||
### Mistake 6: Server-Side Ambiguity
|
||||
```typescript
|
||||
// ❌ BAD: Server can't distinguish intent
|
||||
socket.on('join-arcade-session', ({ userId, roomId }) => {
|
||||
// If roomId exists, did USER want local or room-based play?
|
||||
// This happens when provider always passes roomData?.id
|
||||
})
|
||||
```
|
||||
|
||||
## Testing Requirements
|
||||
|
||||
Tests MUST verify mode isolation:
|
||||
|
||||
### Local Play Tests
|
||||
```typescript
|
||||
it('should NOT sync state when USER is in a room but playing locally', async () => {
|
||||
// Setup: USER is a member of an active room
|
||||
// Action: USER navigates to /arcade/matching
|
||||
// Assert: Game state is NOT shared with other room members
|
||||
// Assert: Other room members' actions do NOT affect this game
|
||||
})
|
||||
|
||||
it('should create isolated sessions for concurrent local games', () => {
|
||||
// Setup: Two USERS who are members of the same room
|
||||
// Action: Both navigate to /arcade/matching separately
|
||||
// Assert: Each has independent game state
|
||||
// Assert: USER A's moves do NOT appear in USER B's game
|
||||
})
|
||||
|
||||
it('should support local multiplayer without network sync', () => {
|
||||
// Setup: USER with 3 active PLAYERS in roster (hot-potato style)
|
||||
// Action: USER plays at /arcade/matching with the 3 active PLAYERS
|
||||
// Assert: All 3 active PLAYERS participate in the same session
|
||||
// Assert: Inactive PLAYERS do NOT participate
|
||||
// Assert: State is NOT synced across network
|
||||
// Assert: Game rotates turns between active PLAYERS locally
|
||||
})
|
||||
|
||||
it('should only include active players in game', () => {
|
||||
// Setup: USER has 5 PLAYERS in roster, but only 2 are active
|
||||
// Action: USER starts a local game
|
||||
// Assert: Only the 2 active PLAYERS are in activePlayers array
|
||||
// Assert: Inactive PLAYERS are not included
|
||||
})
|
||||
|
||||
it('should sync across USER tabs but not across network', () => {
|
||||
// Setup: USER opens /arcade/matching in 2 browser tabs
|
||||
// Action: PLAYER makes move in Tab 1
|
||||
// Assert: Tab 2 sees the move (multi-tab sync)
|
||||
// Assert: Other USERS do NOT see the move (no network sync)
|
||||
})
|
||||
```
|
||||
|
||||
### Room-Based Play Tests
|
||||
```typescript
|
||||
it('should sync state across all room members', async () => {
|
||||
// Setup: Two USERS are members of the same room
|
||||
// Action: USER A's PLAYER flips card at /arcade/room
|
||||
// Assert: USER B sees the card flip in real-time
|
||||
})
|
||||
|
||||
it('should sync across multiple active PLAYERS from multiple USERS', () => {
|
||||
// Setup: USER A has 2 active PLAYERS, USER B has 1 active PLAYER in same room
|
||||
// Action: USER A's PLAYER 1 makes move
|
||||
// Assert: All 3 PLAYERS see the move (networked)
|
||||
})
|
||||
|
||||
it('should only include active players in room games', () => {
|
||||
// Setup: USER A (5 PLAYERS, 2 active), USER B (3 PLAYERS, 1 active) join room
|
||||
// Action: Game starts
|
||||
// Assert: session.activePlayers = [userA_player1, userA_player2, userB_player1]
|
||||
// Assert: Inactive PLAYERS are NOT included
|
||||
})
|
||||
|
||||
it('should handle combined local + networked multiplayer', () => {
|
||||
// Setup: USER A (3 active PLAYERS), USER B (2 active PLAYERS) in same room
|
||||
// Action: Any PLAYER makes a move
|
||||
// Assert: All 5 active PLAYERS see the move across both devices
|
||||
})
|
||||
|
||||
it('should fail gracefully when no room exists', () => {
|
||||
// Setup: USER is not a member of any room
|
||||
// Action: Navigate to /arcade/room
|
||||
// Assert: Shows "No active room" message
|
||||
// Assert: Does not create a session
|
||||
})
|
||||
|
||||
it('should validate PLAYER ownership', async () => {
|
||||
// Setup: USER A in room with active PLAYER 'alice'
|
||||
// Action: USER A attempts move for PLAYER 'bob' (owned by USER B)
|
||||
// Assert: Server rejects the move
|
||||
// Assert: Error indicates unauthorized PLAYER
|
||||
})
|
||||
```
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
When adding a new game or modifying existing ones:
|
||||
|
||||
- [ ] Create separate `LocalGameProvider` and `RoomGameProvider` components
|
||||
- [ ] Local provider never calls `useRoomData()`
|
||||
- [ ] Local provider passes `roomId: undefined` to `useArcadeSession`
|
||||
- [ ] Room provider calls `useRoomData()` and passes `roomId: roomData?.id`
|
||||
- [ ] Both providers use `useGameMode()` to get active players
|
||||
- [ ] Local play page uses `LocalGameProvider`
|
||||
- [ ] `/arcade/room` page uses `RoomGameProvider`
|
||||
- [ ] Game components correctly use PLAYER IDs (not USER IDs) for moves
|
||||
- [ ] Game supports multiple active PLAYERS from same USER (local multiplayer)
|
||||
- [ ] Inactive PLAYERS are never included in game sessions
|
||||
- [ ] Tests verify mode isolation (local doesn't network sync, room-based does)
|
||||
- [ ] Tests verify PLAYER ownership validation
|
||||
- [ ] Tests verify only active PLAYERS participate
|
||||
- [ ] Tests verify local multiplayer works (multiple active PLAYERS, one USER)
|
||||
- [ ] Documentation updated if behavior changes
|
||||
|
||||
## File Organization
|
||||
|
||||
```
|
||||
src/app/arcade/
|
||||
├── [game-name]/ # Local play games
|
||||
│ ├── page.tsx # Uses LocalGameProvider
|
||||
│ └── context/
|
||||
│ ├── LocalGameProvider.tsx # roomId: undefined
|
||||
│ └── RoomGameProvider.tsx # roomId: roomData?.id
|
||||
├── room/ # Room-based play
|
||||
│ └── page.tsx # Uses RoomGameProvider
|
||||
└── ...
|
||||
```
|
||||
|
||||
## Architecture Decision Records
|
||||
|
||||
### Why separate providers instead of auto-detect from route?
|
||||
|
||||
While we could detect mode based on the route (`/arcade/room` vs `/arcade/matching`), separate providers are clearer and prevent accidental misuse. Future developers can immediately see the intent, and the type system can enforce correctness.
|
||||
|
||||
### Why being in a room doesn't mean all games sync?
|
||||
|
||||
A USER being a room member does NOT mean all their games should network sync. They should be able to play local games while remaining in a room for future room-based sessions. Mode is determined by the page they're on, not their room membership status.
|
||||
|
||||
### Why not use a single shared provider with mode props?
|
||||
|
||||
We tried that. It led to the current bug where local play accidentally synced with rooms. Separate providers make the distinction compile-time safe rather than runtime conditional, and eliminate the possibility of accidentally passing `roomId` when we shouldn't.
|
||||
|
||||
### Why do we track sessions by USER but moves by PLAYER?
|
||||
|
||||
- **Sessions** are per-USER because each USER can have their own game session
|
||||
- **Moves** are per-PLAYER because PLAYERS are the game avatars that score points
|
||||
- **Only active PLAYERS** (isActive = true) participate in games
|
||||
- This allows:
|
||||
- One USER with multiple active PLAYERS (local multiplayer / hot-potato)
|
||||
- Multiple USERS in one room (networked play)
|
||||
- Combined: Multiple USERS each with multiple active PLAYERS (local + networked)
|
||||
- Proper ownership validation (server checks USER owns PLAYER)
|
||||
- PLAYERS can be toggled active/inactive without deleting them
|
||||
|
||||
### Why use "local" vs "room-based" instead of "solo" vs "multiplayer"?
|
||||
|
||||
- **"Solo"** is misleading - a USER can have multiple active PLAYERS in local play (hot-potato style)
|
||||
- **"Multiplayer"** is ambiguous - it could mean local multiplayer OR networked multiplayer
|
||||
- **"Local play"** clearly means: no network sync (but can have multiple active PLAYERS)
|
||||
- **"Room-based play"** clearly means: network sync across room members
|
||||
|
||||
## Related Files
|
||||
|
||||
- `src/hooks/useArcadeSession.ts` - Session management with optional roomId
|
||||
- `src/hooks/useArcadeSocket.ts` - WebSocket connection with sync logic (socket rooms: `arcade:${userId}` and `game:${roomId}`)
|
||||
- `src/hooks/useRoomData.ts` - Fetches USER's current room membership
|
||||
- `src/hooks/useViewerId.ts` - Retrieves current USER ID
|
||||
- `src/contexts/GameModeContext.tsx` - Provides active PLAYER information
|
||||
- `src/app/arcade/matching/context/ArcadeMemoryPairsContext.tsx` - Game context (needs refactoring to separate providers)
|
||||
- `src/app/arcade/matching/page.tsx` - Local play entry point
|
||||
- `src/app/arcade/room/page.tsx` - Room-based play entry point
|
||||
- `docs/terminology-user-player-room.md` - Terminology guide (USER/PLAYER/MEMBER)
|
||||
- `docs/MULTIPLAYER_SYNC_ARCHITECTURE.md` - Technical details of room-based sync
|
||||
|
||||
## Version History
|
||||
|
||||
- **2025-10-09**: Initial documentation
|
||||
- Issue identified: Local play was syncing with rooms over network
|
||||
- Root cause: Same provider always fetched `roomData` and passed `roomId` to `useArcadeSession`
|
||||
- Solution: Separate providers for local vs room-based play
|
||||
- Terminology clarification: "local" vs "room-based" (not "solo" vs "multiplayer")
|
||||
- Active players: Only PLAYERS with `isActive = true` participate in games
|
||||
416
apps/web/.claude/ARCADE_SETUP_PATTERN.md
Normal file
416
apps/web/.claude/ARCADE_SETUP_PATTERN.md
Normal file
@@ -0,0 +1,416 @@
|
||||
# Arcade Setup Pattern
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the **standard synchronized setup pattern** for arcade games. Following this pattern ensures that:
|
||||
|
||||
1. ✅ **Setup is synchronized** - All room members see the same setup screen and config changes in real-time
|
||||
2. ✅ **No local state hacks** - Configuration lives entirely in session state, no React state merging
|
||||
3. ✅ **Optimistic updates** - Config changes feel instant with client-side prediction
|
||||
4. ✅ **Consistent pattern** - All games follow the same architecture
|
||||
|
||||
**Reference Implementation**: `src/app/arcade/matching/*` (Matching game)
|
||||
|
||||
---
|
||||
|
||||
## Core Concept
|
||||
|
||||
Setup configuration is **game state**, not UI state. Configuration changes are **moves** that are validated, synchronized, and can be made by any room member.
|
||||
|
||||
### Key Principles
|
||||
|
||||
1. **Game state includes configuration** during ALL phases (setup, playing, results)
|
||||
2. **No local React state** for configuration - use session state directly
|
||||
3. **Standard move types** that all games should implement
|
||||
4. **Setup phase is collaborative** - any room member can configure the game
|
||||
|
||||
---
|
||||
|
||||
## Required Move Types
|
||||
|
||||
Every arcade game must support these standard moves:
|
||||
|
||||
### 1. `GO_TO_SETUP`
|
||||
|
||||
Transitions game to setup phase, allowing reconfiguration.
|
||||
|
||||
```typescript
|
||||
{
|
||||
type: 'GO_TO_SETUP',
|
||||
playerId: string,
|
||||
data: {}
|
||||
}
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
- Can be called from any phase (setup, playing, results)
|
||||
- Sets `gamePhase: 'setup'`
|
||||
- Resets game progression (scores, cards, etc.)
|
||||
- Preserves configuration (players can modify it)
|
||||
- Synchronized across all room members
|
||||
|
||||
### 2. `SET_CONFIG`
|
||||
|
||||
Updates a configuration field during setup phase.
|
||||
|
||||
```typescript
|
||||
{
|
||||
type: 'SET_CONFIG',
|
||||
playerId: string,
|
||||
data: {
|
||||
field: string, // Config field name
|
||||
value: any // New value
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
- Only allowed during setup phase
|
||||
- Validates field name and value
|
||||
- Updates immediately with optimistic update
|
||||
- Synchronized across all room members
|
||||
|
||||
### 3. `START_GAME`
|
||||
|
||||
Starts the game with current configuration.
|
||||
|
||||
```typescript
|
||||
{
|
||||
type: 'START_GAME',
|
||||
playerId: string,
|
||||
data: {
|
||||
activePlayers: string[],
|
||||
playerMetadata: { [playerId: string]: PlayerMetadata },
|
||||
// ... game-specific initial data
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
- Only allowed during setup phase
|
||||
- Uses current session state configuration
|
||||
- Initializes game-specific state
|
||||
- Sets `gamePhase: 'playing'`
|
||||
|
||||
---
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
### 1. Update Validation Types
|
||||
|
||||
Add move types to your game's validation types:
|
||||
|
||||
```typescript
|
||||
// src/lib/arcade/validation/types.ts
|
||||
|
||||
export interface YourGameGoToSetupMove extends GameMove {
|
||||
type: 'GO_TO_SETUP'
|
||||
data: Record<string, never>
|
||||
}
|
||||
|
||||
export interface YourGameSetConfigMove extends GameMove {
|
||||
type: 'SET_CONFIG'
|
||||
data: {
|
||||
field: 'configField1' | 'configField2' | 'configField3'
|
||||
value: any
|
||||
}
|
||||
}
|
||||
|
||||
export type YourGameMove =
|
||||
| YourGameStartGameMove
|
||||
| YourGameGoToSetupMove
|
||||
| YourGameSetConfigMove
|
||||
| ... // other game-specific moves
|
||||
```
|
||||
|
||||
### 2. Implement Validators
|
||||
|
||||
Add validators for setup moves in your game's validator class:
|
||||
|
||||
```typescript
|
||||
// src/lib/arcade/validation/YourGameValidator.ts
|
||||
|
||||
export class YourGameValidator implements GameValidator<YourGameState, YourGameMove> {
|
||||
validateMove(state, move, context) {
|
||||
switch (move.type) {
|
||||
case 'GO_TO_SETUP':
|
||||
return this.validateGoToSetup(state)
|
||||
|
||||
case 'SET_CONFIG':
|
||||
return this.validateSetConfig(state, move.data.field, move.data.value)
|
||||
|
||||
case 'START_GAME':
|
||||
return this.validateStartGame(state, move.data)
|
||||
|
||||
// ... other moves
|
||||
}
|
||||
}
|
||||
|
||||
private validateGoToSetup(state: YourGameState): ValidationResult {
|
||||
return {
|
||||
valid: true,
|
||||
newState: {
|
||||
...state,
|
||||
gamePhase: 'setup',
|
||||
// Reset game progression, preserve configuration
|
||||
// ... reset scores, game data, etc.
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
private validateSetConfig(
|
||||
state: YourGameState,
|
||||
field: string,
|
||||
value: any
|
||||
): ValidationResult {
|
||||
// Only during setup
|
||||
if (state.gamePhase !== 'setup') {
|
||||
return { valid: false, error: 'Cannot change config outside setup' }
|
||||
}
|
||||
|
||||
// Validate field-specific values
|
||||
switch (field) {
|
||||
case 'configField1':
|
||||
if (!isValidValue(value)) {
|
||||
return { valid: false, error: 'Invalid value' }
|
||||
}
|
||||
break
|
||||
// ... validate other fields
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
newState: { ...state, [field]: value },
|
||||
}
|
||||
}
|
||||
|
||||
private validateStartGame(state: YourGameState, data: any): ValidationResult {
|
||||
if (state.gamePhase !== 'setup') {
|
||||
return { valid: false, error: 'Can only start from setup' }
|
||||
}
|
||||
|
||||
// Use current state configuration to initialize game
|
||||
const initialGameData = initializeYourGame(state.configField1, state.configField2)
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
newState: {
|
||||
...state,
|
||||
gamePhase: 'playing',
|
||||
...initialGameData,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Add Optimistic Updates
|
||||
|
||||
Update `applyMoveOptimistically` in your providers:
|
||||
|
||||
```typescript
|
||||
// src/app/arcade/your-game/context/YourGameProvider.tsx
|
||||
|
||||
function applyMoveOptimistically(state: YourGameState, move: GameMove): YourGameState {
|
||||
switch (move.type) {
|
||||
case 'GO_TO_SETUP':
|
||||
return {
|
||||
...state,
|
||||
gamePhase: 'setup',
|
||||
// Reset game state, preserve config
|
||||
}
|
||||
|
||||
case 'SET_CONFIG':
|
||||
const { field, value } = move.data
|
||||
return {
|
||||
...state,
|
||||
[field]: value,
|
||||
}
|
||||
|
||||
case 'START_GAME':
|
||||
return {
|
||||
...state,
|
||||
gamePhase: 'playing',
|
||||
// ... initialize game data from move
|
||||
}
|
||||
|
||||
// ... other moves
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Remove Local State from Providers
|
||||
|
||||
**❌ OLD PATTERN (Don't do this):**
|
||||
|
||||
```typescript
|
||||
// DON'T: Local React state for configuration
|
||||
const [localDifficulty, setLocalDifficulty] = useState(6)
|
||||
|
||||
// DON'T: Merge hack
|
||||
const effectiveState = state.gamePhase === 'setup'
|
||||
? { ...state, difficulty: localDifficulty }
|
||||
: state
|
||||
|
||||
// DON'T: Direct setter
|
||||
const setDifficulty = (value) => setLocalDifficulty(value)
|
||||
```
|
||||
|
||||
**✅ NEW PATTERN (Do this):**
|
||||
|
||||
```typescript
|
||||
// DO: Use session state directly
|
||||
const { state, sendMove } = useArcadeSession(...)
|
||||
|
||||
// DO: Send move for config changes
|
||||
const setDifficulty = useCallback((value) => {
|
||||
const playerId = activePlayers[0] || ''
|
||||
sendMove({
|
||||
type: 'SET_CONFIG',
|
||||
playerId,
|
||||
data: { field: 'difficulty', value },
|
||||
})
|
||||
}, [activePlayers, sendMove])
|
||||
|
||||
// DO: Use state directly (no merging!)
|
||||
const contextValue = { state: { ...state, gameMode }, ... }
|
||||
```
|
||||
|
||||
### 5. Update Action Creators
|
||||
|
||||
All configuration actions should send moves:
|
||||
|
||||
```typescript
|
||||
export function YourGameProvider({ children }) {
|
||||
const { state, sendMove } = useArcadeSession(...)
|
||||
|
||||
const setConfigField = useCallback((value) => {
|
||||
const playerId = activePlayers[0] || ''
|
||||
sendMove({
|
||||
type: 'SET_CONFIG',
|
||||
playerId,
|
||||
data: { field: 'configField', value },
|
||||
})
|
||||
}, [activePlayers, sendMove])
|
||||
|
||||
const goToSetup = useCallback(() => {
|
||||
const playerId = activePlayers[0] || state.currentPlayer || ''
|
||||
sendMove({
|
||||
type: 'GO_TO_SETUP',
|
||||
playerId,
|
||||
data: {},
|
||||
})
|
||||
}, [activePlayers, state.currentPlayer, sendMove])
|
||||
|
||||
const startGame = useCallback(() => {
|
||||
// Use current session state config (not local state!)
|
||||
const initialData = initializeGame(state.config1, state.config2)
|
||||
|
||||
const playerId = activePlayers[0]
|
||||
sendMove({
|
||||
type: 'START_GAME',
|
||||
playerId,
|
||||
data: {
|
||||
...initialData,
|
||||
activePlayers,
|
||||
playerMetadata: capturePlayerMetadata(players, activePlayers),
|
||||
},
|
||||
})
|
||||
}, [state.config1, state.config2, activePlayers, sendMove])
|
||||
|
||||
return <YourGameContext.Provider value={...} />
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Benefits of This Pattern
|
||||
|
||||
### 1. **Synchronized Setup**
|
||||
- User A clicks "Setup" → All room members see setup screen
|
||||
- User B changes difficulty → All room members see the change
|
||||
- User A clicks "Start" → All room members start playing
|
||||
|
||||
### 2. **No Special Cases**
|
||||
- Setup works like gameplay (moves + validation)
|
||||
- No conditional logic based on phase
|
||||
- No React state merging hacks
|
||||
|
||||
### 3. **Easy to Extend**
|
||||
- New games copy the same pattern
|
||||
- Well-documented and tested
|
||||
- Consistent developer experience
|
||||
|
||||
### 4. **Optimistic Updates**
|
||||
- Config changes feel instant
|
||||
- Client-side prediction + server validation
|
||||
- Rollback on validation failure
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
When implementing this pattern, test these scenarios:
|
||||
|
||||
### Local Mode
|
||||
- [ ] Click setup button during game → returns to setup
|
||||
- [ ] Change config fields → updates immediately
|
||||
- [ ] Start game → uses current config
|
||||
|
||||
### Room Mode (Multi-User)
|
||||
- [ ] User A clicks setup → User B sees setup screen
|
||||
- [ ] User A changes difficulty → User B sees change in real-time
|
||||
- [ ] User B changes game type → User A sees change in real-time
|
||||
- [ ] User A starts game → Both users see game with same config
|
||||
|
||||
### Edge Cases
|
||||
- [ ] Change config rapidly → no race conditions
|
||||
- [ ] User with 0 players can see/modify setup
|
||||
- [ ] Setup → Play → Setup preserves last config
|
||||
- [ ] Invalid config values are rejected by validator
|
||||
|
||||
---
|
||||
|
||||
## Migration Guide
|
||||
|
||||
If you have an existing game using local state, follow these steps:
|
||||
|
||||
### Step 1: Add Move Types
|
||||
Add `GO_TO_SETUP` and `SET_CONFIG` to your validation types.
|
||||
|
||||
### Step 2: Implement Validators
|
||||
Add validators for the new moves in your game validator class.
|
||||
|
||||
### Step 3: Add Optimistic Updates
|
||||
Update `applyMoveOptimistically` to handle the new moves.
|
||||
|
||||
### Step 4: Remove Local State
|
||||
1. Delete all `useState` calls for configuration
|
||||
2. Delete the `effectiveState` merging logic
|
||||
3. Update action creators to send moves instead
|
||||
|
||||
### Step 5: Test
|
||||
Run through the testing checklist above.
|
||||
|
||||
---
|
||||
|
||||
## Reference Implementation
|
||||
|
||||
See the Matching game for a complete reference implementation:
|
||||
|
||||
- **Types**: `src/lib/arcade/validation/types.ts`
|
||||
- **Validator**: `src/lib/arcade/validation/MatchingGameValidator.ts`
|
||||
- **Provider**: `src/app/arcade/matching/context/RoomMemoryPairsProvider.tsx`
|
||||
- **Optimistic Updates**: `applyMoveOptimistically` function in provider
|
||||
|
||||
Look for comments marked with:
|
||||
- `// STANDARD ARCADE PATTERN: GO_TO_SETUP`
|
||||
- `// STANDARD ARCADE PATTERN: SET_CONFIG`
|
||||
- `// NO LOCAL STATE`
|
||||
- `// NO MORE effectiveState merging!`
|
||||
|
||||
---
|
||||
|
||||
## Questions?
|
||||
|
||||
If something is unclear or you encounter issues implementing this pattern, refer to the Matching game implementation or update this document with clarifications.
|
||||
@@ -1,6 +1,18 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": ["Bash(npm test:*)", "Read(//Users/antialias/projects/**)"],
|
||||
"allow": [
|
||||
"Bash(npm test:*)",
|
||||
"Read(//Users/antialias/projects/**)",
|
||||
"Bash(npm run lint:*)",
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)",
|
||||
"Bash(git push:*)",
|
||||
"Bash(git pull:*)",
|
||||
"Bash(git stash:*)",
|
||||
"Bash(npm run format:*)",
|
||||
"Bash(npm run pre-commit:*)",
|
||||
"Bash(npm run type-check:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
}
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
import { useGameMode } from '../../../../contexts/GameModeContext'
|
||||
import { pluralizeWord } from '../../../../utils/pluralization'
|
||||
import { useArcadeMemoryPairs } from '../context/ArcadeMemoryPairsContext'
|
||||
import { useMemoryPairs } from '../context/MemoryPairsContext'
|
||||
import { MemoryGrid } from './MemoryGrid'
|
||||
import { PlayerStatusBar } from './PlayerStatusBar'
|
||||
|
||||
export function GamePhase() {
|
||||
const { state, resetGame: _resetGame, activePlayers } = useArcadeMemoryPairs()
|
||||
const { state, resetGame: _resetGame, activePlayers } = useMemoryPairs()
|
||||
const { players: playerMap, activePlayers: activePlayerIds } = useGameMode()
|
||||
|
||||
// Convert Map to array and create mapping from numeric index to player
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useSpring, animated } from '@react-spring/web'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
import { useArcadeMemoryPairs } from '../context/ArcadeMemoryPairsContext'
|
||||
import { useViewerId } from '@/hooks/useViewerId'
|
||||
import { useMemoryPairs } from '../context/MemoryPairsContext'
|
||||
import { getGridConfiguration } from '../utils/cardGeneration'
|
||||
import { GameCard } from './GameCard'
|
||||
|
||||
@@ -80,8 +82,111 @@ function useGridDimensions(gridConfig: any, totalCards: number) {
|
||||
return gridDimensions
|
||||
}
|
||||
|
||||
// Animated hover avatar component
|
||||
function HoverAvatar({
|
||||
playerId,
|
||||
playerInfo,
|
||||
cardElement,
|
||||
isPlayersTurn,
|
||||
}: {
|
||||
playerId: string
|
||||
playerInfo: { emoji: string; name: string; color?: string }
|
||||
cardElement: HTMLElement | null
|
||||
isPlayersTurn: boolean
|
||||
}) {
|
||||
const [position, setPosition] = useState<{ x: number; y: number } | null>(null)
|
||||
const isFirstRender = useRef(true)
|
||||
|
||||
// Update position when card element changes
|
||||
useEffect(() => {
|
||||
if (cardElement) {
|
||||
const rect = cardElement.getBoundingClientRect()
|
||||
// Calculate the center of the card for avatar positioning
|
||||
const avatarCenterX = rect.left + rect.width / 2
|
||||
const avatarCenterY = rect.top + rect.height / 2
|
||||
|
||||
setPosition({
|
||||
x: avatarCenterX,
|
||||
y: avatarCenterY,
|
||||
})
|
||||
}
|
||||
}, [cardElement])
|
||||
|
||||
// Smooth spring animation for position changes
|
||||
const springProps = useSpring({
|
||||
x: position?.x ?? 0,
|
||||
y: position?.y ?? 0,
|
||||
opacity: position && isPlayersTurn && cardElement ? 1 : 0,
|
||||
config: {
|
||||
tension: 280,
|
||||
friction: 60,
|
||||
mass: 1,
|
||||
},
|
||||
immediate: isFirstRender.current, // Skip animation on first render only
|
||||
})
|
||||
|
||||
// Clear first render flag after initial render
|
||||
useEffect(() => {
|
||||
if (position && isFirstRender.current) {
|
||||
isFirstRender.current = false
|
||||
}
|
||||
}, [position])
|
||||
|
||||
// Don't render until we have a position
|
||||
if (!position) return null
|
||||
|
||||
return (
|
||||
<animated.div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
// Don't use translate, just position directly at the calculated point
|
||||
left: springProps.x.to((x) => `${x}px`),
|
||||
top: springProps.y.to((y) => `${y}px`),
|
||||
opacity: springProps.opacity,
|
||||
width: '80px',
|
||||
height: '80px',
|
||||
marginLeft: '-40px', // Center horizontally (half of width)
|
||||
marginTop: '-40px', // Center vertically (half of height)
|
||||
borderRadius: '50%',
|
||||
background: playerInfo.color || 'linear-gradient(135deg, #667eea, #764ba2)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '48px',
|
||||
// 3D elevation effect
|
||||
boxShadow:
|
||||
'0 12px 30px rgba(0,0,0,0.5), 0 6px 12px rgba(0,0,0,0.4), 0 0 40px rgba(102, 126, 234, 0.8)',
|
||||
border: '4px solid white',
|
||||
zIndex: 1000,
|
||||
pointerEvents: 'none',
|
||||
filter: 'drop-shadow(0 0 12px rgba(102, 126, 234, 0.9))',
|
||||
}}
|
||||
className={css({
|
||||
animation: 'hoverFloat 2s ease-in-out infinite',
|
||||
})}
|
||||
title={`${playerInfo.name} is considering this card`}
|
||||
>
|
||||
{playerInfo.emoji}
|
||||
</animated.div>
|
||||
)
|
||||
}
|
||||
|
||||
export function MemoryGrid() {
|
||||
const { state, flipCard } = useArcadeMemoryPairs()
|
||||
const { state, flipCard, hoverCard, gameMode } = useMemoryPairs()
|
||||
const { data: viewerId } = useViewerId()
|
||||
|
||||
// Track card element refs for positioning hover avatars
|
||||
const cardRefs = useRef<Map<string, HTMLElement>>(new Map())
|
||||
|
||||
// Check if it's the local player's turn
|
||||
const isMyTurn = useMemo(() => {
|
||||
if (gameMode === 'single') return true // Always your turn in single player
|
||||
|
||||
// In local games, all players belong to current user, so always their turn
|
||||
// In room games, check if current player belongs to this user
|
||||
const currentPlayerMetadata = state.playerMetadata?.[state.currentPlayer]
|
||||
return currentPlayerMetadata?.userId === viewerId
|
||||
}, [state.currentPlayer, state.playerMetadata, viewerId, gameMode])
|
||||
|
||||
// Hooks must be called before early return
|
||||
const gridConfig = useMemo(() => getGridConfiguration(state.difficulty), [state.difficulty])
|
||||
@@ -95,6 +200,28 @@ export function MemoryGrid() {
|
||||
flipCard(cardId)
|
||||
}
|
||||
|
||||
// Get player metadata for hover avatars
|
||||
const getPlayerHoverInfo = (playerId: string) => {
|
||||
// Get player info from game state metadata
|
||||
const player = state.playerMetadata?.[playerId]
|
||||
return player
|
||||
? {
|
||||
emoji: player.emoji,
|
||||
name: player.name,
|
||||
color: player.color,
|
||||
}
|
||||
: null
|
||||
}
|
||||
|
||||
// Set card ref callback
|
||||
const setCardRef = (cardId: string) => (element: HTMLDivElement | null) => {
|
||||
if (element) {
|
||||
cardRefs.current.set(cardId, element)
|
||||
} else {
|
||||
cardRefs.current.delete(cardId)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
@@ -162,6 +289,7 @@ export function MemoryGrid() {
|
||||
return (
|
||||
<div
|
||||
key={card.id}
|
||||
ref={setCardRef(card.id)}
|
||||
className={css({
|
||||
aspectRatio: '3/4',
|
||||
// Fully responsive card sizing - no fixed pixel sizes
|
||||
@@ -172,7 +300,20 @@ export function MemoryGrid() {
|
||||
opacity: isDimmed ? 0.3 : 1,
|
||||
transition: 'opacity 0.3s ease',
|
||||
filter: isDimmed ? 'grayscale(0.7)' : 'none',
|
||||
position: 'relative',
|
||||
})}
|
||||
onMouseEnter={() => {
|
||||
// Only send hover if it's your turn and card is not matched
|
||||
if (hoverCard && !isMatched && isMyTurn) {
|
||||
hoverCard(card.id)
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
// Clear hover state when mouse leaves card
|
||||
if (hoverCard && !isMatched && isMyTurn) {
|
||||
hoverCard(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<GameCard
|
||||
card={card}
|
||||
@@ -233,23 +374,64 @@ export function MemoryGrid() {
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Animated Hover Avatars - Rendered as fixed positioned elements that smoothly transition */}
|
||||
{/* Render one avatar per remote player - key by playerId to keep component alive */}
|
||||
{state.playerHovers &&
|
||||
Object.entries(state.playerHovers)
|
||||
.filter(([playerId]) => {
|
||||
// Don't show your own hover avatar (only show remote players)
|
||||
// In local games, all players belong to this user
|
||||
// In room games, check if player belongs to different user
|
||||
const player = state.playerMetadata?.[playerId]
|
||||
return player?.userId !== viewerId
|
||||
})
|
||||
.map(([playerId, cardId]) => {
|
||||
const playerInfo = getPlayerHoverInfo(playerId)
|
||||
// Get card element if player is hovering (cardId might be null)
|
||||
const cardElement = cardId ? cardRefs.current.get(cardId) : null
|
||||
// Check if it's this player's turn
|
||||
const isPlayersTurn = state.currentPlayer === playerId
|
||||
|
||||
if (!playerInfo) return null
|
||||
|
||||
// Render avatar even if no cardElement (it will handle hiding itself)
|
||||
return (
|
||||
<HoverAvatar
|
||||
key={playerId} // Key by playerId keeps component alive across card changes!
|
||||
playerId={playerId}
|
||||
playerInfo={playerInfo}
|
||||
cardElement={cardElement}
|
||||
isPlayersTurn={isPlayersTurn}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Add shake animation for mismatch feedback
|
||||
const shakeAnimation = `
|
||||
// Add animations for mismatch feedback and hover avatars
|
||||
const gridAnimations = `
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translate(-50%, -50%) translateX(0); }
|
||||
25% { transform: translate(-50%, -50%) translateX(-5px); }
|
||||
75% { transform: translate(-50%, -50%) translateX(5px); }
|
||||
}
|
||||
|
||||
@keyframes hoverFloat {
|
||||
0%, 100% {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
// Inject animation styles
|
||||
if (typeof document !== 'undefined' && !document.getElementById('memory-grid-animations')) {
|
||||
const style = document.createElement('style')
|
||||
style.id = 'memory-grid-animations'
|
||||
style.textContent = shakeAnimation
|
||||
style.textContent = gridAnimations
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
|
||||
@@ -3,20 +3,18 @@
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { useArcadeRedirect } from '@/hooks/useArcadeRedirect'
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
import { StandardGameLayout } from '../../../../components/StandardGameLayout'
|
||||
import { useFullscreen } from '../../../../contexts/FullscreenContext'
|
||||
import { useArcadeMemoryPairs } from '../context/ArcadeMemoryPairsContext'
|
||||
import { useMemoryPairs } from '../context/MemoryPairsContext'
|
||||
import { GamePhase } from './GamePhase'
|
||||
import { ResultsPhase } from './ResultsPhase'
|
||||
import { SetupPhase } from './SetupPhase'
|
||||
|
||||
export function MemoryPairsGame() {
|
||||
const router = useRouter()
|
||||
const { state, exitSession, resetGame } = useArcadeMemoryPairs()
|
||||
const { state, exitSession, resetGame, goToSetup, canModifyPlayers } = useMemoryPairs()
|
||||
const { setFullscreenElement } = useFullscreen()
|
||||
const { canModifyPlayers } = useArcadeRedirect({ currentGame: 'matching' })
|
||||
const gameRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -37,11 +35,14 @@ export function MemoryPairsGame() {
|
||||
exitSession()
|
||||
router.push('/arcade')
|
||||
}}
|
||||
onSetup={() => {
|
||||
// Exit current session and return to arcade (which will redirect to setup)
|
||||
exitSession()
|
||||
router.push('/arcade/matching')
|
||||
}}
|
||||
onSetup={
|
||||
goToSetup
|
||||
? () => {
|
||||
// Transition to setup phase (will pause game if active)
|
||||
goToSetup()
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onNewGame={() => {
|
||||
resetGame()
|
||||
}}
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
'use client'
|
||||
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
import { useGameMode } from '../../../../contexts/GameModeContext'
|
||||
import { gamePlurals } from '../../../../utils/pluralization'
|
||||
import { useArcadeMemoryPairs } from '../context/ArcadeMemoryPairsContext'
|
||||
import { useMemoryPairs } from '../context/MemoryPairsContext'
|
||||
|
||||
interface PlayerStatusBarProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function PlayerStatusBar({ className }: PlayerStatusBarProps) {
|
||||
const { players: playerMap, activePlayers: activePlayerIds } = useGameMode()
|
||||
const { state } = useArcadeMemoryPairs()
|
||||
const { state } = useMemoryPairs()
|
||||
|
||||
// Get active players array
|
||||
const activePlayersData = Array.from(activePlayerIds)
|
||||
.map((id) => playerMap.get(id))
|
||||
// Get active players from game state (not GameModeContext)
|
||||
// This ensures we only show players actually in this game
|
||||
const activePlayersData = state.activePlayers
|
||||
.map((id) => state.playerMetadata?.[id])
|
||||
.filter((p): p is NonNullable<typeof p> => p !== undefined)
|
||||
|
||||
// Map active players to display data with scores
|
||||
@@ -26,7 +25,8 @@ export function PlayerStatusBar({ className }: PlayerStatusBarProps) {
|
||||
displayEmoji: player.emoji,
|
||||
score: state.scores[player.id] || 0,
|
||||
consecutiveMatches: state.consecutiveMatches?.[player.id] || 0,
|
||||
isLocalPlayer: player.isLocal !== false, // Local if not explicitly marked as remote
|
||||
// In local games all players are local, in room games check metadata
|
||||
isLocalPlayer: state.gameMode === 'single' || state.gameMode === 'multiplayer',
|
||||
}))
|
||||
|
||||
// Check if current player is local (your turn) or remote (waiting)
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
import { useGameMode } from '../../../../contexts/GameModeContext'
|
||||
import { useArcadeMemoryPairs } from '../context/ArcadeMemoryPairsContext'
|
||||
import { useMemoryPairs } from '../context/MemoryPairsContext'
|
||||
import { formatGameTime, getMultiplayerWinner, getPerformanceAnalysis } from '../utils/gameScoring'
|
||||
|
||||
export function ResultsPhase() {
|
||||
const router = useRouter()
|
||||
const { state, resetGame, activePlayers, gameMode } = useArcadeMemoryPairs()
|
||||
const { state, resetGame, activePlayers, gameMode, exitSession } = useMemoryPairs()
|
||||
const { players: playerMap, activePlayers: activePlayerIds } = useGameMode()
|
||||
|
||||
// Get active player data array
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
'use client'
|
||||
|
||||
import { type ReactNode, useCallback, useEffect, useMemo } from 'react'
|
||||
import { useArcadeSession } from '@/hooks/useArcadeSession'
|
||||
import { type ReactNode, useCallback, useEffect, useMemo, useReducer } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useArcadeRedirect } from '@/hooks/useArcadeRedirect'
|
||||
import { useViewerId } from '@/hooks/useViewerId'
|
||||
import type { GameMove } from '@/lib/arcade/validation'
|
||||
import { useGameMode } from '../../../../contexts/GameModeContext'
|
||||
import { useUserPlayers } from '@/hooks/useUserPlayers'
|
||||
import { generateGameCards } from '../utils/cardGeneration'
|
||||
import { validateMatch } from '../utils/matchValidation'
|
||||
import { MemoryPairsContext } from './MemoryPairsContext'
|
||||
import type { GameMode, GameStatistics, MemoryPairsContextValue, MemoryPairsState } from './types'
|
||||
|
||||
// Initial state
|
||||
// Initial state for local-only games
|
||||
const initialState: MemoryPairsState = {
|
||||
cards: [],
|
||||
gameCards: [],
|
||||
@@ -18,13 +19,13 @@ const initialState: MemoryPairsState = {
|
||||
difficulty: 6,
|
||||
turnTimer: 30,
|
||||
gamePhase: 'setup',
|
||||
currentPlayer: '', // Will be set to first player ID on START_GAME
|
||||
currentPlayer: '',
|
||||
matchedPairs: 0,
|
||||
totalPairs: 6,
|
||||
moves: 0,
|
||||
scores: {},
|
||||
activePlayers: [],
|
||||
playerMetadata: {}, // Player metadata for cross-user visibility
|
||||
playerMetadata: {},
|
||||
consecutiveMatches: {},
|
||||
gameStartTime: null,
|
||||
gameEndTime: null,
|
||||
@@ -34,36 +35,43 @@ const initialState: MemoryPairsState = {
|
||||
isProcessingMove: false,
|
||||
showMismatchFeedback: false,
|
||||
lastMatchedPair: null,
|
||||
// PAUSE/RESUME: Initialize paused game fields
|
||||
originalConfig: undefined,
|
||||
pausedGamePhase: undefined,
|
||||
pausedGameState: undefined,
|
||||
playerHovers: {},
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimistic move application (client-side prediction)
|
||||
* The server will validate and send back the authoritative state
|
||||
*/
|
||||
function applyMoveOptimistically(state: MemoryPairsState, move: GameMove): MemoryPairsState {
|
||||
switch (move.type) {
|
||||
// Action types for local reducer
|
||||
type LocalAction =
|
||||
| { type: 'START_GAME'; cards: any[]; activePlayers: string[]; playerMetadata: any }
|
||||
| { type: 'FLIP_CARD'; cardId: string }
|
||||
| { type: 'MATCH_FOUND'; cardIds: [string, string]; playerId: string }
|
||||
| { type: 'MATCH_FAILED'; cardIds: [string, string] }
|
||||
| { type: 'CLEAR_MISMATCH' }
|
||||
| { type: 'SWITCH_PLAYER' }
|
||||
| { type: 'GO_TO_SETUP' }
|
||||
| { type: 'SET_CONFIG'; field: string; value: any }
|
||||
| { type: 'RESUME_GAME' }
|
||||
| { type: 'HOVER_CARD'; playerId: string; cardId: string | null }
|
||||
| { type: 'END_GAME' }
|
||||
|
||||
// Pure client-side reducer with complete game logic
|
||||
function localMemoryPairsReducer(state: MemoryPairsState, action: LocalAction): MemoryPairsState {
|
||||
switch (action.type) {
|
||||
case 'START_GAME':
|
||||
// Generate cards and initialize game
|
||||
return {
|
||||
...state,
|
||||
gamePhase: 'playing',
|
||||
gameCards: move.data.cards,
|
||||
cards: move.data.cards,
|
||||
gameCards: action.cards,
|
||||
cards: action.cards,
|
||||
flippedCards: [],
|
||||
matchedPairs: 0,
|
||||
moves: 0,
|
||||
scores: move.data.activePlayers.reduce((acc: any, p: string) => ({ ...acc, [p]: 0 }), {}),
|
||||
consecutiveMatches: move.data.activePlayers.reduce(
|
||||
(acc: any, p: string) => ({ ...acc, [p]: 0 }),
|
||||
{}
|
||||
),
|
||||
activePlayers: move.data.activePlayers,
|
||||
playerMetadata: move.data.playerMetadata || {}, // Include player metadata
|
||||
currentPlayer: move.data.activePlayers[0] || '',
|
||||
scores: action.activePlayers.reduce((acc: any, p: string) => ({ ...acc, [p]: 0 }), {}),
|
||||
consecutiveMatches: action.activePlayers.reduce((acc: any, p: string) => ({ ...acc, [p]: 0 }), {}),
|
||||
activePlayers: action.activePlayers,
|
||||
playerMetadata: action.playerMetadata,
|
||||
currentPlayer: action.activePlayers[0] || '',
|
||||
gameStartTime: Date.now(),
|
||||
gameEndTime: null,
|
||||
currentMoveStartTime: Date.now(),
|
||||
@@ -71,7 +79,6 @@ function applyMoveOptimistically(state: MemoryPairsState, move: GameMove): Memor
|
||||
isProcessingMove: false,
|
||||
showMismatchFeedback: false,
|
||||
lastMatchedPair: null,
|
||||
// PAUSE/RESUME: Save original config and clear paused state
|
||||
originalConfig: {
|
||||
gameType: state.gameType,
|
||||
difficulty: state.difficulty,
|
||||
@@ -82,8 +89,7 @@ function applyMoveOptimistically(state: MemoryPairsState, move: GameMove): Memor
|
||||
}
|
||||
|
||||
case 'FLIP_CARD': {
|
||||
// Optimistically flip the card
|
||||
const card = state.gameCards.find((c) => c.id === move.data.cardId)
|
||||
const card = state.gameCards.find((c) => c.id === action.cardId)
|
||||
if (!card) return state
|
||||
|
||||
const newFlippedCards = [...state.flippedCards, card]
|
||||
@@ -91,15 +97,69 @@ function applyMoveOptimistically(state: MemoryPairsState, move: GameMove): Memor
|
||||
return {
|
||||
...state,
|
||||
flippedCards: newFlippedCards,
|
||||
currentMoveStartTime:
|
||||
state.flippedCards.length === 0 ? Date.now() : state.currentMoveStartTime,
|
||||
isProcessingMove: newFlippedCards.length === 2, // Processing if 2 cards flipped
|
||||
currentMoveStartTime: state.flippedCards.length === 0 ? Date.now() : state.currentMoveStartTime,
|
||||
isProcessingMove: newFlippedCards.length === 2,
|
||||
showMismatchFeedback: false,
|
||||
}
|
||||
}
|
||||
|
||||
case 'MATCH_FOUND': {
|
||||
const [id1, id2] = action.cardIds
|
||||
const updatedCards = state.gameCards.map((card) =>
|
||||
card.id === id1 || card.id === id2
|
||||
? { ...card, matched: true, matchedBy: action.playerId }
|
||||
: card
|
||||
)
|
||||
|
||||
const newMatchedPairs = state.matchedPairs + 1
|
||||
const newScores = {
|
||||
...state.scores,
|
||||
[action.playerId]: (state.scores[action.playerId] || 0) + 1,
|
||||
}
|
||||
const newConsecutiveMatches = {
|
||||
...state.consecutiveMatches,
|
||||
[action.playerId]: (state.consecutiveMatches[action.playerId] || 0) + 1,
|
||||
}
|
||||
|
||||
// Check if game is complete
|
||||
const gameComplete = newMatchedPairs >= state.totalPairs
|
||||
|
||||
return {
|
||||
...state,
|
||||
gameCards: updatedCards,
|
||||
cards: updatedCards,
|
||||
flippedCards: [],
|
||||
matchedPairs: newMatchedPairs,
|
||||
moves: state.moves + 1,
|
||||
scores: newScores,
|
||||
consecutiveMatches: newConsecutiveMatches,
|
||||
lastMatchedPair: action.cardIds,
|
||||
isProcessingMove: false,
|
||||
showMismatchFeedback: false,
|
||||
gamePhase: gameComplete ? 'results' : state.gamePhase,
|
||||
gameEndTime: gameComplete ? Date.now() : null,
|
||||
// Player keeps their turn on match
|
||||
}
|
||||
}
|
||||
|
||||
case 'MATCH_FAILED': {
|
||||
// Reset consecutive matches for current player
|
||||
const newConsecutiveMatches = {
|
||||
...state.consecutiveMatches,
|
||||
[state.currentPlayer]: 0,
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
moves: state.moves + 1,
|
||||
showMismatchFeedback: true,
|
||||
isProcessingMove: true,
|
||||
consecutiveMatches: newConsecutiveMatches,
|
||||
// Don't clear flipped cards yet - CLEAR_MISMATCH will do that
|
||||
}
|
||||
}
|
||||
|
||||
case 'CLEAR_MISMATCH': {
|
||||
// Clear mismatched cards and feedback
|
||||
return {
|
||||
...state,
|
||||
flippedCards: [],
|
||||
@@ -108,14 +168,24 @@ function applyMoveOptimistically(state: MemoryPairsState, move: GameMove): Memor
|
||||
}
|
||||
}
|
||||
|
||||
case 'SWITCH_PLAYER': {
|
||||
const currentIndex = state.activePlayers.indexOf(state.currentPlayer)
|
||||
const nextIndex = (currentIndex + 1) % state.activePlayers.length
|
||||
const nextPlayer = state.activePlayers[nextIndex]
|
||||
|
||||
return {
|
||||
...state,
|
||||
currentPlayer: nextPlayer,
|
||||
currentMoveStartTime: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
case 'GO_TO_SETUP': {
|
||||
// Return to setup phase - pause game if coming from playing/results
|
||||
const isPausingGame = state.gamePhase === 'playing' || state.gamePhase === 'results'
|
||||
|
||||
return {
|
||||
...state,
|
||||
gamePhase: 'setup',
|
||||
// PAUSE: Save game state if pausing from active game
|
||||
pausedGamePhase: isPausingGame ? state.gamePhase : undefined,
|
||||
pausedGameState: isPausingGame
|
||||
? {
|
||||
@@ -125,12 +195,11 @@ function applyMoveOptimistically(state: MemoryPairsState, move: GameMove): Memor
|
||||
moves: state.moves,
|
||||
scores: state.scores,
|
||||
activePlayers: state.activePlayers,
|
||||
playerMetadata: state.playerMetadata,
|
||||
playerMetadata: state.playerMetadata || {},
|
||||
consecutiveMatches: state.consecutiveMatches,
|
||||
gameStartTime: state.gameStartTime,
|
||||
}
|
||||
: undefined,
|
||||
// Reset visible game state
|
||||
gameCards: [],
|
||||
cards: [],
|
||||
flippedCards: [],
|
||||
@@ -152,16 +221,12 @@ function applyMoveOptimistically(state: MemoryPairsState, move: GameMove): Memor
|
||||
}
|
||||
|
||||
case 'SET_CONFIG': {
|
||||
// Update configuration field optimistically
|
||||
const { field, value } = move.data as { field: string; value: any }
|
||||
const clearPausedGame = !!state.pausedGamePhase
|
||||
|
||||
return {
|
||||
...state,
|
||||
[field]: value,
|
||||
// Update totalPairs if difficulty changes
|
||||
...(field === 'difficulty' ? { totalPairs: value } : {}),
|
||||
// Clear paused game if config changed
|
||||
[action.field]: action.value,
|
||||
...(action.field === 'difficulty' ? { totalPairs: action.value } : {}),
|
||||
...(clearPausedGame
|
||||
? { pausedGamePhase: undefined, pausedGameState: undefined, originalConfig: undefined }
|
||||
: {}),
|
||||
@@ -169,9 +234,8 @@ function applyMoveOptimistically(state: MemoryPairsState, move: GameMove): Memor
|
||||
}
|
||||
|
||||
case 'RESUME_GAME': {
|
||||
// Resume paused game
|
||||
if (!state.pausedGamePhase || !state.pausedGameState) {
|
||||
return state // No paused game, no-op
|
||||
return state
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -187,126 +251,142 @@ function applyMoveOptimistically(state: MemoryPairsState, move: GameMove): Memor
|
||||
playerMetadata: state.pausedGameState.playerMetadata,
|
||||
consecutiveMatches: state.pausedGameState.consecutiveMatches,
|
||||
gameStartTime: state.pausedGameState.gameStartTime,
|
||||
// Clear paused state
|
||||
pausedGamePhase: undefined,
|
||||
pausedGameState: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
case 'HOVER_CARD': {
|
||||
return {
|
||||
...state,
|
||||
playerHovers: {
|
||||
...state.playerHovers,
|
||||
[action.playerId]: action.cardId,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
case 'END_GAME': {
|
||||
return {
|
||||
...state,
|
||||
gamePhase: 'results',
|
||||
gameEndTime: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
// Provider component for LOCAL play (no network sync)
|
||||
// Provider component for LOCAL-ONLY play (no network, no arcade session)
|
||||
export function LocalMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
const router = useRouter()
|
||||
const { data: viewerId } = useViewerId()
|
||||
// NOTE: We deliberately do NOT call useRoomData() for local play
|
||||
const { activePlayerCount, activePlayers: activePlayerIds, players } = useGameMode()
|
||||
|
||||
// Get active player IDs directly as strings (UUIDs)
|
||||
const activePlayers = Array.from(activePlayerIds)
|
||||
// LOCAL-ONLY: Get only the current user's players (no room members)
|
||||
const { data: userPlayers = [] } = useUserPlayers()
|
||||
|
||||
// Use arcade redirect to determine button visibility
|
||||
const { canModifyPlayers } = useArcadeRedirect({ currentGame: 'matching' })
|
||||
|
||||
// Build players map from current user's players only
|
||||
const players = useMemo(() => {
|
||||
const map = new Map()
|
||||
userPlayers.forEach((player) => {
|
||||
map.set(player.id, {
|
||||
id: player.id,
|
||||
name: player.name,
|
||||
emoji: player.emoji,
|
||||
color: player.color,
|
||||
isLocal: true,
|
||||
})
|
||||
})
|
||||
return map
|
||||
}, [userPlayers])
|
||||
|
||||
// Get active player IDs from current user's players only
|
||||
const activePlayers = useMemo(() => {
|
||||
return userPlayers.filter((p) => p.isActive).map((p) => p.id)
|
||||
}, [userPlayers])
|
||||
|
||||
// Derive game mode from active player count
|
||||
const gameMode = activePlayerCount > 1 ? 'multiplayer' : 'single'
|
||||
const gameMode = activePlayers.length > 1 ? 'multiplayer' : 'single'
|
||||
|
||||
// NO LOCAL STATE - Configuration lives in session state
|
||||
// Changes are sent as moves and synchronized (even in local mode for consistency)
|
||||
// Pure client-side state with useReducer
|
||||
const [state, dispatch] = useReducer(localMemoryPairsReducer, initialState)
|
||||
|
||||
// Arcade session integration WITHOUT room sync
|
||||
const {
|
||||
state,
|
||||
sendMove,
|
||||
connected: _connected,
|
||||
exitSession,
|
||||
} = useArcadeSession<MemoryPairsState>({
|
||||
userId: viewerId || '',
|
||||
roomId: undefined, // CRITICAL: No roomId means no network sync
|
||||
initialState,
|
||||
applyMove: applyMoveOptimistically,
|
||||
})
|
||||
|
||||
// Handle mismatch feedback timeout
|
||||
// Handle mismatch feedback timeout and player switching
|
||||
useEffect(() => {
|
||||
if (state.showMismatchFeedback && state.flippedCards.length === 2) {
|
||||
// After 1.5 seconds, send CLEAR_MISMATCH
|
||||
// Server will validate that cards are still in mismatch state before clearing
|
||||
const timeout = setTimeout(() => {
|
||||
sendMove({
|
||||
type: 'CLEAR_MISMATCH',
|
||||
playerId: state.currentPlayer,
|
||||
data: {},
|
||||
})
|
||||
dispatch({ type: 'CLEAR_MISMATCH' })
|
||||
// Switch to next player after mismatch
|
||||
dispatch({ type: 'SWITCH_PLAYER' })
|
||||
}, 1500)
|
||||
|
||||
return () => clearTimeout(timeout)
|
||||
}
|
||||
}, [state.showMismatchFeedback, state.flippedCards.length, sendMove, state.currentPlayer])
|
||||
}, [state.showMismatchFeedback, state.flippedCards.length])
|
||||
|
||||
// Handle automatic match checking when 2 cards flipped
|
||||
useEffect(() => {
|
||||
if (state.flippedCards.length === 2 && !state.showMismatchFeedback) {
|
||||
const [card1, card2] = state.flippedCards
|
||||
const isMatch = validateMatch(card1, card2)
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (isMatch.isValid) {
|
||||
dispatch({
|
||||
type: 'MATCH_FOUND',
|
||||
cardIds: [card1.id, card2.id],
|
||||
playerId: state.currentPlayer,
|
||||
})
|
||||
// Player keeps turn on match - no SWITCH_PLAYER
|
||||
} else {
|
||||
dispatch({
|
||||
type: 'MATCH_FAILED',
|
||||
cardIds: [card1.id, card2.id],
|
||||
})
|
||||
// SWITCH_PLAYER will happen after CLEAR_MISMATCH timeout
|
||||
}
|
||||
}, 600) // Small delay to show both cards
|
||||
|
||||
return () => clearTimeout(timeout)
|
||||
}
|
||||
}, [state.flippedCards, state.showMismatchFeedback, state.currentPlayer])
|
||||
|
||||
// Computed values
|
||||
const isGameActive = state.gamePhase === 'playing'
|
||||
|
||||
const canFlipCard = useCallback(
|
||||
(cardId: string): boolean => {
|
||||
console.log('[LocalProvider][canFlipCard] Checking card:', {
|
||||
cardId,
|
||||
isGameActive,
|
||||
isProcessingMove: state.isProcessingMove,
|
||||
currentPlayer: state.currentPlayer,
|
||||
flippedCardsCount: state.flippedCards.length,
|
||||
})
|
||||
|
||||
if (!isGameActive || state.isProcessingMove) {
|
||||
console.log('[LocalProvider][canFlipCard] Blocked: game not active or processing')
|
||||
return false
|
||||
}
|
||||
|
||||
const card = state.gameCards.find((c) => c.id === cardId)
|
||||
if (!card || card.matched) {
|
||||
console.log('[LocalProvider][canFlipCard] Blocked: card not found or already matched')
|
||||
return false
|
||||
}
|
||||
|
||||
// Can't flip if already flipped
|
||||
if (state.flippedCards.some((c) => c.id === cardId)) {
|
||||
console.log('[LocalProvider][canFlipCard] Blocked: card already flipped')
|
||||
return false
|
||||
}
|
||||
|
||||
// Can't flip more than 2 cards
|
||||
if (state.flippedCards.length >= 2) {
|
||||
console.log('[LocalProvider][canFlipCard] Blocked: 2 cards already flipped')
|
||||
return false
|
||||
}
|
||||
|
||||
// In local play, we allow the current player to flip
|
||||
// Authorization is simpler - just check if it's this player's turn
|
||||
// In local play, all local players can flip during their turn
|
||||
const currentPlayerData = players.get(state.currentPlayer)
|
||||
console.log('[LocalProvider][canFlipCard] Authorization check:', {
|
||||
currentPlayerId: state.currentPlayer,
|
||||
currentPlayerFound: !!currentPlayerData,
|
||||
currentPlayerIsLocal: currentPlayerData?.isLocal,
|
||||
})
|
||||
|
||||
// Block if current player is explicitly marked as remote (shouldn't happen in local play)
|
||||
if (currentPlayerData && currentPlayerData.isLocal === false) {
|
||||
console.log(
|
||||
'[LocalProvider][canFlipCard] BLOCKED: Current player is remote (unexpected in local play)'
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
console.log('[LocalProvider][canFlipCard] ALLOWED: All checks passed')
|
||||
return true
|
||||
},
|
||||
[
|
||||
isGameActive,
|
||||
state.isProcessingMove,
|
||||
state.gameCards,
|
||||
state.flippedCards,
|
||||
state.currentPlayer,
|
||||
players,
|
||||
]
|
||||
[isGameActive, state.isProcessingMove, state.gameCards, state.flippedCards, state.currentPlayer, players]
|
||||
)
|
||||
|
||||
const currentGameStatistics: GameStatistics = useMemo(
|
||||
@@ -324,7 +404,6 @@ export function LocalMemoryPairsProvider({ children }: { children: ReactNode })
|
||||
[state.moves, state.matchedPairs, state.totalPairs, state.gameStartTime, state.gameEndTime]
|
||||
)
|
||||
|
||||
// PAUSE/RESUME: Computed values for pause/resume functionality
|
||||
const hasConfigChanged = useMemo(() => {
|
||||
if (!state.originalConfig) return false
|
||||
return (
|
||||
@@ -338,16 +417,13 @@ export function LocalMemoryPairsProvider({ children }: { children: ReactNode })
|
||||
return !!state.pausedGamePhase && !!state.pausedGameState && !hasConfigChanged
|
||||
}, [state.pausedGamePhase, state.pausedGameState, hasConfigChanged])
|
||||
|
||||
// Action creators - send moves to arcade session
|
||||
// Action creators
|
||||
const startGame = useCallback(() => {
|
||||
// Must have at least one active player
|
||||
if (activePlayers.length === 0) {
|
||||
console.error('[LocalMemoryPairs] Cannot start game without active players')
|
||||
return
|
||||
}
|
||||
|
||||
// Capture player metadata from local players map
|
||||
// This ensures all room members can display player info even if they don't own the players
|
||||
const playerMetadata: { [playerId: string]: any } = {}
|
||||
for (const playerId of activePlayers) {
|
||||
const playerData = players.get(playerId)
|
||||
@@ -362,56 +438,31 @@ export function LocalMemoryPairsProvider({ children }: { children: ReactNode })
|
||||
}
|
||||
}
|
||||
|
||||
// Use current session state configuration (no local state!)
|
||||
const cards = generateGameCards(state.gameType, state.difficulty)
|
||||
// Use first active player as playerId for START_GAME move
|
||||
const firstPlayer = activePlayers[0]
|
||||
sendMove({
|
||||
dispatch({
|
||||
type: 'START_GAME',
|
||||
playerId: firstPlayer,
|
||||
data: {
|
||||
cards,
|
||||
activePlayers,
|
||||
playerMetadata,
|
||||
},
|
||||
cards,
|
||||
activePlayers,
|
||||
playerMetadata,
|
||||
})
|
||||
}, [state.gameType, state.difficulty, activePlayers, players, viewerId, sendMove])
|
||||
}, [state.gameType, state.difficulty, activePlayers, players, viewerId])
|
||||
|
||||
const flipCard = useCallback(
|
||||
(cardId: string) => {
|
||||
console.log('[LocalProvider] flipCard called:', {
|
||||
cardId,
|
||||
viewerId,
|
||||
currentPlayer: state.currentPlayer,
|
||||
activePlayers: state.activePlayers,
|
||||
gamePhase: state.gamePhase,
|
||||
canFlip: canFlipCard(cardId),
|
||||
})
|
||||
|
||||
if (!canFlipCard(cardId)) {
|
||||
console.log('[LocalProvider] Cannot flip card - canFlipCard returned false')
|
||||
return
|
||||
}
|
||||
|
||||
const move = {
|
||||
type: 'FLIP_CARD' as const,
|
||||
playerId: state.currentPlayer, // Use the current player ID from game state (database player ID)
|
||||
data: { cardId },
|
||||
}
|
||||
console.log('[LocalProvider] Sending FLIP_CARD move via sendMove:', move)
|
||||
sendMove(move)
|
||||
dispatch({ type: 'FLIP_CARD', cardId })
|
||||
},
|
||||
[canFlipCard, sendMove, viewerId, state.currentPlayer, state.activePlayers, state.gamePhase]
|
||||
[canFlipCard]
|
||||
)
|
||||
|
||||
const resetGame = useCallback(() => {
|
||||
// Must have at least one active player
|
||||
if (activePlayers.length === 0) {
|
||||
console.error('[LocalMemoryPairs] Cannot reset game without active players')
|
||||
return
|
||||
}
|
||||
|
||||
// Capture player metadata from local players map
|
||||
const playerMetadata: { [playerId: string]: any } = {}
|
||||
for (const playerId of activePlayers) {
|
||||
const playerData = players.get(playerId)
|
||||
@@ -426,97 +477,71 @@ export function LocalMemoryPairsProvider({ children }: { children: ReactNode })
|
||||
}
|
||||
}
|
||||
|
||||
// Use current session state configuration (no local state!)
|
||||
const cards = generateGameCards(state.gameType, state.difficulty)
|
||||
// Use first active player as playerId for START_GAME move
|
||||
const firstPlayer = activePlayers[0]
|
||||
sendMove({
|
||||
dispatch({
|
||||
type: 'START_GAME',
|
||||
playerId: firstPlayer,
|
||||
data: {
|
||||
cards,
|
||||
activePlayers,
|
||||
playerMetadata,
|
||||
},
|
||||
cards,
|
||||
activePlayers,
|
||||
playerMetadata,
|
||||
})
|
||||
}, [state.gameType, state.difficulty, activePlayers, players, viewerId, sendMove])
|
||||
}, [state.gameType, state.difficulty, activePlayers, players, viewerId])
|
||||
|
||||
const setGameType = useCallback(
|
||||
(gameType: typeof state.gameType) => {
|
||||
// Use first active player as playerId, or empty string if none
|
||||
const playerId = activePlayers[0] || ''
|
||||
sendMove({
|
||||
type: 'SET_CONFIG',
|
||||
playerId,
|
||||
data: { field: 'gameType', value: gameType },
|
||||
})
|
||||
},
|
||||
[activePlayers, sendMove]
|
||||
)
|
||||
const setGameType = useCallback((gameType: typeof state.gameType) => {
|
||||
dispatch({ type: 'SET_CONFIG', field: 'gameType', value: gameType })
|
||||
}, [])
|
||||
|
||||
const setDifficulty = useCallback(
|
||||
(difficulty: typeof state.difficulty) => {
|
||||
const playerId = activePlayers[0] || ''
|
||||
sendMove({
|
||||
type: 'SET_CONFIG',
|
||||
playerId,
|
||||
data: { field: 'difficulty', value: difficulty },
|
||||
})
|
||||
},
|
||||
[activePlayers, sendMove]
|
||||
)
|
||||
const setDifficulty = useCallback((difficulty: typeof state.difficulty) => {
|
||||
dispatch({ type: 'SET_CONFIG', field: 'difficulty', value: difficulty })
|
||||
}, [])
|
||||
|
||||
const setTurnTimer = useCallback(
|
||||
(turnTimer: typeof state.turnTimer) => {
|
||||
const playerId = activePlayers[0] || ''
|
||||
sendMove({
|
||||
type: 'SET_CONFIG',
|
||||
playerId,
|
||||
data: { field: 'turnTimer', value: turnTimer },
|
||||
})
|
||||
},
|
||||
[activePlayers, sendMove]
|
||||
)
|
||||
const setTurnTimer = useCallback((turnTimer: typeof state.turnTimer) => {
|
||||
dispatch({ type: 'SET_CONFIG', field: 'turnTimer', value: turnTimer })
|
||||
}, [])
|
||||
|
||||
const resumeGame = useCallback(() => {
|
||||
// PAUSE/RESUME: Resume paused game if config unchanged
|
||||
if (!canResumeGame) {
|
||||
console.warn('[LocalMemoryPairs] Cannot resume - no paused game or config changed')
|
||||
return
|
||||
}
|
||||
|
||||
const playerId = activePlayers[0] || state.currentPlayer || ''
|
||||
sendMove({
|
||||
type: 'RESUME_GAME',
|
||||
playerId,
|
||||
data: {},
|
||||
})
|
||||
}, [canResumeGame, activePlayers, state.currentPlayer, sendMove])
|
||||
dispatch({ type: 'RESUME_GAME' })
|
||||
}, [canResumeGame])
|
||||
|
||||
const goToSetup = useCallback(() => {
|
||||
// Send GO_TO_SETUP move - synchronized across all room members
|
||||
const playerId = activePlayers[0] || state.currentPlayer || ''
|
||||
sendMove({
|
||||
type: 'GO_TO_SETUP',
|
||||
playerId,
|
||||
data: {},
|
||||
})
|
||||
}, [activePlayers, state.currentPlayer, sendMove])
|
||||
dispatch({ type: 'GO_TO_SETUP' })
|
||||
}, [])
|
||||
|
||||
const hoverCard = useCallback(
|
||||
(cardId: string | null) => {
|
||||
const playerId = state.currentPlayer || activePlayers[0] || ''
|
||||
if (!playerId) return
|
||||
|
||||
dispatch({
|
||||
type: 'HOVER_CARD',
|
||||
playerId,
|
||||
cardId,
|
||||
})
|
||||
},
|
||||
[state.currentPlayer, activePlayers]
|
||||
)
|
||||
|
||||
const exitSession = useCallback(() => {
|
||||
router.push('/arcade')
|
||||
}, [router])
|
||||
|
||||
// NO MORE effectiveState merging! Just use session state directly with gameMode added
|
||||
const effectiveState = { ...state, gameMode } as MemoryPairsState & { gameMode: GameMode }
|
||||
|
||||
const contextValue: MemoryPairsContextValue = {
|
||||
state: effectiveState,
|
||||
dispatch: () => {
|
||||
// No-op - replaced with sendMove
|
||||
console.warn('dispatch() is deprecated in arcade mode, use action creators instead')
|
||||
// No-op - local provider uses action creators instead
|
||||
console.warn('dispatch() is not available in local mode, use action creators instead')
|
||||
},
|
||||
isGameActive,
|
||||
canFlipCard,
|
||||
currentGameStatistics,
|
||||
hasConfigChanged,
|
||||
canResumeGame,
|
||||
canModifyPlayers,
|
||||
startGame,
|
||||
resumeGame,
|
||||
flipCard,
|
||||
@@ -525,6 +550,7 @@ export function LocalMemoryPairsProvider({ children }: { children: ReactNode })
|
||||
setGameType,
|
||||
setDifficulty,
|
||||
setTurnTimer,
|
||||
hoverCard,
|
||||
exitSession,
|
||||
gameMode,
|
||||
activePlayers,
|
||||
|
||||
@@ -251,7 +251,7 @@ function memoryPairsReducer(state: MemoryPairsState, action: MemoryPairsAction):
|
||||
}
|
||||
|
||||
// Create context
|
||||
const MemoryPairsContext = createContext<MemoryPairsContextValue | null>(null)
|
||||
export const MemoryPairsContext = createContext<MemoryPairsContextValue | null>(null)
|
||||
|
||||
// Provider component
|
||||
export function MemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { type ReactNode, useCallback, useEffect, useMemo } from 'react'
|
||||
import { useArcadeRedirect } from '@/hooks/useArcadeRedirect'
|
||||
import { useArcadeSession } from '@/hooks/useArcadeSession'
|
||||
import { useRoomData } from '@/hooks/useRoomData'
|
||||
import { useViewerId } from '@/hooks/useViewerId'
|
||||
@@ -213,6 +214,8 @@ function applyMoveOptimistically(state: MemoryPairsState, move: GameMove): Memor
|
||||
}
|
||||
|
||||
// Provider component for ROOM-BASED play (with network sync)
|
||||
// NOTE: This provider should ONLY be used for room-based multiplayer games.
|
||||
// For arcade sessions without rooms, use LocalMemoryPairsProvider instead.
|
||||
export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
const { data: viewerId } = useViewerId()
|
||||
const { roomData } = useRoomData() // Fetch room data for room-based play
|
||||
@@ -557,6 +560,7 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
currentGameStatistics,
|
||||
hasConfigChanged,
|
||||
canResumeGame,
|
||||
canModifyPlayers: false, // Room-based games: always show buttons (false = show buttons)
|
||||
startGame,
|
||||
resumeGame,
|
||||
flipCard,
|
||||
@@ -573,3 +577,6 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
return <MemoryPairsContext.Provider value={contextValue}>{children}</MemoryPairsContext.Provider>
|
||||
}
|
||||
|
||||
// Export the hook for this provider
|
||||
export { useMemoryPairs } from './MemoryPairsContext'
|
||||
|
||||
20
apps/web/src/app/arcade/matching/context/index.ts
Normal file
20
apps/web/src/app/arcade/matching/context/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Central export point for arcade matching game context
|
||||
* Re-exports the hook from the appropriate provider
|
||||
*/
|
||||
|
||||
// Export the hook (works with both local and room providers)
|
||||
export { useMemoryPairs } from './MemoryPairsContext'
|
||||
|
||||
// Export the room provider (networked multiplayer)
|
||||
export { RoomMemoryPairsProvider } from './RoomMemoryPairsProvider'
|
||||
|
||||
// Export types
|
||||
export type {
|
||||
GameCard,
|
||||
GameMode,
|
||||
GamePhase,
|
||||
GameType,
|
||||
MemoryPairsState,
|
||||
MemoryPairsContextValue,
|
||||
} from './types'
|
||||
@@ -59,6 +59,7 @@ export interface MemoryPairsState {
|
||||
moves: number
|
||||
scores: PlayerScore
|
||||
activePlayers: Player[] // Track active player IDs
|
||||
playerMetadata?: { [playerId: string]: any } // Player metadata for cross-user visibility
|
||||
consecutiveMatches: { [playerId: string]: number } // Track consecutive matches per player
|
||||
|
||||
// Timing
|
||||
@@ -72,6 +73,24 @@ export interface MemoryPairsState {
|
||||
isProcessingMove: boolean
|
||||
showMismatchFeedback: boolean
|
||||
lastMatchedPair: [string, string] | null
|
||||
|
||||
// PAUSE/RESUME: Paused game state
|
||||
originalConfig?: { gameType: GameType; difficulty: Difficulty; turnTimer: number }
|
||||
pausedGamePhase?: GamePhase
|
||||
pausedGameState?: {
|
||||
gameCards: GameCard[]
|
||||
currentPlayer: Player
|
||||
matchedPairs: number
|
||||
moves: number
|
||||
scores: PlayerScore
|
||||
activePlayers: Player[]
|
||||
playerMetadata: { [playerId: string]: any }
|
||||
consecutiveMatches: { [playerId: string]: number }
|
||||
gameStartTime: number | null
|
||||
}
|
||||
|
||||
// HOVER: Networked hover state
|
||||
playerHovers?: { [playerId: string]: string | null }
|
||||
}
|
||||
|
||||
export type MemoryPairsAction =
|
||||
@@ -101,6 +120,11 @@ export interface MemoryPairsContextValue {
|
||||
currentGameStatistics: GameStatistics
|
||||
gameMode: GameMode // Derived from global context
|
||||
activePlayers: Player[] // Active player IDs from arena
|
||||
canModifyPlayers: boolean // Whether players can be added/removed (controls button visibility)
|
||||
|
||||
// PAUSE/RESUME: Computed pause/resume values
|
||||
hasConfigChanged?: boolean
|
||||
canResumeGame?: boolean
|
||||
|
||||
// Actions
|
||||
startGame: () => void
|
||||
@@ -108,6 +132,10 @@ export interface MemoryPairsContextValue {
|
||||
resetGame: () => void
|
||||
setGameType: (type: GameType) => void
|
||||
setDifficulty: (difficulty: Difficulty) => void
|
||||
setTurnTimer?: (timer: number) => void
|
||||
goToSetup?: () => void
|
||||
resumeGame?: () => void
|
||||
hoverCard?: (cardId: string | null) => void
|
||||
exitSession: () => void
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { ArcadeGuardedPage } from '@/components/ArcadeGuardedPage'
|
||||
import { MemoryPairsGame } from './components/MemoryPairsGame'
|
||||
import { RoomMemoryPairsProvider } from './context/RoomMemoryPairsProvider'
|
||||
import { LocalMemoryPairsProvider } from './context/LocalMemoryPairsProvider'
|
||||
|
||||
export default function MatchingPage() {
|
||||
return (
|
||||
<ArcadeGuardedPage>
|
||||
<RoomMemoryPairsProvider>
|
||||
<LocalMemoryPairsProvider>
|
||||
<MemoryPairsGame />
|
||||
</RoomMemoryPairsProvider>
|
||||
</LocalMemoryPairsProvider>
|
||||
</ArcadeGuardedPage>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useRoomData } from '@/hooks/useRoomData'
|
||||
import { MemoryPairsGame } from '../matching/components/MemoryPairsGame'
|
||||
import { ArcadeMemoryPairsProvider } from '../matching/context/ArcadeMemoryPairsContext'
|
||||
import { RoomMemoryPairsProvider } from '../matching/context/RoomMemoryPairsProvider'
|
||||
|
||||
/**
|
||||
* /arcade/room - Renders the game for the user's current room
|
||||
@@ -68,9 +68,9 @@ export default function RoomPage() {
|
||||
switch (roomData.gameName) {
|
||||
case 'matching':
|
||||
return (
|
||||
<ArcadeMemoryPairsProvider>
|
||||
<RoomMemoryPairsProvider>
|
||||
<MemoryPairsGame />
|
||||
</ArcadeMemoryPairsProvider>
|
||||
</RoomMemoryPairsProvider>
|
||||
)
|
||||
|
||||
// TODO: Add other games (complement-race, memory-quiz, etc.)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "soroban-monorepo",
|
||||
"version": "2.12.0",
|
||||
"version": "2.16.3",
|
||||
"private": true,
|
||||
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
|
||||
"workspaces": [
|
||||
|
||||
Reference in New Issue
Block a user