Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f160d2e4af | ||
|
|
d14979907c | ||
|
|
c32f4dd1f6 | ||
|
|
b5ee04f576 | ||
|
|
ce30fcaf55 | ||
|
|
05eacac438 | ||
|
|
65828950a2 | ||
|
|
2856f4b83f | ||
|
|
3a01f4637d | ||
|
|
97378b70b7 | ||
|
|
3158addda1 | ||
|
|
71b0aac13c | ||
|
|
0543377bda | ||
|
|
d03c789879 | ||
|
|
15029ae52f | ||
|
|
a83dc097e4 | ||
|
|
0abec1a3bb | ||
|
|
5171be3d37 | ||
|
|
fc9eb253ad | ||
|
|
d474ef07d6 | ||
|
|
3cdc0695f4 | ||
|
|
10cf71527f | ||
|
|
678f4423b6 | ||
|
|
c5268b79de | ||
|
|
d9aadd1f81 | ||
|
|
4686f59d24 | ||
|
|
1219539585 | ||
|
|
87cc0b64fb | ||
|
|
c640a79a44 | ||
|
|
0d85331652 | ||
|
|
28a2e7d651 | ||
|
|
a27c36193e | ||
|
|
cc80a1454b | ||
|
|
8175c43533 | ||
|
|
b49630f3cb | ||
|
|
c4d8032d02 | ||
|
|
01ff114258 | ||
|
|
d173a178bc | ||
|
|
431668729c |
124
CHANGELOG.md
124
CHANGELOG.md
@@ -1,3 +1,127 @@
|
||||
## [2.12.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.11.0...v2.12.0) (2025-10-09)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add networked hover state infrastructure for multiplayer presence ([d149799](https://github.com/antialias/soroban-abacus-flashcards/commit/d14979907c5df9b793a1c110028fc5b54457f507))
|
||||
|
||||
## [2.11.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.10.1...v2.11.0) (2025-10-09)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add pause/resume game state architecture ([05eacac](https://github.com/antialias/soroban-abacus-flashcards/commit/05eacac438dbaf405ce91e188c53dbbe2e9f9507))
|
||||
* add Resume button and config change warning to setup UI ([b5ee04f](https://github.com/antialias/soroban-abacus-flashcards/commit/b5ee04f57651f53517468fcc4c456f0ccb65a8e2))
|
||||
* implement pause/resume in game providers with optimistic updates ([ce30fca](https://github.com/antialias/soroban-abacus-flashcards/commit/ce30fcaf55270f9089249bd13ba73a25fbfa5ab4))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* convert guestId to internal userId for player ownership check ([3a01f46](https://github.com/antialias/soroban-abacus-flashcards/commit/3a01f4637d2081c66fe37c7f8cfee229442ec744))
|
||||
* implement shared session architecture for room-based multiplayer ([2856f4b](https://github.com/antialias/soroban-abacus-flashcards/commit/2856f4b83fbcc6483d96cc6e7da2fe5bc911625d))
|
||||
|
||||
## [2.10.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.10.1...v2.10.2) (2025-10-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* convert guestId to internal userId for player ownership check ([3a01f46](https://github.com/antialias/soroban-abacus-flashcards/commit/3a01f4637d2081c66fe37c7f8cfee229442ec744))
|
||||
* implement shared session architecture for room-based multiplayer ([2856f4b](https://github.com/antialias/soroban-abacus-flashcards/commit/2856f4b83fbcc6483d96cc6e7da2fe5bc911625d))
|
||||
|
||||
## [2.10.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.10.0...v2.10.1) (2025-10-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* enforce player ownership authorization for multiplayer games ([71b0aac](https://github.com/antialias/soroban-abacus-flashcards/commit/71b0aac13c970c03fe8d296d41e9472ad72a00fa))
|
||||
|
||||
## [2.10.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.9.0...v2.10.0) (2025-10-09)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* implement rich Radix UI tooltips for player avatars ([d03c789](https://github.com/antialias/soroban-abacus-flashcards/commit/d03c7898799b378f912f47d7267a00bc7ce3d580))
|
||||
|
||||
## [2.9.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.7...v2.9.0) (2025-10-09)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* implement auto-save for player settings modal ([a83dc09](https://github.com/antialias/soroban-abacus-flashcards/commit/a83dc097e43c265a297281da54754f58ac831754))
|
||||
|
||||
## [2.8.7](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.6...v2.8.7) (2025-10-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* enable real-time player name updates across room members ([5171be3](https://github.com/antialias/soroban-abacus-flashcards/commit/5171be3d37980eb1c98aa0d1e1d6e06f589763d1))
|
||||
|
||||
## [2.8.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.5...v2.8.6) (2025-10-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* prevent duplicate display of network avatars in nav ([d474ef0](https://github.com/antialias/soroban-abacus-flashcards/commit/d474ef07d69cf0b4f5dedd404616e3bbee7289fe))
|
||||
|
||||
## [2.8.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.4...v2.8.5) (2025-10-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* remove redirect loop by not redirecting from room page ([10cf715](https://github.com/antialias/soroban-abacus-flashcards/commit/10cf71527f7cede7fd93e502dbfc59df99b5a524))
|
||||
|
||||
## [2.8.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.3...v2.8.4) (2025-10-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* prevent redirect loops by checking if already at target URL ([c5268b7](https://github.com/antialias/soroban-abacus-flashcards/commit/c5268b79dee66aa02e14e2024fe1c6242a172ed3))
|
||||
|
||||
## [2.8.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.2...v2.8.3) (2025-10-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* remove ArcadeGuardedPage from room page to prevent redirect loop ([4686f59](https://github.com/antialias/soroban-abacus-flashcards/commit/4686f59d245b2b502dc0764c223a5ce84bf1af44))
|
||||
|
||||
## [2.8.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.1...v2.8.2) (2025-10-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* revert to showing only active players in room games ([87cc0b6](https://github.com/antialias/soroban-abacus-flashcards/commit/87cc0b64fb5f3debaf1d2f122aecfefc62922fed))
|
||||
|
||||
## [2.8.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.0...v2.8.1) (2025-10-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* include all players from room members in room games ([28a2e7d](https://github.com/antialias/soroban-abacus-flashcards/commit/28a2e7d6511e70b83adf7d0465789a91026bc1f7))
|
||||
|
||||
## [2.8.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.7.4...v2.8.0) (2025-10-08)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* implement room-wide multi-user game state synchronization ([8175c43](https://github.com/antialias/soroban-abacus-flashcards/commit/8175c43533c474fff48eb128c97747033bfb434a))
|
||||
|
||||
|
||||
### Tests
|
||||
|
||||
* add comprehensive tests for arcade guard and room navigation ([b49630f](https://github.com/antialias/soroban-abacus-flashcards/commit/b49630f3cb02ebbac75b4680948bbface314dccb))
|
||||
|
||||
## [2.7.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.7.3...v2.7.4) (2025-10-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* respect enabled flag in useArcadeGuard WebSocket redirects ([01ff114](https://github.com/antialias/soroban-abacus-flashcards/commit/01ff114258ff7ab43ef2bd79b41c7035fe02ac70))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* move room management pages to /arcade-rooms ([4316687](https://github.com/antialias/soroban-abacus-flashcards/commit/431668729cfb145d6e0c13947de2a82f27fa400d))
|
||||
|
||||
## [2.7.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.7.2...v2.7.3) (2025-10-08)
|
||||
|
||||
|
||||
|
||||
@@ -1,42 +1,6 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npm run build:*)",
|
||||
"Bash(npx tsc:*)",
|
||||
"Bash(curl:*)",
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit -m \"$(cat <<''EOF''\nfix: lazy-load database connection to prevent build-time access\n\nRefactor db/index.ts to use lazy initialization via Proxy pattern.\nThis prevents the database from being accessed at module import time,\nwhich was causing Next.js build failures in CI/CD environments where\nno database file exists.\n\nThe database connection is now created only when first accessed at\nruntime, allowing static site generation to complete successfully.\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude <noreply@anthropic.com>\nEOF\n)\")",
|
||||
"Bash(git push:*)",
|
||||
"Read(//Users/antialias/projects/soroban-abacus-flashcards/**)",
|
||||
"Bash(npm install:*)",
|
||||
"Bash(cat:*)",
|
||||
"Bash(pnpm add:*)",
|
||||
"Bash(npx biome check:*)",
|
||||
"Bash(npx:*)",
|
||||
"Bash(eslint:*)",
|
||||
"Bash(npm run lint:fix:*)",
|
||||
"Bash(npm run format:*)",
|
||||
"Bash(npm run lint:*)",
|
||||
"Bash(pnpm install:*)",
|
||||
"Bash(pnpm run:*)",
|
||||
"Bash(rm:*)",
|
||||
"Bash(lsof:*)",
|
||||
"Bash(xargs kill:*)",
|
||||
"Bash(tee:*)",
|
||||
"Bash(for file in src/app/arcade/complement-race/components/RaceTrack/CircularTrack.tsx src/app/arcade/complement-race/components/RaceTrack/LinearTrack.tsx src/app/games/complement-race/components/RaceTrack/CircularTrack.tsx src/app/games/complement-race/components/RaceTrack/LinearTrack.tsx)",
|
||||
"Bash(do)",
|
||||
"Bash(done)",
|
||||
"Bash(for file in src/app/arcade/complement-race/components/RaceTrack/SteamTrainJourney.tsx src/app/games/complement-race/components/RaceTrack/SteamTrainJourney.tsx)",
|
||||
"Bash(for file in src/app/arcade/complement-race/hooks/useTrackManagement.ts src/app/games/complement-race/hooks/useTrackManagement.ts)",
|
||||
"Bash(echo \"EXIT CODE: $?\")",
|
||||
"Bash(git commit -m \"$(cat <<''EOF''\nfeat: add Biome + ESLint linting setup\n\nAdd Biome for formatting and general linting, with minimal ESLint\nconfiguration for React Hooks rules only. This provides:\n\n- Fast formatting via Biome (10-100x faster than Prettier)\n- General JS/TS linting via Biome\n- React Hooks validation via ESLint (rules-of-hooks)\n- Import organization via Biome\n\nConfiguration files:\n- biome.jsonc: Biome config with custom rule overrides\n- eslint.config.js: Minimal flat config for React Hooks only\n- .gitignore: Added Biome cache exclusion\n- LINTING.md: Documentation for the setup\n\nScripts added to package.json:\n- npm run lint: Check all files\n- npm run lint:fix: Auto-fix issues\n- npm run format: Format all files\n- npm run check: Full Biome check\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude <noreply@anthropic.com>\nEOF\n)\")",
|
||||
"Bash(git commit:*)",
|
||||
"Bash(npm run pre-commit:*)",
|
||||
"Bash(npm run:*)",
|
||||
"Bash(git pull:*)",
|
||||
"Bash(git stash:*)",
|
||||
"Bash(members of the room\" requirement for room-based gameplay.\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude <noreply@anthropic.com>\nEOF\n)\")"
|
||||
],
|
||||
"allow": ["Bash(npm test:*)", "Read(//Users/antialias/projects/**)"],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
}
|
||||
|
||||
490
apps/web/docs/MULTIPLAYER_SYNC_ARCHITECTURE.md
Normal file
490
apps/web/docs/MULTIPLAYER_SYNC_ARCHITECTURE.md
Normal file
@@ -0,0 +1,490 @@
|
||||
# Multiplayer Synchronization Architecture
|
||||
|
||||
## Current State: Single-User Multi-Tab Sync
|
||||
|
||||
### How it Works
|
||||
|
||||
**Client-Side Flow:**
|
||||
|
||||
1. User opens game in Tab A and Tab B
|
||||
2. Both tabs create WebSocket connections via `useArcadeSocket()`
|
||||
3. Both emit `join-arcade-session` with `userId`
|
||||
4. Server adds both sockets to `arcade:${userId}` room
|
||||
|
||||
**When User Makes a Move (from Tab A):**
|
||||
|
||||
```typescript
|
||||
// Client (Tab A)
|
||||
sendMove({ type: 'FLIP_CARD', playerId: 'player-1', data: { cardId: 'card-5' } })
|
||||
|
||||
// Optimistic update applied locally
|
||||
state = applyMoveOptimistically(state, move)
|
||||
|
||||
// Socket emits to server
|
||||
socket.emit('game-move', { userId, move })
|
||||
```
|
||||
|
||||
**Server Processing:**
|
||||
|
||||
```typescript
|
||||
// socket-server.ts line 71
|
||||
socket.on('game-move', async (data) => {
|
||||
// Validate move
|
||||
const result = await applyGameMove(data.userId, data.move)
|
||||
|
||||
if (result.success) {
|
||||
// ✅ Broadcast to ALL tabs of this user
|
||||
io.to(`arcade:${data.userId}`).emit('move-accepted', {
|
||||
gameState: result.session.gameState,
|
||||
version: result.session.version,
|
||||
move: data.move
|
||||
})
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**Both Tabs Receive Update:**
|
||||
|
||||
```typescript
|
||||
// Client (Tab A and Tab B)
|
||||
socket.on('move-accepted', (data) => {
|
||||
// Update server state
|
||||
optimistic.handleMoveAccepted(data.gameState, data.version, data.move)
|
||||
|
||||
// Tab A: Remove from pending queue (was optimistic)
|
||||
// Tab B: Just sync with server state (wasn't expecting it)
|
||||
})
|
||||
```
|
||||
|
||||
### Key Components
|
||||
|
||||
1. **`useOptimisticGameState`** - Manages optimistic updates
|
||||
- Keeps `serverState` (last confirmed by server)
|
||||
- Keeps `pendingMoves[]` (not yet confirmed)
|
||||
- Current state = serverState + all pending moves applied
|
||||
|
||||
2. **`useArcadeSession`** - Combines socket + optimistic state
|
||||
- Connects socket
|
||||
- Applies moves optimistically
|
||||
- Sends moves to server
|
||||
- Handles server responses
|
||||
|
||||
3. **Socket Rooms** - Server-side broadcast channels
|
||||
- `arcade:${userId}` - All tabs of one user
|
||||
- Each socket can be in multiple rooms
|
||||
- `io.to(room).emit()` broadcasts to all sockets in that room
|
||||
|
||||
4. **Session Storage** - Database
|
||||
- One session per user (userId is unique key)
|
||||
- Contains `gameState`, `version`, `roomId`
|
||||
- Optimistic locking via version number
|
||||
|
||||
---
|
||||
|
||||
## Required: Room-Based Multi-User Sync
|
||||
|
||||
### The Goal
|
||||
|
||||
Multiple users in the same room at `/arcade/room` should all see synchronized game state:
|
||||
|
||||
- User A (2 tabs): Tab A1, Tab A2
|
||||
- User B (1 tab): Tab B1
|
||||
- User C (2 tabs): Tab C1, Tab C2
|
||||
|
||||
When User A makes a move in Tab A1:
|
||||
- **All of User A's tabs** see the move (Tab A1, Tab A2)
|
||||
- **All of User B's tabs** see the move (Tab B1)
|
||||
- **All of User C's tabs** see the move (Tab C1, Tab C2)
|
||||
|
||||
### The Challenge
|
||||
|
||||
Current architecture only broadcasts within one user:
|
||||
```typescript
|
||||
// ❌ Only reaches User A's tabs
|
||||
io.to(`arcade:${userA}`).emit('move-accepted', ...)
|
||||
```
|
||||
|
||||
We need to broadcast to the entire room:
|
||||
```typescript
|
||||
// ✅ Reaches all users in the room
|
||||
io.to(`game:${roomId}`).emit('move-accepted', ...)
|
||||
```
|
||||
|
||||
### The Solution
|
||||
|
||||
#### 1. Add Room-Based Game Socket Room
|
||||
|
||||
When a user joins `/arcade/room`, they join TWO socket rooms:
|
||||
|
||||
```typescript
|
||||
// socket-server.ts - extend join-arcade-session
|
||||
socket.on('join-arcade-session', async ({ userId, roomId }) => {
|
||||
// Join user's personal room (for multi-tab sync)
|
||||
socket.join(`arcade:${userId}`)
|
||||
|
||||
// If this session is part of a room, also join the game room
|
||||
if (roomId) {
|
||||
socket.join(`game:${roomId}`)
|
||||
console.log(`🎮 User ${userId} joined game room ${roomId}`)
|
||||
}
|
||||
|
||||
// Send current session state...
|
||||
})
|
||||
```
|
||||
|
||||
#### 2. Broadcast to Both Rooms
|
||||
|
||||
When processing moves for room-based sessions:
|
||||
|
||||
```typescript
|
||||
// socket-server.ts - modify game-move handler
|
||||
socket.on('game-move', async (data) => {
|
||||
const result = await applyGameMove(data.userId, data.move)
|
||||
|
||||
if (result.success && result.session) {
|
||||
const moveAcceptedData = {
|
||||
gameState: result.session.gameState,
|
||||
version: result.session.version,
|
||||
move: data.move,
|
||||
}
|
||||
|
||||
// Broadcast to user's own tabs (for optimistic update reconciliation)
|
||||
io.to(`arcade:${data.userId}`).emit('move-accepted', moveAcceptedData)
|
||||
|
||||
// If this is a room-based session, ALSO broadcast to all room members
|
||||
if (result.session.roomId) {
|
||||
io.to(`game:${result.session.roomId}`).emit('move-accepted', moveAcceptedData)
|
||||
console.log(`📢 Broadcasted move to room ${result.session.roomId}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**Why broadcast to both?**
|
||||
- `arcade:${userId}` - So the acting user's tabs can reconcile their optimistic updates
|
||||
- `game:${roomId}` - So all other users in the room receive the update
|
||||
|
||||
#### 3. Client Handles Own vs. Other Moves
|
||||
|
||||
The client already handles this correctly via optimistic updates:
|
||||
|
||||
```typescript
|
||||
// User A (Tab A1) - Makes move
|
||||
sendMove({ type: 'FLIP_CARD', ... })
|
||||
// → Applies optimistically immediately
|
||||
// → Sends to server
|
||||
// → Receives move-accepted
|
||||
// → Reconciles: removes from pending queue
|
||||
|
||||
// User B (Tab B1) - Sees move from User A
|
||||
// → Receives move-accepted (unexpected)
|
||||
// → Reconciles: clears pending queue, syncs with server state
|
||||
// → Result: sees User A's move immediately
|
||||
```
|
||||
|
||||
The beauty is that `handleMoveAccepted()` already handles both cases:
|
||||
- **Own move**: Remove from pending queue
|
||||
- **Other's move**: Clear pending queue (since server state is now ahead)
|
||||
|
||||
#### 4. Pass roomId in join-arcade-session
|
||||
|
||||
Client needs to send roomId when joining:
|
||||
|
||||
```typescript
|
||||
// hooks/useArcadeSocket.ts
|
||||
const joinSession = useCallback((userId: string, roomId?: string) => {
|
||||
if (!socket) return
|
||||
socket.emit('join-arcade-session', { userId, roomId })
|
||||
}, [socket])
|
||||
|
||||
// hooks/useArcadeSession.ts
|
||||
useEffect(() => {
|
||||
if (connected && autoJoin && userId) {
|
||||
// Get roomId from session or room context
|
||||
const roomId = getRoomId() // Need to provide this
|
||||
joinSession(userId, roomId)
|
||||
}
|
||||
}, [connected, autoJoin, userId, joinSession])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Server-Side Changes
|
||||
|
||||
**File: `socket-server.ts`**
|
||||
|
||||
1. ✅ Accept `roomId` in `join-arcade-session` event
|
||||
```typescript
|
||||
socket.on('join-arcade-session', async ({ userId, roomId }) => {
|
||||
socket.join(`arcade:${userId}`)
|
||||
|
||||
// Join game room if session is room-based
|
||||
if (roomId) {
|
||||
socket.join(`game:${roomId}`)
|
||||
}
|
||||
|
||||
// Rest of logic...
|
||||
})
|
||||
```
|
||||
|
||||
2. ✅ Broadcast to room in `game-move` handler
|
||||
```typescript
|
||||
if (result.success && result.session) {
|
||||
const moveData = {
|
||||
gameState: result.session.gameState,
|
||||
version: result.session.version,
|
||||
move: data.move,
|
||||
}
|
||||
|
||||
// Broadcast to user's tabs
|
||||
io.to(`arcade:${data.userId}`).emit('move-accepted', moveData)
|
||||
|
||||
// ALSO broadcast to room if room-based session
|
||||
if (result.session.roomId) {
|
||||
io.to(`game:${result.session.roomId}`).emit('move-accepted', moveData)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. ✅ Handle room disconnects
|
||||
```typescript
|
||||
socket.on('disconnect', () => {
|
||||
// Leave all rooms (handled automatically by socket.io)
|
||||
// But log for debugging
|
||||
if (currentUserId && currentRoomId) {
|
||||
console.log(`User ${currentUserId} left game room ${currentRoomId}`)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Phase 2: Client-Side Changes
|
||||
|
||||
**File: `hooks/useArcadeSocket.ts`**
|
||||
|
||||
1. ✅ Add roomId parameter to joinSession
|
||||
```typescript
|
||||
export interface UseArcadeSocketReturn {
|
||||
// ... existing
|
||||
joinSession: (userId: string, roomId?: string) => void
|
||||
}
|
||||
|
||||
const joinSession = useCallback((userId: string, roomId?: string) => {
|
||||
if (!socket) return
|
||||
socket.emit('join-arcade-session', { userId, roomId })
|
||||
}, [socket])
|
||||
```
|
||||
|
||||
**File: `hooks/useArcadeSession.ts`**
|
||||
|
||||
2. ✅ Accept roomId in options
|
||||
```typescript
|
||||
export interface UseArcadeSessionOptions<TState> {
|
||||
userId: string
|
||||
roomId?: string // NEW
|
||||
initialState: TState
|
||||
applyMove: (state: TState, move: GameMove) => TState
|
||||
// ... rest
|
||||
}
|
||||
|
||||
export function useArcadeSession<TState>(options: UseArcadeSessionOptions<TState>) {
|
||||
const { userId, roomId, ...optimisticOptions } = options
|
||||
|
||||
// Auto-join with roomId
|
||||
useEffect(() => {
|
||||
if (connected && autoJoin && userId) {
|
||||
joinSession(userId, roomId)
|
||||
}
|
||||
}, [connected, autoJoin, userId, roomId, joinSession])
|
||||
|
||||
// ... rest
|
||||
}
|
||||
```
|
||||
|
||||
**File: `app/arcade/matching/context/ArcadeMemoryPairsContext.tsx`**
|
||||
|
||||
3. ✅ Get roomId from room data and pass to session
|
||||
```typescript
|
||||
import { useRoomData } from '@/hooks/useRoomData'
|
||||
|
||||
export function ArcadeMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
const { data: viewerId } = useViewerId()
|
||||
const { roomData } = useRoomData()
|
||||
|
||||
// Arcade session integration
|
||||
const { state, sendMove, ... } = useArcadeSession<MemoryPairsState>({
|
||||
userId: viewerId || '',
|
||||
roomId: roomData?.id, // NEW - pass room ID
|
||||
initialState,
|
||||
applyMove: applyMoveOptimistically,
|
||||
})
|
||||
|
||||
// ... rest stays the same
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 3: Testing
|
||||
|
||||
1. **Multi-Tab Test (Single User)**
|
||||
- Open `/arcade/room` in 2 tabs as User A
|
||||
- Make move in Tab 1
|
||||
- Verify Tab 2 updates immediately
|
||||
|
||||
2. **Multi-User Test (Different Users)**
|
||||
- User A opens `/arcade/room` in 1 tab
|
||||
- User B opens `/arcade/room` in 1 tab (same room)
|
||||
- User A makes move
|
||||
- Verify User B sees move immediately
|
||||
|
||||
3. **Multi-User Multi-Tab Test**
|
||||
- User A: 2 tabs (Tab A1, Tab A2)
|
||||
- User B: 2 tabs (Tab B1, Tab B2)
|
||||
- User A makes move in Tab A1
|
||||
- Verify all 4 tabs update
|
||||
|
||||
4. **Rapid Move Test**
|
||||
- User A and User B both make moves rapidly
|
||||
- Verify no conflicts
|
||||
- Verify all moves are processed in order
|
||||
|
||||
---
|
||||
|
||||
## Edge Cases to Handle
|
||||
|
||||
### 1. User Leaves Room Mid-Game
|
||||
|
||||
**Current behavior:** Session persists, user can rejoin
|
||||
|
||||
**Required behavior:**
|
||||
- If user leaves room (HTTP POST to `/api/arcade/rooms/[roomId]/leave`):
|
||||
- Delete their session
|
||||
- Emit `session-ended` to their tabs
|
||||
- Other users continue playing
|
||||
|
||||
### 2. Version Conflicts
|
||||
|
||||
**Already handled** by optimistic locking:
|
||||
- Each move increments version
|
||||
- Client tracks server version
|
||||
- If conflict detected, reconciliation happens automatically
|
||||
|
||||
### 3. Session Without Room
|
||||
|
||||
**Already handled** by session-manager.ts:
|
||||
- Sessions without `roomId` are considered orphaned
|
||||
- They're cleaned up on next access (lines 111-115)
|
||||
|
||||
### 4. Multiple Users Same Move
|
||||
|
||||
**Handled by server validation:**
|
||||
- Server processes moves sequentially
|
||||
- First valid move wins
|
||||
- Second move gets rejected if it's now invalid
|
||||
- Client rolls back rejected move
|
||||
|
||||
---
|
||||
|
||||
## Benefits of This Architecture
|
||||
|
||||
1. **Reuses existing optimistic update system**
|
||||
- No changes needed to client-side optimistic logic
|
||||
- Already handles own vs. others' moves
|
||||
|
||||
2. **Minimal changes required**
|
||||
- Add `roomId` parameter (3 places)
|
||||
- Add one `io.to()` broadcast (1 place)
|
||||
- Wire up roomId from context (1 place)
|
||||
|
||||
3. **Backward compatible**
|
||||
- Non-room sessions still work (roomId is optional)
|
||||
- Solo play unaffected
|
||||
|
||||
4. **Scalable**
|
||||
- Socket.io handles multiple rooms efficiently
|
||||
- No N² broadcasting (room-based is O(N))
|
||||
|
||||
5. **Already tested pattern**
|
||||
- Multi-tab sync proves the broadcast pattern works
|
||||
- Just extending to more sockets (different users)
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### 1. Validate Room Membership
|
||||
|
||||
Before processing moves, verify user is in the room:
|
||||
|
||||
```typescript
|
||||
// session-manager.ts - in applyGameMove()
|
||||
const session = await getArcadeSession(userId)
|
||||
|
||||
if (session.roomId) {
|
||||
// Verify user is a member of this room
|
||||
const membership = await getRoomMember(session.roomId, userId)
|
||||
if (!membership) {
|
||||
return { success: false, error: 'User not in room' }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Verify Player Ownership
|
||||
|
||||
Ensure users can only make moves for their own players:
|
||||
|
||||
```typescript
|
||||
// Already handled in validator
|
||||
// move.playerId must be in session.activePlayers
|
||||
// activePlayers are owned by the userId making the move
|
||||
```
|
||||
|
||||
This is already enforced by how activePlayers are set up in the room.
|
||||
|
||||
---
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### 1. Broadcasting Overhead
|
||||
|
||||
- **Current**: 1 user × N tabs = N broadcasts per move
|
||||
- **New**: M users × N tabs each = (M×N) broadcasts per move
|
||||
- **Impact**: Linear with room size, not quadratic
|
||||
- **Acceptable**: Socket.io is optimized for this
|
||||
|
||||
### 2. Database Queries
|
||||
|
||||
- No change: Still 1 database write per move
|
||||
- Session is stored per-user, not per-room
|
||||
- Room data is separate (cached, not updated per move)
|
||||
|
||||
### 3. Memory
|
||||
|
||||
- Each socket joins 2 rooms instead of 1
|
||||
- Negligible: Socket.io uses efficient room data structures
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
### Unit Tests
|
||||
|
||||
- [ ] `useArcadeSocket` accepts and passes roomId
|
||||
- [ ] `useArcadeSession` accepts and passes roomId
|
||||
- [ ] Server joins `game:${roomId}` room when roomId provided
|
||||
|
||||
### Integration Tests
|
||||
|
||||
- [ ] Single user, 2 tabs: both tabs sync
|
||||
- [ ] 2 users, 1 tab each: both users sync
|
||||
- [ ] 2 users, 2 tabs each: all 4 tabs sync
|
||||
- [ ] User leaves room: session deleted, others continue
|
||||
- [ ] Rapid concurrent moves: all processed correctly
|
||||
|
||||
### Manual Tests
|
||||
|
||||
- [ ] Open room in 2 browsers (different users)
|
||||
- [ ] Play full game to completion
|
||||
- [ ] Verify scores sync correctly
|
||||
- [ ] Verify turn changes sync correctly
|
||||
- [ ] Verify game completion syncs correctly
|
||||
445
apps/web/pnpm-lock.yaml
generated
Normal file
445
apps/web/pnpm-lock.yaml
generated
Normal file
@@ -0,0 +1,445 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@radix-ui/react-tooltip':
|
||||
specifier: ^1.2.8
|
||||
version: 1.2.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@soroban/abacus-react':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/abacus-react
|
||||
'@soroban/client':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/core/client/typescript
|
||||
'@soroban/core':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/core/client/node
|
||||
'@soroban/templates':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/templates
|
||||
react:
|
||||
specifier: ^18.2.0
|
||||
version: 18.3.1
|
||||
react-dom:
|
||||
specifier: ^18.2.0
|
||||
version: 18.3.1(react@18.3.1)
|
||||
|
||||
packages:
|
||||
|
||||
'@floating-ui/core@1.7.3':
|
||||
resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==}
|
||||
|
||||
'@floating-ui/dom@1.7.4':
|
||||
resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==}
|
||||
|
||||
'@floating-ui/react-dom@2.1.6':
|
||||
resolution: {integrity: sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
react-dom: '>=16.8.0'
|
||||
|
||||
'@floating-ui/utils@0.2.10':
|
||||
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
|
||||
|
||||
'@radix-ui/primitive@1.1.3':
|
||||
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
|
||||
|
||||
'@radix-ui/react-arrow@1.1.7':
|
||||
resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-compose-refs@1.1.2':
|
||||
resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-context@1.1.2':
|
||||
resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-dismissable-layer@1.1.11':
|
||||
resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-id@1.1.1':
|
||||
resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-popper@1.2.8':
|
||||
resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-portal@1.1.9':
|
||||
resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-presence@1.1.5':
|
||||
resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-primitive@2.1.3':
|
||||
resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-slot@1.2.3':
|
||||
resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-tooltip@1.2.8':
|
||||
resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-callback-ref@1.1.1':
|
||||
resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-controllable-state@1.2.2':
|
||||
resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-effect-event@0.0.2':
|
||||
resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-escape-keydown@1.1.1':
|
||||
resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-layout-effect@1.1.1':
|
||||
resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-rect@1.1.1':
|
||||
resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-size@1.1.1':
|
||||
resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-visually-hidden@1.2.3':
|
||||
resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/rect@1.1.1':
|
||||
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
|
||||
|
||||
js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
loose-envify@1.4.0:
|
||||
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
|
||||
hasBin: true
|
||||
|
||||
react-dom@18.3.1:
|
||||
resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==}
|
||||
peerDependencies:
|
||||
react: ^18.3.1
|
||||
|
||||
react@18.3.1:
|
||||
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
scheduler@0.23.2:
|
||||
resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@floating-ui/core@1.7.3':
|
||||
dependencies:
|
||||
'@floating-ui/utils': 0.2.10
|
||||
|
||||
'@floating-ui/dom@1.7.4':
|
||||
dependencies:
|
||||
'@floating-ui/core': 1.7.3
|
||||
'@floating-ui/utils': 0.2.10
|
||||
|
||||
'@floating-ui/react-dom@2.1.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@floating-ui/dom': 1.7.4
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
'@floating-ui/utils@0.2.10': {}
|
||||
|
||||
'@radix-ui/primitive@1.1.3': {}
|
||||
|
||||
'@radix-ui/react-arrow@1.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@radix-ui/react-primitive': 2.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
'@radix-ui/react-compose-refs@1.1.2(react@18.3.1)':
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
|
||||
'@radix-ui/react-context@1.1.2(react@18.3.1)':
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
|
||||
'@radix-ui/react-dismissable-layer@1.1.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-compose-refs': 1.1.2(react@18.3.1)
|
||||
'@radix-ui/react-primitive': 2.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-use-callback-ref': 1.1.1(react@18.3.1)
|
||||
'@radix-ui/react-use-escape-keydown': 1.1.1(react@18.3.1)
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
'@radix-ui/react-id@1.1.1(react@18.3.1)':
|
||||
dependencies:
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(react@18.3.1)
|
||||
react: 18.3.1
|
||||
|
||||
'@radix-ui/react-popper@1.2.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@floating-ui/react-dom': 2.1.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-arrow': 1.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-compose-refs': 1.1.2(react@18.3.1)
|
||||
'@radix-ui/react-context': 1.1.2(react@18.3.1)
|
||||
'@radix-ui/react-primitive': 2.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-use-callback-ref': 1.1.1(react@18.3.1)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(react@18.3.1)
|
||||
'@radix-ui/react-use-rect': 1.1.1(react@18.3.1)
|
||||
'@radix-ui/react-use-size': 1.1.1(react@18.3.1)
|
||||
'@radix-ui/rect': 1.1.1
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
'@radix-ui/react-portal@1.1.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@radix-ui/react-primitive': 2.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(react@18.3.1)
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
'@radix-ui/react-presence@1.1.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.2(react@18.3.1)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(react@18.3.1)
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
'@radix-ui/react-primitive@2.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@radix-ui/react-slot': 1.2.3(react@18.3.1)
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
'@radix-ui/react-slot@1.2.3(react@18.3.1)':
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.2(react@18.3.1)
|
||||
react: 18.3.1
|
||||
|
||||
'@radix-ui/react-tooltip@1.2.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-compose-refs': 1.1.2(react@18.3.1)
|
||||
'@radix-ui/react-context': 1.1.2(react@18.3.1)
|
||||
'@radix-ui/react-dismissable-layer': 1.1.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-id': 1.1.1(react@18.3.1)
|
||||
'@radix-ui/react-popper': 1.2.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-portal': 1.1.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-presence': 1.1.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-primitive': 2.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-slot': 1.2.3(react@18.3.1)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(react@18.3.1)
|
||||
'@radix-ui/react-visually-hidden': 1.2.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
'@radix-ui/react-use-callback-ref@1.1.1(react@18.3.1)':
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
|
||||
'@radix-ui/react-use-controllable-state@1.2.2(react@18.3.1)':
|
||||
dependencies:
|
||||
'@radix-ui/react-use-effect-event': 0.0.2(react@18.3.1)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(react@18.3.1)
|
||||
react: 18.3.1
|
||||
|
||||
'@radix-ui/react-use-effect-event@0.0.2(react@18.3.1)':
|
||||
dependencies:
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(react@18.3.1)
|
||||
react: 18.3.1
|
||||
|
||||
'@radix-ui/react-use-escape-keydown@1.1.1(react@18.3.1)':
|
||||
dependencies:
|
||||
'@radix-ui/react-use-callback-ref': 1.1.1(react@18.3.1)
|
||||
react: 18.3.1
|
||||
|
||||
'@radix-ui/react-use-layout-effect@1.1.1(react@18.3.1)':
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
|
||||
'@radix-ui/react-use-rect@1.1.1(react@18.3.1)':
|
||||
dependencies:
|
||||
'@radix-ui/rect': 1.1.1
|
||||
react: 18.3.1
|
||||
|
||||
'@radix-ui/react-use-size@1.1.1(react@18.3.1)':
|
||||
dependencies:
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(react@18.3.1)
|
||||
react: 18.3.1
|
||||
|
||||
'@radix-ui/react-visually-hidden@1.2.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@radix-ui/react-primitive': 2.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
'@radix-ui/rect@1.1.1': {}
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
loose-envify@1.4.0:
|
||||
dependencies:
|
||||
js-tokens: 4.0.0
|
||||
|
||||
react-dom@18.3.1(react@18.3.1):
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
react: 18.3.1
|
||||
scheduler: 0.23.2
|
||||
|
||||
react@18.3.1:
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
|
||||
scheduler@0.23.2:
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
createArcadeSession,
|
||||
deleteArcadeSession,
|
||||
getArcadeSession,
|
||||
getArcadeSessionByRoom,
|
||||
updateSessionActivity,
|
||||
} from './src/lib/arcade/session-manager'
|
||||
import { createRoom, getRoomById } from './src/lib/arcade/room-manager'
|
||||
@@ -42,45 +43,72 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
let currentUserId: string | null = null
|
||||
|
||||
// Join arcade session room
|
||||
socket.on('join-arcade-session', async ({ userId }: { userId: string }) => {
|
||||
currentUserId = userId
|
||||
socket.join(`arcade:${userId}`)
|
||||
console.log(`👤 User ${userId} joined arcade room`)
|
||||
socket.on(
|
||||
'join-arcade-session',
|
||||
async ({ userId, roomId }: { userId: string; roomId?: string }) => {
|
||||
currentUserId = userId
|
||||
socket.join(`arcade:${userId}`)
|
||||
console.log(`👤 User ${userId} joined arcade room`)
|
||||
|
||||
// Send current session state if exists
|
||||
try {
|
||||
const session = await getArcadeSession(userId)
|
||||
if (session) {
|
||||
socket.emit('session-state', {
|
||||
gameState: session.gameState,
|
||||
currentGame: session.currentGame,
|
||||
gameUrl: session.gameUrl,
|
||||
activePlayers: session.activePlayers,
|
||||
version: session.version,
|
||||
})
|
||||
} else {
|
||||
socket.emit('no-active-session')
|
||||
// If this session is part of a room, also join the game room for multi-user sync
|
||||
if (roomId) {
|
||||
socket.join(`game:${roomId}`)
|
||||
console.log(`🎮 User ${userId} joined game room ${roomId}`)
|
||||
}
|
||||
|
||||
// Send current session state if exists
|
||||
// For room-based games, look up shared room session
|
||||
try {
|
||||
const session = roomId
|
||||
? await getArcadeSessionByRoom(roomId)
|
||||
: await getArcadeSession(userId)
|
||||
|
||||
if (session) {
|
||||
console.log('[join-arcade-session] Found session:', {
|
||||
userId,
|
||||
roomId,
|
||||
version: session.version,
|
||||
sessionUserId: session.userId,
|
||||
})
|
||||
socket.emit('session-state', {
|
||||
gameState: session.gameState,
|
||||
currentGame: session.currentGame,
|
||||
gameUrl: session.gameUrl,
|
||||
activePlayers: session.activePlayers,
|
||||
version: session.version,
|
||||
})
|
||||
} else {
|
||||
console.log('[join-arcade-session] No active session found for:', {
|
||||
userId,
|
||||
roomId,
|
||||
})
|
||||
socket.emit('no-active-session')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching session:', error)
|
||||
socket.emit('session-error', { error: 'Failed to fetch session' })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching session:', error)
|
||||
socket.emit('session-error', { error: 'Failed to fetch session' })
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
// Handle game moves
|
||||
socket.on('game-move', async (data: { userId: string; move: GameMove }) => {
|
||||
socket.on('game-move', async (data: { userId: string; move: GameMove; roomId?: string }) => {
|
||||
console.log('🎮 Game move received:', {
|
||||
userId: data.userId,
|
||||
moveType: data.move.type,
|
||||
playerId: data.move.playerId,
|
||||
timestamp: data.move.timestamp,
|
||||
roomId: data.roomId,
|
||||
fullMove: JSON.stringify(data.move, null, 2),
|
||||
})
|
||||
|
||||
try {
|
||||
// Special handling for START_GAME - create session if it doesn't exist
|
||||
if (data.move.type === 'START_GAME') {
|
||||
const existingSession = await getArcadeSession(data.userId)
|
||||
// For room-based games, check if room session exists
|
||||
const existingSession = data.roomId
|
||||
? await getArcadeSessionByRoom(data.roomId)
|
||||
: await getArcadeSession(data.userId)
|
||||
|
||||
if (!existingSession) {
|
||||
console.log('🎯 Creating new session for START_GAME')
|
||||
@@ -165,15 +193,24 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
}
|
||||
}
|
||||
|
||||
const result = await applyGameMove(data.userId, data.move)
|
||||
// Apply game move - use roomId for room-based games to access shared session
|
||||
const result = await applyGameMove(data.userId, data.move, data.roomId)
|
||||
|
||||
if (result.success && result.session) {
|
||||
// Broadcast the updated state to all devices for this user
|
||||
io!.to(`arcade:${data.userId}`).emit('move-accepted', {
|
||||
const moveAcceptedData = {
|
||||
gameState: result.session.gameState,
|
||||
version: result.session.version,
|
||||
move: data.move,
|
||||
})
|
||||
}
|
||||
|
||||
// Broadcast the updated state to all devices for this user
|
||||
io!.to(`arcade:${data.userId}`).emit('move-accepted', moveAcceptedData)
|
||||
|
||||
// If this is a room-based session, ALSO broadcast to all users in the room
|
||||
if (result.session.roomId) {
|
||||
io!.to(`game:${result.session.roomId}`).emit('move-accepted', moveAcceptedData)
|
||||
console.log(`📢 Broadcasted move to game room ${result.session.roomId}`)
|
||||
}
|
||||
|
||||
// Update activity timestamp
|
||||
await updateSessionActivity(data.userId)
|
||||
|
||||
@@ -12,14 +12,11 @@ import { getViewerId } from '@/lib/viewer'
|
||||
export async function GET() {
|
||||
try {
|
||||
const userId = await getViewerId()
|
||||
console.log('[Current Room API] Fetching for user:', userId)
|
||||
|
||||
// Get all rooms user is in (should be at most 1 due to modal room enforcement)
|
||||
const roomIds = await getUserRooms(userId)
|
||||
console.log('[Current Room API] User rooms:', roomIds)
|
||||
|
||||
if (roomIds.length === 0) {
|
||||
console.log('[Current Room API] User is not in any room')
|
||||
return NextResponse.json({ room: null }, { status: 200 })
|
||||
}
|
||||
|
||||
@@ -28,7 +25,6 @@ export async function GET() {
|
||||
// Get room data
|
||||
const room = await getRoomById(roomId)
|
||||
if (!room) {
|
||||
console.log('[Current Room API] Room not found:', roomId)
|
||||
return NextResponse.json({ error: 'Room not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
@@ -44,12 +40,6 @@ export async function GET() {
|
||||
memberPlayersObj[uid] = players
|
||||
}
|
||||
|
||||
console.log('[Current Room API] Returning room:', {
|
||||
roomId: room.id,
|
||||
roomName: room.name,
|
||||
memberCount: members.length,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
room,
|
||||
members,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { io, type Socket } from 'socket.io-client'
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { useViewerId } from '@/hooks/useViewerId'
|
||||
|
||||
@@ -154,8 +154,8 @@ export default function RoomDetailPage() {
|
||||
|
||||
const startGame = () => {
|
||||
if (!room) return
|
||||
// Navigate to the game with the room ID
|
||||
router.push(`/arcade/rooms/${roomId}/${room.gameName}`)
|
||||
// Navigate to the room game page
|
||||
router.push('/arcade/room')
|
||||
}
|
||||
|
||||
const joinRoom = async () => {
|
||||
@@ -264,7 +264,7 @@ export default function RoomDetailPage() {
|
||||
{error || 'Room not found'}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.push('/arcade/rooms')}
|
||||
onClick={() => router.push('/arcade-rooms')}
|
||||
className={css({
|
||||
px: '6',
|
||||
py: '3',
|
||||
@@ -325,7 +325,7 @@ export default function RoomDetailPage() {
|
||||
>
|
||||
<div className={css({ mb: '4' })}>
|
||||
<button
|
||||
onClick={() => router.push('/arcade/rooms')}
|
||||
onClick={() => router.push('/arcade-rooms')}
|
||||
className={css({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
@@ -621,7 +621,7 @@ export default function RoomDetailPage() {
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => router.push('/arcade/rooms')}
|
||||
onClick={() => router.push('/arcade-rooms')}
|
||||
className={css({
|
||||
flex: 1,
|
||||
px: '6',
|
||||
316
apps/web/src/app/arcade-rooms/__tests__/room-navigation.test.tsx
Normal file
316
apps/web/src/app/arcade-rooms/__tests__/room-navigation.test.tsx
Normal file
@@ -0,0 +1,316 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import * as nextNavigation from 'next/navigation'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as arcadeGuard from '@/hooks/useArcadeGuard'
|
||||
import * as roomData from '@/hooks/useRoomData'
|
||||
import * as viewerId from '@/hooks/useViewerId'
|
||||
|
||||
// Mock Next.js navigation
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: vi.fn(),
|
||||
usePathname: vi.fn(),
|
||||
useParams: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock hooks
|
||||
vi.mock('@/hooks/useArcadeGuard')
|
||||
vi.mock('@/hooks/useRoomData')
|
||||
vi.mock('@/hooks/useViewerId')
|
||||
vi.mock('@/hooks/useUserPlayers', () => ({
|
||||
useUserPlayers: () => ({ data: [], isLoading: false }),
|
||||
useCreatePlayer: () => ({ mutate: vi.fn() }),
|
||||
useUpdatePlayer: () => ({ mutate: vi.fn() }),
|
||||
useDeletePlayer: () => ({ mutate: vi.fn() }),
|
||||
}))
|
||||
vi.mock('@/hooks/useArcadeSocket', () => ({
|
||||
useArcadeSocket: () => ({
|
||||
connected: false,
|
||||
joinSession: vi.fn(),
|
||||
socket: null,
|
||||
sendMove: vi.fn(),
|
||||
exitSession: vi.fn(),
|
||||
pingSession: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock styled-system
|
||||
vi.mock('../../../../styled-system/css', () => ({
|
||||
css: () => '',
|
||||
}))
|
||||
|
||||
// Mock components
|
||||
vi.mock('@/components/PageWithNav', () => ({
|
||||
PageWithNav: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
// Import pages after mocks
|
||||
import RoomBrowserPage from '../page'
|
||||
|
||||
describe('Room Navigation with Active Sessions', () => {
|
||||
const mockRouter = {
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
back: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.spyOn(nextNavigation, 'useRouter').mockReturnValue(mockRouter as any)
|
||||
vi.spyOn(nextNavigation, 'usePathname').mockReturnValue('/arcade-rooms')
|
||||
vi.spyOn(viewerId, 'useViewerId').mockReturnValue({
|
||||
data: 'test-user',
|
||||
isLoading: false,
|
||||
isPending: false,
|
||||
error: null,
|
||||
} as any)
|
||||
global.fetch = vi.fn()
|
||||
})
|
||||
|
||||
describe('RoomBrowserPage', () => {
|
||||
it('should render room browser without redirecting when user has active game session', async () => {
|
||||
// User has an active game session
|
||||
vi.spyOn(arcadeGuard, 'useArcadeGuard').mockReturnValue({
|
||||
hasActiveSession: true,
|
||||
loading: false,
|
||||
activeSession: {
|
||||
gameUrl: '/arcade/room',
|
||||
currentGame: 'matching',
|
||||
},
|
||||
})
|
||||
|
||||
// User is in a room
|
||||
vi.spyOn(roomData, 'useRoomData').mockReturnValue({
|
||||
roomData: {
|
||||
id: 'room-1',
|
||||
name: 'Test Room',
|
||||
code: 'ABC123',
|
||||
gameName: 'matching',
|
||||
members: [],
|
||||
memberPlayers: {},
|
||||
},
|
||||
isLoading: false,
|
||||
isInRoom: true,
|
||||
notifyRoomOfPlayerUpdate: vi.fn(),
|
||||
})
|
||||
|
||||
// Mock rooms API
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
rooms: [
|
||||
{
|
||||
id: 'room-1',
|
||||
code: 'ABC123',
|
||||
name: 'Test Room',
|
||||
gameName: 'matching',
|
||||
status: 'lobby',
|
||||
createdAt: new Date(),
|
||||
creatorName: 'Test User',
|
||||
isLocked: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
render(<RoomBrowserPage />)
|
||||
|
||||
// Should render the page
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('🎮 Multiplayer Rooms')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Should NOT redirect to /arcade/room
|
||||
expect(mockRouter.push).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should NOT redirect when PageWithNav uses arcade guard with enabled=false', async () => {
|
||||
// Simulate PageWithNav calling useArcadeGuard with enabled=false
|
||||
const arcadeGuardSpy = vi.spyOn(arcadeGuard, 'useArcadeGuard')
|
||||
|
||||
// User has an active game session
|
||||
arcadeGuardSpy.mockReturnValue({
|
||||
hasActiveSession: true,
|
||||
loading: false,
|
||||
activeSession: {
|
||||
gameUrl: '/arcade/room',
|
||||
currentGame: 'matching',
|
||||
},
|
||||
})
|
||||
|
||||
vi.spyOn(roomData, 'useRoomData').mockReturnValue({
|
||||
roomData: null,
|
||||
isLoading: false,
|
||||
isInRoom: false,
|
||||
notifyRoomOfPlayerUpdate: vi.fn(),
|
||||
})
|
||||
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ rooms: [] }),
|
||||
})
|
||||
|
||||
render(<RoomBrowserPage />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('🎮 Multiplayer Rooms')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// PageWithNav should have called useArcadeGuard with enabled=false
|
||||
// This is tested in PageWithNav's own tests, but we verify no redirect happened
|
||||
expect(mockRouter.push).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should allow navigation to room detail even with active session', async () => {
|
||||
vi.spyOn(arcadeGuard, 'useArcadeGuard').mockReturnValue({
|
||||
hasActiveSession: true,
|
||||
loading: false,
|
||||
activeSession: {
|
||||
gameUrl: '/arcade/room',
|
||||
currentGame: 'matching',
|
||||
},
|
||||
})
|
||||
|
||||
vi.spyOn(roomData, 'useRoomData').mockReturnValue({
|
||||
roomData: null,
|
||||
isLoading: false,
|
||||
isInRoom: false,
|
||||
notifyRoomOfPlayerUpdate: vi.fn(),
|
||||
})
|
||||
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
rooms: [
|
||||
{
|
||||
id: 'room-1',
|
||||
code: 'ABC123',
|
||||
name: 'Test Room',
|
||||
gameName: 'matching',
|
||||
status: 'lobby',
|
||||
createdAt: new Date(),
|
||||
creatorName: 'Test User',
|
||||
isLocked: false,
|
||||
isMember: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
render(<RoomBrowserPage />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Room')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Click on the room card
|
||||
const roomCard = screen.getByText('Test Room').parentElement
|
||||
roomCard?.click()
|
||||
|
||||
// Should navigate to room detail, not to /arcade/room
|
||||
await waitFor(() => {
|
||||
expect(mockRouter.push).toHaveBeenCalledWith('/arcade-rooms/room-1')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Room navigation edge cases', () => {
|
||||
it('should handle rapid navigation between room pages without redirect loops', async () => {
|
||||
vi.spyOn(arcadeGuard, 'useArcadeGuard').mockReturnValue({
|
||||
hasActiveSession: true,
|
||||
loading: false,
|
||||
activeSession: {
|
||||
gameUrl: '/arcade/room',
|
||||
currentGame: 'matching',
|
||||
},
|
||||
})
|
||||
|
||||
vi.spyOn(roomData, 'useRoomData').mockReturnValue({
|
||||
roomData: null,
|
||||
isLoading: false,
|
||||
isInRoom: false,
|
||||
notifyRoomOfPlayerUpdate: vi.fn(),
|
||||
})
|
||||
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ rooms: [] }),
|
||||
})
|
||||
|
||||
const { rerender } = render(<RoomBrowserPage />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('🎮 Multiplayer Rooms')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Simulate pathname changes (navigating between room pages)
|
||||
vi.spyOn(nextNavigation, 'usePathname').mockReturnValue('/arcade-rooms/room-1')
|
||||
rerender(<RoomBrowserPage />)
|
||||
|
||||
vi.spyOn(nextNavigation, 'usePathname').mockReturnValue('/arcade-rooms')
|
||||
rerender(<RoomBrowserPage />)
|
||||
|
||||
// Should never redirect to game page
|
||||
expect(mockRouter.push).not.toHaveBeenCalledWith('/arcade/room')
|
||||
})
|
||||
|
||||
it('should allow user to leave room and browse other rooms during active game', async () => {
|
||||
// User is in a room with an active game
|
||||
vi.spyOn(arcadeGuard, 'useArcadeGuard').mockReturnValue({
|
||||
hasActiveSession: true,
|
||||
loading: false,
|
||||
activeSession: {
|
||||
gameUrl: '/arcade/room',
|
||||
currentGame: 'matching',
|
||||
},
|
||||
})
|
||||
|
||||
vi.spyOn(roomData, 'useRoomData').mockReturnValue({
|
||||
roomData: {
|
||||
id: 'room-1',
|
||||
name: 'Current Room',
|
||||
code: 'ABC123',
|
||||
gameName: 'matching',
|
||||
members: [],
|
||||
memberPlayers: {},
|
||||
},
|
||||
isLoading: false,
|
||||
isInRoom: true,
|
||||
notifyRoomOfPlayerUpdate: vi.fn(),
|
||||
})
|
||||
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
rooms: [
|
||||
{
|
||||
id: 'room-1',
|
||||
name: 'Current Room',
|
||||
code: 'ABC123',
|
||||
gameName: 'matching',
|
||||
status: 'playing',
|
||||
isMember: true,
|
||||
},
|
||||
{
|
||||
id: 'room-2',
|
||||
name: 'Other Room',
|
||||
code: 'DEF456',
|
||||
gameName: 'memory-quiz',
|
||||
status: 'lobby',
|
||||
isMember: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
render(<RoomBrowserPage />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Current Room')).toBeInTheDocument()
|
||||
expect(screen.getByText('Other Room')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Should be able to view both rooms without redirect
|
||||
expect(mockRouter.push).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { css } from '../../../styled-system/css'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
|
||||
interface Room {
|
||||
@@ -66,7 +66,7 @@ export default function RoomBrowserPage() {
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
router.push(`/arcade/rooms/${data.room.id}`)
|
||||
router.push(`/arcade-rooms/${data.room.id}`)
|
||||
} catch (err) {
|
||||
console.error('Failed to create room:', err)
|
||||
alert('Failed to create room')
|
||||
@@ -103,7 +103,7 @@ export default function RoomBrowserPage() {
|
||||
// Could show a toast notification here in the future
|
||||
}
|
||||
|
||||
router.push(`/arcade/rooms/${roomId}`)
|
||||
router.push(`/arcade-rooms/${roomId}`)
|
||||
} catch (err) {
|
||||
console.error('Failed to join room:', err)
|
||||
alert('Failed to join room')
|
||||
@@ -219,7 +219,7 @@ export default function RoomBrowserPage() {
|
||||
})}
|
||||
>
|
||||
<div
|
||||
onClick={() => router.push(`/arcade/rooms/${room.id}`)}
|
||||
onClick={() => router.push(`/arcade-rooms/${room.id}`)}
|
||||
className={css({ flex: 1, cursor: 'pointer' })}
|
||||
>
|
||||
<div
|
||||
@@ -26,8 +26,13 @@ export function PlayerStatusBar({ className }: PlayerStatusBarProps) {
|
||||
displayEmoji: player.emoji,
|
||||
score: state.scores[player.id] || 0,
|
||||
consecutiveMatches: state.consecutiveMatches?.[player.id] || 0,
|
||||
isLocalPlayer: player.isLocal !== false, // Local if not explicitly marked as remote
|
||||
}))
|
||||
|
||||
// Check if current player is local (your turn) or remote (waiting)
|
||||
const currentPlayer = activePlayers.find((p) => p.id === state.currentPlayer)
|
||||
const isYourTurn = currentPlayer?.isLocalPlayer === true
|
||||
|
||||
// Get celebration level based on consecutive matches
|
||||
const getCelebrationLevel = (consecutiveMatches: number) => {
|
||||
if (consecutiveMatches >= 5) return 'legendary'
|
||||
@@ -250,14 +255,16 @@ export function PlayerStatusBar({ className }: PlayerStatusBarProps) {
|
||||
{isCurrentPlayer && (
|
||||
<span
|
||||
className={css({
|
||||
color: 'red.600',
|
||||
color: player.isLocalPlayer ? 'red.600' : 'blue.600',
|
||||
fontWeight: 'black',
|
||||
fontSize: isCurrentPlayer ? { base: 'sm', md: 'lg' } : 'inherit',
|
||||
animation: 'none',
|
||||
textShadow: '0 0 15px currentColor',
|
||||
animation: player.isLocalPlayer
|
||||
? 'none'
|
||||
: 'gentle-pulse 2s ease-in-out infinite',
|
||||
textShadow: player.isLocalPlayer ? '0 0 15px currentColor' : 'none',
|
||||
})}
|
||||
>
|
||||
{' • Your turn'}
|
||||
{player.isLocalPlayer ? ' • Your turn' : ' • Their turn'}
|
||||
</span>
|
||||
)}
|
||||
{player.consecutiveMatches > 1 && (
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
import { useGameMode } from '../../../../contexts/GameModeContext'
|
||||
import { useArcadeMemoryPairs } from '../context/ArcadeMemoryPairsContext'
|
||||
import { useMemoryPairs } from '../context/MemoryPairsContext'
|
||||
|
||||
// Add bounce animation for the start button
|
||||
const bounceAnimation = `
|
||||
@@ -32,14 +33,87 @@ export function SetupPhase() {
|
||||
state,
|
||||
setGameType,
|
||||
setDifficulty,
|
||||
setTurnTimer,
|
||||
startGame,
|
||||
resumeGame,
|
||||
canResumeGame,
|
||||
hasConfigChanged,
|
||||
activePlayers: _activePlayers,
|
||||
} = useArcadeMemoryPairs()
|
||||
} = useMemoryPairs()
|
||||
|
||||
const { activePlayerCount, gameMode: _globalGameMode } = useGameMode()
|
||||
|
||||
const handleStartGame = () => {
|
||||
startGame()
|
||||
// PAUSE/RESUME: Warning dialog state
|
||||
const [showConfigWarning, setShowConfigWarning] = useState(false)
|
||||
const [hasSeenWarning, setHasSeenWarning] = useState(false)
|
||||
const [pendingConfigChange, setPendingConfigChange] = useState<{
|
||||
type: 'gameType' | 'difficulty' | 'turnTimer'
|
||||
value: any
|
||||
} | null>(null)
|
||||
|
||||
// Check if we should show warning when changing config
|
||||
const shouldShowWarning = state.pausedGamePhase && !hasSeenWarning && !hasConfigChanged
|
||||
|
||||
// Config change handlers that check for paused game
|
||||
const handleSetGameType = (value: typeof state.gameType) => {
|
||||
if (shouldShowWarning) {
|
||||
setPendingConfigChange({ type: 'gameType', value })
|
||||
setShowConfigWarning(true)
|
||||
} else {
|
||||
setGameType(value)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSetDifficulty = (value: typeof state.difficulty) => {
|
||||
if (shouldShowWarning) {
|
||||
setPendingConfigChange({ type: 'difficulty', value })
|
||||
setShowConfigWarning(true)
|
||||
} else {
|
||||
setDifficulty(value)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSetTurnTimer = (value: typeof state.turnTimer) => {
|
||||
if (shouldShowWarning) {
|
||||
setPendingConfigChange({ type: 'turnTimer', value })
|
||||
setShowConfigWarning(true)
|
||||
} else {
|
||||
setTurnTimer(value)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply pending config change after warning
|
||||
const applyPendingChange = () => {
|
||||
if (pendingConfigChange) {
|
||||
switch (pendingConfigChange.type) {
|
||||
case 'gameType':
|
||||
setGameType(pendingConfigChange.value)
|
||||
break
|
||||
case 'difficulty':
|
||||
setDifficulty(pendingConfigChange.value)
|
||||
break
|
||||
case 'turnTimer':
|
||||
setTurnTimer(pendingConfigChange.value)
|
||||
break
|
||||
}
|
||||
setHasSeenWarning(true)
|
||||
setPendingConfigChange(null)
|
||||
setShowConfigWarning(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel config change
|
||||
const cancelConfigChange = () => {
|
||||
setPendingConfigChange(null)
|
||||
setShowConfigWarning(false)
|
||||
}
|
||||
|
||||
const handleStartOrResumeGame = () => {
|
||||
if (canResumeGame) {
|
||||
resumeGame()
|
||||
} else {
|
||||
startGame()
|
||||
}
|
||||
}
|
||||
|
||||
const getButtonStyles = (
|
||||
@@ -150,6 +224,94 @@ export function SetupPhase() {
|
||||
minHeight: 0, // Allow shrinking
|
||||
})}
|
||||
>
|
||||
{/* PAUSE/RESUME: Config change warning */}
|
||||
{showConfigWarning && (
|
||||
<div
|
||||
className={css({
|
||||
p: '4',
|
||||
background:
|
||||
'linear-gradient(135deg, rgba(251, 191, 36, 0.15), rgba(245, 158, 11, 0.15))',
|
||||
border: '2px solid',
|
||||
borderColor: 'yellow.400',
|
||||
rounded: 'xl',
|
||||
textAlign: 'center',
|
||||
boxShadow: '0 4px 12px rgba(251, 191, 36, 0.2)',
|
||||
})}
|
||||
>
|
||||
<p
|
||||
className={css({
|
||||
color: 'yellow.700',
|
||||
fontSize: { base: '15px', md: '17px' },
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '8px',
|
||||
})}
|
||||
>
|
||||
⚠️ Warning: Changing Settings Will End Current Game
|
||||
</p>
|
||||
<p
|
||||
className={css({
|
||||
color: 'gray.600',
|
||||
fontSize: { base: '13px', md: '14px' },
|
||||
marginBottom: '12px',
|
||||
})}
|
||||
>
|
||||
You have a paused game in progress. Changing any setting will end it and you won't be
|
||||
able to resume.
|
||||
</p>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
justifyContent: 'center',
|
||||
flexWrap: 'wrap',
|
||||
})}
|
||||
>
|
||||
<button
|
||||
className={css({
|
||||
background: 'linear-gradient(135deg, #10b981, #059669)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 16px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
boxShadow: '0 2px 8px rgba(16, 185, 129, 0.3)',
|
||||
_hover: {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 4px 12px rgba(16, 185, 129, 0.4)',
|
||||
},
|
||||
})}
|
||||
onClick={cancelConfigChange}
|
||||
>
|
||||
✓ Keep Game & Cancel Change
|
||||
</button>
|
||||
<button
|
||||
className={css({
|
||||
background: 'linear-gradient(135deg, #ef4444, #dc2626)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 16px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
boxShadow: '0 2px 8px rgba(239, 68, 68, 0.3)',
|
||||
_hover: {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 4px 12px rgba(239, 68, 68, 0.4)',
|
||||
},
|
||||
})}
|
||||
onClick={applyPendingChange}
|
||||
>
|
||||
✗ End Game & Apply Change
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warning if no players */}
|
||||
{activePlayerCount === 0 && (
|
||||
<div
|
||||
@@ -200,7 +362,7 @@ export function SetupPhase() {
|
||||
>
|
||||
<button
|
||||
className={getButtonStyles(state.gameType === 'abacus-numeral', 'secondary')}
|
||||
onClick={() => setGameType('abacus-numeral')}
|
||||
onClick={() => handleSetGameType('abacus-numeral')}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
@@ -246,7 +408,7 @@ export function SetupPhase() {
|
||||
</button>
|
||||
<button
|
||||
className={getButtonStyles(state.gameType === 'complement-pairs', 'secondary')}
|
||||
onClick={() => setGameType('complement-pairs')}
|
||||
onClick={() => handleSetGameType('complement-pairs')}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
@@ -342,7 +504,7 @@ export function SetupPhase() {
|
||||
<button
|
||||
key={difficulty}
|
||||
className={getButtonStyles(state.difficulty === difficulty, 'difficulty')}
|
||||
onClick={() => setDifficulty(difficulty)}
|
||||
onClick={() => handleSetDifficulty(difficulty)}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
@@ -414,7 +576,7 @@ export function SetupPhase() {
|
||||
<button
|
||||
key={timer}
|
||||
className={getButtonStyles(state.turnTimer === timer, 'secondary')}
|
||||
onClick={() => dispatch({ type: 'SET_TURN_TIMER', timer })}
|
||||
onClick={() => handleSetTurnTimer(timer)}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
@@ -464,7 +626,9 @@ export function SetupPhase() {
|
||||
>
|
||||
<button
|
||||
className={css({
|
||||
background: 'linear-gradient(135deg, #ff6b6b 0%, #ee5a24 50%, #ff9ff3 100%)',
|
||||
background: canResumeGame
|
||||
? 'linear-gradient(135deg, #10b981 0%, #059669 50%, #34d399 100%)'
|
||||
: 'linear-gradient(135deg, #ff6b6b 0%, #ee5a24 50%, #ff9ff3 100%)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: { base: '16px', sm: '20px', md: '24px' },
|
||||
@@ -473,7 +637,9 @@ export function SetupPhase() {
|
||||
fontWeight: 'black',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.4s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
boxShadow: '0 8px 20px rgba(255, 107, 107, 0.4), inset 0 2px 0 rgba(255,255,255,0.3)',
|
||||
boxShadow: canResumeGame
|
||||
? '0 8px 20px rgba(16, 185, 129, 0.4), inset 0 2px 0 rgba(255,255,255,0.3)'
|
||||
: '0 8px 20px rgba(255, 107, 107, 0.4), inset 0 2px 0 rgba(255,255,255,0.3)',
|
||||
textShadow: '0 2px 4px rgba(0,0,0,0.3)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
@@ -491,9 +657,12 @@ export function SetupPhase() {
|
||||
},
|
||||
_hover: {
|
||||
transform: { base: 'translateY(-2px)', md: 'translateY(-3px) scale(1.02)' },
|
||||
boxShadow:
|
||||
'0 12px 30px rgba(255, 107, 107, 0.6), inset 0 2px 0 rgba(255,255,255,0.3)',
|
||||
background: 'linear-gradient(135deg, #ff5252 0%, #dd2c00 50%, #e91e63 100%)',
|
||||
boxShadow: canResumeGame
|
||||
? '0 12px 30px rgba(16, 185, 129, 0.6), inset 0 2px 0 rgba(255,255,255,0.3)'
|
||||
: '0 12px 30px rgba(255, 107, 107, 0.6), inset 0 2px 0 rgba(255,255,255,0.3)',
|
||||
background: canResumeGame
|
||||
? 'linear-gradient(135deg, #059669 0%, #047857 50%, #10b981 100%)'
|
||||
: 'linear-gradient(135deg, #ff5252 0%, #dd2c00 50%, #e91e63 100%)',
|
||||
_before: {
|
||||
left: '100%',
|
||||
},
|
||||
@@ -502,7 +671,7 @@ export function SetupPhase() {
|
||||
transform: 'translateY(-1px) scale(1.01)',
|
||||
},
|
||||
})}
|
||||
onClick={handleStartGame}
|
||||
onClick={handleStartOrResumeGame}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
@@ -518,9 +687,9 @@ export function SetupPhase() {
|
||||
animation: 'bounce 2s infinite',
|
||||
})}
|
||||
>
|
||||
🚀
|
||||
{canResumeGame ? '▶️' : '🚀'}
|
||||
</span>
|
||||
<span>START GAME</span>
|
||||
<span>{canResumeGame ? 'RESUME GAME' : 'START GAME'}</span>
|
||||
<span
|
||||
className={css({
|
||||
fontSize: { base: '18px', sm: '20px', md: '24px' },
|
||||
@@ -528,7 +697,7 @@ export function SetupPhase() {
|
||||
animationDelay: '0.5s',
|
||||
})}
|
||||
>
|
||||
🎮
|
||||
{canResumeGame ? '🎮' : '🎮'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { createContext, type ReactNode, useCallback, useContext, 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'
|
||||
@@ -104,6 +105,7 @@ const ArcadeMemoryPairsContext = createContext<MemoryPairsContextValue | null>(n
|
||||
// Provider component
|
||||
export function ArcadeMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
const { data: viewerId } = useViewerId()
|
||||
const { roomData } = useRoomData()
|
||||
const { activePlayerCount, activePlayers: activePlayerIds } = useGameMode()
|
||||
|
||||
// Get active player IDs directly as strings (UUIDs)
|
||||
@@ -112,7 +114,7 @@ export function ArcadeMemoryPairsProvider({ children }: { children: ReactNode })
|
||||
// Derive game mode from active player count
|
||||
const gameMode = activePlayerCount > 1 ? 'multiplayer' : 'single'
|
||||
|
||||
// Arcade session integration
|
||||
// Arcade session integration with room-wide sync
|
||||
const {
|
||||
state,
|
||||
sendMove,
|
||||
@@ -120,6 +122,7 @@ export function ArcadeMemoryPairsProvider({ children }: { children: ReactNode })
|
||||
exitSession,
|
||||
} = useArcadeSession<MemoryPairsState>({
|
||||
userId: viewerId || '',
|
||||
roomId: roomData?.id, // Enable multi-user sync for room-based games
|
||||
initialState,
|
||||
applyMove: applyMoveOptimistically,
|
||||
})
|
||||
@@ -143,22 +146,77 @@ export function ArcadeMemoryPairsProvider({ children }: { children: ReactNode })
|
||||
// Computed values
|
||||
const isGameActive = state.gamePhase === 'playing'
|
||||
|
||||
const { players } = useGameMode()
|
||||
|
||||
const canFlipCard = useCallback(
|
||||
(cardId: string): boolean => {
|
||||
if (!isGameActive || state.isProcessingMove) return false
|
||||
console.log('[canFlipCard] Checking card:', {
|
||||
cardId,
|
||||
isGameActive,
|
||||
isProcessingMove: state.isProcessingMove,
|
||||
currentPlayer: state.currentPlayer,
|
||||
hasRoomData: !!roomData,
|
||||
flippedCardsCount: state.flippedCards.length,
|
||||
})
|
||||
|
||||
if (!isGameActive || state.isProcessingMove) {
|
||||
console.log('[canFlipCard] Blocked: game not active or processing')
|
||||
return false
|
||||
}
|
||||
|
||||
const card = state.gameCards.find((c) => c.id === cardId)
|
||||
if (!card || card.matched) return false
|
||||
if (!card || card.matched) {
|
||||
console.log('[canFlipCard] Blocked: card not found or already matched')
|
||||
return false
|
||||
}
|
||||
|
||||
// Can't flip if already flipped
|
||||
if (state.flippedCards.some((c) => c.id === cardId)) return false
|
||||
if (state.flippedCards.some((c) => c.id === cardId)) {
|
||||
console.log('[canFlipCard] Blocked: card already flipped')
|
||||
return false
|
||||
}
|
||||
|
||||
// Can't flip more than 2 cards
|
||||
if (state.flippedCards.length >= 2) return false
|
||||
if (state.flippedCards.length >= 2) {
|
||||
console.log('[canFlipCard] Blocked: 2 cards already flipped')
|
||||
return false
|
||||
}
|
||||
|
||||
// Authorization check: Only allow flipping if it's your player's turn
|
||||
if (roomData && state.currentPlayer) {
|
||||
const currentPlayerData = players.get(state.currentPlayer)
|
||||
console.log('[canFlipCard] Authorization check:', {
|
||||
currentPlayerId: state.currentPlayer,
|
||||
currentPlayerFound: !!currentPlayerData,
|
||||
currentPlayerIsLocal: currentPlayerData?.isLocal,
|
||||
})
|
||||
|
||||
// Block if current player is explicitly marked as remote (isLocal === false)
|
||||
if (currentPlayerData && currentPlayerData.isLocal === false) {
|
||||
console.log('[canFlipCard] BLOCKED: Current player is remote (not your turn)')
|
||||
return false
|
||||
}
|
||||
|
||||
// If player data not found in map, this might be an issue - allow for now but warn
|
||||
if (!currentPlayerData) {
|
||||
console.warn(
|
||||
'[canFlipCard] WARNING: Current player not found in players map, allowing move'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[canFlipCard] ALLOWED: All checks passed')
|
||||
return true
|
||||
},
|
||||
[isGameActive, state.isProcessingMove, state.gameCards, state.flippedCards]
|
||||
[
|
||||
isGameActive,
|
||||
state.isProcessingMove,
|
||||
state.gameCards,
|
||||
state.flippedCards,
|
||||
state.currentPlayer,
|
||||
roomData,
|
||||
players,
|
||||
]
|
||||
)
|
||||
|
||||
const currentGameStatistics: GameStatistics = useMemo(
|
||||
@@ -195,7 +253,7 @@ export function ArcadeMemoryPairsProvider({ children }: { children: ReactNode })
|
||||
activePlayers,
|
||||
},
|
||||
})
|
||||
}, [state.gameType, state.difficulty, activePlayers, sendMove])
|
||||
}, [state.gameType, state.difficulty, activePlayers, sendMove, roomData])
|
||||
|
||||
const flipCard = useCallback(
|
||||
(cardId: string) => {
|
||||
|
||||
@@ -0,0 +1,534 @@
|
||||
'use client'
|
||||
|
||||
import { type ReactNode, useCallback, useEffect, useMemo } from 'react'
|
||||
import { useArcadeSession } from '@/hooks/useArcadeSession'
|
||||
import { useViewerId } from '@/hooks/useViewerId'
|
||||
import type { GameMove } from '@/lib/arcade/validation'
|
||||
import { useGameMode } from '../../../../contexts/GameModeContext'
|
||||
import { generateGameCards } from '../utils/cardGeneration'
|
||||
import { MemoryPairsContext } from './MemoryPairsContext'
|
||||
import type { GameMode, GameStatistics, MemoryPairsContextValue, MemoryPairsState } from './types'
|
||||
|
||||
// Initial state
|
||||
const initialState: MemoryPairsState = {
|
||||
cards: [],
|
||||
gameCards: [],
|
||||
flippedCards: [],
|
||||
gameType: 'abacus-numeral',
|
||||
difficulty: 6,
|
||||
turnTimer: 30,
|
||||
gamePhase: 'setup',
|
||||
currentPlayer: '', // Will be set to first player ID on START_GAME
|
||||
matchedPairs: 0,
|
||||
totalPairs: 6,
|
||||
moves: 0,
|
||||
scores: {},
|
||||
activePlayers: [],
|
||||
playerMetadata: {}, // Player metadata for cross-user visibility
|
||||
consecutiveMatches: {},
|
||||
gameStartTime: null,
|
||||
gameEndTime: null,
|
||||
currentMoveStartTime: null,
|
||||
timerInterval: null,
|
||||
celebrationAnimations: [],
|
||||
isProcessingMove: false,
|
||||
showMismatchFeedback: false,
|
||||
lastMatchedPair: null,
|
||||
// PAUSE/RESUME: Initialize paused game fields
|
||||
originalConfig: undefined,
|
||||
pausedGamePhase: undefined,
|
||||
pausedGameState: undefined,
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimistic move application (client-side prediction)
|
||||
* The server will validate and send back the authoritative state
|
||||
*/
|
||||
function applyMoveOptimistically(state: MemoryPairsState, move: GameMove): MemoryPairsState {
|
||||
switch (move.type) {
|
||||
case 'START_GAME':
|
||||
// Generate cards and initialize game
|
||||
return {
|
||||
...state,
|
||||
gamePhase: 'playing',
|
||||
gameCards: move.data.cards,
|
||||
cards: move.data.cards,
|
||||
flippedCards: [],
|
||||
matchedPairs: 0,
|
||||
moves: 0,
|
||||
scores: move.data.activePlayers.reduce((acc: any, p: string) => ({ ...acc, [p]: 0 }), {}),
|
||||
consecutiveMatches: move.data.activePlayers.reduce(
|
||||
(acc: any, p: string) => ({ ...acc, [p]: 0 }),
|
||||
{}
|
||||
),
|
||||
activePlayers: move.data.activePlayers,
|
||||
playerMetadata: move.data.playerMetadata || {}, // Include player metadata
|
||||
currentPlayer: move.data.activePlayers[0] || '',
|
||||
gameStartTime: Date.now(),
|
||||
gameEndTime: null,
|
||||
currentMoveStartTime: Date.now(),
|
||||
celebrationAnimations: [],
|
||||
isProcessingMove: false,
|
||||
showMismatchFeedback: false,
|
||||
lastMatchedPair: null,
|
||||
// PAUSE/RESUME: Save original config and clear paused state
|
||||
originalConfig: {
|
||||
gameType: state.gameType,
|
||||
difficulty: state.difficulty,
|
||||
turnTimer: state.turnTimer,
|
||||
},
|
||||
pausedGamePhase: undefined,
|
||||
pausedGameState: undefined,
|
||||
}
|
||||
|
||||
case 'FLIP_CARD': {
|
||||
// Optimistically flip the card
|
||||
const card = state.gameCards.find((c) => c.id === move.data.cardId)
|
||||
if (!card) return state
|
||||
|
||||
const newFlippedCards = [...state.flippedCards, card]
|
||||
|
||||
return {
|
||||
...state,
|
||||
flippedCards: newFlippedCards,
|
||||
currentMoveStartTime:
|
||||
state.flippedCards.length === 0 ? Date.now() : state.currentMoveStartTime,
|
||||
isProcessingMove: newFlippedCards.length === 2, // Processing if 2 cards flipped
|
||||
showMismatchFeedback: false,
|
||||
}
|
||||
}
|
||||
|
||||
case 'CLEAR_MISMATCH': {
|
||||
// Clear mismatched cards and feedback
|
||||
return {
|
||||
...state,
|
||||
flippedCards: [],
|
||||
showMismatchFeedback: false,
|
||||
isProcessingMove: false,
|
||||
}
|
||||
}
|
||||
|
||||
case 'GO_TO_SETUP': {
|
||||
// Return to setup phase - pause game if coming from playing/results
|
||||
const isPausingGame = state.gamePhase === 'playing' || state.gamePhase === 'results'
|
||||
|
||||
return {
|
||||
...state,
|
||||
gamePhase: 'setup',
|
||||
// PAUSE: Save game state if pausing from active game
|
||||
pausedGamePhase: isPausingGame ? state.gamePhase : undefined,
|
||||
pausedGameState: isPausingGame
|
||||
? {
|
||||
gameCards: state.gameCards,
|
||||
currentPlayer: state.currentPlayer,
|
||||
matchedPairs: state.matchedPairs,
|
||||
moves: state.moves,
|
||||
scores: state.scores,
|
||||
activePlayers: state.activePlayers,
|
||||
playerMetadata: state.playerMetadata,
|
||||
consecutiveMatches: state.consecutiveMatches,
|
||||
gameStartTime: state.gameStartTime,
|
||||
}
|
||||
: undefined,
|
||||
// Reset visible game state
|
||||
gameCards: [],
|
||||
cards: [],
|
||||
flippedCards: [],
|
||||
currentPlayer: '',
|
||||
matchedPairs: 0,
|
||||
moves: 0,
|
||||
scores: {},
|
||||
activePlayers: [],
|
||||
playerMetadata: {},
|
||||
consecutiveMatches: {},
|
||||
gameStartTime: null,
|
||||
gameEndTime: null,
|
||||
currentMoveStartTime: null,
|
||||
celebrationAnimations: [],
|
||||
isProcessingMove: false,
|
||||
showMismatchFeedback: false,
|
||||
lastMatchedPair: null,
|
||||
}
|
||||
}
|
||||
|
||||
case 'SET_CONFIG': {
|
||||
// Update configuration field optimistically
|
||||
const { field, value } = move.data as { field: string; value: any }
|
||||
const clearPausedGame = !!state.pausedGamePhase
|
||||
|
||||
return {
|
||||
...state,
|
||||
[field]: value,
|
||||
// Update totalPairs if difficulty changes
|
||||
...(field === 'difficulty' ? { totalPairs: value } : {}),
|
||||
// Clear paused game if config changed
|
||||
...(clearPausedGame
|
||||
? { pausedGamePhase: undefined, pausedGameState: undefined, originalConfig: undefined }
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
case 'RESUME_GAME': {
|
||||
// Resume paused game
|
||||
if (!state.pausedGamePhase || !state.pausedGameState) {
|
||||
return state // No paused game, no-op
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
gamePhase: state.pausedGamePhase,
|
||||
gameCards: state.pausedGameState.gameCards,
|
||||
cards: state.pausedGameState.gameCards,
|
||||
currentPlayer: state.pausedGameState.currentPlayer,
|
||||
matchedPairs: state.pausedGameState.matchedPairs,
|
||||
moves: state.pausedGameState.moves,
|
||||
scores: state.pausedGameState.scores,
|
||||
activePlayers: state.pausedGameState.activePlayers,
|
||||
playerMetadata: state.pausedGameState.playerMetadata,
|
||||
consecutiveMatches: state.pausedGameState.consecutiveMatches,
|
||||
gameStartTime: state.pausedGameState.gameStartTime,
|
||||
// Clear paused state
|
||||
pausedGamePhase: undefined,
|
||||
pausedGameState: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
// Provider component for LOCAL play (no network sync)
|
||||
export function LocalMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
const { data: viewerId } = useViewerId()
|
||||
// NOTE: We deliberately do NOT call useRoomData() for local play
|
||||
const { activePlayerCount, activePlayers: activePlayerIds, players } = useGameMode()
|
||||
|
||||
// Get active player IDs directly as strings (UUIDs)
|
||||
const activePlayers = Array.from(activePlayerIds)
|
||||
|
||||
// Derive game mode from active player count
|
||||
const gameMode = activePlayerCount > 1 ? 'multiplayer' : 'single'
|
||||
|
||||
// NO LOCAL STATE - Configuration lives in session state
|
||||
// Changes are sent as moves and synchronized (even in local mode for consistency)
|
||||
|
||||
// Arcade session integration WITHOUT room sync
|
||||
const {
|
||||
state,
|
||||
sendMove,
|
||||
connected: _connected,
|
||||
exitSession,
|
||||
} = useArcadeSession<MemoryPairsState>({
|
||||
userId: viewerId || '',
|
||||
roomId: undefined, // CRITICAL: No roomId means no network sync
|
||||
initialState,
|
||||
applyMove: applyMoveOptimistically,
|
||||
})
|
||||
|
||||
// Handle mismatch feedback timeout
|
||||
useEffect(() => {
|
||||
if (state.showMismatchFeedback && state.flippedCards.length === 2) {
|
||||
// After 1.5 seconds, send CLEAR_MISMATCH
|
||||
// Server will validate that cards are still in mismatch state before clearing
|
||||
const timeout = setTimeout(() => {
|
||||
sendMove({
|
||||
type: 'CLEAR_MISMATCH',
|
||||
playerId: state.currentPlayer,
|
||||
data: {},
|
||||
})
|
||||
}, 1500)
|
||||
|
||||
return () => clearTimeout(timeout)
|
||||
}
|
||||
}, [state.showMismatchFeedback, state.flippedCards.length, sendMove, state.currentPlayer])
|
||||
|
||||
// Computed values
|
||||
const isGameActive = state.gamePhase === 'playing'
|
||||
|
||||
const canFlipCard = useCallback(
|
||||
(cardId: string): boolean => {
|
||||
console.log('[LocalProvider][canFlipCard] Checking card:', {
|
||||
cardId,
|
||||
isGameActive,
|
||||
isProcessingMove: state.isProcessingMove,
|
||||
currentPlayer: state.currentPlayer,
|
||||
flippedCardsCount: state.flippedCards.length,
|
||||
})
|
||||
|
||||
if (!isGameActive || state.isProcessingMove) {
|
||||
console.log('[LocalProvider][canFlipCard] Blocked: game not active or processing')
|
||||
return false
|
||||
}
|
||||
|
||||
const card = state.gameCards.find((c) => c.id === cardId)
|
||||
if (!card || card.matched) {
|
||||
console.log('[LocalProvider][canFlipCard] Blocked: card not found or already matched')
|
||||
return false
|
||||
}
|
||||
|
||||
// Can't flip if already flipped
|
||||
if (state.flippedCards.some((c) => c.id === cardId)) {
|
||||
console.log('[LocalProvider][canFlipCard] Blocked: card already flipped')
|
||||
return false
|
||||
}
|
||||
|
||||
// Can't flip more than 2 cards
|
||||
if (state.flippedCards.length >= 2) {
|
||||
console.log('[LocalProvider][canFlipCard] Blocked: 2 cards already flipped')
|
||||
return false
|
||||
}
|
||||
|
||||
// In local play, we allow the current player to flip
|
||||
// Authorization is simpler - just check if it's this player's turn
|
||||
const currentPlayerData = players.get(state.currentPlayer)
|
||||
console.log('[LocalProvider][canFlipCard] Authorization check:', {
|
||||
currentPlayerId: state.currentPlayer,
|
||||
currentPlayerFound: !!currentPlayerData,
|
||||
currentPlayerIsLocal: currentPlayerData?.isLocal,
|
||||
})
|
||||
|
||||
// Block if current player is explicitly marked as remote (shouldn't happen in local play)
|
||||
if (currentPlayerData && currentPlayerData.isLocal === false) {
|
||||
console.log(
|
||||
'[LocalProvider][canFlipCard] BLOCKED: Current player is remote (unexpected in local play)'
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
console.log('[LocalProvider][canFlipCard] ALLOWED: All checks passed')
|
||||
return true
|
||||
},
|
||||
[
|
||||
isGameActive,
|
||||
state.isProcessingMove,
|
||||
state.gameCards,
|
||||
state.flippedCards,
|
||||
state.currentPlayer,
|
||||
players,
|
||||
]
|
||||
)
|
||||
|
||||
const currentGameStatistics: GameStatistics = useMemo(
|
||||
() => ({
|
||||
totalMoves: state.moves,
|
||||
matchedPairs: state.matchedPairs,
|
||||
totalPairs: state.totalPairs,
|
||||
gameTime: state.gameStartTime ? (state.gameEndTime || Date.now()) - state.gameStartTime : 0,
|
||||
accuracy: state.moves > 0 ? (state.matchedPairs / state.moves) * 100 : 0,
|
||||
averageTimePerMove:
|
||||
state.moves > 0 && state.gameStartTime
|
||||
? ((state.gameEndTime || Date.now()) - state.gameStartTime) / state.moves
|
||||
: 0,
|
||||
}),
|
||||
[state.moves, state.matchedPairs, state.totalPairs, state.gameStartTime, state.gameEndTime]
|
||||
)
|
||||
|
||||
// PAUSE/RESUME: Computed values for pause/resume functionality
|
||||
const hasConfigChanged = useMemo(() => {
|
||||
if (!state.originalConfig) return false
|
||||
return (
|
||||
state.gameType !== state.originalConfig.gameType ||
|
||||
state.difficulty !== state.originalConfig.difficulty ||
|
||||
state.turnTimer !== state.originalConfig.turnTimer
|
||||
)
|
||||
}, [state.gameType, state.difficulty, state.turnTimer, state.originalConfig])
|
||||
|
||||
const canResumeGame = useMemo(() => {
|
||||
return !!state.pausedGamePhase && !!state.pausedGameState && !hasConfigChanged
|
||||
}, [state.pausedGamePhase, state.pausedGameState, hasConfigChanged])
|
||||
|
||||
// Action creators - send moves to arcade session
|
||||
const startGame = useCallback(() => {
|
||||
// Must have at least one active player
|
||||
if (activePlayers.length === 0) {
|
||||
console.error('[LocalMemoryPairs] Cannot start game without active players')
|
||||
return
|
||||
}
|
||||
|
||||
// Capture player metadata from local players map
|
||||
// This ensures all room members can display player info even if they don't own the players
|
||||
const playerMetadata: { [playerId: string]: any } = {}
|
||||
for (const playerId of activePlayers) {
|
||||
const playerData = players.get(playerId)
|
||||
if (playerData) {
|
||||
playerMetadata[playerId] = {
|
||||
id: playerId,
|
||||
name: playerData.name,
|
||||
emoji: playerData.emoji,
|
||||
userId: viewerId || '',
|
||||
color: playerData.color,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use current session state configuration (no local state!)
|
||||
const cards = generateGameCards(state.gameType, state.difficulty)
|
||||
// Use first active player as playerId for START_GAME move
|
||||
const firstPlayer = activePlayers[0]
|
||||
sendMove({
|
||||
type: 'START_GAME',
|
||||
playerId: firstPlayer,
|
||||
data: {
|
||||
cards,
|
||||
activePlayers,
|
||||
playerMetadata,
|
||||
},
|
||||
})
|
||||
}, [state.gameType, state.difficulty, activePlayers, players, viewerId, sendMove])
|
||||
|
||||
const flipCard = useCallback(
|
||||
(cardId: string) => {
|
||||
console.log('[LocalProvider] flipCard called:', {
|
||||
cardId,
|
||||
viewerId,
|
||||
currentPlayer: state.currentPlayer,
|
||||
activePlayers: state.activePlayers,
|
||||
gamePhase: state.gamePhase,
|
||||
canFlip: canFlipCard(cardId),
|
||||
})
|
||||
|
||||
if (!canFlipCard(cardId)) {
|
||||
console.log('[LocalProvider] Cannot flip card - canFlipCard returned false')
|
||||
return
|
||||
}
|
||||
|
||||
const move = {
|
||||
type: 'FLIP_CARD' as const,
|
||||
playerId: state.currentPlayer, // Use the current player ID from game state (database player ID)
|
||||
data: { cardId },
|
||||
}
|
||||
console.log('[LocalProvider] Sending FLIP_CARD move via sendMove:', move)
|
||||
sendMove(move)
|
||||
},
|
||||
[canFlipCard, sendMove, viewerId, state.currentPlayer, state.activePlayers, state.gamePhase]
|
||||
)
|
||||
|
||||
const resetGame = useCallback(() => {
|
||||
// Must have at least one active player
|
||||
if (activePlayers.length === 0) {
|
||||
console.error('[LocalMemoryPairs] Cannot reset game without active players')
|
||||
return
|
||||
}
|
||||
|
||||
// Capture player metadata from local players map
|
||||
const playerMetadata: { [playerId: string]: any } = {}
|
||||
for (const playerId of activePlayers) {
|
||||
const playerData = players.get(playerId)
|
||||
if (playerData) {
|
||||
playerMetadata[playerId] = {
|
||||
id: playerId,
|
||||
name: playerData.name,
|
||||
emoji: playerData.emoji,
|
||||
userId: viewerId || '',
|
||||
color: playerData.color,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use current session state configuration (no local state!)
|
||||
const cards = generateGameCards(state.gameType, state.difficulty)
|
||||
// Use first active player as playerId for START_GAME move
|
||||
const firstPlayer = activePlayers[0]
|
||||
sendMove({
|
||||
type: 'START_GAME',
|
||||
playerId: firstPlayer,
|
||||
data: {
|
||||
cards,
|
||||
activePlayers,
|
||||
playerMetadata,
|
||||
},
|
||||
})
|
||||
}, [state.gameType, state.difficulty, activePlayers, players, viewerId, sendMove])
|
||||
|
||||
const setGameType = useCallback(
|
||||
(gameType: typeof state.gameType) => {
|
||||
// Use first active player as playerId, or empty string if none
|
||||
const playerId = activePlayers[0] || ''
|
||||
sendMove({
|
||||
type: 'SET_CONFIG',
|
||||
playerId,
|
||||
data: { field: 'gameType', value: gameType },
|
||||
})
|
||||
},
|
||||
[activePlayers, sendMove]
|
||||
)
|
||||
|
||||
const setDifficulty = useCallback(
|
||||
(difficulty: typeof state.difficulty) => {
|
||||
const playerId = activePlayers[0] || ''
|
||||
sendMove({
|
||||
type: 'SET_CONFIG',
|
||||
playerId,
|
||||
data: { field: 'difficulty', value: difficulty },
|
||||
})
|
||||
},
|
||||
[activePlayers, sendMove]
|
||||
)
|
||||
|
||||
const setTurnTimer = useCallback(
|
||||
(turnTimer: typeof state.turnTimer) => {
|
||||
const playerId = activePlayers[0] || ''
|
||||
sendMove({
|
||||
type: 'SET_CONFIG',
|
||||
playerId,
|
||||
data: { field: 'turnTimer', value: turnTimer },
|
||||
})
|
||||
},
|
||||
[activePlayers, sendMove]
|
||||
)
|
||||
|
||||
const resumeGame = useCallback(() => {
|
||||
// PAUSE/RESUME: Resume paused game if config unchanged
|
||||
if (!canResumeGame) {
|
||||
console.warn('[LocalMemoryPairs] Cannot resume - no paused game or config changed')
|
||||
return
|
||||
}
|
||||
|
||||
const playerId = activePlayers[0] || state.currentPlayer || ''
|
||||
sendMove({
|
||||
type: 'RESUME_GAME',
|
||||
playerId,
|
||||
data: {},
|
||||
})
|
||||
}, [canResumeGame, activePlayers, state.currentPlayer, sendMove])
|
||||
|
||||
const goToSetup = useCallback(() => {
|
||||
// Send GO_TO_SETUP move - synchronized across all room members
|
||||
const playerId = activePlayers[0] || state.currentPlayer || ''
|
||||
sendMove({
|
||||
type: 'GO_TO_SETUP',
|
||||
playerId,
|
||||
data: {},
|
||||
})
|
||||
}, [activePlayers, state.currentPlayer, sendMove])
|
||||
|
||||
// NO MORE effectiveState merging! Just use session state directly with gameMode added
|
||||
const effectiveState = { ...state, gameMode } as MemoryPairsState & { gameMode: GameMode }
|
||||
|
||||
const contextValue: MemoryPairsContextValue = {
|
||||
state: effectiveState,
|
||||
dispatch: () => {
|
||||
// No-op - replaced with sendMove
|
||||
console.warn('dispatch() is deprecated in arcade mode, use action creators instead')
|
||||
},
|
||||
isGameActive,
|
||||
canFlipCard,
|
||||
currentGameStatistics,
|
||||
hasConfigChanged,
|
||||
canResumeGame,
|
||||
startGame,
|
||||
resumeGame,
|
||||
flipCard,
|
||||
resetGame,
|
||||
goToSetup,
|
||||
setGameType,
|
||||
setDifficulty,
|
||||
setTurnTimer,
|
||||
exitSession,
|
||||
gameMode,
|
||||
activePlayers,
|
||||
}
|
||||
|
||||
return <MemoryPairsContext.Provider value={contextValue}>{children}</MemoryPairsContext.Provider>
|
||||
}
|
||||
@@ -0,0 +1,575 @@
|
||||
'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>
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import { ArcadeGuardedPage } from '@/components/ArcadeGuardedPage'
|
||||
import { MemoryPairsGame } from './components/MemoryPairsGame'
|
||||
import { ArcadeMemoryPairsProvider } from './context/ArcadeMemoryPairsContext'
|
||||
import { RoomMemoryPairsProvider } from './context/RoomMemoryPairsProvider'
|
||||
|
||||
export default function MatchingPage() {
|
||||
return (
|
||||
<ArcadeGuardedPage>
|
||||
<ArcadeMemoryPairsProvider>
|
||||
<RoomMemoryPairsProvider>
|
||||
<MemoryPairsGame />
|
||||
</ArcadeMemoryPairsProvider>
|
||||
</RoomMemoryPairsProvider>
|
||||
</ArcadeGuardedPage>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect } from 'react'
|
||||
import { ArcadeGuardedPage } from '@/components/ArcadeGuardedPage'
|
||||
import { useRoomData } from '@/hooks/useRoomData'
|
||||
import { MemoryPairsGame } from '../matching/components/MemoryPairsGame'
|
||||
import { ArcadeMemoryPairsProvider } from '../matching/context/ArcadeMemoryPairsContext'
|
||||
@@ -10,24 +7,14 @@ import { ArcadeMemoryPairsProvider } from '../matching/context/ArcadeMemoryPairs
|
||||
/**
|
||||
* /arcade/room - Renders the game for the user's current room
|
||||
* Since users can only be in one room at a time, this is a simple singular route
|
||||
*
|
||||
* Note: We don't redirect to /arcade if no room exists because:
|
||||
* - It would conflict with arcade session redirects and create loops
|
||||
* - useArcadeRedirect on /arcade page handles redirecting to active sessions
|
||||
*/
|
||||
export default function RoomPage() {
|
||||
const router = useRouter()
|
||||
const { roomData, isLoading } = useRoomData()
|
||||
|
||||
// Debug logging
|
||||
useEffect(() => {
|
||||
console.log('[RoomPage] State:', { isLoading, hasRoomData: !!roomData, roomData })
|
||||
}, [isLoading, roomData])
|
||||
|
||||
// Redirect to arcade if no room
|
||||
useEffect(() => {
|
||||
if (!isLoading && !roomData) {
|
||||
console.log('[RoomPage] No active room, redirecting to /arcade')
|
||||
router.push('/arcade')
|
||||
}
|
||||
}, [isLoading, roomData, router])
|
||||
|
||||
// Show loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -46,20 +33,44 @@ export default function RoomPage() {
|
||||
)
|
||||
}
|
||||
|
||||
// Show nothing while redirecting
|
||||
// Show error if no room (instead of redirecting)
|
||||
if (!roomData) {
|
||||
return null
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
fontSize: '18px',
|
||||
color: '#666',
|
||||
gap: '1rem',
|
||||
}}
|
||||
>
|
||||
<div>No active room found</div>
|
||||
<a
|
||||
href="/arcade"
|
||||
style={{
|
||||
color: '#3b82f6',
|
||||
textDecoration: 'underline',
|
||||
}}
|
||||
>
|
||||
Go to Champion Arena
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Render the appropriate game based on room's gameName
|
||||
// Note: We don't use ArcadeGuardedPage here because room-based games
|
||||
// have their own navigation logic via useRoomData
|
||||
switch (roomData.gameName) {
|
||||
case 'matching':
|
||||
return (
|
||||
<ArcadeGuardedPage>
|
||||
<ArcadeMemoryPairsProvider>
|
||||
<MemoryPairsGame />
|
||||
</ArcadeMemoryPairsProvider>
|
||||
</ArcadeGuardedPage>
|
||||
<ArcadeMemoryPairsProvider>
|
||||
<MemoryPairsGame />
|
||||
</ArcadeMemoryPairsProvider>
|
||||
)
|
||||
|
||||
// TODO: Add other games (complement-race, memory-quiz, etc.)
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useParams } from 'next/navigation'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { ComplementRaceGame } from '@/app/arcade/complement-race/components/ComplementRaceGame'
|
||||
import { ComplementRaceProvider } from '@/app/arcade/complement-race/context/ComplementRaceContext'
|
||||
|
||||
export default function RoomComplementRacePage() {
|
||||
const params = useParams()
|
||||
const roomId = params.roomId as string
|
||||
|
||||
// TODO Phase 4: Integrate room context with game state
|
||||
// - Connect to room socket events
|
||||
// - Sync game state across players
|
||||
// - Handle multiplayer race dynamics
|
||||
|
||||
return (
|
||||
<PageWithNav navTitle="Speed Complement Race" navEmoji="🏁">
|
||||
<ComplementRaceProvider>
|
||||
<ComplementRaceGame />
|
||||
</ComplementRaceProvider>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useParams } from 'next/navigation'
|
||||
import { ArcadeGuardedPage } from '@/components/ArcadeGuardedPage'
|
||||
import { MemoryPairsGame } from '@/app/arcade/matching/components/MemoryPairsGame'
|
||||
import { ArcadeMemoryPairsProvider } from '@/app/arcade/matching/context/ArcadeMemoryPairsContext'
|
||||
|
||||
export default function RoomMatchingPage() {
|
||||
const params = useParams()
|
||||
const roomId = params.roomId as string
|
||||
|
||||
// TODO Phase 4: Integrate room context with game state
|
||||
// - Connect to room socket events
|
||||
// - Sync game state across players
|
||||
// - Handle multiplayer moves
|
||||
|
||||
return (
|
||||
<ArcadeGuardedPage>
|
||||
<ArcadeMemoryPairsProvider>
|
||||
<MemoryPairsGame />
|
||||
</ArcadeMemoryPairsProvider>
|
||||
</ArcadeGuardedPage>
|
||||
)
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useParams } from 'next/navigation'
|
||||
|
||||
// Temporarily redirect to solo arcade version
|
||||
// TODO Phase 4: Implement room-aware memory quiz with multiplayer sync
|
||||
export default function RoomMemoryQuizPage() {
|
||||
const params = useParams()
|
||||
const roomId = params.roomId as string
|
||||
|
||||
// Import and use the arcade version for now
|
||||
// This prevents 404s while we work on full multiplayer integration
|
||||
const MemoryQuizGame = require('@/app/arcade/memory-quiz/page').default
|
||||
|
||||
return <MemoryQuizGame />
|
||||
}
|
||||
@@ -40,6 +40,20 @@ export interface GameStatistics {
|
||||
averageTimePerMove: number
|
||||
}
|
||||
|
||||
export interface PlayerMetadata {
|
||||
id: string // Player ID
|
||||
name: string
|
||||
emoji: string
|
||||
userId: string // Which user owns this player
|
||||
color?: string
|
||||
}
|
||||
|
||||
export interface GameConfiguration {
|
||||
gameType: GameType
|
||||
difficulty: Difficulty
|
||||
turnTimer: number
|
||||
}
|
||||
|
||||
export interface MemoryPairsState {
|
||||
// Core game data
|
||||
cards: GameCard[]
|
||||
@@ -51,6 +65,22 @@ export interface MemoryPairsState {
|
||||
difficulty: Difficulty
|
||||
turnTimer: number // Seconds for two-player mode
|
||||
|
||||
// Paused game state - for Resume functionality
|
||||
originalConfig?: GameConfiguration // Config when game started - used to detect changes
|
||||
pausedGamePhase?: 'playing' | 'results' // Set when GO_TO_SETUP called from active game
|
||||
pausedGameState?: {
|
||||
// Snapshot of game state when paused
|
||||
gameCards: GameCard[]
|
||||
currentPlayer: Player
|
||||
matchedPairs: number
|
||||
moves: number
|
||||
scores: PlayerScore
|
||||
activePlayers: Player[]
|
||||
playerMetadata: { [playerId: string]: PlayerMetadata }
|
||||
consecutiveMatches: { [playerId: string]: number }
|
||||
gameStartTime: number | null
|
||||
}
|
||||
|
||||
// Game progression
|
||||
gamePhase: GamePhase
|
||||
currentPlayer: Player
|
||||
@@ -59,6 +89,7 @@ export interface MemoryPairsState {
|
||||
moves: number
|
||||
scores: PlayerScore
|
||||
activePlayers: Player[] // Track active player IDs
|
||||
playerMetadata: { [playerId: string]: PlayerMetadata } // Player metadata snapshot for cross-user visibility
|
||||
consecutiveMatches: { [playerId: string]: number } // Track consecutive matches per player
|
||||
|
||||
// Timing
|
||||
@@ -72,6 +103,9 @@ export interface MemoryPairsState {
|
||||
isProcessingMove: boolean
|
||||
showMismatchFeedback: boolean
|
||||
lastMatchedPair: [string, string] | null
|
||||
|
||||
// Hover state for networked presence
|
||||
playerHovers: { [playerId: string]: string | null } // playerId -> cardId (or null if not hovering)
|
||||
}
|
||||
|
||||
export type MemoryPairsAction =
|
||||
@@ -101,13 +135,19 @@ export interface MemoryPairsContextValue {
|
||||
currentGameStatistics: GameStatistics
|
||||
gameMode: GameMode // Derived from global context
|
||||
activePlayers: Player[] // Active player IDs from arena
|
||||
hasConfigChanged: boolean // True if current config differs from originalConfig
|
||||
canResumeGame: boolean // True if there's a paused game and config hasn't changed
|
||||
|
||||
// Actions
|
||||
startGame: () => void
|
||||
resumeGame: () => void
|
||||
flipCard: (cardId: string) => void
|
||||
resetGame: () => void
|
||||
setGameType: (type: GameType) => void
|
||||
setDifficulty: (difficulty: Difficulty) => void
|
||||
setTurnTimer: (timer: number) => void
|
||||
hoverCard: (cardId: string | null) => void // Send hover state for networked presence
|
||||
goToSetup: () => void
|
||||
exitSession: () => void // Exit arcade session (no-op for non-arcade mode)
|
||||
}
|
||||
|
||||
@@ -133,14 +173,6 @@ export interface GameGridProps {
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
// Configuration interfaces
|
||||
export interface GameConfiguration {
|
||||
gameMode: GameMode
|
||||
gameType: GameType
|
||||
difficulty: Difficulty
|
||||
turnTimer: number
|
||||
}
|
||||
|
||||
export interface MatchValidationResult {
|
||||
isValid: boolean
|
||||
reason?: string
|
||||
|
||||
@@ -58,14 +58,14 @@ export function PageWithNav({
|
||||
}
|
||||
|
||||
// Get active and inactive players as arrays
|
||||
// Only show LOCAL players in the active/inactive lists (remote players shown separately in networkPlayers)
|
||||
const activePlayerList = Array.from(activePlayers)
|
||||
.map((id) => players.get(id))
|
||||
.filter((p) => p !== undefined)
|
||||
.map((p) => ({ id: p.id, name: p.name, emoji: p.emoji }))
|
||||
.filter((p): p is NonNullable<typeof p> => p !== undefined && p.isLocal !== false) // Filter out remote players
|
||||
|
||||
const inactivePlayerList = Array.from(players.values())
|
||||
.filter((p) => !activePlayers.has(p.id))
|
||||
.map((p) => ({ id: p.id, name: p.name, emoji: p.emoji }))
|
||||
const inactivePlayerList = Array.from(players.values()).filter(
|
||||
(p) => !activePlayers.has(p.id) && p.isLocal !== false
|
||||
) // Filter out remote players
|
||||
|
||||
// Compute game mode from active player count
|
||||
const gameMode =
|
||||
@@ -98,7 +98,13 @@ export function PageWithNav({
|
||||
: undefined
|
||||
|
||||
// Compute network players (other players in the room, excluding current user)
|
||||
const networkPlayers: Array<{ id: string; emoji?: string; name?: string }> =
|
||||
const networkPlayers: Array<{
|
||||
id: string
|
||||
emoji?: string
|
||||
name?: string
|
||||
color?: string
|
||||
memberName?: string
|
||||
}> =
|
||||
isInRoom && roomData
|
||||
? roomData.members
|
||||
.filter((member) => member.userId !== viewerId)
|
||||
@@ -107,7 +113,9 @@ export function PageWithNav({
|
||||
return memberPlayerList.map((player) => ({
|
||||
id: player.id,
|
||||
emoji: player.emoji,
|
||||
name: `${player.name} (${member.displayName})`,
|
||||
name: player.name,
|
||||
color: player.color,
|
||||
memberName: member.displayName,
|
||||
}))
|
||||
})
|
||||
: []
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import React from 'react'
|
||||
import { PlayerTooltip } from './PlayerTooltip'
|
||||
|
||||
interface Player {
|
||||
id: string
|
||||
name: string
|
||||
emoji: string
|
||||
color?: string
|
||||
createdAt?: Date | number
|
||||
isLocal?: boolean
|
||||
}
|
||||
|
||||
interface ActivePlayersListProps {
|
||||
@@ -24,105 +28,111 @@ export function ActivePlayersList({
|
||||
return (
|
||||
<>
|
||||
{activePlayers.map((player) => (
|
||||
<div
|
||||
<PlayerTooltip
|
||||
key={player.id}
|
||||
style={{
|
||||
position: 'relative',
|
||||
fontSize: shouldEmphasize ? '48px' : '20px',
|
||||
lineHeight: 1,
|
||||
transition: 'font-size 0.4s cubic-bezier(0.4, 0, 0.2, 1), filter 0.4s ease',
|
||||
filter: shouldEmphasize ? 'drop-shadow(0 4px 8px rgba(0,0,0,0.25))' : 'none',
|
||||
cursor: shouldEmphasize ? 'pointer' : 'default',
|
||||
}}
|
||||
title={player.name}
|
||||
onClick={() => shouldEmphasize && onConfigurePlayer(player.id)}
|
||||
onMouseEnter={() => shouldEmphasize && setHoveredPlayerId(player.id)}
|
||||
onMouseLeave={() => shouldEmphasize && setHoveredPlayerId(null)}
|
||||
playerName={player.name}
|
||||
playerColor={player.color}
|
||||
isLocal={player.isLocal !== false}
|
||||
createdAt={player.createdAt}
|
||||
>
|
||||
{player.emoji}
|
||||
{shouldEmphasize && hoveredPlayerId === player.id && (
|
||||
<>
|
||||
{/* Configure button - bottom left */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onConfigurePlayer(player.id)
|
||||
}}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '-4px',
|
||||
left: '-4px',
|
||||
width: '18px',
|
||||
height: '18px',
|
||||
borderRadius: '50%',
|
||||
border: '2px solid white',
|
||||
background: '#6b7280',
|
||||
color: 'white',
|
||||
fontSize: '10px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
|
||||
transition: 'all 0.2s ease',
|
||||
padding: 0,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#3b82f6'
|
||||
e.currentTarget.style.transform = 'scale(1.15)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#6b7280'
|
||||
e.currentTarget.style.transform = 'scale(1)'
|
||||
}}
|
||||
aria-label={`Configure ${player.name}`}
|
||||
>
|
||||
⚙
|
||||
</button>
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
fontSize: shouldEmphasize ? '48px' : '20px',
|
||||
lineHeight: 1,
|
||||
transition: 'font-size 0.4s cubic-bezier(0.4, 0, 0.2, 1), filter 0.4s ease',
|
||||
filter: shouldEmphasize ? 'drop-shadow(0 4px 8px rgba(0,0,0,0.25))' : 'none',
|
||||
cursor: shouldEmphasize ? 'pointer' : 'default',
|
||||
}}
|
||||
onClick={() => shouldEmphasize && onConfigurePlayer(player.id)}
|
||||
onMouseEnter={() => shouldEmphasize && setHoveredPlayerId(player.id)}
|
||||
onMouseLeave={() => shouldEmphasize && setHoveredPlayerId(null)}
|
||||
>
|
||||
{player.emoji}
|
||||
{shouldEmphasize && hoveredPlayerId === player.id && (
|
||||
<>
|
||||
{/* Configure button - bottom left */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onConfigurePlayer(player.id)
|
||||
}}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '-4px',
|
||||
left: '-4px',
|
||||
width: '18px',
|
||||
height: '18px',
|
||||
borderRadius: '50%',
|
||||
border: '2px solid white',
|
||||
background: '#6b7280',
|
||||
color: 'white',
|
||||
fontSize: '10px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
|
||||
transition: 'all 0.2s ease',
|
||||
padding: 0,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#3b82f6'
|
||||
e.currentTarget.style.transform = 'scale(1.15)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#6b7280'
|
||||
e.currentTarget.style.transform = 'scale(1)'
|
||||
}}
|
||||
aria-label={`Configure ${player.name}`}
|
||||
>
|
||||
⚙
|
||||
</button>
|
||||
|
||||
{/* Remove button - top right */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onRemovePlayer(player.id)
|
||||
}}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-4px',
|
||||
right: '-4px',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
borderRadius: '50%',
|
||||
border: '2px solid white',
|
||||
background: '#ef4444',
|
||||
color: 'white',
|
||||
fontSize: '12px',
|
||||
fontWeight: 'bold',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
|
||||
transition: 'all 0.2s ease',
|
||||
padding: 0,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#dc2626'
|
||||
e.currentTarget.style.transform = 'scale(1.1)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#ef4444'
|
||||
e.currentTarget.style.transform = 'scale(1)'
|
||||
}}
|
||||
aria-label={`Remove ${player.name}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* Remove button - top right */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onRemovePlayer(player.id)
|
||||
}}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-4px',
|
||||
right: '-4px',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
borderRadius: '50%',
|
||||
border: '2px solid white',
|
||||
background: '#ef4444',
|
||||
color: 'white',
|
||||
fontSize: '12px',
|
||||
fontWeight: 'bold',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
|
||||
transition: 'all 0.2s ease',
|
||||
padding: 0,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#dc2626'
|
||||
e.currentTarget.style.transform = 'scale(1.1)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#ef4444'
|
||||
e.currentTarget.style.transform = 'scale(1)'
|
||||
}}
|
||||
aria-label={`Remove ${player.name}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PlayerTooltip>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import React from 'react'
|
||||
import { PlayerTooltip } from './PlayerTooltip'
|
||||
|
||||
interface NetworkPlayer {
|
||||
id: string
|
||||
emoji?: string
|
||||
name?: string
|
||||
color?: string
|
||||
memberName?: string
|
||||
}
|
||||
|
||||
interface NetworkPlayerIndicatorProps {
|
||||
@@ -17,92 +20,97 @@ interface NetworkPlayerIndicatorProps {
|
||||
*/
|
||||
export function NetworkPlayerIndicator({ player, shouldEmphasize }: NetworkPlayerIndicatorProps) {
|
||||
const [isHovered, setIsHovered] = React.useState(false)
|
||||
const playerName = player.name || `Network Player ${player.id.slice(0, 8)}`
|
||||
const extraInfo = player.memberName ? `Controlled by ${player.memberName}` : undefined
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
fontSize: shouldEmphasize ? '48px' : '20px',
|
||||
lineHeight: 1,
|
||||
transition: 'all 0.4s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
cursor: 'default',
|
||||
}}
|
||||
title={player.name || `Network Player ${player.id.slice(0, 8)}`}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
<PlayerTooltip
|
||||
playerName={playerName}
|
||||
playerColor={player.color}
|
||||
isLocal={false}
|
||||
extraInfo={extraInfo}
|
||||
>
|
||||
{/* Network frame border */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: '-6px',
|
||||
borderRadius: '8px',
|
||||
background: `
|
||||
position: 'relative',
|
||||
fontSize: shouldEmphasize ? '48px' : '20px',
|
||||
lineHeight: 1,
|
||||
transition: 'all 0.4s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
cursor: 'default',
|
||||
}}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{/* Network frame border */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: '-6px',
|
||||
borderRadius: '8px',
|
||||
background: `
|
||||
linear-gradient(135deg,
|
||||
rgba(59, 130, 246, 0.4),
|
||||
rgba(147, 51, 234, 0.4),
|
||||
rgba(236, 72, 153, 0.4))
|
||||
`,
|
||||
opacity: isHovered ? 1 : 0.7,
|
||||
transition: 'opacity 0.2s ease',
|
||||
zIndex: -1,
|
||||
}}
|
||||
/>
|
||||
opacity: isHovered ? 1 : 0.7,
|
||||
transition: 'opacity 0.2s ease',
|
||||
zIndex: -1,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Animated network signal indicator */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-8px',
|
||||
right: '-8px',
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(34, 197, 94, 0.9)',
|
||||
boxShadow: '0 0 8px rgba(34, 197, 94, 0.6)',
|
||||
animation: 'networkPulse 2s ease-in-out infinite',
|
||||
zIndex: 1,
|
||||
}}
|
||||
title="Connected"
|
||||
/>
|
||||
{/* Animated network signal indicator */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-8px',
|
||||
right: '-8px',
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(34, 197, 94, 0.9)',
|
||||
boxShadow: '0 0 8px rgba(34, 197, 94, 0.6)',
|
||||
animation: 'networkPulse 2s ease-in-out infinite',
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Player emoji or fallback */}
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
filter: shouldEmphasize ? 'drop-shadow(0 4px 8px rgba(0,0,0,0.25))' : 'none',
|
||||
}}
|
||||
>
|
||||
{player.emoji || '🌐'}
|
||||
</div>
|
||||
{/* Player emoji or fallback */}
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
filter: shouldEmphasize ? 'drop-shadow(0 4px 8px rgba(0,0,0,0.25))' : 'none',
|
||||
}}
|
||||
>
|
||||
{player.emoji || '🌐'}
|
||||
</div>
|
||||
|
||||
{/* Network icon badge */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '-4px',
|
||||
left: '-4px',
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
borderRadius: '50%',
|
||||
border: '2px solid white',
|
||||
background: 'linear-gradient(135deg, #3b82f6, #8b5cf6)',
|
||||
color: 'white',
|
||||
fontSize: '8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 2px 6px rgba(0,0,0,0.3)',
|
||||
zIndex: 1,
|
||||
}}
|
||||
title="Network Player"
|
||||
>
|
||||
📡
|
||||
</div>
|
||||
{/* Network icon badge */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '-4px',
|
||||
left: '-4px',
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
borderRadius: '50%',
|
||||
border: '2px solid white',
|
||||
background: 'linear-gradient(135deg, #3b82f6, #8b5cf6)',
|
||||
color: 'white',
|
||||
fontSize: '8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 2px 6px rgba(0,0,0,0.3)',
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
📡
|
||||
</div>
|
||||
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
@keyframes networkPulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
@@ -114,8 +122,9 @@ export function NetworkPlayerIndicator({ player, shouldEmphasize }: NetworkPlaye
|
||||
}
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</PlayerTooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { EmojiPicker } from '../../app/games/matching/components/EmojiPicker'
|
||||
import { useGameMode } from '../../contexts/GameModeContext'
|
||||
|
||||
@@ -11,17 +11,36 @@ export function PlayerConfigDialog({ playerId, onClose }: PlayerConfigDialogProp
|
||||
// All hooks must be called before early return
|
||||
const { getPlayer, updatePlayer, players } = useGameMode()
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
|
||||
const [localName, setLocalName] = useState('')
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
const player = getPlayer(playerId)
|
||||
const [tempName, setTempName] = useState(player?.name || '')
|
||||
|
||||
// Initialize local name from player
|
||||
useEffect(() => {
|
||||
if (player) {
|
||||
setLocalName(player.name)
|
||||
}
|
||||
}, [player])
|
||||
|
||||
if (!player) {
|
||||
return null
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
updatePlayer(playerId, { name: tempName })
|
||||
onClose()
|
||||
const handleNameChange = (newName: string) => {
|
||||
setLocalName(newName)
|
||||
|
||||
// Debounce the update to avoid too many API calls
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current)
|
||||
}
|
||||
|
||||
setIsSaving(true)
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
updatePlayer(playerId, { name: newName })
|
||||
setIsSaving(false)
|
||||
}, 500) // Wait 500ms after user stops typing
|
||||
}
|
||||
|
||||
const handleEmojiSelect = (emoji: string) => {
|
||||
@@ -30,7 +49,21 @@ export function PlayerConfigDialog({ playerId, onClose }: PlayerConfigDialogProp
|
||||
}
|
||||
|
||||
// Get player number for UI theming (first 4 players get special colors)
|
||||
const allPlayers = Array.from(players.values()).sort((a, b) => a.createdAt - b.createdAt)
|
||||
const allPlayers = Array.from(players.values()).sort((a, b) => {
|
||||
const aTime =
|
||||
typeof a.createdAt === 'number'
|
||||
? a.createdAt
|
||||
: a.createdAt instanceof Date
|
||||
? a.createdAt.getTime()
|
||||
: 0
|
||||
const bTime =
|
||||
typeof b.createdAt === 'number'
|
||||
? b.createdAt
|
||||
: b.createdAt instanceof Date
|
||||
? b.createdAt.getTime()
|
||||
: 0
|
||||
return aTime - bTime
|
||||
})
|
||||
const playerIndex = allPlayers.findIndex((p) => p.id === playerId)
|
||||
const displayNumber = playerIndex + 1
|
||||
|
||||
@@ -81,22 +114,35 @@ export function PlayerConfigDialog({ playerId, onClose }: PlayerConfigDialogProp
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: '24px',
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
background: `linear-gradient(135deg, ${gradientColor}, ${gradientColor}dd)`,
|
||||
backgroundClip: 'text',
|
||||
color: 'transparent',
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
Configure Player
|
||||
</h2>
|
||||
<div>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
background: `linear-gradient(135deg, ${gradientColor}, ${gradientColor}dd)`,
|
||||
backgroundClip: 'text',
|
||||
color: 'transparent',
|
||||
margin: 0,
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
Player Settings
|
||||
</h2>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: isSaving ? '#f59e0b' : '#10b981',
|
||||
fontWeight: '500',
|
||||
opacity: 0.8,
|
||||
}}
|
||||
>
|
||||
{isSaving ? '💾 Saving...' : '✓ Changes saved automatically'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
@@ -198,7 +244,7 @@ export function PlayerConfigDialog({ playerId, onClose }: PlayerConfigDialogProp
|
||||
</div>
|
||||
|
||||
{/* Name Input */}
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
@@ -212,8 +258,8 @@ export function PlayerConfigDialog({ playerId, onClose }: PlayerConfigDialogProp
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={tempName}
|
||||
onChange={(e) => setTempName(e.target.value)}
|
||||
value={localName}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
placeholder="Player Name"
|
||||
maxLength={20}
|
||||
style={{
|
||||
@@ -243,69 +289,9 @@ export function PlayerConfigDialog({ playerId, onClose }: PlayerConfigDialogProp
|
||||
textAlign: 'right',
|
||||
}}
|
||||
>
|
||||
{tempName.length}/20 characters
|
||||
{localName.length}/20 characters
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px',
|
||||
background: 'white',
|
||||
border: '2px solid #e5e7eb',
|
||||
borderRadius: '12px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#6b7280',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#f9fafb'
|
||||
e.currentTarget.style.borderColor = '#d1d5db'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'white'
|
||||
e.currentTarget.style.borderColor = '#e5e7eb'
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px',
|
||||
background: `linear-gradient(135deg, ${gradientColor}, ${gradientColor}dd)`,
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-2px)'
|
||||
e.currentTarget.style.boxShadow = '0 6px 16px rgba(0,0,0,0.2)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)'
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(0,0,0,0.15)'
|
||||
}}
|
||||
>
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
164
apps/web/src/components/nav/PlayerTooltip.tsx
Normal file
164
apps/web/src/components/nav/PlayerTooltip.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import type React from 'react'
|
||||
|
||||
interface PlayerTooltipProps {
|
||||
children: React.ReactNode
|
||||
playerName: string
|
||||
playerColor?: string
|
||||
isLocal?: boolean
|
||||
createdAt?: Date | number
|
||||
extraInfo?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Radix-based tooltip for displaying rich player information
|
||||
* Shows player name, type (local/network), color, and other details
|
||||
*/
|
||||
export function PlayerTooltip({
|
||||
children,
|
||||
playerName,
|
||||
playerColor,
|
||||
isLocal = true,
|
||||
createdAt,
|
||||
extraInfo,
|
||||
}: PlayerTooltipProps) {
|
||||
// Format creation time
|
||||
const getCreatedTimeAgo = () => {
|
||||
if (!createdAt) return null
|
||||
|
||||
const now = Date.now()
|
||||
const created =
|
||||
typeof createdAt === 'number'
|
||||
? createdAt
|
||||
: createdAt instanceof Date
|
||||
? createdAt.getTime()
|
||||
: 0
|
||||
const diff = now - created
|
||||
|
||||
const minutes = Math.floor(diff / 60000)
|
||||
const hours = Math.floor(diff / 3600000)
|
||||
const days = Math.floor(diff / 86400000)
|
||||
|
||||
if (days > 0) return `${days}d ago`
|
||||
if (hours > 0) return `${hours}h ago`
|
||||
if (minutes > 0) return `${minutes}m ago`
|
||||
return 'just now'
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip.Provider delayDuration={200}>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>{children}</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content
|
||||
side="bottom"
|
||||
sideOffset={8}
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, rgba(17, 24, 39, 0.97), rgba(31, 41, 55, 0.97))',
|
||||
backdropFilter: 'blur(8px)',
|
||||
borderRadius: '12px',
|
||||
padding: '12px 16px',
|
||||
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.1)',
|
||||
maxWidth: '280px',
|
||||
zIndex: 9999,
|
||||
animation: 'tooltipFadeIn 0.2s ease-out',
|
||||
}}
|
||||
>
|
||||
{/* Player name with color accent */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
{playerColor && (
|
||||
<div
|
||||
style={{
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
borderRadius: '50%',
|
||||
background: playerColor,
|
||||
boxShadow: `0 0 8px ${playerColor}50`,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
fontSize: '15px',
|
||||
fontWeight: '600',
|
||||
color: 'white',
|
||||
lineHeight: 1.3,
|
||||
}}
|
||||
>
|
||||
{playerName}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Player type badge */}
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '6px',
|
||||
background: isLocal
|
||||
? 'rgba(16, 185, 129, 0.15)'
|
||||
: 'linear-gradient(135deg, rgba(59, 130, 246, 0.15), rgba(147, 51, 234, 0.15))',
|
||||
border: `1px solid ${isLocal ? 'rgba(16, 185, 129, 0.3)' : 'rgba(147, 51, 234, 0.3)'}`,
|
||||
fontSize: '11px',
|
||||
fontWeight: '600',
|
||||
color: isLocal ? 'rgba(167, 243, 208, 1)' : 'rgba(196, 181, 253, 1)',
|
||||
marginBottom: extraInfo || createdAt ? '8px' : 0,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '10px' }}>{isLocal ? '●' : '📡'}</span>
|
||||
{isLocal ? 'Your Player' : 'Network Player'}
|
||||
</div>
|
||||
|
||||
{/* Additional info */}
|
||||
{(extraInfo || createdAt) && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: 'rgba(209, 213, 219, 0.9)',
|
||||
lineHeight: 1.4,
|
||||
marginTop: '4px',
|
||||
}}
|
||||
>
|
||||
{extraInfo && <div>{extraInfo}</div>}
|
||||
{createdAt && <div style={{ opacity: 0.7 }}>Joined {getCreatedTimeAgo()}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Tooltip.Arrow
|
||||
style={{
|
||||
fill: 'rgba(17, 24, 39, 0.97)',
|
||||
}}
|
||||
/>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
@keyframes tooltipFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
</Tooltip.Provider>
|
||||
)
|
||||
}
|
||||
@@ -68,7 +68,7 @@ export function GameModeProvider({ children }: { children: ReactNode }) {
|
||||
const { mutate: createPlayer } = useCreatePlayer()
|
||||
const { mutate: updatePlayerMutation } = useUpdatePlayer()
|
||||
const { mutate: deletePlayer } = useDeletePlayer()
|
||||
const { roomData } = useRoomData()
|
||||
const { roomData, notifyRoomOfPlayerUpdate } = useRoomData()
|
||||
const { data: viewerId } = useViewerId()
|
||||
|
||||
const [isInitialized, setIsInitialized] = useState(false)
|
||||
@@ -167,14 +167,27 @@ export function GameModeProvider({ children }: { children: ReactNode }) {
|
||||
isActive: playerData?.isActive ?? false,
|
||||
}
|
||||
|
||||
createPlayer(newPlayer)
|
||||
createPlayer(newPlayer, {
|
||||
onSuccess: () => {
|
||||
// Notify room members if in a room
|
||||
notifyRoomOfPlayerUpdate()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const updatePlayer = (id: string, updates: Partial<Player>) => {
|
||||
const player = players.get(id)
|
||||
// Only allow updating local players
|
||||
if (player?.isLocal) {
|
||||
updatePlayerMutation({ id, updates })
|
||||
updatePlayerMutation(
|
||||
{ id, updates },
|
||||
{
|
||||
onSuccess: () => {
|
||||
// Notify room members if in a room
|
||||
notifyRoomOfPlayerUpdate()
|
||||
},
|
||||
}
|
||||
)
|
||||
} else {
|
||||
console.warn('[GameModeContext] Cannot update remote player:', id)
|
||||
}
|
||||
@@ -184,7 +197,12 @@ export function GameModeProvider({ children }: { children: ReactNode }) {
|
||||
const player = players.get(id)
|
||||
// Only allow removing local players
|
||||
if (player?.isLocal) {
|
||||
deletePlayer(id)
|
||||
deletePlayer(id, {
|
||||
onSuccess: () => {
|
||||
// Notify room members if in a room
|
||||
notifyRoomOfPlayerUpdate()
|
||||
},
|
||||
})
|
||||
} else {
|
||||
console.warn('[GameModeContext] Cannot remove remote player:', id)
|
||||
}
|
||||
@@ -194,7 +212,15 @@ export function GameModeProvider({ children }: { children: ReactNode }) {
|
||||
const player = players.get(id)
|
||||
// Only allow changing active status of local players
|
||||
if (player?.isLocal) {
|
||||
updatePlayerMutation({ id, updates: { isActive: active } })
|
||||
updatePlayerMutation(
|
||||
{ id, updates: { isActive: active } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
// Notify room members if in a room
|
||||
notifyRoomOfPlayerUpdate()
|
||||
},
|
||||
}
|
||||
)
|
||||
} else {
|
||||
console.warn('[GameModeContext] Cannot change active status of remote player:', id)
|
||||
}
|
||||
@@ -227,6 +253,11 @@ export function GameModeProvider({ children }: { children: ReactNode }) {
|
||||
isActive: index === 0,
|
||||
})
|
||||
})
|
||||
|
||||
// Notify room members after reset (slight delay to ensure mutations complete)
|
||||
setTimeout(() => {
|
||||
notifyRoomOfPlayerUpdate()
|
||||
}, 100)
|
||||
}
|
||||
|
||||
const activePlayerCount = activePlayers.size
|
||||
|
||||
@@ -65,9 +65,11 @@ describe('useArcadeGuard', () => {
|
||||
|
||||
it('should fetch active session on mount', async () => {
|
||||
const mockSession = {
|
||||
gameUrl: '/arcade/matching',
|
||||
currentGame: 'matching',
|
||||
gameState: {},
|
||||
session: {
|
||||
gameUrl: '/arcade/matching',
|
||||
currentGame: 'matching',
|
||||
gameState: {},
|
||||
},
|
||||
}
|
||||
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
@@ -91,9 +93,11 @@ describe('useArcadeGuard', () => {
|
||||
|
||||
it('should redirect to active session if on different page', async () => {
|
||||
const mockSession = {
|
||||
gameUrl: '/arcade/memory-quiz',
|
||||
currentGame: 'memory-quiz',
|
||||
gameState: {},
|
||||
session: {
|
||||
gameUrl: '/arcade/memory-quiz',
|
||||
currentGame: 'memory-quiz',
|
||||
gameState: {},
|
||||
},
|
||||
}
|
||||
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
@@ -112,9 +116,11 @@ describe('useArcadeGuard', () => {
|
||||
|
||||
it('should NOT redirect if already on active session page', async () => {
|
||||
const mockSession = {
|
||||
gameUrl: '/arcade/matching',
|
||||
currentGame: 'matching',
|
||||
gameState: {},
|
||||
session: {
|
||||
gameUrl: '/arcade/matching',
|
||||
currentGame: 'matching',
|
||||
gameState: {},
|
||||
},
|
||||
}
|
||||
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
@@ -152,9 +158,11 @@ describe('useArcadeGuard', () => {
|
||||
it('should call onRedirect callback when redirecting', async () => {
|
||||
const onRedirect = vi.fn()
|
||||
const mockSession = {
|
||||
gameUrl: '/arcade/memory-quiz',
|
||||
currentGame: 'memory-quiz',
|
||||
gameState: {},
|
||||
session: {
|
||||
gameUrl: '/arcade/memory-quiz',
|
||||
currentGame: 'memory-quiz',
|
||||
gameState: {},
|
||||
},
|
||||
}
|
||||
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
@@ -248,9 +256,11 @@ describe('useArcadeGuard', () => {
|
||||
})
|
||||
|
||||
const mockSession = {
|
||||
gameUrl: '/arcade/matching',
|
||||
currentGame: 'matching',
|
||||
gameState: {},
|
||||
session: {
|
||||
gameUrl: '/arcade/matching',
|
||||
currentGame: 'matching',
|
||||
gameState: {},
|
||||
},
|
||||
}
|
||||
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
@@ -285,4 +295,136 @@ describe('useArcadeGuard', () => {
|
||||
// Should not crash, just set loading to false
|
||||
expect(result.current.hasActiveSession).toBe(false)
|
||||
})
|
||||
|
||||
describe('enabled flag behavior', () => {
|
||||
it('should NOT redirect from HTTP check when enabled=false', async () => {
|
||||
const mockSession = {
|
||||
session: {
|
||||
gameUrl: '/arcade/memory-quiz',
|
||||
currentGame: 'memory-quiz',
|
||||
gameState: {},
|
||||
},
|
||||
}
|
||||
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockSession,
|
||||
})
|
||||
|
||||
vi.spyOn(nextNavigation, 'usePathname').mockReturnValue('/arcade-rooms')
|
||||
|
||||
renderHook(() => useArcadeGuard({ enabled: false }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Should NOT redirect
|
||||
expect(mockRouter.push).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should NOT redirect from WebSocket when enabled=false', async () => {
|
||||
let onSessionStateCallback: ((data: any) => void) | null = null
|
||||
|
||||
vi.spyOn(arcadeSocket, 'useArcadeSocket').mockImplementation((events) => {
|
||||
onSessionStateCallback = events?.onSessionState || null
|
||||
return mockUseArcadeSocket
|
||||
})
|
||||
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
})
|
||||
|
||||
vi.spyOn(nextNavigation, 'usePathname').mockReturnValue('/arcade-rooms')
|
||||
|
||||
const { result } = renderHook(() => useArcadeGuard({ enabled: false }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
|
||||
// Simulate session-state event from WebSocket
|
||||
onSessionStateCallback?.({
|
||||
gameUrl: '/arcade/room',
|
||||
currentGame: 'matching',
|
||||
gameState: {},
|
||||
activePlayers: [1],
|
||||
version: 1,
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
// Should track the session
|
||||
expect(result.current.hasActiveSession).toBe(true)
|
||||
expect(result.current.activeSession).toEqual({
|
||||
gameUrl: '/arcade/room',
|
||||
currentGame: 'matching',
|
||||
})
|
||||
})
|
||||
|
||||
// But should NOT redirect since enabled=false
|
||||
expect(mockRouter.push).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should STILL redirect from WebSocket when enabled=true', async () => {
|
||||
let onSessionStateCallback: ((data: any) => void) | null = null
|
||||
|
||||
vi.spyOn(arcadeSocket, 'useArcadeSocket').mockImplementation((events) => {
|
||||
onSessionStateCallback = events?.onSessionState || null
|
||||
return mockUseArcadeSocket
|
||||
})
|
||||
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
})
|
||||
|
||||
vi.spyOn(nextNavigation, 'usePathname').mockReturnValue('/arcade-rooms')
|
||||
|
||||
renderHook(() => useArcadeGuard({ enabled: true }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUseArcadeSocket.joinSession).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Simulate session-state event from WebSocket
|
||||
onSessionStateCallback?.({
|
||||
gameUrl: '/arcade/room',
|
||||
currentGame: 'matching',
|
||||
gameState: {},
|
||||
activePlayers: [1],
|
||||
version: 1,
|
||||
})
|
||||
|
||||
// Should redirect when enabled=true
|
||||
await waitFor(() => {
|
||||
expect(mockRouter.push).toHaveBeenCalledWith('/arcade/room')
|
||||
})
|
||||
})
|
||||
|
||||
it('should track session state even when enabled=false', async () => {
|
||||
const mockSession = {
|
||||
session: {
|
||||
gameUrl: '/arcade/room',
|
||||
currentGame: 'matching',
|
||||
gameState: {},
|
||||
},
|
||||
}
|
||||
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockSession,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useArcadeGuard({ enabled: false }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
|
||||
// Should still provide session info even without redirects
|
||||
expect(result.current.hasActiveSession).toBe(false) // No fetch happened
|
||||
expect(result.current.activeSession).toBe(null)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -73,11 +73,14 @@ export function useArcadeGuard(options: UseArcadeGuardOptions = {}): UseArcadeGu
|
||||
currentGame: data.currentGame,
|
||||
})
|
||||
|
||||
// Redirect if we're not already on the active game page
|
||||
if (pathname !== data.gameUrl) {
|
||||
// Redirect if we're not already on the active game page (only if enabled)
|
||||
const isAlreadyAtTarget = pathname === data.gameUrl
|
||||
if (enabled && !isAlreadyAtTarget) {
|
||||
console.log('[ArcadeGuard] Redirecting to active session:', data.gameUrl)
|
||||
onRedirect?.(data.gameUrl)
|
||||
router.push(data.gameUrl)
|
||||
} else if (isAlreadyAtTarget) {
|
||||
console.log('[ArcadeGuard] Already at target URL, no redirect needed')
|
||||
}
|
||||
},
|
||||
|
||||
@@ -126,11 +129,14 @@ export function useArcadeGuard(options: UseArcadeGuardOptions = {}): UseArcadeGu
|
||||
currentGame: session.currentGame,
|
||||
})
|
||||
|
||||
// Redirect if we're not already on the active game page
|
||||
if (pathname !== session.gameUrl) {
|
||||
// Redirect if we're not already on the active game page (only if enabled)
|
||||
const isAlreadyAtTarget = pathname === session.gameUrl
|
||||
if (enabled && !isAlreadyAtTarget) {
|
||||
console.log('[ArcadeGuard] Redirecting to active session:', session.gameUrl)
|
||||
onRedirect?.(session.gameUrl)
|
||||
router.push(session.gameUrl)
|
||||
} else if (isAlreadyAtTarget) {
|
||||
console.log('[ArcadeGuard] Already at target URL, no redirect needed')
|
||||
}
|
||||
} else if (response.status === 404) {
|
||||
// No active session
|
||||
|
||||
@@ -71,10 +71,13 @@ export function useArcadeRedirect(options: UseArcadeRedirectOptions = {}): UseAr
|
||||
// Determine if we need to redirect
|
||||
const isArcadeLobby = currentGame === null || currentGame === undefined
|
||||
const isWrongGame = currentGame && currentGame !== data.currentGame
|
||||
const isAlreadyAtTarget = _pathname === data.gameUrl
|
||||
|
||||
if (isArcadeLobby || isWrongGame) {
|
||||
if ((isArcadeLobby || isWrongGame) && !isAlreadyAtTarget) {
|
||||
console.log('[ArcadeRedirect] Redirecting to active game:', data.gameUrl)
|
||||
router.push(data.gameUrl)
|
||||
} else if (isAlreadyAtTarget) {
|
||||
console.log('[ArcadeRedirect] Already at target URL, no redirect needed')
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -12,6 +12,12 @@ export interface UseArcadeSessionOptions<TState> extends UseOptimisticGameStateO
|
||||
*/
|
||||
userId: string
|
||||
|
||||
/**
|
||||
* Room ID for multi-user sync (optional)
|
||||
* If provided, game state will sync across all users in the room
|
||||
*/
|
||||
roomId?: string
|
||||
|
||||
/**
|
||||
* Auto-join session on mount
|
||||
* @default true
|
||||
@@ -42,8 +48,9 @@ export interface UseArcadeSessionReturn<TState> {
|
||||
|
||||
/**
|
||||
* Send a game move (applies optimistically and sends to server)
|
||||
* Note: playerId must be provided by caller (not omitted)
|
||||
*/
|
||||
sendMove: (move: Omit<GameMove, 'playerId' | 'timestamp'>) => void
|
||||
sendMove: (move: Omit<GameMove, 'timestamp'>) => void
|
||||
|
||||
/**
|
||||
* Exit the arcade session
|
||||
@@ -76,7 +83,7 @@ export interface UseArcadeSessionReturn<TState> {
|
||||
export function useArcadeSession<TState>(
|
||||
options: UseArcadeSessionOptions<TState>
|
||||
): UseArcadeSessionReturn<TState> {
|
||||
const { userId, autoJoin = true, ...optimisticOptions } = options
|
||||
const { userId, roomId, autoJoin = true, ...optimisticOptions } = options
|
||||
|
||||
// Optimistic state management
|
||||
const optimistic = useOptimisticGameState<TState>(optimisticOptions)
|
||||
@@ -122,9 +129,9 @@ export function useArcadeSession<TState>(
|
||||
// Auto-join session when connected
|
||||
useEffect(() => {
|
||||
if (connected && autoJoin && userId) {
|
||||
joinSession(userId)
|
||||
joinSession(userId, roomId)
|
||||
}
|
||||
}, [connected, autoJoin, userId, joinSession])
|
||||
}, [connected, autoJoin, userId, roomId, joinSession])
|
||||
|
||||
// Send move with optimistic update
|
||||
const sendMove = useCallback(
|
||||
@@ -143,10 +150,10 @@ export function useArcadeSession<TState>(
|
||||
// Apply optimistically
|
||||
optimistic.applyOptimisticMove(fullMove)
|
||||
|
||||
// Send to server
|
||||
socketSendMove(userId, fullMove)
|
||||
// Send to server with roomId for room-based games
|
||||
socketSendMove(userId, fullMove, roomId)
|
||||
},
|
||||
[userId, optimistic, socketSendMove]
|
||||
[userId, roomId, optimistic, socketSendMove]
|
||||
)
|
||||
|
||||
const exitSession = useCallback(() => {
|
||||
@@ -156,9 +163,9 @@ export function useArcadeSession<TState>(
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
if (connected && userId) {
|
||||
joinSession(userId)
|
||||
joinSession(userId, roomId)
|
||||
}
|
||||
}, [connected, userId, joinSession])
|
||||
}, [connected, userId, roomId, joinSession])
|
||||
|
||||
return {
|
||||
state: optimistic.state,
|
||||
|
||||
@@ -20,8 +20,8 @@ export interface ArcadeSocketEvents {
|
||||
export interface UseArcadeSocketReturn {
|
||||
socket: Socket | null
|
||||
connected: boolean
|
||||
joinSession: (userId: string) => void
|
||||
sendMove: (userId: string, move: GameMove) => void
|
||||
joinSession: (userId: string, roomId?: string) => void
|
||||
sendMove: (userId: string, move: GameMove, roomId?: string) => void
|
||||
exitSession: (userId: string) => void
|
||||
pingSession: (userId: string) => void
|
||||
}
|
||||
@@ -103,24 +103,28 @@ export function useArcadeSocket(events: ArcadeSocketEvents = {}): UseArcadeSocke
|
||||
}, [])
|
||||
|
||||
const joinSession = useCallback(
|
||||
(userId: string) => {
|
||||
(userId: string, roomId?: string) => {
|
||||
if (!socket) {
|
||||
console.warn('[ArcadeSocket] Cannot join session - socket not connected')
|
||||
return
|
||||
}
|
||||
console.log('[ArcadeSocket] Joining session for user:', userId)
|
||||
socket.emit('join-arcade-session', { userId })
|
||||
console.log(
|
||||
'[ArcadeSocket] Joining session for user:',
|
||||
userId,
|
||||
roomId ? `in room ${roomId}` : '(solo)'
|
||||
)
|
||||
socket.emit('join-arcade-session', { userId, roomId })
|
||||
},
|
||||
[socket]
|
||||
)
|
||||
|
||||
const sendMove = useCallback(
|
||||
(userId: string, move: GameMove) => {
|
||||
(userId: string, move: GameMove, roomId?: string) => {
|
||||
if (!socket) {
|
||||
console.warn('[ArcadeSocket] Cannot send move - socket not connected')
|
||||
return
|
||||
}
|
||||
const payload = { userId, move }
|
||||
const payload = { userId, move, roomId }
|
||||
console.log(
|
||||
'[ArcadeSocket] Sending game-move event with payload:',
|
||||
JSON.stringify(payload, null, 2)
|
||||
|
||||
@@ -40,25 +40,21 @@ export function useRoomData() {
|
||||
// Fetch the user's current room
|
||||
useEffect(() => {
|
||||
if (!userId) {
|
||||
console.log('[useRoomData] No userId, clearing room data')
|
||||
setRoomData(null)
|
||||
setHasAttemptedFetch(false)
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[useRoomData] Fetching current room for user:', userId)
|
||||
setIsLoading(true)
|
||||
setHasAttemptedFetch(false)
|
||||
|
||||
// Fetch current room data
|
||||
fetch('/api/arcade/rooms/current')
|
||||
.then((res) => {
|
||||
console.log('[useRoomData] API response status:', res.status)
|
||||
if (!res.ok) throw new Error('Failed to fetch current room')
|
||||
return res.json()
|
||||
})
|
||||
.then((data) => {
|
||||
console.log('[useRoomData] API response data:', data)
|
||||
if (data.room) {
|
||||
const roomData = {
|
||||
id: data.room.id,
|
||||
@@ -68,10 +64,8 @@ export function useRoomData() {
|
||||
members: data.members || [],
|
||||
memberPlayers: data.memberPlayers || {},
|
||||
}
|
||||
console.log('[useRoomData] Setting room data:', roomData)
|
||||
setRoomData(roomData)
|
||||
} else {
|
||||
console.log('[useRoomData] No room in response, clearing room data')
|
||||
setRoomData(null)
|
||||
}
|
||||
setIsLoading(false)
|
||||
@@ -98,13 +92,12 @@ export function useRoomData() {
|
||||
const sock = io({ path: '/api/socket' })
|
||||
|
||||
sock.on('connect', () => {
|
||||
console.log('[useRoomData] Socket connected, joining room:', roomData.id)
|
||||
// Join the room to receive updates
|
||||
sock.emit('join-room', { roomId: roomData.id, userId })
|
||||
})
|
||||
|
||||
sock.on('disconnect', () => {
|
||||
console.log('[useRoomData] Socket disconnected')
|
||||
// Socket disconnected
|
||||
})
|
||||
|
||||
setSocket(sock)
|
||||
@@ -127,7 +120,6 @@ export function useRoomData() {
|
||||
members: RoomMember[]
|
||||
memberPlayers: Record<string, RoomPlayer[]>
|
||||
}) => {
|
||||
console.log('[useRoomData] Received room-joined event:', data)
|
||||
if (data.roomId === roomData.id) {
|
||||
setRoomData((prev) => {
|
||||
if (!prev) return null
|
||||
@@ -146,7 +138,6 @@ export function useRoomData() {
|
||||
members: RoomMember[]
|
||||
memberPlayers: Record<string, RoomPlayer[]>
|
||||
}) => {
|
||||
console.log('[useRoomData] Received member-joined event:', data)
|
||||
if (data.roomId === roomData.id) {
|
||||
setRoomData((prev) => {
|
||||
if (!prev) return null
|
||||
@@ -165,7 +156,6 @@ export function useRoomData() {
|
||||
members: RoomMember[]
|
||||
memberPlayers: Record<string, RoomPlayer[]>
|
||||
}) => {
|
||||
console.log('[useRoomData] Received member-left event:', data)
|
||||
if (data.roomId === roomData.id) {
|
||||
setRoomData((prev) => {
|
||||
if (!prev) return null
|
||||
@@ -182,7 +172,6 @@ export function useRoomData() {
|
||||
roomId: string
|
||||
memberPlayers: Record<string, RoomPlayer[]>
|
||||
}) => {
|
||||
console.log('[useRoomData] Received room-players-updated event:', data)
|
||||
if (data.roomId === roomData.id) {
|
||||
setRoomData((prev) => {
|
||||
if (!prev) return null
|
||||
@@ -207,10 +196,19 @@ export function useRoomData() {
|
||||
}
|
||||
}, [socket, roomData?.id])
|
||||
|
||||
// Function to notify room members of player updates
|
||||
const notifyRoomOfPlayerUpdate = () => {
|
||||
if (socket && roomData?.id && userId) {
|
||||
console.log('[useRoomData] Notifying room of player update')
|
||||
socket.emit('players-updated', { roomId: roomData.id, userId })
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
roomData,
|
||||
// Loading if: userId is pending, currently fetching, or have userId but haven't tried fetching yet
|
||||
isLoading: isUserIdPending || isLoading || (!!userId && !hasAttemptedFetch),
|
||||
isInRoom: !!roomData,
|
||||
notifyRoomOfPlayerUpdate,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,29 @@ import { db, schema } from '@/db'
|
||||
import type { Player } from '@/db/schema/players'
|
||||
|
||||
/**
|
||||
* Get a user's active players
|
||||
* These are the players that will participate when the user joins a game
|
||||
* Get all players for a user (regardless of isActive status)
|
||||
* @param viewerId - The guestId from the cookie (same as what getViewerId() returns)
|
||||
*/
|
||||
export async function getAllPlayers(viewerId: string): Promise<Player[]> {
|
||||
// First get the user record by guestId
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(schema.users.guestId, viewerId),
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Now query all players by the actual user.id (no isActive filter)
|
||||
return await db.query.players.findMany({
|
||||
where: eq(schema.players.userId, user.id),
|
||||
orderBy: schema.players.createdAt,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user's active players (solo mode)
|
||||
* These are the players that will participate when the user joins a solo game
|
||||
* @param viewerId - The guestId from the cookie (same as what getViewerId() returns)
|
||||
*/
|
||||
export async function getActivePlayers(viewerId: string): Promise<Player[]> {
|
||||
@@ -30,7 +51,8 @@ export async function getActivePlayers(viewerId: string): Promise<Player[]> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active players for all members in a room
|
||||
* Get active players for all members in a room
|
||||
* Returns only players marked isActive=true from each room member
|
||||
* Returns a map of userId -> Player[]
|
||||
*/
|
||||
export async function getRoomActivePlayers(roomId: string): Promise<Map<string, Player[]>> {
|
||||
@@ -39,7 +61,7 @@ export async function getRoomActivePlayers(roomId: string): Promise<Map<string,
|
||||
where: eq(schema.roomMembers.roomId, roomId),
|
||||
})
|
||||
|
||||
// Fetch active players for each member
|
||||
// Fetch active players for each member (respects isActive flag)
|
||||
const playerMap = new Map<string, Player[]>()
|
||||
for (const member of members) {
|
||||
const players = await getActivePlayers(member.userId)
|
||||
|
||||
@@ -37,8 +37,35 @@ async function getUserIdFromGuestId(guestId: string): Promise<string | undefined
|
||||
return user?.id
|
||||
}
|
||||
|
||||
/**
|
||||
* Get arcade session by room ID (for room-based multiplayer games)
|
||||
* Returns the shared session for all room members
|
||||
* @param roomId - The room ID
|
||||
*/
|
||||
export async function getArcadeSessionByRoom(
|
||||
roomId: string
|
||||
): Promise<schema.ArcadeSession | undefined> {
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(schema.arcadeSessions)
|
||||
.where(eq(schema.arcadeSessions.roomId, roomId))
|
||||
.limit(1)
|
||||
|
||||
if (!session) return undefined
|
||||
|
||||
// Check if session has expired
|
||||
if (session.expiresAt < new Date()) {
|
||||
// Clean up expired room session
|
||||
await db.delete(schema.arcadeSessions).where(eq(schema.arcadeSessions.roomId, roomId))
|
||||
return undefined
|
||||
}
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new arcade session
|
||||
* For room-based games, checks if a session already exists for the room
|
||||
*/
|
||||
export async function createArcadeSession(
|
||||
options: CreateSessionOptions
|
||||
@@ -46,6 +73,19 @@ export async function createArcadeSession(
|
||||
const now = new Date()
|
||||
const expiresAt = new Date(now.getTime() + TTL_HOURS * 60 * 60 * 1000)
|
||||
|
||||
// For room-based games, check if session already exists for this room
|
||||
if (options.roomId) {
|
||||
const existingRoomSession = await getArcadeSessionByRoom(options.roomId)
|
||||
if (existingRoomSession) {
|
||||
console.log('[Session Manager] Room session already exists, returning existing:', {
|
||||
roomId: options.roomId,
|
||||
sessionUserId: existingRoomSession.userId,
|
||||
version: existingRoomSession.version,
|
||||
})
|
||||
return existingRoomSession
|
||||
}
|
||||
}
|
||||
|
||||
// Find or create user by guest ID
|
||||
let user = await db.query.users.findFirst({
|
||||
where: eq(schema.users.guestId, options.userId),
|
||||
@@ -80,6 +120,12 @@ export async function createArcadeSession(
|
||||
version: 1,
|
||||
}
|
||||
|
||||
console.log('[Session Manager] Creating new session:', {
|
||||
userId: user.id,
|
||||
roomId: options.roomId,
|
||||
gameName: options.gameName,
|
||||
})
|
||||
|
||||
const [session] = await db.insert(schema.arcadeSessions).values(newSession).returning()
|
||||
return session
|
||||
}
|
||||
@@ -130,9 +176,18 @@ export async function getArcadeSession(guestId: string): Promise<schema.ArcadeSe
|
||||
|
||||
/**
|
||||
* Apply a game move to the session (with validation)
|
||||
* @param userId - The guest ID from the cookie
|
||||
* @param move - The game move to apply
|
||||
* @param roomId - Optional room ID for room-based games (enables shared session)
|
||||
*/
|
||||
export async function applyGameMove(userId: string, move: GameMove): Promise<SessionUpdateResult> {
|
||||
const session = await getArcadeSession(userId)
|
||||
export async function applyGameMove(
|
||||
userId: string,
|
||||
move: GameMove,
|
||||
roomId?: string
|
||||
): Promise<SessionUpdateResult> {
|
||||
// For room-based games, look up the shared room session
|
||||
// For solo games, look up the user's personal session
|
||||
const session = roomId ? await getArcadeSessionByRoom(roomId) : await getArcadeSession(userId)
|
||||
|
||||
if (!session) {
|
||||
return {
|
||||
@@ -159,8 +214,40 @@ export async function applyGameMove(userId: string, move: GameMove): Promise<Ses
|
||||
gameStatePhase: (session.gameState as any)?.gamePhase,
|
||||
})
|
||||
|
||||
// Validate the move
|
||||
const validationResult = validator.validateMove(session.gameState, move)
|
||||
// Fetch player ownership for authorization checks (room-based games)
|
||||
let playerOwnership: Record<string, string> | undefined
|
||||
let internalUserId: string | undefined
|
||||
if (session.roomId) {
|
||||
try {
|
||||
// Convert guestId to internal userId for ownership comparison
|
||||
internalUserId = await getUserIdFromGuestId(userId)
|
||||
if (!internalUserId) {
|
||||
console.error('[SessionManager] Failed to convert guestId to userId:', userId)
|
||||
return {
|
||||
success: false,
|
||||
error: 'User not found',
|
||||
}
|
||||
}
|
||||
|
||||
const players = await db.query.players.findMany({
|
||||
columns: {
|
||||
id: true,
|
||||
userId: true,
|
||||
},
|
||||
})
|
||||
playerOwnership = Object.fromEntries(players.map((p) => [p.id, p.userId]))
|
||||
console.log('[SessionManager] Player ownership map:', playerOwnership)
|
||||
console.log('[SessionManager] Internal userId for authorization:', internalUserId)
|
||||
} catch (error) {
|
||||
console.error('[SessionManager] Failed to fetch player ownership:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate the move with authorization context (use internal userId, not guestId)
|
||||
const validationResult = validator.validateMove(session.gameState, move, {
|
||||
userId: internalUserId || userId, // Use internal userId for room-based games
|
||||
playerOwnership,
|
||||
})
|
||||
|
||||
console.log('[SessionManager] Validation result:', {
|
||||
valid: validationResult.valid,
|
||||
|
||||
@@ -15,17 +15,38 @@ import { canFlipCard, validateMatch } from '@/app/games/matching/utils/matchVali
|
||||
import type { GameValidator, MatchingGameMove, ValidationResult } from './types'
|
||||
|
||||
export class MatchingGameValidator implements GameValidator<MemoryPairsState, MatchingGameMove> {
|
||||
validateMove(state: MemoryPairsState, move: MatchingGameMove): ValidationResult {
|
||||
validateMove(
|
||||
state: MemoryPairsState,
|
||||
move: MatchingGameMove,
|
||||
context?: { userId?: string; playerOwnership?: Record<string, string> }
|
||||
): ValidationResult {
|
||||
switch (move.type) {
|
||||
case 'FLIP_CARD':
|
||||
return this.validateFlipCard(state, move.data.cardId, move.playerId)
|
||||
return this.validateFlipCard(state, move.data.cardId, move.playerId, context)
|
||||
|
||||
case 'START_GAME':
|
||||
return this.validateStartGame(state, move.data.activePlayers, move.data.cards)
|
||||
return this.validateStartGame(
|
||||
state,
|
||||
move.data.activePlayers,
|
||||
move.data.cards,
|
||||
move.data.playerMetadata
|
||||
)
|
||||
|
||||
case 'CLEAR_MISMATCH':
|
||||
return this.validateClearMismatch(state)
|
||||
|
||||
case 'GO_TO_SETUP':
|
||||
return this.validateGoToSetup(state)
|
||||
|
||||
case 'SET_CONFIG':
|
||||
return this.validateSetConfig(state, move.data.field, move.data.value)
|
||||
|
||||
case 'RESUME_GAME':
|
||||
return this.validateResumeGame(state)
|
||||
|
||||
case 'HOVER_CARD':
|
||||
return this.validateHoverCard(state, move.data.cardId, move.playerId)
|
||||
|
||||
default:
|
||||
return {
|
||||
valid: false,
|
||||
@@ -37,7 +58,8 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
|
||||
private validateFlipCard(
|
||||
state: MemoryPairsState,
|
||||
cardId: string,
|
||||
playerId: string
|
||||
playerId: string,
|
||||
context?: { userId?: string; playerOwnership?: Record<string, string> }
|
||||
): ValidationResult {
|
||||
// Game must be in playing phase
|
||||
if (state.gamePhase !== 'playing') {
|
||||
@@ -63,6 +85,22 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
|
||||
}
|
||||
}
|
||||
|
||||
// Check player ownership authorization (if context provided)
|
||||
if (context?.userId && context?.playerOwnership) {
|
||||
const playerOwner = context.playerOwnership[playerId]
|
||||
if (playerOwner && playerOwner !== context.userId) {
|
||||
console.log('[Validator] Player ownership check failed:', {
|
||||
playerId,
|
||||
playerOwner,
|
||||
requestingUserId: context.userId,
|
||||
})
|
||||
return {
|
||||
valid: false,
|
||||
error: 'You can only move your own players',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find the card
|
||||
const card = state.gameCards.find((c) => c.id === cardId)
|
||||
if (!card) {
|
||||
@@ -162,7 +200,8 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
|
||||
private validateStartGame(
|
||||
state: MemoryPairsState,
|
||||
activePlayers: Player[],
|
||||
cards?: GameCard[]
|
||||
cards?: GameCard[],
|
||||
playerMetadata?: { [playerId: string]: any }
|
||||
): ValidationResult {
|
||||
// Allow starting a new game from any phase (for "New Game" button)
|
||||
|
||||
@@ -182,6 +221,7 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
|
||||
gameCards,
|
||||
cards: gameCards,
|
||||
activePlayers,
|
||||
playerMetadata: playerMetadata || {}, // Store player metadata for cross-user visibility
|
||||
gamePhase: 'playing',
|
||||
gameStartTime: Date.now(),
|
||||
currentPlayer: activePlayers[0],
|
||||
@@ -190,6 +230,15 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
|
||||
moves: 0,
|
||||
scores: activePlayers.reduce((acc, p) => ({ ...acc, [p]: 0 }), {}),
|
||||
consecutiveMatches: activePlayers.reduce((acc, p) => ({ ...acc, [p]: 0 }), {}),
|
||||
// PAUSE/RESUME: Save original config so we can detect changes
|
||||
originalConfig: {
|
||||
gameType: state.gameType,
|
||||
difficulty: state.difficulty,
|
||||
turnTimer: state.turnTimer,
|
||||
},
|
||||
// Clear any paused game state (starting fresh)
|
||||
pausedGamePhase: undefined,
|
||||
pausedGameState: undefined,
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -199,6 +248,16 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
|
||||
}
|
||||
|
||||
private validateClearMismatch(state: MemoryPairsState): ValidationResult {
|
||||
// Only clear if there's actually a mismatch showing
|
||||
// This prevents race conditions where CLEAR_MISMATCH arrives after cards have already been cleared
|
||||
if (!state.showMismatchFeedback || state.flippedCards.length === 0) {
|
||||
// Nothing to clear - return current state unchanged
|
||||
return {
|
||||
valid: true,
|
||||
newState: state,
|
||||
}
|
||||
}
|
||||
|
||||
// Clear mismatched cards and feedback
|
||||
return {
|
||||
valid: true,
|
||||
@@ -211,6 +270,243 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* STANDARD ARCADE PATTERN: GO_TO_SETUP
|
||||
*
|
||||
* Transitions the game back to setup phase, allowing players to reconfigure
|
||||
* the game. This is synchronized across all room members.
|
||||
*
|
||||
* Can be called from any phase (setup, playing, results).
|
||||
*
|
||||
* PAUSE/RESUME: If called from 'playing' or 'results', saves game state
|
||||
* to allow resuming later (if config unchanged).
|
||||
*
|
||||
* Pattern for all arcade games:
|
||||
* - Validates the move is allowed
|
||||
* - Sets gamePhase to 'setup'
|
||||
* - Preserves current configuration (gameType, difficulty, etc.)
|
||||
* - Saves game state for resume if coming from active game
|
||||
* - Resets game progression state (scores, cards, etc.)
|
||||
*/
|
||||
private validateGoToSetup(state: MemoryPairsState): ValidationResult {
|
||||
// Determine if we're pausing an active game (for Resume functionality)
|
||||
const isPausingGame = state.gamePhase === 'playing' || state.gamePhase === 'results'
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
newState: {
|
||||
...state,
|
||||
gamePhase: 'setup',
|
||||
|
||||
// Pause/Resume: Save game state if pausing from active game
|
||||
pausedGamePhase: isPausingGame ? state.gamePhase : undefined,
|
||||
pausedGameState: isPausingGame
|
||||
? {
|
||||
gameCards: state.gameCards,
|
||||
currentPlayer: state.currentPlayer,
|
||||
matchedPairs: state.matchedPairs,
|
||||
moves: state.moves,
|
||||
scores: state.scores,
|
||||
activePlayers: state.activePlayers,
|
||||
playerMetadata: state.playerMetadata,
|
||||
consecutiveMatches: state.consecutiveMatches,
|
||||
gameStartTime: state.gameStartTime,
|
||||
}
|
||||
: undefined,
|
||||
|
||||
// Keep originalConfig if it exists (was set when game started)
|
||||
// This allows detecting if config changed while paused
|
||||
|
||||
// Reset visible game progression
|
||||
gameCards: [],
|
||||
cards: [],
|
||||
flippedCards: [],
|
||||
currentPlayer: '',
|
||||
matchedPairs: 0,
|
||||
moves: 0,
|
||||
scores: {},
|
||||
activePlayers: [],
|
||||
playerMetadata: {},
|
||||
consecutiveMatches: {},
|
||||
gameStartTime: null,
|
||||
gameEndTime: null,
|
||||
currentMoveStartTime: null,
|
||||
celebrationAnimations: [],
|
||||
isProcessingMove: false,
|
||||
showMismatchFeedback: false,
|
||||
lastMatchedPair: null,
|
||||
// Preserve configuration - players can modify in setup
|
||||
// gameType, difficulty, turnTimer stay as-is
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* STANDARD ARCADE PATTERN: SET_CONFIG
|
||||
*
|
||||
* Updates a configuration field during setup phase. This is synchronized
|
||||
* across all room members in real-time, allowing collaborative setup.
|
||||
*
|
||||
* Pattern for all arcade games:
|
||||
* - Only allowed during setup phase
|
||||
* - Validates field name and value
|
||||
* - Updates the configuration field
|
||||
* - Other room members see the change immediately (optimistic + server validation)
|
||||
*
|
||||
* @param state Current game state
|
||||
* @param field Configuration field name
|
||||
* @param value New value for the field
|
||||
*/
|
||||
private validateSetConfig(
|
||||
state: MemoryPairsState,
|
||||
field: 'gameType' | 'difficulty' | 'turnTimer',
|
||||
value: any
|
||||
): ValidationResult {
|
||||
// Can only change config during setup phase
|
||||
if (state.gamePhase !== 'setup') {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Cannot change configuration outside of setup phase',
|
||||
}
|
||||
}
|
||||
|
||||
// Validate field-specific values
|
||||
switch (field) {
|
||||
case 'gameType':
|
||||
if (value !== 'abacus-numeral' && value !== 'complement-pairs') {
|
||||
return { valid: false, error: `Invalid gameType: ${value}` }
|
||||
}
|
||||
break
|
||||
|
||||
case 'difficulty':
|
||||
if (![6, 8, 12, 15].includes(value)) {
|
||||
return { valid: false, error: `Invalid difficulty: ${value}` }
|
||||
}
|
||||
break
|
||||
|
||||
case 'turnTimer':
|
||||
if (typeof value !== 'number' || value < 5 || value > 300) {
|
||||
return { valid: false, error: `Invalid turnTimer: ${value}` }
|
||||
}
|
||||
break
|
||||
|
||||
default:
|
||||
return { valid: false, error: `Unknown config field: ${field}` }
|
||||
}
|
||||
|
||||
// PAUSE/RESUME: If there's a paused game and config is changing,
|
||||
// clear the paused game state (can't resume anymore)
|
||||
const clearPausedGame = !!state.pausedGamePhase
|
||||
|
||||
// Apply the configuration change
|
||||
return {
|
||||
valid: true,
|
||||
newState: {
|
||||
...state,
|
||||
[field]: value,
|
||||
// Update totalPairs if difficulty changes
|
||||
...(field === 'difficulty' ? { totalPairs: value } : {}),
|
||||
// Clear paused game if config changed
|
||||
...(clearPausedGame
|
||||
? { pausedGamePhase: undefined, pausedGameState: undefined, originalConfig: undefined }
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* STANDARD ARCADE PATTERN: RESUME_GAME
|
||||
*
|
||||
* Resumes a paused game if configuration hasn't changed.
|
||||
* Restores the saved game state from when GO_TO_SETUP was called.
|
||||
*
|
||||
* Pattern for all arcade games:
|
||||
* - Validates there's a paused game
|
||||
* - Validates config hasn't changed since pause
|
||||
* - Restores game state and phase
|
||||
* - Clears paused game state
|
||||
*/
|
||||
private validateResumeGame(state: MemoryPairsState): ValidationResult {
|
||||
// Must be in setup phase
|
||||
if (state.gamePhase !== 'setup') {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Can only resume from setup phase',
|
||||
}
|
||||
}
|
||||
|
||||
// Must have a paused game
|
||||
if (!state.pausedGamePhase || !state.pausedGameState) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'No paused game to resume',
|
||||
}
|
||||
}
|
||||
|
||||
// Config must match original (no changes while paused)
|
||||
if (state.originalConfig) {
|
||||
const configChanged =
|
||||
state.gameType !== state.originalConfig.gameType ||
|
||||
state.difficulty !== state.originalConfig.difficulty ||
|
||||
state.turnTimer !== state.originalConfig.turnTimer
|
||||
|
||||
if (configChanged) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Cannot resume - configuration has changed',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Restore the paused game
|
||||
return {
|
||||
valid: true,
|
||||
newState: {
|
||||
...state,
|
||||
gamePhase: state.pausedGamePhase,
|
||||
gameCards: state.pausedGameState.gameCards,
|
||||
cards: state.pausedGameState.gameCards,
|
||||
currentPlayer: state.pausedGameState.currentPlayer,
|
||||
matchedPairs: state.pausedGameState.matchedPairs,
|
||||
moves: state.pausedGameState.moves,
|
||||
scores: state.pausedGameState.scores,
|
||||
activePlayers: state.pausedGameState.activePlayers,
|
||||
playerMetadata: state.pausedGameState.playerMetadata,
|
||||
consecutiveMatches: state.pausedGameState.consecutiveMatches,
|
||||
gameStartTime: state.pausedGameState.gameStartTime,
|
||||
// Clear paused state
|
||||
pausedGamePhase: undefined,
|
||||
pausedGameState: undefined,
|
||||
// Keep originalConfig for potential future pauses
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate hover state update for networked presence
|
||||
*
|
||||
* Hover moves are lightweight and always valid - they just update
|
||||
* which card a player is hovering over for UI feedback to other players.
|
||||
*/
|
||||
private validateHoverCard(
|
||||
state: MemoryPairsState,
|
||||
cardId: string | null,
|
||||
playerId: string
|
||||
): ValidationResult {
|
||||
// Hover is always valid - it's just UI state for networked presence
|
||||
// Update the player's hover state
|
||||
return {
|
||||
valid: true,
|
||||
newState: {
|
||||
...state,
|
||||
playerHovers: {
|
||||
...state.playerHovers,
|
||||
[playerId]: cardId,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
isGameComplete(state: MemoryPairsState): boolean {
|
||||
return state.gamePhase === 'results' || state.matchedPairs === state.totalPairs
|
||||
}
|
||||
@@ -234,6 +530,7 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
|
||||
moves: 0,
|
||||
scores: {},
|
||||
activePlayers: [],
|
||||
playerMetadata: {}, // Initialize empty player metadata
|
||||
consecutiveMatches: {},
|
||||
gameStartTime: null,
|
||||
gameEndTime: null,
|
||||
@@ -243,6 +540,12 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
|
||||
isProcessingMove: false,
|
||||
showMismatchFeedback: false,
|
||||
lastMatchedPair: null,
|
||||
// PAUSE/RESUME: Initialize paused game fields
|
||||
originalConfig: undefined,
|
||||
pausedGamePhase: undefined,
|
||||
pausedGameState: undefined,
|
||||
// HOVER: Initialize hover state
|
||||
playerHovers: {},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface MatchingStartGameMove extends GameMove {
|
||||
data: {
|
||||
activePlayers: string[] // Player IDs (UUIDs)
|
||||
cards?: any[] // GameCard type from context
|
||||
playerMetadata?: { [playerId: string]: any } // Player metadata for cross-user visibility
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,22 +42,63 @@ export interface MatchingClearMismatchMove extends GameMove {
|
||||
data: Record<string, never>
|
||||
}
|
||||
|
||||
// Standard setup moves - pattern for all arcade games
|
||||
export interface MatchingGoToSetupMove extends GameMove {
|
||||
type: 'GO_TO_SETUP'
|
||||
data: Record<string, never>
|
||||
}
|
||||
|
||||
export interface MatchingSetConfigMove extends GameMove {
|
||||
type: 'SET_CONFIG'
|
||||
data: {
|
||||
field: 'gameType' | 'difficulty' | 'turnTimer'
|
||||
value: any
|
||||
}
|
||||
}
|
||||
|
||||
export interface MatchingResumeGameMove extends GameMove {
|
||||
type: 'RESUME_GAME'
|
||||
data: Record<string, never>
|
||||
}
|
||||
|
||||
export interface MatchingHoverCardMove extends GameMove {
|
||||
type: 'HOVER_CARD'
|
||||
data: {
|
||||
cardId: string | null // null when mouse leaves card
|
||||
}
|
||||
}
|
||||
|
||||
export type MatchingGameMove =
|
||||
| MatchingFlipCardMove
|
||||
| MatchingStartGameMove
|
||||
| MatchingClearMismatchMove
|
||||
| MatchingGoToSetupMove
|
||||
| MatchingSetConfigMove
|
||||
| MatchingResumeGameMove
|
||||
| MatchingHoverCardMove
|
||||
|
||||
// Generic game state union
|
||||
export type GameState = MemoryPairsState // Add other game states as union later
|
||||
|
||||
/**
|
||||
* Validation context for authorization checks
|
||||
*/
|
||||
export interface ValidationContext {
|
||||
userId?: string
|
||||
playerOwnership?: Record<string, string> // playerId -> userId mapping
|
||||
}
|
||||
|
||||
/**
|
||||
* Base validator interface that all games must implement
|
||||
*/
|
||||
export interface GameValidator<TState = unknown, TMove extends GameMove = GameMove> {
|
||||
/**
|
||||
* Validate a game move and return the new state if valid
|
||||
* @param state Current game state
|
||||
* @param move The move to validate
|
||||
* @param context Optional validation context for authorization checks
|
||||
*/
|
||||
validateMove(state: TState, move: TMove): ValidationResult
|
||||
validateMove(state: TState, move: TMove, context?: ValidationContext): ValidationResult
|
||||
|
||||
/**
|
||||
* Check if the game is in a terminal state (completed)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "soroban-monorepo",
|
||||
"version": "2.7.3",
|
||||
"version": "2.12.0",
|
||||
"private": true,
|
||||
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
|
||||
"workspaces": [
|
||||
|
||||
Reference in New Issue
Block a user