Compare commits

...

17 Commits

Author SHA1 Message Date
semantic-release-bot
cc80a1454b chore(release): 2.8.0 [skip ci]
## [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](8175c43533))

### Tests

* add comprehensive tests for arcade guard and room navigation ([b49630f](b49630f3cb))
2025-10-08 16:17:21 +00:00
Thomas Hallock
8175c43533 feat: implement room-wide multi-user game state synchronization
Enable real-time game state sync across all users in a room playing
at /arcade/room. Previously only synced across one user's tabs, now
syncs across all room members.

Server changes (socket-server.ts):
- Accept optional roomId in join-arcade-session event
- Join socket to both arcade:userId and game:roomId rooms
- Broadcast move-accepted to both rooms when processing moves
- Log game room joins for debugging

Client changes:
- useArcadeSocket: Accept roomId parameter in joinSession()
- useArcadeSession: Accept roomId in options, pass to joinSession()
- ArcadeMemoryPairsContext: Get roomId from useRoomData() and wire up

How it works:
- User A joins game room → joins arcade:userA + game:room123
- User B joins game room → joins arcade:userB + game:room123
- User A makes move → broadcasts to both rooms
- User A's tabs receive on arcade:userA (reconcile optimistic update)
- User B's tabs receive on game:room123 (sync with server state)
- Optimistic update system handles both cases automatically

Architecture:
- Reuses existing optimistic update reconciliation
- Minimal changes (5 edits across 4 files)
- Backward compatible (roomId is optional)
- Solo play unaffected

See docs/MULTIPLAYER_SYNC_ARCHITECTURE.md for full details.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 11:16:27 -05:00
Thomas Hallock
b49630f3cb test: add comprehensive tests for arcade guard and room navigation
Add tests to prevent regression of the enabled flag bug and verify
room navigation behavior with active game sessions.

New tests:
- useArcadeGuard with enabled=false blocks HTTP redirects
- useArcadeGuard with enabled=false blocks WebSocket redirects
- useArcadeGuard with enabled=true still allows redirects
- Room browser renders without redirect when user has active session
- Room navigation works with active sessions
- No redirect loops during rapid navigation
- Users can browse rooms during active gameplay

Fixes in existing tests:
- Updated mock response format to match API (wrapped in { session })
- All 16 useArcadeGuard tests passing
- All 5 room navigation tests passing

This ensures the /arcade-rooms pages remain accessible during
active game sessions, preventing the bug where users were
immediately redirected to /arcade/room.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 11:08:21 -05:00
semantic-release-bot
c4d8032d02 chore(release): 2.7.4 [skip ci]
## [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](01ff114258))

### Code Refactoring

* move room management pages to /arcade-rooms ([4316687](431668729c))
2025-10-08 16:06:05 +00:00
Thomas Hallock
01ff114258 fix: respect enabled flag in useArcadeGuard WebSocket redirects
The useArcadeGuard hook was ignoring the enabled flag for WebSocket
redirects, causing unwanted navigation from /arcade-rooms to /arcade/room.

When enabled=false (used by PageWithNav), the hook should only track
session state without triggering redirects.

Changes:
- Check enabled flag before redirecting in WebSocket onSessionState
- Check enabled flag before redirecting in HTTP checkSession
- Allows room management pages to use PageWithNav without redirects

Fixes the issue where visiting /arcade-rooms with an active game
session would immediately redirect to /arcade/room.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 11:05:07 -05:00
Thomas Hallock
d173a178bc chore: remove debug logging from room data fetching
Clean up console.log statements that were added for debugging the
room navigation race condition. Keeping only error logging.

Files cleaned:
- src/hooks/useRoomData.ts - removed trace logs for fetch/socket events
- src/app/arcade/room/page.tsx - removed state logging
- src/app/api/arcade/rooms/current/route.ts - removed request logging

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 11:01:47 -05:00
Thomas Hallock
431668729c refactor: move room management pages to /arcade-rooms
Move room browser and detail pages from /arcade/rooms to /arcade-rooms
to eliminate conflicts with arcade session redirect logic. This allows
users to navigate to room management pages even when they have an
active game session.

Changes:
- Move /arcade/rooms/page.tsx → /arcade-rooms/page.tsx (room browser)
- Move /arcade/rooms/[roomId]/page.tsx → /arcade-rooms/[roomId]/page.tsx (room detail)
- Update all router.push() calls to use /arcade-rooms
- Fix styled-system import paths for new location
- Delete old /arcade/rooms directory

