Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc9eb253ad | ||
|
|
d474ef07d6 | ||
|
|
3cdc0695f4 | ||
|
|
10cf71527f | ||
|
|
678f4423b6 | ||
|
|
c5268b79de | ||
|
|
d9aadd1f81 | ||
|
|
4686f59d24 | ||
|
|
1219539585 | ||
|
|
87cc0b64fb | ||
|
|
c640a79a44 | ||
|
|
0d85331652 | ||
|
|
28a2e7d651 | ||
|
|
a27c36193e | ||
|
|
cc80a1454b | ||
|
|
8175c43533 | ||
|
|
b49630f3cb | ||
|
|
c4d8032d02 | ||
|
|
01ff114258 | ||
|
|
d173a178bc | ||
|
|
431668729c | ||
|
|
b5d0bee120 | ||
|
|
9dac431c1f | ||
|
|
5bbb212da9 | ||
|
|
c30f585810 | ||
|
|
0a768c65fb | ||
|
|
5ed2ab21ca |
87
CHANGELOG.md
87
CHANGELOG.md
@@ -1,3 +1,90 @@
|
||||
## [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)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* set room sessions to use /arcade/room URL ([9dac431](https://github.com/antialias/soroban-abacus-flashcards/commit/9dac431c1f91c246f67a059cda3cff6cbef40a43))
|
||||
|
||||
## [2.7.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.7.1...v2.7.2) (2025-10-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add hasAttemptedFetch flag to prevent premature redirect ([c30f585](https://github.com/antialias/soroban-abacus-flashcards/commit/c30f58581028878350282cad5231d614590d9f2b))
|
||||
|
||||
## [2.7.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.7.0...v2.7.1) (2025-10-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* resolve race condition in /arcade/room redirect ([5ed2ab2](https://github.com/antialias/soroban-abacus-flashcards/commit/5ed2ab21cab408147081a493c8dd6b1de48b2d01))
|
||||
|
||||
## [2.7.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.6.0...v2.7.0) (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:*)"],
|
||||
"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
|
||||
@@ -42,30 +42,39 @@ 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
|
||||
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')
|
||||
}
|
||||
} 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 }) => {
|
||||
@@ -142,7 +151,7 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
await createArcadeSession({
|
||||
userId: data.userId,
|
||||
gameName: 'matching',
|
||||
gameUrl: '/arcade/matching',
|
||||
gameUrl: '/arcade/room', // Room-based sessions use /arcade/room
|
||||
initialState,
|
||||
activePlayers,
|
||||
roomId: room.id,
|
||||
@@ -168,12 +177,20 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
const result = await applyGameMove(data.userId, data.move)
|
||||
|
||||
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)
|
||||
|
||||
@@ -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',
|
||||
311
apps/web/src/app/arcade-rooms/__tests__/room-navigation.test.tsx
Normal file
311
apps/web/src/app/arcade-rooms/__tests__/room-navigation.test.tsx
Normal file
@@ -0,0 +1,311 @@
|
||||
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,
|
||||
})
|
||||
|
||||
// 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,
|
||||
})
|
||||
|
||||
;(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,
|
||||
})
|
||||
|
||||
;(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,
|
||||
})
|
||||
|
||||
;(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,
|
||||
})
|
||||
|
||||
;(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
|
||||
@@ -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,
|
||||
})
|
||||
@@ -195,7 +198,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) => {
|
||||
|
||||
@@ -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,19 +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()
|
||||
|
||||
// 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 (
|
||||
@@ -41,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 />
|
||||
}
|
||||
@@ -58,13 +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)
|
||||
.filter((p) => p !== undefined && p.isLocal !== false) // Filter out remote players
|
||||
.map((p) => ({ id: p.id, name: p.name, emoji: p.emoji }))
|
||||
|
||||
const inactivePlayerList = Array.from(players.values())
|
||||
.filter((p) => !activePlayers.has(p.id))
|
||||
.filter((p) => !activePlayers.has(p.id) && p.isLocal !== false) // Filter out remote players
|
||||
.map((p) => ({ id: p.id, name: p.name, emoji: p.emoji }))
|
||||
|
||||
// Compute game mode from active player count
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, type ReactNode, useContext, useEffect, useMemo, useState } from 'react'
|
||||
import { io } from 'socket.io-client'
|
||||
import type { Player as DBPlayer } from '@/db/schema/players'
|
||||
import {
|
||||
useCreatePlayer,
|
||||
@@ -158,26 +157,6 @@ export function GameModeProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}, [dbPlayers, isLoading, isInitialized, createPlayer])
|
||||
|
||||
// When in a room, broadcast player updates to other members
|
||||
useEffect(() => {
|
||||
if (!roomData || !viewerId || !isInitialized) return
|
||||
|
||||
const socket = io({ path: '/api/socket' })
|
||||
|
||||
// Wait for connection before emitting
|
||||
socket.on('connect', () => {
|
||||
console.log('[GameModeContext] Emitting players-updated for room:', roomData.id)
|
||||
socket.emit('players-updated', {
|
||||
roomId: roomData.id,
|
||||
userId: viewerId,
|
||||
})
|
||||
})
|
||||
|
||||
return () => {
|
||||
socket.disconnect()
|
||||
}
|
||||
}, [dbPlayers, roomData, viewerId, isInitialized])
|
||||
|
||||
const addPlayer = (playerData?: Partial<Player>) => {
|
||||
const playerList = Array.from(players.values())
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -76,7 +82,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 +128,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(
|
||||
@@ -156,9 +162,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,7 +20,7 @@ export interface ArcadeSocketEvents {
|
||||
export interface UseArcadeSocketReturn {
|
||||
socket: Socket | null
|
||||
connected: boolean
|
||||
joinSession: (userId: string) => void
|
||||
joinSession: (userId: string, roomId?: string) => void
|
||||
sendMove: (userId: string, move: GameMove) => void
|
||||
exitSession: (userId: string) => void
|
||||
pingSession: (userId: string) => void
|
||||
@@ -103,13 +103,17 @@ 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]
|
||||
)
|
||||
|
||||
@@ -31,19 +31,22 @@ export interface RoomData {
|
||||
* Returns null if user is not in any room
|
||||
*/
|
||||
export function useRoomData() {
|
||||
const { data: userId } = useViewerId()
|
||||
const { data: userId, isPending: isUserIdPending } = useViewerId()
|
||||
const [socket, setSocket] = useState<Socket | null>(null)
|
||||
const [roomData, setRoomData] = useState<RoomData | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [hasAttemptedFetch, setHasAttemptedFetch] = useState(false)
|
||||
|
||||
// Fetch the user's current room
|
||||
useEffect(() => {
|
||||
if (!userId) {
|
||||
setRoomData(null)
|
||||
setHasAttemptedFetch(false)
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
setHasAttemptedFetch(false)
|
||||
|
||||
// Fetch current room data
|
||||
fetch('/api/arcade/rooms/current')
|
||||
@@ -53,23 +56,26 @@ export function useRoomData() {
|
||||
})
|
||||
.then((data) => {
|
||||
if (data.room) {
|
||||
setRoomData({
|
||||
const roomData = {
|
||||
id: data.room.id,
|
||||
name: data.room.name,
|
||||
code: data.room.code,
|
||||
gameName: data.room.gameName,
|
||||
members: data.members || [],
|
||||
memberPlayers: data.memberPlayers || {},
|
||||
})
|
||||
}
|
||||
setRoomData(roomData)
|
||||
} else {
|
||||
setRoomData(null)
|
||||
}
|
||||
setIsLoading(false)
|
||||
setHasAttemptedFetch(true)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to fetch room data:', error)
|
||||
console.error('[useRoomData] Failed to fetch room data:', error)
|
||||
setRoomData(null)
|
||||
setIsLoading(false)
|
||||
setHasAttemptedFetch(true)
|
||||
})
|
||||
}, [userId])
|
||||
|
||||
@@ -86,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)
|
||||
@@ -115,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
|
||||
@@ -134,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
|
||||
@@ -153,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
|
||||
@@ -170,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
|
||||
@@ -197,7 +198,8 @@ export function useRoomData() {
|
||||
|
||||
return {
|
||||
roomData,
|
||||
isLoading,
|
||||
// Loading if: userId is pending, currently fetching, or have userId but haven't tried fetching yet
|
||||
isLoading: isUserIdPending || isLoading || (!!userId && !hasAttemptedFetch),
|
||||
isInRoom: !!roomData,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "soroban-monorepo",
|
||||
"version": "2.7.0",
|
||||
"version": "2.8.6",
|
||||
"private": true,
|
||||
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
|
||||
"workspaces": [
|
||||
|
||||
Reference in New Issue
Block a user