Benefits:
- Room management pages are now outside /arcade namespace
- No exceptions needed in useArcadeRedirect hook
- Users can access room pages during active gameplay
- Cleaner separation between gameplay and room management

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 11:00:08 -05:00
semantic-release-bot
b5d0bee120 chore(release): 2.7.3 [skip ci]
## [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](9dac431c1f))
2025-10-08 15:44:46 +00:00
Thomas Hallock
9dac431c1f fix: set room sessions to use /arcade/room URL
When creating an arcade session for a room-based game, the gameUrl was
hardcoded to '/arcade/matching'. This caused useArcadeRedirect to
redirect users from /arcade/room to /arcade/matching, breaking the
simplified room addressing model.

Fix:
- Changed gameUrl to '/arcade/room' for room-based sessions
- Now users stay on /arcade/room for the duration of room gameplay
- Solo sessions still use game-specific URLs like /arcade/matching

This ensures the user experience matches the intended design:
- /arcade/room - room-based multiplayer (regardless of game type)
- /arcade/[game] - solo/local multiplayer for specific games

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 10:43:48 -05:00
semantic-release-bot
5bbb212da9 chore(release): 2.7.2 [skip ci]
## [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](c30f585810))
2025-10-08 15:40:52 +00:00
Thomas Hallock
c30f585810 fix: add hasAttemptedFetch flag to prevent premature redirect
The previous fix didn't fully resolve the race condition. When userId
finished loading, there was a brief moment where:
- isUserIdPending = false (userId loaded)
- isLoading = false (fetch hasn't started yet)
- roomData = null

This triggered the redirect before the room fetch even began.

Solution:
- Added hasAttemptedFetch flag to track fetch attempt state
- Updated isLoading to include: isUserIdPending || isLoading || (!!userId && !hasAttemptedFetch)
- Now the page stays in loading state until we've both loaded userId AND attempted the room fetch

This ensures we never redirect while a fetch is pending.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 10:39:54 -05:00
semantic-release-bot
0a768c65fb chore(release): 2.7.1 [skip ci]
## [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](5ed2ab21ca))
2025-10-08 15:26:51 +00:00
Thomas Hallock
5ed2ab21ca fix: resolve race condition in /arcade/room redirect
The /arcade/room page was redirecting to /arcade before userId loaded,
causing a race condition where the page would redirect even when the user
was in a valid room.

Root cause:
- useViewerId() loads asynchronously
- useRoomData depended on userId but didn't expose userId loading state
- Page checked !isLoading && !roomData and redirected immediately
- By the time userId loaded and room data fetched, redirect already happened

Fix:
- Track isPending from useViewerId in useRoomData
- Combine isUserIdPending with room data loading state
- Page now waits for both userId and room data before redirecting

Added debug logging to help diagnose future issues.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 10:25:53 -05:00
semantic-release-bot
1cb175982a chore(release): 2.7.0 [skip ci]
## [2.7.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.6.0...v2.7.0) (2025-10-08)

### Features

* extend GameModeContext to support room-based multiplayer ([ee6094d](ee6094d59d))
2025-10-08 15:14:28 +00:00
Thomas Hallock
ee6094d59d feat: extend GameModeContext to support room-based multiplayer
When a user is in a room, GameModeContext now merges players from all
room members to create a unified player set for gameplay. This enables
true multiplayer where all participants' active players participate
together in the game.

Key changes:
- Added useRoomData and useViewerId to GameModeContext
- Local players (from DB) are marked with isLocal: true
- Remote players (from other room members) are marked with isLocal: false
- Players map merges local + remote players when in a room
- activePlayers set includes all active players from all room members
- Edit operations (update/remove/setActive) only work on local players
- Socket broadcast when local players change to notify room members
- When not in a room, behavior is unchanged (solo/local multiplayer)

This implements the "players is the union of all active players for all
members of the room" requirement for room-based gameplay.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 10:13:33 -05:00
semantic-release-bot
9d0c488f2b chore(release): 2.6.0 [skip ci]
## [2.6.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.5.0...v2.6.0) (2025-10-08)

### Features

* refactor room addressing to /arcade/room ([e7d2a73](e7d2a73ddf))
2025-10-08 15:08:52 +00:00
Thomas Hallock
e7d2a73ddf feat: refactor room addressing to /arcade/room
Simplify room URL structure so users access their room's game at
/arcade/room instead of /arcade/rooms/[roomId]/[game]. Since users can
only be in one room at a time (modal room enforcement), this provides a
cleaner addressing model.

Changes:
- useRoomData now fetches user's current room from /api/arcade/rooms/current
- Created /api/arcade/rooms/current endpoint to get user's active room
- Created /arcade/room page that renders the appropriate game for the room
- Removed URL parsing logic in favor of backend room lookup
- Socket connection and real-time updates still work with new structure

Next step: Extend GameModeContext to merge players from all room members
so gameplay uses the union of all active players in the room.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 10:07:58 -05:00
20 changed files with 1395 additions and 227 deletions

View File

@@ -1,3 +1,62 @@
## [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)
### Features
* extend GameModeContext to support room-based multiplayer ([ee6094d](https://github.com/antialias/soroban-abacus-flashcards/commit/ee6094d59d26a9e80ba5d023ca6dc13143bea308))
## [2.6.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.5.0...v2.6.0) (2025-10-08)
### Features
* refactor room addressing to /arcade/room ([e7d2a73](https://github.com/antialias/soroban-abacus-flashcards/commit/e7d2a73ddf2048691325a18e3d71a7ece444c131))
## [2.5.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.4.6...v2.5.0) (2025-10-08)

View File

@@ -1,41 +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:*)"
],
"allow": ["Bash(npm test:*)"],
"deny": [],
"ask": []
}

View 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

View File

@@ -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)

View File

@@ -0,0 +1,52 @@
import { NextResponse } from 'next/server'
import { getUserRooms } from '@/lib/arcade/room-membership'
import { getRoomById } from '@/lib/arcade/room-manager'
import { getRoomMembers } from '@/lib/arcade/room-membership'
import { getRoomActivePlayers } from '@/lib/arcade/player-manager'
import { getViewerId } from '@/lib/viewer'
/**
* GET /api/arcade/rooms/current
* Returns the user's current room (if any)
*/
export async function GET() {
try {
const userId = await getViewerId()
// Get all rooms user is in (should be at most 1 due to modal room enforcement)
const roomIds = await getUserRooms(userId)
if (roomIds.length === 0) {
return NextResponse.json({ room: null }, { status: 200 })
}
const roomId = roomIds[0]
// Get room data
const room = await getRoomById(roomId)
if (!room) {
return NextResponse.json({ error: 'Room not found' }, { status: 404 })
}
// Get members
const members = await getRoomMembers(roomId)
// Get active players for all members
const memberPlayers = await getRoomActivePlayers(roomId)
// Convert Map to object for JSON serialization
const memberPlayersObj: Record<string, any[]> = {}
for (const [uid, players] of memberPlayers.entries()) {
memberPlayersObj[uid] = players
}
return NextResponse.json({
room,
members,
memberPlayers: memberPlayersObj,
})
} catch (error) {
console.error('[Current Room API] Error:', error)
return NextResponse.json({ error: 'Failed to fetch current room' }, { status: 500 })
}
}

View File

@@ -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',

View 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()
})
})
})

View File

@@ -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

View File

@@ -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,
})

View File

@@ -0,0 +1,76 @@
'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'
/**
* /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
*/
export default function RoomPage() {
const router = useRouter()
const { roomData, isLoading } = useRoomData()
// Redirect to arcade if no room
useEffect(() => {
if (!isLoading && !roomData) {
router.push('/arcade')
}
}, [isLoading, roomData, router])
// Show loading state
if (isLoading) {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
fontSize: '18px',
color: '#666',
}}
>
Loading room...
</div>
)
}
// Show nothing while redirecting
if (!roomData) {
return null
}
// Render the appropriate game based on room's gameName
switch (roomData.gameName) {
case 'matching':
return (
<ArcadeGuardedPage>
<ArcadeMemoryPairsProvider>
<MemoryPairsGame />
</ArcadeMemoryPairsProvider>
</ArcadeGuardedPage>
)
// TODO: Add other games (complement-race, memory-quiz, etc.)
default:
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
fontSize: '18px',
color: '#666',
}}
>
Game "{roomData.gameName}" not yet supported
</div>
)
}
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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 />
}

View File

@@ -8,6 +8,8 @@ import {
useUpdatePlayer,
useUserPlayers,
} from '@/hooks/useUserPlayers'
import { useRoomData } from '@/hooks/useRoomData'
import { useViewerId } from '@/hooks/useViewerId'
import { getNextPlayerColor } from '../types/player'
// Client-side Player type (compatible with old type)
@@ -66,28 +68,72 @@ export function GameModeProvider({ children }: { children: ReactNode }) {
const { mutate: createPlayer } = useCreatePlayer()
const { mutate: updatePlayerMutation } = useUpdatePlayer()
const { mutate: deletePlayer } = useDeletePlayer()
const { roomData } = useRoomData()
const { data: viewerId } = useViewerId()
const [isInitialized, setIsInitialized] = useState(false)
// Convert DB players to Map
const players = useMemo(() => {
// Convert DB players to Map (local players)
const localPlayers = useMemo(() => {
const map = new Map<string, Player>()
dbPlayers.forEach((dbPlayer) => {
map.set(dbPlayer.id, toClientPlayer(dbPlayer))
map.set(dbPlayer.id, {
...toClientPlayer(dbPlayer),
isLocal: true,
})
})
return map
}, [dbPlayers])
// Track active players from DB isActive status
// When in a room, merge all players from all room members
const players = useMemo(() => {
const map = new Map<string, Player>(localPlayers)
if (roomData) {
// Add players from other room members (marked as remote)
Object.entries(roomData.memberPlayers).forEach(([userId, memberPlayers]) => {
// Skip the current user's players (already in localPlayers)
if (userId === viewerId) return
memberPlayers.forEach((roomPlayer) => {
map.set(roomPlayer.id, {
id: roomPlayer.id,
name: roomPlayer.name,
emoji: roomPlayer.emoji,
color: roomPlayer.color,
createdAt: Date.now(),
isActive: true, // Players in memberPlayers are active
isLocal: false, // Remote player
})
})
})
}
return map
}, [localPlayers, roomData, viewerId])
// Track active players (local + room members when in a room)
const activePlayers = useMemo(() => {
const set = new Set<string>()
dbPlayers.forEach((player) => {
if (player.isActive) {
set.add(player.id)
}
})
if (roomData) {
// In room mode: all players from all members are active
Object.values(roomData.memberPlayers).forEach((memberPlayers) => {
memberPlayers.forEach((player) => {
set.add(player.id)
})
})
} else {
// Solo mode: only local active players
dbPlayers.forEach((player) => {
if (player.isActive) {
set.add(player.id)
}
})
}
return set
}, [dbPlayers])
}, [dbPlayers, roomData])
// Initialize with default players if none exist
useEffect(() => {
@@ -125,15 +171,33 @@ export function GameModeProvider({ children }: { children: ReactNode }) {
}
const updatePlayer = (id: string, updates: Partial<Player>) => {
updatePlayerMutation({ id, updates })
const player = players.get(id)
// Only allow updating local players
if (player?.isLocal) {
updatePlayerMutation({ id, updates })
} else {
console.warn('[GameModeContext] Cannot update remote player:', id)
}
}
const removePlayer = (id: string) => {
deletePlayer(id)
const player = players.get(id)
// Only allow removing local players
if (player?.isLocal) {
deletePlayer(id)
} else {
console.warn('[GameModeContext] Cannot remove remote player:', id)
}
}
const setActive = (id: string, active: boolean) => {
updatePlayerMutation({ id, updates: { isActive: active } })
const player = players.get(id)
// Only allow changing active status of local players
if (player?.isLocal) {
updatePlayerMutation({ id, updates: { isActive: active } })
} else {
console.warn('[GameModeContext] Cannot change active status of remote player:', id)
}
}
const getActivePlayers = (): Player[] => {

View File

@@ -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)
})
})
})

View File

@@ -73,8 +73,8 @@ 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)
if (enabled && pathname !== data.gameUrl) {
console.log('[ArcadeGuard] Redirecting to active session:', data.gameUrl)
onRedirect?.(data.gameUrl)
router.push(data.gameUrl)
@@ -126,8 +126,8 @@ 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)
if (enabled && pathname !== session.gameUrl) {
console.log('[ArcadeGuard] Redirecting to active session:', session.gameUrl)
onRedirect?.(session.gameUrl)
router.push(session.gameUrl)

View File

@@ -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,

View File

@@ -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]
)

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { usePathname } from 'next/navigation'
import { io, type Socket } from 'socket.io-client'
import { useViewerId } from './useViewerId'
export interface RoomMember {
id: string
@@ -27,21 +27,61 @@ export interface RoomData {
}
/**
* Hook to fetch and subscribe to room data when on a room page
* Returns null if not on a room page
* Hook to fetch and subscribe to the user's current room data
* Returns null if user is not in any room
*/
export function useRoomData() {
const pathname = usePathname()
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)
// Extract roomId from pathname like /arcade/rooms/[roomId]/...
const roomId = pathname?.match(/\/arcade\/rooms\/([^/]+)/)?.[1]
// Initialize socket connection when on a room page
// Fetch the user's current room
useEffect(() => {
if (!roomId) {
if (!userId) {
setRoomData(null)
setHasAttemptedFetch(false)
return
}
setIsLoading(true)
setHasAttemptedFetch(false)
// Fetch current room data
fetch('/api/arcade/rooms/current')
.then((res) => {
if (!res.ok) throw new Error('Failed to fetch current room')
return res.json()
})
.then((data) => {
if (data.room) {
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('[useRoomData] Failed to fetch room data:', error)
setRoomData(null)
setIsLoading(false)
setHasAttemptedFetch(true)
})
}, [userId])
// Initialize socket connection when user has a room
useEffect(() => {
if (!roomData?.id || !userId) {
if (socket) {
socket.disconnect()
setSocket(null)
@@ -50,47 +90,47 @@ export function useRoomData() {
}
const sock = io({ path: '/api/socket' })
sock.on('connect', () => {
// Join the room to receive updates
sock.emit('join-room', { roomId: roomData.id, userId })
})
sock.on('disconnect', () => {
// Socket disconnected
})
setSocket(sock)
return () => {
sock.disconnect()
if (sock.connected) {
// Leave the room before disconnecting
sock.emit('leave-room', { roomId: roomData.id, userId })
sock.disconnect()
}
}
}, [roomId])
useEffect(() => {
if (!roomId) {
setRoomData(null)
return
}
setIsLoading(true)
// Fetch initial room data
fetch(`/api/arcade/rooms/${roomId}`)
.then((res) => {
if (!res.ok) throw new Error('Failed to fetch room')
return res.json()
})
.then((data) => {
setRoomData({
id: data.room.id,
name: data.room.name,
code: data.room.code,
gameName: data.room.gameName,
members: data.members || [],
memberPlayers: data.memberPlayers || {},
})
setIsLoading(false)
})
.catch((error) => {
console.error('Failed to fetch room data:', error)
setIsLoading(false)
})
}, [roomId])
}, [roomData?.id, userId])
// Subscribe to real-time updates via socket
useEffect(() => {
if (!socket || !roomId) return
if (!socket || !roomData?.id) return
const handleRoomJoined = (data: {
roomId: string
members: RoomMember[]
memberPlayers: Record<string, RoomPlayer[]>
}) => {
if (data.roomId === roomData.id) {
setRoomData((prev) => {
if (!prev) return null
return {
...prev,
members: data.members,
memberPlayers: data.memberPlayers,
}
})
}
}
const handleMemberJoined = (data: {
roomId: string
@@ -98,7 +138,7 @@ export function useRoomData() {
members: RoomMember[]
memberPlayers: Record<string, RoomPlayer[]>
}) => {
if (data.roomId === roomId) {
if (data.roomId === roomData.id) {
setRoomData((prev) => {
if (!prev) return null
return {
@@ -116,7 +156,7 @@ export function useRoomData() {
members: RoomMember[]
memberPlayers: Record<string, RoomPlayer[]>
}) => {
if (data.roomId === roomId) {
if (data.roomId === roomData.id) {
setRoomData((prev) => {
if (!prev) return null
return {
@@ -132,7 +172,7 @@ export function useRoomData() {
roomId: string
memberPlayers: Record<string, RoomPlayer[]>
}) => {
if (data.roomId === roomId) {
if (data.roomId === roomData.id) {
setRoomData((prev) => {
if (!prev) return null
return {
@@ -143,20 +183,23 @@ export function useRoomData() {
}
}
socket.on('room-joined', handleRoomJoined)
socket.on('member-joined', handleMemberJoined)
socket.on('member-left', handleMemberLeft)
socket.on('room-players-updated', handleRoomPlayersUpdated)
return () => {
socket.off('room-joined', handleRoomJoined)
socket.off('member-joined', handleMemberJoined)
socket.off('member-left', handleMemberLeft)
socket.off('room-players-updated', handleRoomPlayersUpdated)
}
}, [socket, roomId])
}, [socket, roomData?.id])
return {
roomData,
isLoading,
isInRoom: !!roomId,
// Loading if: userId is pending, currently fetching, or have userId but haven't tried fetching yet
isLoading: isUserIdPending || isLoading || (!!userId && !hasAttemptedFetch),
isInRoom: !!roomData,
}
}

View File

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