Compare commits

...

18 Commits

Author SHA1 Message Date
semantic-release-bot
9fa5652173 chore(release): 2.4.5 [skip ci]
## [2.4.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.4.4...v2.4.5) (2025-10-08)

### Bug Fixes

* send all members (not just online) in socket broadcasts ([3fa6cce](3fa6cce17a))
2025-10-08 14:03:35 +00:00
Thomas Hallock
3fa6cce17a fix: send all members (not just online) in socket broadcasts
The root issue was that socket broadcasts were sending only online
members while the initial page load showed all members, causing
inconsistency.

Changes:
- Join/leave API routes now send `members` (all) instead of `onlineMembers`
- Socket server join/leave handlers now send `members` instead of `onlineMembers`
- Client event handlers updated to expect `data.members` instead of `data.onlineMembers`
- Removed unused `getOnlineRoomMembers` import from socket-server.ts

Now when a user joins, other users immediately see them in the members
list without needing to reload the page. Both members and players are
updated together in the same broadcast.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 09:02:44 -05:00
semantic-release-bot
8f3dd9ec92 chore(release): 2.4.4 [skip ci]
## [2.4.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.4.3...v2.4.4) (2025-10-08)

### Bug Fixes

* correctly access getSocketIO from dynamic import ([30abf33](30abf33ee8))
2025-10-08 13:57:35 +00:00
Thomas Hallock
30abf33ee8 fix: correctly access getSocketIO from dynamic import
The dynamic import returns a module namespace object, so we need to
access socketServerModule.getSocketIO() rather than treating the
module itself as the function.

Simplified the wrapper to directly cache and use the module, checking
that getSocketIO exists before calling it.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 08:56:47 -05:00
semantic-release-bot
caa2bea7a8 chore(release): 2.4.3 [skip ci]
## [2.4.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.4.2...v2.4.3) (2025-10-08)

### Bug Fixes

* resolve socket-server import path for Next.js build ([12c3c37](12c3c37ff8))
2025-10-08 13:56:18 +00:00
Thomas Hallock
12c3c37ff8 fix: resolve socket-server import path for Next.js build
Create a wrapper module in src/lib/socket-io.ts that provides access
to the socket.io server instance for API routes, avoiding the build
error caused by importing from outside the src directory.

The wrapper uses dynamic imports to lazy-load the socket server module
only on the server-side, making it safe for Next.js to bundle.

Changes:
- Add src/lib/socket-io.ts with async getSocketIO() function
- Update join route to use @/lib/socket-io import
- Update leave route to use @/lib/socket-io import
- Both routes now await getSocketIO() since it's async

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 08:55:28 -05:00
semantic-release-bot
5c32209a2c chore(release): 2.4.2 [skip ci]
## [2.4.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.4.1...v2.4.2) (2025-10-08)

### Bug Fixes

* broadcast member join/leave events immediately via API ([ebfc88c](ebfc88c5ea))
2025-10-08 13:52:13 +00:00
Thomas Hallock
ebfc88c5ea fix: broadcast member join/leave events immediately via API
Add socket.io broadcasts to join and leave API routes to notify
all users in a room immediately when someone joins or leaves,
without waiting for socket connection.

Previously:
- API added member to database
- No socket event until user's browser connected
- Other users didn't see new member until page reload
- Players showed up but member didn't (timing race condition)

Now:
- API broadcasts `member-joined` immediately after adding member
- API broadcasts `member-left` immediately after removing member
- All connected users get real-time updates instantly
- Both members list and players list update simultaneously

Changes:
- Export `getSocketIO()` from socket-server.ts for API access
- Join route broadcasts member-joined with updated members/players
- Leave route broadcasts member-left with updated members/players
- Socket broadcasts are non-blocking (won't fail the request)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 08:51:17 -05:00
semantic-release-bot
7eb55899c8 chore(release): 2.4.1 [skip ci]
## [2.4.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.4.0...v2.4.1) (2025-10-08)

### Bug Fixes

* make leave room button actually remove user from room ([49f12f8](49f12f8cab))
2025-10-08 13:46:48 +00:00
Thomas Hallock
49f12f8cab fix: make leave room button actually remove user from room
When clicking "Leave Room", the user is now properly removed from
room membership (not just marked offline) and navigated to /arcade.

Previously:
- Just navigated to /arcade/rooms
- User remained as offline member of the room
- Socket cleanup only marked user offline

Now:
- Calls POST /api/arcade/rooms/:roomId/leave
- Actually removes user from room_members table
- Navigates to /arcade home page
- Users can be in at most 1 room (or 0 rooms)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 08:45:51 -05:00
semantic-release-bot
b4c8cfaad2 chore(release): 2.4.0 [skip ci]
## [2.4.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.3.1...v2.4.0) (2025-10-08)

### Features

* add arcade room/session info and network players to nav ([6800747](6800747f80))
* add real-time WebSocket updates for room membership ([7ebb2be](7ebb2be392))
* implement modal room enforcement (one room per user) ([f005fbb](f005fbbb77))
* improve room navigation and membership UI ([bc219c2](bc219c2ad6))

### Bug Fixes

* auto-cleanup orphaned arcade sessions without valid rooms ([3c002ab](3c002ab29d))
* show correct join/leave button based on room membership ([5751dfe](5751dfef5c))
2025-10-08 13:42:45 +00:00
Thomas Hallock
f005fbbb77 feat: implement modal room enforcement (one room per user)
Implement hybrid database + application-level enforcement to ensure users
can only be in one room at a time, with graceful auto-leave behavior and
clear error messaging.

## Changes

### Database Layer
- Add unique index on `room_members.user_id` to enforce one room per user
- Migration includes cleanup of any existing duplicate memberships
- Constraint provides safety net if application logic fails

### Application Layer
- Auto-leave logic: when joining a room, automatically remove user from
  all other rooms first
- Return `AutoLeaveResult` with metadata about rooms that were left
- Idempotent rejoining: rejoining the same room just updates status

### API Layer
- Join route returns auto-leave information in response
- Catches and handles constraint violations with 409 Conflict
- User-friendly error messages when conflicts occur

### Frontend
- Room list and detail pages handle ROOM_MEMBERSHIP_CONFLICT errors
- Show alerts when user needs to leave current room
- Refresh room list after conflicts to show current state

### Testing
- 7 integration tests for modal room behavior
- Tests cover: first join, auto-leave, rejoining, multi-user scenarios,
  constraint enforcement, and metadata accuracy
- Updated existing unit tests for new return signature

## Technical Details

- `addRoomMember()` now returns `{ member, autoLeaveResult? }`
- Auto-leave happens before new room join, preventing race conditions
- Database unique constraint as ultimate safety net
- Socket events remain status-only (joining goes through API)

## Testing
-  All modal room tests pass (7/7)
-  All room API e2e tests pass (12/12)
-  Format and lint checks pass

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 08:41:39 -05:00
Thomas Hallock
5751dfef5c fix: show correct join/leave button based on room membership
Fixes issue where non-members see "Leave Room" button instead of
"Join Room" when viewing a room they haven't joined.

Changes:
- Added `isMember` check by comparing viewerId with members list
- Conditional button rendering:
  - **Members see**: "Leave Room" + "Start Game" buttons
  - **Non-members see**: "Back to Rooms" + "Join Room" buttons
- Added `joinRoom()` function to handle joining from room detail page
- Join button respects room.isLocked status
- After joining, room data refreshes to update UI

This prevents confusion about membership status and provides the
correct action buttons based on whether the user is in the room.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 07:13:57 -05:00
Thomas Hallock
7ebb2be392 feat: add real-time WebSocket updates for room membership
Fixes issue where room members and players don't update in real-time
when someone joins from another tab/session.

Socket Events Added:
- `join-room`: Client → Server when user enters room page
  - Server marks member as online
  - Emits `room-joined` to the connecting user with full room state
  - Broadcasts `member-joined` to all other room members

- `leave-room`: Client → Server when user leaves room
  - Server marks member as offline
  - Broadcasts `member-left` to remaining members

- `players-updated`: Client → Server when user's active players change
  - Broadcasts `room-players-updated` to all members

Implementation:
- Added socket.join(`room:${roomId}`) for room-based broadcasting
- Integrated with room-membership manager (setMemberOnline)
- Fetches members, onlineMembers, and memberPlayers for each update
- Converts Map to Object for JSON serialization
- Uses io.to() for broadcasting to room members

Client Impact:
- Existing room detail page already listens for these events
- Real-time updates now work across tabs and sessions
- Members list and active players update automatically

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 07:12:23 -05:00
Thomas Hallock
bc219c2ad6 feat: improve room navigation and membership UI
Fixes two UX issues in the arcade rooms list:

1. **Show membership status**: Rooms now display whether the user is
   already a member
   - API returns `isMember` flag for each room
   - UI shows "✓ Joined" badge for joined rooms
   - Shows "Join Room" button for non-joined rooms

2. **Allow navigation without joining**: Users can now view rooms
   without automatically joining
   - Entire room card is clickable to navigate to room details
   - "Join Room" button specifically handles membership (with stopPropagation)
   - Users can browse room details before deciding to join

Changes:
- API (src/app/api/arcade/rooms/route.ts):
  - Added `isMember` check using viewerId
  - Enriched room response with membership status

- Frontend (src/app/arcade/rooms/page.tsx):
  - Added `isMember` to Room interface
  - Made room cards clickable for navigation
  - Show "✓ Joined" badge when user is a member
  - Show "Join Room" button when user is not a member
  - Button click stops propagation to prevent double navigation

This improves discoverability and prevents confusion about membership
status.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 07:07:24 -05:00
Thomas Hallock
3c002ab29d fix: auto-cleanup orphaned arcade sessions without valid rooms
Fixes critical bug where users were redirected to non-existent games
after room TTL deletion. This occurred because:

1. User creates arcade session in a room
2. Room expires via TTL cleanup
3. Session persists as orphan (roomId = null or points to deleted room)
4. useArcadeRedirect finds orphaned session
5. User redirected to /arcade/matching with no valid game state

Changes:

**Session validation (session-manager.ts)**
- getArcadeSession() now validates room association
- Auto-deletes sessions with no roomId
- Auto-deletes sessions pointing to non-existent rooms
- Returns undefined for orphaned sessions

**Session creation (session-manager.ts, route.ts, socket-server.ts)**
- createArcadeSession() now requires roomId parameter
- Socket server checks for existing user rooms before creating new ones
- Socket server auto-creates rooms when needed for backward compatibility
- API route requires roomId in request body

**Tests**
- Added orphaned-session-cleanup.test.ts: Unit/integration tests
- Added orphaned-session.e2e.test.ts: E2E regression tests
- Updated existing tests to provide roomId
- Tests cover TTL deletion, null roomId, and race conditions

This ensures sessions are always tied to valid rooms and prevents
orphaned session redirect loops.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 07:03:36 -05:00
Thomas Hallock
6800747f80 feat: add arcade room/session info and network players to nav
Add visual indicators for arcade sessions and other players:

Components added:
- NetworkPlayerIndicator: Shows network players with special
  "network" frame border (animated pulse, network icon badge)
- RoomInfo: Displays current arcade session info (game name,
  player count)

Modified:
- GameContextNav: Accept and render networkPlayers and roomInfo
- PageWithNav: Fetch arcade session info via useArcadeGuard and
  pass to GameContextNav

Visual features:
- Network players have gradient border and pulsing connection indicator
- Room info shows game name and player count in styled container
- Network players are visually distinct from local players
- Only shown when in active arcade session

This provides visibility into multiplayer state and prepares
for full room system implementation.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 06:51:38 -05:00
Thomas Hallock
99906ae53d format 2025-10-07 15:45:57 -05:00
45 changed files with 5923 additions and 54 deletions

57
.claude/terminology.md Normal file
View File

@@ -0,0 +1,57 @@
# Soroban Abacus Flashcards - Terminology Reference
## User vs Player vs Room Member
**CRITICAL**: Do not confuse these three concepts!
### Quick Reference
- **USER** = Identity/account (one per person, identified by `guestId` cookie)
- **PLAYER** = Game avatar/profile (multiple per user, from `players` table)
- **ROOM MEMBER** = USER's participation in a multiplayer room
### Key Rule
**When a USER joins a room, their ACTIVE PLAYERS join the game.**
Example:
- USER "Jane" has 3 players: Alice, Bob, Charlie
- Alice and Bob are active (`isActive: true`)
- When Jane joins a room, Alice and Bob participate in the game
- The `arcade_sessions.activePlayers` array contains `[alice_id, bob_id]`
### Database Schema
```
users (identity)
├─ players (avatars/profiles) - where isActive = true
└─ room_members (room participation)
arcade_sessions
├─ userId: references users.id
├─ activePlayers: Array<player.id> ← PLAYER IDs, not USER IDs!
└─ roomId: references arcade_rooms.id
```
### Common Mistakes to Avoid
❌ Using USER ID in `activePlayers` - should be PLAYER IDs
❌ Assuming one USER = one PLAYER - users can have multiple players
❌ Tracking game moves/scores by USER - should track by PLAYER
❌ Confusing room_members.displayName with players.name - different concepts
### Full Documentation
See: `docs/terminology-user-player-room.md` for complete explanation with examples.
## Other Project-Specific Terms
### Arcade vs Games
- **`/games/*`** - Single player or local multiplayer (same device)
- **`/arcade/*`** - Online multiplayer with sessions and rooms
### Session Types
- **Solo Session**: `arcade_sessions.roomId = null`, user playing alone
- **Room Session**: `arcade_sessions.roomId = room_xyz`, shared game state across room members

View File

@@ -1,3 +1,54 @@
## [2.4.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.4.4...v2.4.5) (2025-10-08)
### Bug Fixes
* send all members (not just online) in socket broadcasts ([3fa6cce](https://github.com/antialias/soroban-abacus-flashcards/commit/3fa6cce17a7acd940cf5a9e6433bf6c4b497540c))
## [2.4.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.4.3...v2.4.4) (2025-10-08)
### Bug Fixes
* correctly access getSocketIO from dynamic import ([30abf33](https://github.com/antialias/soroban-abacus-flashcards/commit/30abf33ee86b36f2a98014e5b017fa8e466a2107))
## [2.4.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.4.2...v2.4.3) (2025-10-08)
### Bug Fixes
* resolve socket-server import path for Next.js build ([12c3c37](https://github.com/antialias/soroban-abacus-flashcards/commit/12c3c37ff8e1d3df71d72e527c08fa975043c504))
## [2.4.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.4.1...v2.4.2) (2025-10-08)
### Bug Fixes
* broadcast member join/leave events immediately via API ([ebfc88c](https://github.com/antialias/soroban-abacus-flashcards/commit/ebfc88c5ea0a8a0fdda039fa129e1054b9c42e65))
## [2.4.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.4.0...v2.4.1) (2025-10-08)
### Bug Fixes
* make leave room button actually remove user from room ([49f12f8](https://github.com/antialias/soroban-abacus-flashcards/commit/49f12f8cab631fedd33f1bc09febfdc95e444625))
## [2.4.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.3.1...v2.4.0) (2025-10-08)
### Features
* add arcade room/session info and network players to nav ([6800747](https://github.com/antialias/soroban-abacus-flashcards/commit/6800747f80a29c91ba0311a8330d594c1074097d))
* add real-time WebSocket updates for room membership ([7ebb2be](https://github.com/antialias/soroban-abacus-flashcards/commit/7ebb2be3927762a5fe9b6fb7fb15d6b88abb7b6a))
* implement modal room enforcement (one room per user) ([f005fbb](https://github.com/antialias/soroban-abacus-flashcards/commit/f005fbbb773f4d250b80d71593490976af82d5a5))
* improve room navigation and membership UI ([bc219c2](https://github.com/antialias/soroban-abacus-flashcards/commit/bc219c2ad66707f03e7a6cf587b9d190c736e26d))
### Bug Fixes
* auto-cleanup orphaned arcade sessions without valid rooms ([3c002ab](https://github.com/antialias/soroban-abacus-flashcards/commit/3c002ab29d1b72a0e1ffb70bb0744dc560e7bdc2))
* show correct join/leave button based on room membership ([5751dfe](https://github.com/antialias/soroban-abacus-flashcards/commit/5751dfef5c81981937cd5300c4256e5b74bb7488))
## [2.3.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.3.0...v2.3.1) (2025-10-07)

View File

@@ -30,7 +30,11 @@
"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(git commit:*)",
"Bash(npm run pre-commit:*)",
"Bash(npm run:*)",
"Bash(git pull:*)",
"Bash(git stash:*)"
],
"deny": [],
"ask": []

View File

@@ -0,0 +1,455 @@
/**
* @vitest-environment node
*/
import { eq } from 'drizzle-orm'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { db, schema } from '../src/db'
import { createRoom } from '../src/lib/arcade/room-manager'
import { addRoomMember } from '../src/lib/arcade/room-membership'
/**
* Arcade Rooms API E2E Tests
*
* Tests the full arcade room system:
* - Room CRUD operations
* - Member management
* - Access control
* - Room code lookups
*/
describe('Arcade Rooms API', () => {
let testUserId1: string
let testUserId2: string
let testGuestId1: string
let testGuestId2: string
let testRoomId: string
beforeEach(async () => {
// Create test users
testGuestId1 = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}`
testGuestId2 = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}`
const [user1] = await db.insert(schema.users).values({ guestId: testGuestId1 }).returning()
const [user2] = await db.insert(schema.users).values({ guestId: testGuestId2 }).returning()
testUserId1 = user1.id
testUserId2 = user2.id
})
afterEach(async () => {
// Clean up rooms (cascade deletes members)
if (testRoomId) {
await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, testRoomId))
}
// Clean up users
await db.delete(schema.users).where(eq(schema.users.id, testUserId1))
await db.delete(schema.users).where(eq(schema.users.id, testUserId2))
})
describe('Room Creation', () => {
it('creates a room with valid data', async () => {
const room = await createRoom({
name: 'Test Room',
createdBy: testGuestId1,
creatorName: 'Test User',
gameName: 'matching',
gameConfig: { difficulty: 6 },
})
testRoomId = room.id
expect(room).toBeDefined()
expect(room.name).toBe('Test Room')
expect(room.createdBy).toBe(testGuestId1)
expect(room.gameName).toBe('matching')
expect(room.status).toBe('lobby')
expect(room.isLocked).toBe(false)
expect(room.ttlMinutes).toBe(60)
expect(room.code).toMatch(/^[A-Z0-9]{6}$/)
})
it('creates room with custom TTL', async () => {
const room = await createRoom({
name: 'Custom TTL Room',
createdBy: testGuestId1,
creatorName: 'Test User',
gameName: 'matching',
gameConfig: {},
ttlMinutes: 120,
})
testRoomId = room.id
expect(room.ttlMinutes).toBe(120)
})
it('generates unique room codes', async () => {
const room1 = await createRoom({
name: 'Room 1',
createdBy: testGuestId1,
creatorName: 'User 1',
gameName: 'matching',
gameConfig: {},
})
const room2 = await createRoom({
name: 'Room 2',
createdBy: testGuestId2,
creatorName: 'User 2',
gameName: 'matching',
gameConfig: {},
})
// Clean up both rooms
testRoomId = room1.id
await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, room2.id))
expect(room1.code).not.toBe(room2.code)
})
})
describe('Room Retrieval', () => {
beforeEach(async () => {
// Create a test room
const room = await createRoom({
name: 'Retrieval Test Room',
createdBy: testGuestId1,
creatorName: 'Test User',
gameName: 'matching',
gameConfig: {},
})
testRoomId = room.id
})
it('retrieves room by ID', async () => {
const room = await db.query.arcadeRooms.findFirst({
where: eq(schema.arcadeRooms.id, testRoomId),
})
expect(room).toBeDefined()
expect(room?.id).toBe(testRoomId)
expect(room?.name).toBe('Retrieval Test Room')
})
it('retrieves room by code', async () => {
const createdRoom = await db.query.arcadeRooms.findFirst({
where: eq(schema.arcadeRooms.id, testRoomId),
})
const room = await db.query.arcadeRooms.findFirst({
where: eq(schema.arcadeRooms.code, createdRoom!.code),
})
expect(room).toBeDefined()
expect(room?.id).toBe(testRoomId)
})
it('returns undefined for non-existent room', async () => {
const room = await db.query.arcadeRooms.findFirst({
where: eq(schema.arcadeRooms.id, 'nonexistent-room-id'),
})
expect(room).toBeUndefined()
})
})
describe('Room Updates', () => {
beforeEach(async () => {
const room = await createRoom({
name: 'Update Test Room',
createdBy: testGuestId1,
creatorName: 'Test User',
gameName: 'matching',
gameConfig: {},
})
testRoomId = room.id
})
it('updates room name', async () => {
const [updated] = await db
.update(schema.arcadeRooms)
.set({ name: 'Updated Name' })
.where(eq(schema.arcadeRooms.id, testRoomId))
.returning()
expect(updated.name).toBe('Updated Name')
})
it('locks room', async () => {
const [updated] = await db
.update(schema.arcadeRooms)
.set({ isLocked: true })
.where(eq(schema.arcadeRooms.id, testRoomId))
.returning()
expect(updated.isLocked).toBe(true)
})
it('updates room status', async () => {
const [updated] = await db
.update(schema.arcadeRooms)
.set({ status: 'playing' })
.where(eq(schema.arcadeRooms.id, testRoomId))
.returning()
expect(updated.status).toBe('playing')
})
it('updates lastActivity on any change', async () => {
const originalRoom = await db.query.arcadeRooms.findFirst({
where: eq(schema.arcadeRooms.id, testRoomId),
})
// Wait a bit to ensure different timestamp (at least 1 second for SQLite timestamp resolution)
await new Promise((resolve) => setTimeout(resolve, 1100))
const [updated] = await db
.update(schema.arcadeRooms)
.set({ name: 'Activity Test', lastActivity: new Date() })
.where(eq(schema.arcadeRooms.id, testRoomId))
.returning()
expect(updated.lastActivity.getTime()).toBeGreaterThan(originalRoom!.lastActivity.getTime())
})
})
describe('Room Deletion', () => {
it('deletes room', async () => {
const room = await createRoom({
name: 'Delete Test Room',
createdBy: testGuestId1,
creatorName: 'Test User',
gameName: 'matching',
gameConfig: {},
})
await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, room.id))
const deleted = await db.query.arcadeRooms.findFirst({
where: eq(schema.arcadeRooms.id, room.id),
})
expect(deleted).toBeUndefined()
})
it('cascades delete to room members', async () => {
const room = await createRoom({
name: 'Cascade Test Room',
createdBy: testGuestId1,
creatorName: 'Test User',
gameName: 'matching',
gameConfig: {},
})
// Add member
await addRoomMember({
roomId: room.id,
userId: testGuestId1,
displayName: 'Test User',
})
// Verify member exists
const membersBefore = await db.query.roomMembers.findMany({
where: eq(schema.roomMembers.roomId, room.id),
})
expect(membersBefore).toHaveLength(1)
// Delete room
await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, room.id))
// Verify members deleted
const membersAfter = await db.query.roomMembers.findMany({
where: eq(schema.roomMembers.roomId, room.id),
})
expect(membersAfter).toHaveLength(0)
})
})
describe('Room Members', () => {
beforeEach(async () => {
const room = await createRoom({
name: 'Members Test Room',
createdBy: testGuestId1,
creatorName: 'Test User 1',
gameName: 'matching',
gameConfig: {},
})
testRoomId = room.id
})
it('adds member to room', async () => {
const result = await addRoomMember({
roomId: testRoomId,
userId: testGuestId1,
displayName: 'Test User 1',
isCreator: true,
})
expect(result.member).toBeDefined()
expect(result.member.roomId).toBe(testRoomId)
expect(result.member.userId).toBe(testGuestId1)
expect(result.member.displayName).toBe('Test User 1')
expect(result.member.isCreator).toBe(true)
expect(result.member.isOnline).toBe(true)
})
it('adds multiple members to room', async () => {
await addRoomMember({
roomId: testRoomId,
userId: testGuestId1,
displayName: 'User 1',
})
await addRoomMember({
roomId: testRoomId,
userId: testGuestId2,
displayName: 'User 2',
})
const members = await db.query.roomMembers.findMany({
where: eq(schema.roomMembers.roomId, testRoomId),
})
expect(members).toHaveLength(2)
})
it('updates existing member instead of creating duplicate', async () => {
// Add member first time
await addRoomMember({
roomId: testRoomId,
userId: testGuestId1,
displayName: 'First Time',
})
// Add same member again
await addRoomMember({
roomId: testRoomId,
userId: testGuestId1,
displayName: 'Second Time',
})
const members = await db.query.roomMembers.findMany({
where: eq(schema.roomMembers.roomId, testRoomId),
})
// Should still only have 1 member
expect(members).toHaveLength(1)
})
it('removes member from room', async () => {
const result = await addRoomMember({
roomId: testRoomId,
userId: testGuestId1,
displayName: 'Test User',
})
await db.delete(schema.roomMembers).where(eq(schema.roomMembers.id, result.member.id))
const members = await db.query.roomMembers.findMany({
where: eq(schema.roomMembers.roomId, testRoomId),
})
expect(members).toHaveLength(0)
})
it('tracks online status', async () => {
const result = await addRoomMember({
roomId: testRoomId,
userId: testGuestId1,
displayName: 'Test User',
})
expect(result.member.isOnline).toBe(true)
// Set offline
const [updated] = await db
.update(schema.roomMembers)
.set({ isOnline: false })
.where(eq(schema.roomMembers.id, result.member.id))
.returning()
expect(updated.isOnline).toBe(false)
})
})
describe('Access Control', () => {
beforeEach(async () => {
const room = await createRoom({
name: 'Access Test Room',
createdBy: testGuestId1,
creatorName: 'Creator',
gameName: 'matching',
gameConfig: {},
})
testRoomId = room.id
})
it('identifies room creator correctly', async () => {
const room = await db.query.arcadeRooms.findFirst({
where: eq(schema.arcadeRooms.id, testRoomId),
})
expect(room?.createdBy).toBe(testGuestId1)
})
it('distinguishes creator from other users', async () => {
const room = await db.query.arcadeRooms.findFirst({
where: eq(schema.arcadeRooms.id, testRoomId),
})
expect(room?.createdBy).not.toBe(testGuestId2)
})
})
describe('Room Listing', () => {
beforeEach(async () => {
// Create multiple test rooms
const room1 = await createRoom({
name: 'Matching Room',
createdBy: testGuestId1,
creatorName: 'User 1',
gameName: 'matching',
gameConfig: {},
})
const room2 = await createRoom({
name: 'Memory Quiz Room',
createdBy: testGuestId2,
creatorName: 'User 2',
gameName: 'memory-quiz',
gameConfig: {},
})
testRoomId = room1.id
// Clean up room2 after test
afterEach(async () => {
await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, room2.id))
})
})
it('lists all active rooms', async () => {
const rooms = await db.query.arcadeRooms.findMany({
where: eq(schema.arcadeRooms.status, 'lobby'),
})
expect(rooms.length).toBeGreaterThanOrEqual(2)
})
it('excludes locked rooms from listing', async () => {
// Lock one room
await db
.update(schema.arcadeRooms)
.set({ isLocked: true })
.where(eq(schema.arcadeRooms.id, testRoomId))
const unlockedRooms = await db.query.arcadeRooms.findMany({
where: eq(schema.arcadeRooms.isLocked, false),
})
expect(unlockedRooms.every((r) => !r.isLocked)).toBe(true)
})
})
})

View File

@@ -76,7 +76,7 @@ describe('Middleware E2E', () => {
const originalEnv = process.env.NODE_ENV
Object.defineProperty(process.env, 'NODE_ENV', {
value: 'production',
configurable: true
configurable: true,
})
const req = new NextRequest('http://localhost:3000/')
@@ -87,7 +87,7 @@ describe('Middleware E2E', () => {
Object.defineProperty(process.env, 'NODE_ENV', {
value: originalEnv,
configurable: true
configurable: true,
})
})
@@ -95,7 +95,7 @@ describe('Middleware E2E', () => {
const originalEnv = process.env.NODE_ENV
Object.defineProperty(process.env, 'NODE_ENV', {
value: 'development',
configurable: true
configurable: true,
})
const req = new NextRequest('http://localhost:3000/')
@@ -106,7 +106,7 @@ describe('Middleware E2E', () => {
Object.defineProperty(process.env, 'NODE_ENV', {
value: originalEnv,
configurable: true
configurable: true,
})
})

View File

@@ -0,0 +1,203 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { eq } from 'drizzle-orm'
import { db, schema } from '../src/db'
import { createArcadeSession, getArcadeSession } from '../src/lib/arcade/session-manager'
import { cleanupExpiredRooms, createRoom } from '../src/lib/arcade/room-manager'
/**
* E2E Test: Orphaned Session After Room TTL Deletion
*
* This test simulates the exact scenario reported by the user:
* 1. User creates a game session in a room
* 2. Room expires via TTL cleanup
* 3. User navigates to /arcade
* 4. System should NOT redirect to the orphaned game
* 5. User should see the arcade lobby normally
*/
describe('E2E: Orphaned Session Cleanup on Navigation', () => {
const testUserId = 'e2e-user-id'
const testGuestId = 'e2e-guest-id'
let testRoomId: string
beforeEach(async () => {
// Create test user (simulating new or returning visitor)
await db
.insert(schema.users)
.values({
id: testUserId,
guestId: testGuestId,
createdAt: new Date(),
})
.onConflictDoNothing()
})
afterEach(async () => {
// Clean up test data
await db.delete(schema.arcadeSessions).where(eq(schema.arcadeSessions.userId, testUserId))
await db.delete(schema.users).where(eq(schema.users.id, testUserId))
if (testRoomId) {
try {
await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, testRoomId))
} catch {
// Room may already be deleted
}
}
})
it('should not redirect user to orphaned game after room TTL cleanup', async () => {
// === SETUP PHASE ===
// User creates or joins a room
const room = await createRoom({
name: 'My Game Room',
createdBy: testGuestId,
creatorName: 'Test Player',
gameName: 'matching',
gameConfig: { difficulty: 6, gameType: 'abacus-numeral', turnTimer: 30 },
ttlMinutes: 1, // Short TTL for testing
})
testRoomId = room.id
// User starts a game session
const session = await createArcadeSession({
userId: testGuestId,
gameName: 'matching',
gameUrl: '/arcade/matching',
initialState: {
gamePhase: 'playing',
cards: [],
gameCards: [],
flippedCards: [],
matchedPairs: 0,
totalPairs: 6,
currentPlayer: 'player-1',
difficulty: 6,
gameType: 'abacus-numeral',
turnTimer: 30,
},
activePlayers: ['player-1'],
roomId: room.id,
})
// Verify session was created
expect(session).toBeDefined()
expect(session.roomId).toBe(room.id)
// === TTL EXPIRATION PHASE ===
// Simulate time passing - room's TTL expires
// Set lastActivity to past so cleanup detects it
await db
.update(schema.arcadeRooms)
.set({
lastActivity: new Date(Date.now() - 2 * 60 * 1000), // 2 minutes ago
})
.where(eq(schema.arcadeRooms.id, room.id))
// Run cleanup (simulating background cleanup job)
const deletedCount = await cleanupExpiredRooms()
expect(deletedCount).toBeGreaterThan(0) // Room should be deleted
// === USER NAVIGATION PHASE ===
// User navigates to /arcade (arcade lobby)
// The useArcadeRedirect hook calls getArcadeSession to check for active session
const activeSession = await getArcadeSession(testGuestId)
// === ASSERTION PHASE ===
// Expected behavior: NO active session returned
// This prevents redirect to /arcade/matching which would be broken
expect(activeSession).toBeUndefined()
// Verify the orphaned session was cleaned up from database
const [orphanedSessionCheck] = await db
.select()
.from(schema.arcadeSessions)
.where(eq(schema.arcadeSessions.userId, testUserId))
.limit(1)
expect(orphanedSessionCheck).toBeUndefined()
})
it('should allow user to start new game after orphaned session cleanup', async () => {
// === SETUP: Create and orphan a session ===
const oldRoom = await createRoom({
name: 'Old Room',
createdBy: testGuestId,
creatorName: 'Test Player',
gameName: 'matching',
gameConfig: { difficulty: 6 },
ttlMinutes: 1,
})
await createArcadeSession({
userId: testGuestId,
gameName: 'matching',
gameUrl: '/arcade/matching',
initialState: { gamePhase: 'setup' },
activePlayers: ['player-1'],
roomId: oldRoom.id,
})
// Delete room (TTL cleanup)
await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, oldRoom.id))
// === ACTION: User tries to access arcade ===
const orphanedSession = await getArcadeSession(testGuestId)
expect(orphanedSession).toBeUndefined() // Orphan cleaned up
// === ACTION: User creates new room and session ===
const newRoom = await createRoom({
name: 'New Room',
createdBy: testGuestId,
creatorName: 'Test Player',
gameName: 'matching',
gameConfig: { difficulty: 8 },
ttlMinutes: 60,
})
testRoomId = newRoom.id
const newSession = await createArcadeSession({
userId: testGuestId,
gameName: 'matching',
gameUrl: '/arcade/matching',
initialState: { gamePhase: 'setup' },
activePlayers: ['player-1', 'player-2'],
roomId: newRoom.id,
})
// === ASSERTION: New session works correctly ===
expect(newSession).toBeDefined()
expect(newSession.roomId).toBe(newRoom.id)
const activeSession = await getArcadeSession(testGuestId)
expect(activeSession).toBeDefined()
expect(activeSession?.roomId).toBe(newRoom.id)
})
it('should handle race condition: getArcadeSession called while room is being deleted', async () => {
// Create room and session
const room = await createRoom({
name: 'Race Condition Room',
createdBy: testGuestId,
creatorName: 'Test Player',
gameName: 'matching',
gameConfig: { difficulty: 6 },
ttlMinutes: 60,
})
testRoomId = room.id
await createArcadeSession({
userId: testGuestId,
gameName: 'matching',
gameUrl: '/arcade/matching',
initialState: { gamePhase: 'setup' },
activePlayers: ['player-1'],
roomId: room.id,
})
// Simulate race: delete room while getArcadeSession is checking
await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, room.id))
// Should gracefully handle and return undefined
const result = await getArcadeSession(testGuestId)
expect(result).toBeUndefined()
})
})

View File

@@ -0,0 +1,169 @@
# User/Player/Room Member Inconsistencies - FIXED ✅
All critical inconsistencies between users, players, and room members have been resolved.
## Summary of Fixes
### 1. ✅ Backend - Player Fetching
**Created**: `src/lib/arcade/player-manager.ts`
- `getActivePlayers(userId)` - Get a user's active players
- `getRoomActivePlayers(roomId)` - Get all active players for all members in a room
- `getRoomPlayerIds(roomId)` - Get flat list of all player IDs in a room
- `validatePlayerInRoom(playerId, roomId)` - Validate player belongs to room member
- `getPlayer(playerId)` - Get single player by ID
- `getPlayers(playerIds[])` - Get multiple players by IDs
### 2. ✅ API Endpoints Updated
**`/api/arcade/rooms/:roomId/join` (POST)**
```typescript
// Now returns:
{
member: RoomMember,
room: Room,
activePlayers: Player[], // USER's active players
alreadyMember: boolean
}
```
**`/api/arcade/rooms/:roomId` (GET)**
```typescript
// Now returns:
{
room: Room,
members: RoomMember[],
memberPlayers: Record<userId, Player[]>, // Map of all members' players
canModerate: boolean
}
```
**`/api/arcade/rooms` (GET)**
```typescript
// Now returns:
{
rooms: Array<{
...roomData,
memberCount: number, // Number of users in room
playerCount: number // Total players across all users
}>
}
```
### 3. ✅ Socket Events Updated
**`join-room` event**
```typescript
// Server emits:
socket.emit('room-joined', {
room,
members,
onlineMembers,
memberPlayers: Record<userId, Player[]>, // All members' players
activePlayers: Player[] // This user's active players
})
socket.to(`room:${roomId}`).emit('member-joined', {
member,
activePlayers: Player[], // New member's active players
onlineMembers,
memberPlayers: Record<userId, Player[]>
})
```
**`room-game-move` event**
```typescript
// Now validates:
1. User is a room member (userId check)
2. Player belongs to a room member (playerId validation)
// Rejects move if playerId doesn't belong to any room member
```
### 4. ✅ Frontend UI Updated
**Room Lobby (`/arcade/rooms/[roomId]/page.tsx`)**
Before:
```
Member: Jane
Status: Online
```
After:
```
Member: Jane
Status: Online
Players: 👧 Alice, 👦 Bob
```
**Room Browser (`/arcade/rooms/page.tsx`)**
Before:
```
Room: Math Masters
Host: Jane | Game: matching | Status: Waiting
```
After:
```
Room: Math Masters
Host: Jane | Game: matching | 👥 3 members | 🎯 7 players | Status: Waiting
```
## Key Changes Summary
| Component | Change |
|-----------|--------|
| **Helper Functions** | Created `player-manager.ts` with 6 new functions |
| **Join Endpoint** | Now fetches and returns user's active players |
| **Room Detail Endpoint** | Returns player map for all members |
| **Rooms List Endpoint** | Returns member and player counts |
| **Socket join-room** | Broadcasts active players to room |
| **Socket room-game-move** | Validates player IDs belong to members |
| **Room Lobby UI** | Shows each member's players |
| **Room Browser UI** | Shows total member and player counts |
## Validation Rules Enforced
1.**Room membership tracked by USER ID** - Correct
2.**Game participation tracked by PLAYER IDs** - Fixed
3.**When user joins room, their active players join game** - Implemented
4.**Socket moves validate player belongs to room** - Added validation
5.**UI shows both members and their players** - Updated
## TypeScript Validation
All changes pass TypeScript validation with 0 errors in modified files:
- `src/lib/arcade/player-manager.ts`
- `src/app/api/arcade/rooms/route.ts`
- `src/app/api/arcade/rooms/[roomId]/route.ts`
- `src/app/api/arcade/rooms/[roomId]/join/route.ts`
- `src/app/arcade/rooms/page.tsx`
- `src/app/arcade/rooms/[roomId]/page.tsx`
- `socket-server.ts`
## Testing Checklist
- [ ] Create a user with multiple active players
- [ ] Join a room and verify all active players are shown
- [ ] Have multiple users join the same room
- [ ] Verify each user's players are displayed correctly
- [ ] Verify room browser shows correct member/player counts
- [ ] Start a game and verify all player IDs are collected
- [ ] Test that invalid player IDs are rejected in game moves
## Documentation Created
1. `docs/terminology-user-player-room.md` - Complete explanation
2. `.claude/terminology.md` - Quick reference for AI
3. `docs/INCONSISTENCIES.md` - Analysis of issues (pre-fix)
4. `docs/FIXES-APPLIED.md` - This document
## Next Steps (Phase 4)
The system is now ready for full multiplayer game integration:
1. When room game starts, collect all player IDs from all members
2. Set `arcade_sessions.activePlayers` to all room player IDs
3. Game state tracks scores/moves by PLAYER ID
4. Broadcast game updates to all room members

View File

@@ -0,0 +1,189 @@
# Current Implementation vs Correct Design - Inconsistencies
## ❌ Inconsistency 1: Room Join Doesn't Fetch Active Players
**Current Code** (`/api/arcade/rooms/:roomId/join`):
```typescript
// Only creates room_member record with userId
const member = await addRoomMember({
roomId,
userId: viewerId, // ✅ Correct: USER ID
displayName,
isCreator: false,
})
// ❌ Missing: Does not fetch user's active players
```
**Should Be**:
```typescript
// 1. Create room member
const member = await addRoomMember({ ... })
// 2. Fetch user's active players
const activePlayers = await db.query.players.findMany({
where: and(
eq(players.userId, viewerId),
eq(players.isActive, true)
)
})
// 3. Return both member and their active players
return { member, activePlayers }
```
---
## ❌ Inconsistency 2: Socket Events Use USER ID Instead of PLAYER ID
**Current Code** (`socket-server.ts`):
```typescript
socket.on('join-room', ({ roomId, userId }) => {
// Uses USER ID for presence
await setMemberOnline(roomId, userId, true)
socket.emit('room-joined', { members })
})
socket.on('room-game-move', ({ roomId, userId, move }) => {
// ❌ Wrong: Uses USER ID for game moves
// Should use PLAYER ID
})
```
**Should Be**:
```typescript
socket.on('join-room', ({ roomId, userId }) => {
// ✅ Correct: Use USER ID for room presence
await setMemberOnline(roomId, userId, true)
// ❌ Missing: Should also fetch and broadcast active players
const activePlayers = await getActivePlayers(userId)
socket.emit('room-joined', { members, activePlayers })
})
socket.on('room-game-move', ({ roomId, playerId, move }) => {
// ✅ Correct: Use PLAYER ID for game actions
// Validate that playerId belongs to a member in this room
})
```
---
## ❌ Inconsistency 3: Room Member Interface Missing Player Association
**Current Code** (`room_members` table):
```typescript
interface RoomMember {
id: string
roomId: string
userId: string // ✅ Correct: USER ID
displayName: string
isCreator: boolean
// ❌ Missing: No link to user's players
}
```
**Need to Add** (runtime association, not DB schema):
```typescript
interface RoomMemberWithPlayers {
member: RoomMember
activePlayers: Player[] // The user's active players
}
```
---
## ❌ Inconsistency 4: Client UI Shows Room Members, Not Players
**Current Code** (`/arcade/rooms/[roomId]/page.tsx`):
```typescript
// Shows room members (users)
{members.map((member) => (
<div key={member.id}>
{member.displayName} {/* USER's display name */}
</div>
))}
// ❌ Missing: Should show the PLAYERS that will participate
```
**Should Show**:
```typescript
{members.map((member) => (
<div key={member.id}>
<div>{member.displayName} (Room Member)</div>
<div>Players:
{member.activePlayers.map(player => (
<span key={player.id}>{player.emoji} {player.name}</span>
))}
</div>
</div>
))}
```
---
## Summary of Required Changes
### Phase 1: Backend - Player Fetching
1.`room_members` table correctly uses USER ID (no change needed)
2.`/api/arcade/rooms/:roomId/join` - Fetch and return active players
3.`/api/arcade/rooms/:roomId` GET - Include active players in response
4. ❌ Create helper: `getActivePlayers(userId) => Player[]`
### Phase 2: Socket Layer - Player Association
1.`join-room` event - Broadcast active players to room
2.`room-game-move` event - Accept PLAYER ID, not USER ID
3. ❌ Validate PLAYER ID belongs to a room member
### Phase 3: Frontend - Player Display
1. ❌ Room lobby - Show each member's active players
2. ❌ Game setup - Use PLAYER IDs for `activePlayers` array
3. ❌ Move/action events - Send PLAYER ID
### Phase 4: Game Integration
1. ❌ When room game starts, collect all PLAYER IDs from all members
2. ❌ Arcade session `activePlayers` should contain all room PLAYER IDs
3. ❌ Game state tracks scores/moves by PLAYER ID, not USER ID
---
## Test Scenarios
### Scenario 1: Single Player Per User
```
USER Jane (guest_123)
└─ PLAYER Alice (active)
Joins room → Room shows "Jane: Alice 👧"
Game starts → activePlayers: ["alice_id"]
```
### Scenario 2: Multiple Players Per User
```
USER Jane (guest_123)
├─ PLAYER Alice (active)
└─ PLAYER Bob (active)
Joins room → Room shows "Jane: Alice 👧, Bob 👦"
Game starts → activePlayers: ["alice_id", "bob_id"]
```
### Scenario 3: Multi-User Room
```
USER Jane
└─ PLAYER Alice, Bob (active)
USER Mark
└─ PLAYER Mario (active)
USER Sara
└─ PLAYER Luna, Nova, Star (active)
Room shows:
- Jane: Alice 👧, Bob 👦
- Mark: Mario 🍄
- Sara: Luna 🌙, Nova ✨, Star ⭐
Game starts → activePlayers: [alice, bob, mario, luna, nova, star]
Total: 6 players across 3 users
```

View File

@@ -0,0 +1,153 @@
# User vs Player vs Room Member - Terminology Guide
**Critical Distinction**: Users, Players, and Room Members are three different concepts in the system.
## Core Concepts
### 1. **USER** (Identity Layer)
- **Table**: `users`
- **Purpose**: Identity - guest or authenticated account
- **Identified by**: `guestId` (HttpOnly cookie)
- **Retrieved via**: `useViewerId()` hook
- **Scope**: One per browser/account
- **Example**: A person visiting the site
### 2. **PLAYER** (Game Avatar Layer)
- **Table**: `players`
- **Purpose**: Game profiles/avatars that represent a participant in the game
- **Belongs to**: USER (via `userId` FK)
- **Properties**: name, emoji, color, `isActive`
- **Scope**: A USER can have MULTIPLE players (e.g., "Alice 👧", "Bob 👦", "Charlie 🧒")
- **Used in**: All game contexts - both local and online multiplayer
- **Active Players**: Players where `isActive = true` are the ones currently participating
### 3. **ROOM MEMBER** (Room Participation Layer)
- **Table**: `room_members`
- **Purpose**: Tracks a USER's participation in a multiplayer room
- **Identified by**: `userId` (references the guest/user)
- **Properties**: `displayName`, `isCreator`, `isOnline`, `joinedAt`
- **Scope**: One record per USER per room
## How They Work Together
### When a USER joins a room:
1. **Room Member Created**: A `room_members` record is created with the USER's ID
2. **Active Players Join**: The USER's ACTIVE PLAYERS (where `isActive = true`) participate in the game
3. **Arcade Session**: The `arcade_sessions.activePlayers` field contains the PLAYER IDs (from `players` table)
### Example Flow:
```
USER: guest_abc123 (Jane)
├─ PLAYER: player_001 (name: "Alice 👧", isActive: true)
├─ PLAYER: player_002 (name: "Bob 👦", isActive: true)
└─ PLAYER: player_003 (name: "Charlie 🧒", isActive: false)
When USER joins ROOM "Math Masters":
→ ROOM_MEMBER created: {userId: "guest_abc123", displayName: "Jane", roomId: "room_xyz"}
→ PLAYERS joining game: ["player_001", "player_002"] (only active ones)
→ ARCADE_SESSION.activePlayers: ["player_001", "player_002"]
```
### Multi-User Room Example:
```
ROOM "Math Masters" (room_xyz):
ROOM_MEMBER 1:
userId: guest_abc123 (Jane)
└─ PLAYERS in game: ["player_001" (Alice), "player_002" (Bob)]
ROOM_MEMBER 2:
userId: guest_def456 (Mark)
└─ PLAYERS in game: ["player_003" (Mario)]
ROOM_MEMBER 3:
userId: guest_ghi789 (Sara)
└─ PLAYERS in game: ["player_004" (Luna), "player_005" (Nova), "player_006" (Star)]
Total PLAYERS in this game: 6 players across 3 users
```
## Database Schema Relationships
```
users (1) ──< (many) players
└──< (many) room_members
└──< belongs to arcade_rooms
arcade_sessions:
- userId: references users.id
- activePlayers: JSON array of player.id values
- roomId: references arcade_rooms.id (null for solo play)
```
## Implementation Rules
### ✅ Correct Usage
- **Room membership**: Track by USER ID
- **Game participation**: Track by PLAYER IDs
- **Presence/online status**: Track by USER ID (room member)
- **Scores/moves**: Track by PLAYER ID
- **Room creator**: Track by USER ID
### ❌ Common Mistakes
- ❌ Using USER ID where PLAYER ID is needed
- ❌ Assuming one USER = one PLAYER
- ❌ Tracking scores by USER instead of PLAYER
- ❌ Mixing room_members.displayName with players.name
## API Design Patterns
### When a USER joins a room:
```typescript
// 1. Add user as room member
POST /api/arcade/rooms/:roomId/join
Body: {
userId: string // USER ID (from useViewerId)
displayName: string // Room member display name
}
// 2. System retrieves user's active players
const activePlayers = await db.query.players.findMany({
where: and(
eq(players.userId, userId),
eq(players.isActive, true)
)
})
// 3. Game starts with those player IDs
const session = {
userId,
activePlayers: activePlayers.map(p => p.id), // PLAYER IDs
roomId
}
```
### Socket Events
```typescript
// User joins room (presence)
socket.emit('join-room', { roomId, userId })
// Player makes a move (game action)
socket.emit('game-move', {
roomId,
playerId, // PLAYER ID, not USER ID
move
})
```
## Summary
- **USER** = Identity/account (one per person)
- **PLAYER** = Game avatar/profile (multiple per user)
- **ROOM MEMBER** = USER's participation in a room
- **When USER joins room** → Their ACTIVE PLAYERS join the game
- **`activePlayers` field** → Array of PLAYER IDs from `players` table

View File

@@ -0,0 +1,15 @@
-- Step 1: Clean up any duplicate room memberships
-- Keep only the most recent membership for each user (by last_seen timestamp)
DELETE FROM `room_members`
WHERE `id` NOT IN (
SELECT `id` FROM (
SELECT `id`, ROW_NUMBER() OVER (
PARTITION BY `user_id`
ORDER BY `last_seen` DESC, `joined_at` DESC
) as rn
FROM `room_members`
) WHERE rn = 1
);--> statement-breakpoint
-- Step 2: Add unique constraint to enforce one room per user
CREATE UNIQUE INDEX `idx_room_members_user_id_unique` ON `room_members` (`user_id`);

View File

@@ -0,0 +1,660 @@
{
"version": "6",
"dialect": "sqlite",
"id": "cbd94d51-1454-467c-a471-ccbfca886a1a",
"prevId": "68cc273f-0d84-4a46-ae41-124a3e06096b",
"tables": {
"abacus_settings": {
"name": "abacus_settings",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"color_scheme": {
"name": "color_scheme",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'place-value'"
},
"bead_shape": {
"name": "bead_shape",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'diamond'"
},
"color_palette": {
"name": "color_palette",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'default'"
},
"hide_inactive_beads": {
"name": "hide_inactive_beads",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"colored_numerals": {
"name": "colored_numerals",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"scale_factor": {
"name": "scale_factor",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"show_numbers": {
"name": "show_numbers",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"animated": {
"name": "animated",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"interactive": {
"name": "interactive",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"gestures": {
"name": "gestures",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"sound_enabled": {
"name": "sound_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"sound_volume": {
"name": "sound_volume",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0.8
}
},
"indexes": {},
"foreignKeys": {
"abacus_settings_user_id_users_id_fk": {
"name": "abacus_settings_user_id_users_id_fk",
"tableFrom": "abacus_settings",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"arcade_rooms": {
"name": "arcade_rooms",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"code": {
"name": "code",
"type": "text(6)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_by": {
"name": "created_by",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"creator_name": {
"name": "creator_name",
"type": "text(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"last_activity": {
"name": "last_activity",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ttl_minutes": {
"name": "ttl_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 60
},
"is_locked": {
"name": "is_locked",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"game_name": {
"name": "game_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"game_config": {
"name": "game_config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'lobby'"
},
"current_session_id": {
"name": "current_session_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"total_games_played": {
"name": "total_games_played",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {
"arcade_rooms_code_unique": {
"name": "arcade_rooms_code_unique",
"columns": ["code"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"arcade_sessions": {
"name": "arcade_sessions",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"current_game": {
"name": "current_game",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"game_url": {
"name": "game_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"game_state": {
"name": "game_state",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"active_players": {
"name": "active_players",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"room_id": {
"name": "room_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"started_at": {
"name": "started_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"last_activity_at": {
"name": "last_activity_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_active": {
"name": "is_active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"version": {
"name": "version",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
}
},
"indexes": {},
"foreignKeys": {
"arcade_sessions_user_id_users_id_fk": {
"name": "arcade_sessions_user_id_users_id_fk",
"tableFrom": "arcade_sessions",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"arcade_sessions_room_id_arcade_rooms_id_fk": {
"name": "arcade_sessions_room_id_arcade_rooms_id_fk",
"tableFrom": "arcade_sessions",
"tableTo": "arcade_rooms",
"columnsFrom": ["room_id"],
"columnsTo": ["id"],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"players": {
"name": "players",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"emoji": {
"name": "emoji",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"color": {
"name": "color",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_active": {
"name": "is_active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"players_user_id_idx": {
"name": "players_user_id_idx",
"columns": ["user_id"],
"isUnique": false
}
},
"foreignKeys": {
"players_user_id_users_id_fk": {
"name": "players_user_id_users_id_fk",
"tableFrom": "players",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"room_members": {
"name": "room_members",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"room_id": {
"name": "room_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"display_name": {
"name": "display_name",
"type": "text(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_creator": {
"name": "is_creator",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"joined_at": {
"name": "joined_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"last_seen": {
"name": "last_seen",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_online": {
"name": "is_online",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
}
},
"indexes": {
"room_members_user_id_unique": {
"name": "room_members_user_id_unique",
"columns": ["user_id"],
"isUnique": true
},
"idx_room_members_user_id_unique": {
"name": "idx_room_members_user_id_unique",
"columns": ["user_id"],
"isUnique": true
}
},
"foreignKeys": {
"room_members_room_id_arcade_rooms_id_fk": {
"name": "room_members_room_id_arcade_rooms_id_fk",
"tableFrom": "room_members",
"tableTo": "arcade_rooms",
"columnsFrom": ["room_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user_stats": {
"name": "user_stats",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"games_played": {
"name": "games_played",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"total_wins": {
"name": "total_wins",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"favorite_game_type": {
"name": "favorite_game_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"best_time": {
"name": "best_time",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"highest_accuracy": {
"name": "highest_accuracy",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {},
"foreignKeys": {
"user_stats_user_id_users_id_fk": {
"name": "user_stats_user_id_users_id_fk",
"tableFrom": "user_stats",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"guest_id": {
"name": "guest_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"upgraded_at": {
"name": "upgraded_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"users_guest_id_unique": {
"name": "users_guest_id_unique",
"columns": ["guest_id"],
"isUnique": true
},
"users_email_unique": {
"name": "users_email_unique",
"columns": ["email"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -29,6 +29,13 @@
"when": 1759781243105,
"tag": "0003_naive_reptil",
"breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1759930182541,
"tag": "0004_shiny_madelyne_pryor",
"breakpoints": true
}
]
}

View File

@@ -1,5 +1,6 @@
import type { Server as HTTPServer } from 'http'
import { Server as SocketIOServer } from 'socket.io'
import type { Server as SocketIOServerType } from 'socket.io'
import {
applyGameMove,
createArcadeSession,
@@ -7,11 +8,25 @@ import {
getArcadeSession,
updateSessionActivity,
} from './src/lib/arcade/session-manager'
import type { GameMove } from './src/lib/arcade/validation'
import { createRoom, getRoomById } from './src/lib/arcade/room-manager'
import { getRoomMembers, getUserRooms, setMemberOnline } from './src/lib/arcade/room-membership'
import { getRoomActivePlayers } from './src/lib/arcade/player-manager'
import type { GameMove, GameName } from './src/lib/arcade/validation'
import { matchingGameValidator } from './src/lib/arcade/validation/MatchingGameValidator'
// Global socket.io server instance
let io: SocketIOServerType | null = null
/**
* Get the socket.io server instance
* Returns null if not initialized
*/
export function getSocketIO(): SocketIOServerType | null {
return io
}
export function initializeSocketServer(httpServer: HTTPServer) {
const io = new SocketIOServer(httpServer, {
io = new SocketIOServer(httpServer, {
path: '/api/socket',
cors: {
origin: process.env.NEXT_PUBLIC_URL || 'http://localhost:3000',
@@ -85,15 +100,52 @@ export function initializeSocketServer(httpServer: HTTPServer) {
turnTimer: 30,
})
// Check if user is already in a room for this game
const userRoomIds = await getUserRooms(data.userId)
let room = null
// Look for an existing active room for this game
for (const roomId of userRoomIds) {
const existingRoom = await getRoomById(roomId)
if (
existingRoom &&
existingRoom.gameName === 'matching' &&
existingRoom.status !== 'finished'
) {
room = existingRoom
console.log('🏠 Using existing room:', room.code)
break
}
}
// If no suitable room exists, create a new one
if (!room) {
room = await createRoom({
name: 'Auto-generated Room',
createdBy: data.userId,
creatorName: 'Player',
gameName: 'matching' as GameName,
gameConfig: {
difficulty: 6,
gameType: 'abacus-numeral',
turnTimer: 30,
},
ttlMinutes: 60,
})
console.log('🏠 Created new room:', room.code)
}
// Now create the session linked to the room
await createArcadeSession({
userId: data.userId,
gameName: 'matching',
gameUrl: '/arcade/matching',
initialState,
activePlayers,
roomId: room.id,
})
console.log('✅ Session created successfully')
console.log('✅ Session created successfully with room association')
// Notify all connected clients about the new session
const newSession = await getArcadeSession(data.userId)
@@ -162,6 +214,111 @@ export function initializeSocketServer(httpServer: HTTPServer) {
}
})
// Room: Join
socket.on('join-room', async ({ roomId, userId }: { roomId: string; userId: string }) => {
console.log(`🏠 User ${userId} joining room ${roomId}`)
try {
// Join the socket room
socket.join(`room:${roomId}`)
// Mark member as online
await setMemberOnline(roomId, userId, true)
// Get room data
const members = await getRoomMembers(roomId)
const memberPlayers = await getRoomActivePlayers(roomId)
// Convert memberPlayers Map to object for JSON serialization
const memberPlayersObj: Record<string, any[]> = {}
for (const [uid, players] of memberPlayers.entries()) {
memberPlayersObj[uid] = players
}
// Send current room state to the joining user
socket.emit('room-joined', {
roomId,
members,
memberPlayers: memberPlayersObj,
})
// Notify all other members in the room
socket.to(`room:${roomId}`).emit('member-joined', {
roomId,
userId,
members,
memberPlayers: memberPlayersObj,
})
console.log(`✅ User ${userId} joined room ${roomId}`)
} catch (error) {
console.error('Error joining room:', error)
socket.emit('room-error', { error: 'Failed to join room' })
}
})
// Room: Leave
socket.on('leave-room', async ({ roomId, userId }: { roomId: string; userId: string }) => {
console.log(`🚪 User ${userId} leaving room ${roomId}`)
try {
// Leave the socket room
socket.leave(`room:${roomId}`)
// Mark member as offline
await setMemberOnline(roomId, userId, false)
// Get updated members
const members = await getRoomMembers(roomId)
const memberPlayers = await getRoomActivePlayers(roomId)
// Convert memberPlayers Map to object
const memberPlayersObj: Record<string, any[]> = {}
for (const [uid, players] of memberPlayers.entries()) {
memberPlayersObj[uid] = players
}
// Notify remaining members
io.to(`room:${roomId}`).emit('member-left', {
roomId,
userId,
members,
memberPlayers: memberPlayersObj,
})
console.log(`✅ User ${userId} left room ${roomId}`)
} catch (error) {
console.error('Error leaving room:', error)
}
})
// Room: Players updated
socket.on('players-updated', async ({ roomId, userId }: { roomId: string; userId: string }) => {
console.log(`🎯 Players updated for user ${userId} in room ${roomId}`)
try {
// Get updated player data
const memberPlayers = await getRoomActivePlayers(roomId)
// Convert memberPlayers Map to object
const memberPlayersObj: Record<string, any[]> = {}
for (const [uid, players] of memberPlayers.entries()) {
memberPlayersObj[uid] = players
}
// Broadcast to all members in the room (including sender)
io.to(`room:${roomId}`).emit('room-players-updated', {
roomId,
memberPlayers: memberPlayersObj,
})
console.log(`✅ Broadcasted player updates for room ${roomId}`)
} catch (error) {
console.error('Error updating room players:', error)
socket.emit('room-error', { error: 'Failed to update players' })
}
})
socket.on('disconnect', () => {
console.log('🔌 Client disconnected:', socket.id)
if (currentUserId) {

View File

@@ -47,10 +47,16 @@ export async function GET(request: NextRequest) {
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { userId, gameName, gameUrl, initialState, activePlayers } = body
const { userId, gameName, gameUrl, initialState, activePlayers, roomId } = body
if (!userId || !gameName || !gameUrl || !initialState || !activePlayers) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 })
if (!userId || !gameName || !gameUrl || !initialState || !activePlayers || !roomId) {
return NextResponse.json(
{
error:
'Missing required fields (userId, gameName, gameUrl, initialState, activePlayers, roomId)',
},
{ status: 400 }
)
}
const session = await createArcadeSession({
@@ -59,6 +65,7 @@ export async function POST(request: NextRequest) {
gameUrl,
initialState,
activePlayers,
roomId,
})
return NextResponse.json({

View File

@@ -0,0 +1,125 @@
import { type NextRequest, NextResponse } from 'next/server'
import { getRoomById, touchRoom } from '@/lib/arcade/room-manager'
import { addRoomMember, getRoomMembers } from '@/lib/arcade/room-membership'
import { getActivePlayers, getRoomActivePlayers } from '@/lib/arcade/player-manager'
import { getViewerId } from '@/lib/viewer'
import { getSocketIO } from '@/lib/socket-io'
type RouteContext = {
params: Promise<{ roomId: string }>
}
/**
* POST /api/arcade/rooms/:roomId/join
* Join a room
* Body:
* - displayName?: string (optional, will generate from viewerId if not provided)
*/
export async function POST(req: NextRequest, context: RouteContext) {
try {
const { roomId } = await context.params
const viewerId = await getViewerId()
const body = await req.json().catch(() => ({}))
// Get room
const room = await getRoomById(roomId)
if (!room) {
return NextResponse.json({ error: 'Room not found' }, { status: 404 })
}
// Check if room is locked
if (room.isLocked) {
return NextResponse.json({ error: 'Room is locked' }, { status: 403 })
}
// Get or generate display name
const displayName = body.displayName || `Guest ${viewerId.slice(-4)}`
// Validate display name length
if (displayName.length > 50) {
return NextResponse.json(
{ error: 'Display name too long (max 50 characters)' },
{ status: 400 }
)
}
// Add member (with auto-leave logic for modal room enforcement)
const { member, autoLeaveResult } = await addRoomMember({
roomId,
userId: viewerId,
displayName,
isCreator: false,
})
// Fetch user's active players (these will participate in the game)
const activePlayers = await getActivePlayers(viewerId)
// Update room activity to refresh TTL
await touchRoom(roomId)
// Broadcast to all users in the room via socket
const io = await getSocketIO()
if (io) {
try {
const members = await getRoomMembers(roomId)
const memberPlayers = await getRoomActivePlayers(roomId)
// Convert memberPlayers Map to object for JSON serialization
const memberPlayersObj: Record<string, any[]> = {}
for (const [uid, players] of memberPlayers.entries()) {
memberPlayersObj[uid] = players
}
// Broadcast to all users in this room
io.to(`room:${roomId}`).emit('member-joined', {
roomId,
userId: viewerId,
members,
memberPlayers: memberPlayersObj,
})
console.log(`[Join API] Broadcasted member-joined for user ${viewerId} in room ${roomId}`)
} catch (socketError) {
// Log but don't fail the request if socket broadcast fails
console.error('[Join API] Failed to broadcast member-joined:', socketError)
}
}
// Build response with auto-leave info if applicable
return NextResponse.json(
{
member,
room,
activePlayers, // The user's active players that will join the game
autoLeave: autoLeaveResult
? {
leftRooms: autoLeaveResult.leftRooms,
roomCount: autoLeaveResult.leftRooms.length,
message: `You were automatically removed from ${autoLeaveResult.leftRooms.length} other room(s)`,
}
: undefined,
},
{ status: 201 }
)
} catch (error: any) {
console.error('Failed to join room:', error)
// Handle specific constraint violation error
if (error.message?.includes('ROOM_MEMBERSHIP_CONFLICT')) {
return NextResponse.json(
{
error: 'You are already in another room',
code: 'ROOM_MEMBERSHIP_CONFLICT',
message:
'You can only be in one room at a time. Please leave your current room before joining a new one.',
userMessage:
'⚠️ Already in Another Room\n\nYou can only be in one room at a time. Please refresh the page and try again.',
},
{ status: 409 } // 409 Conflict
)
}
// Generic error
return NextResponse.json({ error: 'Failed to join room' }, { status: 500 })
}
}

View File

@@ -0,0 +1,69 @@
import { type NextRequest, NextResponse } from 'next/server'
import { getRoomById } from '@/lib/arcade/room-manager'
import { getRoomMembers, isMember, removeMember } from '@/lib/arcade/room-membership'
import { getRoomActivePlayers } from '@/lib/arcade/player-manager'
import { getViewerId } from '@/lib/viewer'
import { getSocketIO } from '@/lib/socket-io'
type RouteContext = {
params: Promise<{ roomId: string }>
}
/**
* POST /api/arcade/rooms/:roomId/leave
* Leave a room
*/
export async function POST(_req: NextRequest, context: RouteContext) {
try {
const { roomId } = await context.params
const viewerId = await getViewerId()
// Get room
const room = await getRoomById(roomId)
if (!room) {
return NextResponse.json({ error: 'Room not found' }, { status: 404 })
}
// Check if member
const isMemberOfRoom = await isMember(roomId, viewerId)
if (!isMemberOfRoom) {
return NextResponse.json({ error: 'Not a member of this room' }, { status: 400 })
}
// Remove member
await removeMember(roomId, viewerId)
// Broadcast to all remaining users in the room via socket
const io = await getSocketIO()
if (io) {
try {
const members = await getRoomMembers(roomId)
const memberPlayers = await getRoomActivePlayers(roomId)
// Convert memberPlayers Map to object for JSON serialization
const memberPlayersObj: Record<string, any[]> = {}
for (const [uid, players] of memberPlayers.entries()) {
memberPlayersObj[uid] = players
}
// Broadcast to all users in this room
io.to(`room:${roomId}`).emit('member-left', {
roomId,
userId: viewerId,
members,
memberPlayers: memberPlayersObj,
})
console.log(`[Leave API] Broadcasted member-left for user ${viewerId} in room ${roomId}`)
} catch (socketError) {
// Log but don't fail the request if socket broadcast fails
console.error('[Leave API] Failed to broadcast member-left:', socketError)
}
}
return NextResponse.json({ success: true })
} catch (error) {
console.error('Failed to leave room:', error)
return NextResponse.json({ error: 'Failed to leave room' }, { status: 500 })
}
}

View File

@@ -0,0 +1,50 @@
import { type NextRequest, NextResponse } from 'next/server'
import { getRoomById, isRoomCreator } from '@/lib/arcade/room-manager'
import { isMember, removeMember } from '@/lib/arcade/room-membership'
import { getViewerId } from '@/lib/viewer'
type RouteContext = {
params: Promise<{ roomId: string; userId: string }>
}
/**
* DELETE /api/arcade/rooms/:roomId/members/:userId
* Kick a member from room (creator only)
*/
export async function DELETE(_req: NextRequest, context: RouteContext) {
try {
const { roomId, userId } = await context.params
const viewerId = await getViewerId()
// Get room
const room = await getRoomById(roomId)
if (!room) {
return NextResponse.json({ error: 'Room not found' }, { status: 404 })
}
// Check if requester is room creator
const isCreator = await isRoomCreator(roomId, viewerId)
if (!isCreator) {
return NextResponse.json({ error: 'Only room creator can kick members' }, { status: 403 })
}
// Cannot kick self
if (userId === viewerId) {
return NextResponse.json({ error: 'Cannot kick yourself' }, { status: 400 })
}
// Check if target user is a member
const isTargetMember = await isMember(roomId, userId)
if (!isTargetMember) {
return NextResponse.json({ error: 'User is not a member of this room' }, { status: 404 })
}
// Remove member
await removeMember(roomId, userId)
return NextResponse.json({ success: true })
} catch (error) {
console.error('Failed to kick member:', error)
return NextResponse.json({ error: 'Failed to kick member' }, { status: 500 })
}
}

View File

@@ -0,0 +1,35 @@
import { type NextRequest, NextResponse } from 'next/server'
import { getRoomById } from '@/lib/arcade/room-manager'
import { getOnlineMemberCount, getRoomMembers } from '@/lib/arcade/room-membership'
type RouteContext = {
params: Promise<{ roomId: string }>
}
/**
* GET /api/arcade/rooms/:roomId/members
* Get all members in a room
*/
export async function GET(_req: NextRequest, context: RouteContext) {
try {
const { roomId } = await context.params
// Get room
const room = await getRoomById(roomId)
if (!room) {
return NextResponse.json({ error: 'Room not found' }, { status: 404 })
}
// Get members
const members = await getRoomMembers(roomId)
const onlineCount = await getOnlineMemberCount(roomId)
return NextResponse.json({
members,
onlineCount,
})
} catch (error) {
console.error('Failed to fetch members:', error)
return NextResponse.json({ error: 'Failed to fetch members' }, { status: 500 })
}
}

View File

@@ -0,0 +1,132 @@
import { type NextRequest, NextResponse } from 'next/server'
import {
deleteRoom,
getRoomById,
isRoomCreator,
touchRoom,
updateRoom,
} from '@/lib/arcade/room-manager'
import { getRoomMembers } from '@/lib/arcade/room-membership'
import { getActivePlayers } from '@/lib/arcade/player-manager'
import { getViewerId } from '@/lib/viewer'
type RouteContext = {
params: Promise<{ roomId: string }>
}
/**
* GET /api/arcade/rooms/:roomId
* Get room details including members
*/
export async function GET(_req: NextRequest, context: RouteContext) {
try {
const { roomId } = await context.params
const viewerId = await getViewerId()
const room = await getRoomById(roomId)
if (!room) {
return NextResponse.json({ error: 'Room not found' }, { status: 404 })
}
const members = await getRoomMembers(roomId)
const canModerate = await isRoomCreator(roomId, viewerId)
// Fetch active players for each member
// This creates a map of userId -> Player[]
const memberPlayers: Record<string, any[]> = {}
for (const member of members) {
const activePlayers = await getActivePlayers(member.userId)
memberPlayers[member.userId] = activePlayers
}
// Update room activity when viewing (keeps active rooms fresh)
await touchRoom(roomId)
return NextResponse.json({
room,
members,
memberPlayers, // Map of userId -> active Player[] for each member
canModerate,
})
} catch (error) {
console.error('Failed to fetch room:', error)
return NextResponse.json({ error: 'Failed to fetch room' }, { status: 500 })
}
}
/**
* PATCH /api/arcade/rooms/:roomId
* Update room (creator only)
* Body:
* - name?: string
* - isLocked?: boolean
* - status?: 'lobby' | 'playing' | 'finished'
*/
export async function PATCH(req: NextRequest, context: RouteContext) {
try {
const { roomId } = await context.params
const viewerId = await getViewerId()
const body = await req.json()
// Check if user is room creator
const isCreator = await isRoomCreator(roomId, viewerId)
if (!isCreator) {
return NextResponse.json({ error: 'Only room creator can update room' }, { status: 403 })
}
// Validate name length if provided
if (body.name && body.name.length > 50) {
return NextResponse.json({ error: 'Room name too long (max 50 characters)' }, { status: 400 })
}
// Validate status if provided
if (body.status && !['lobby', 'playing', 'finished'].includes(body.status)) {
return NextResponse.json({ error: 'Invalid status' }, { status: 400 })
}
const updates: {
name?: string
isLocked?: boolean
status?: 'lobby' | 'playing' | 'finished'
} = {}
if (body.name !== undefined) updates.name = body.name
if (body.isLocked !== undefined) updates.isLocked = body.isLocked
if (body.status !== undefined) updates.status = body.status
const room = await updateRoom(roomId, updates)
if (!room) {
return NextResponse.json({ error: 'Room not found' }, { status: 404 })
}
return NextResponse.json({ room })
} catch (error) {
console.error('Failed to update room:', error)
return NextResponse.json({ error: 'Failed to update room' }, { status: 500 })
}
}
/**
* DELETE /api/arcade/rooms/:roomId
* Delete room (creator only)
*/
export async function DELETE(_req: NextRequest, context: RouteContext) {
try {
const { roomId } = await context.params
const viewerId = await getViewerId()
// Check if user is room creator
const isCreator = await isRoomCreator(roomId, viewerId)
if (!isCreator) {
return NextResponse.json({ error: 'Only room creator can delete room' }, { status: 403 })
}
await deleteRoom(roomId)
return NextResponse.json({ success: true })
} catch (error) {
console.error('Failed to delete room:', error)
return NextResponse.json({ error: 'Failed to delete room' }, { status: 500 })
}
}

View File

@@ -0,0 +1,39 @@
import { type NextRequest, NextResponse } from 'next/server'
import { getRoomByCode } from '@/lib/arcade/room-manager'
import { normalizeRoomCode } from '@/lib/arcade/room-code'
type RouteContext = {
params: Promise<{ code: string }>
}
/**
* GET /api/arcade/rooms/code/:code
* Get room by join code (for resolving codes to room IDs)
*/
export async function GET(_req: NextRequest, context: RouteContext) {
try {
const { code } = await context.params
// Normalize the code (uppercase, remove spaces/dashes)
const normalizedCode = normalizeRoomCode(code)
// Get room
const room = await getRoomByCode(normalizedCode)
if (!room) {
return NextResponse.json({ error: 'Room not found' }, { status: 404 })
}
// Generate redirect URL
const baseUrl = process.env.NEXT_PUBLIC_URL || 'http://localhost:3000'
const redirectUrl = `${baseUrl}/arcade/rooms/${room.id}`
return NextResponse.json({
roomId: room.id,
redirectUrl,
room,
})
} catch (error) {
console.error('Failed to find room by code:', error)
return NextResponse.json({ error: 'Failed to find room by code' }, { status: 500 })
}
}

View File

@@ -0,0 +1,126 @@
import { type NextRequest, NextResponse } from 'next/server'
import { createRoom, listActiveRooms } from '@/lib/arcade/room-manager'
import { addRoomMember, getRoomMembers, isMember } from '@/lib/arcade/room-membership'
import { getRoomActivePlayers } from '@/lib/arcade/player-manager'
import { getViewerId } from '@/lib/viewer'
import type { GameName } from '@/lib/arcade/validation'
/**
* GET /api/arcade/rooms
* List all active public rooms (lobby view)
* Query params:
* - gameName?: string - Filter by game
*/
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url)
const gameName = searchParams.get('gameName') as GameName | null
const viewerId = await getViewerId()
const rooms = await listActiveRooms(gameName || undefined)
// Enrich with member counts, player counts, and membership status
const roomsWithCounts = await Promise.all(
rooms.map(async (room) => {
const members = await getRoomMembers(room.id)
const playerMap = await getRoomActivePlayers(room.id)
const userIsMember = await isMember(room.id, viewerId)
let totalPlayers = 0
for (const players of playerMap.values()) {
totalPlayers += players.length
}
return {
id: room.id,
name: room.name,
code: room.code,
gameName: room.gameName,
status: room.status,
createdAt: room.createdAt,
creatorName: room.creatorName,
isLocked: room.isLocked,
memberCount: members.length,
playerCount: totalPlayers,
isMember: userIsMember,
}
})
)
return NextResponse.json({ rooms: roomsWithCounts })
} catch (error) {
console.error('Failed to fetch rooms:', error)
return NextResponse.json({ error: 'Failed to fetch rooms' }, { status: 500 })
}
}
/**
* POST /api/arcade/rooms
* Create a new room
* Body:
* - name: string
* - gameName: string
* - gameConfig?: object
* - ttlMinutes?: number
*/
export async function POST(req: NextRequest) {
try {
const viewerId = await getViewerId()
const body = await req.json()
// Validate required fields
if (!body.name || !body.gameName) {
return NextResponse.json(
{ error: 'Missing required fields: name, gameName' },
{ status: 400 }
)
}
// Validate game name
const validGames: GameName[] = ['matching', 'memory-quiz', 'complement-race']
if (!validGames.includes(body.gameName)) {
return NextResponse.json({ error: 'Invalid game name' }, { status: 400 })
}
// Validate name length
if (body.name.length > 50) {
return NextResponse.json({ error: 'Room name too long (max 50 characters)' }, { status: 400 })
}
// Get display name from body or generate from viewerId
const displayName = body.creatorName || `Guest ${viewerId.slice(-4)}`
// Create room
const room = await createRoom({
name: body.name,
createdBy: viewerId,
creatorName: displayName,
gameName: body.gameName,
gameConfig: body.gameConfig || {},
ttlMinutes: body.ttlMinutes,
})
// Add creator as first member
await addRoomMember({
roomId: room.id,
userId: viewerId,
displayName,
isCreator: true,
})
// Generate join URL
const baseUrl = process.env.NEXT_PUBLIC_URL || 'http://localhost:3000'
const joinUrl = `${baseUrl}/arcade/rooms/${room.id}`
return NextResponse.json(
{
room,
joinUrl,
},
{ status: 201 }
)
} catch (error) {
console.error('Failed to create room:', error)
return NextResponse.json({ error: 'Failed to create room' }, { status: 500 })
}
}

View File

@@ -0,0 +1,57 @@
import { NextResponse } from 'next/server'
import { getViewerId } from '@/lib/viewer'
import { getActivePlayers } from '@/lib/arcade/player-manager'
import { db, schema } from '@/db'
import { eq } from 'drizzle-orm'
/**
* GET /api/debug/active-players
* Debug endpoint to check active players for current user
*/
export async function GET() {
try {
const viewerId = await getViewerId()
// Get user record
const user = await db.query.users.findFirst({
where: eq(schema.users.guestId, viewerId),
})
if (!user) {
return NextResponse.json({ error: 'User not found', viewerId }, { status: 404 })
}
// Get ALL players for this user
const allPlayers = await db.query.players.findMany({
where: eq(schema.players.userId, user.id),
})
// Get active players using the helper
const activePlayers = await getActivePlayers(viewerId)
return NextResponse.json({
viewerId,
userId: user.id,
allPlayers: allPlayers.map((p) => ({
id: p.id,
name: p.name,
emoji: p.emoji,
isActive: p.isActive,
})),
activePlayers: activePlayers.map((p) => ({
id: p.id,
name: p.name,
emoji: p.emoji,
isActive: p.isActive,
})),
activeCount: activePlayers.length,
totalCount: allPlayers.length,
})
} catch (error) {
console.error('Failed to fetch active players:', error)
return NextResponse.json(
{ error: 'Failed to fetch active players', details: String(error) },
{ status: 500 }
)
}
}

View File

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

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

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

@@ -0,0 +1,665 @@
'use client'
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 { PageWithNav } from '@/components/PageWithNav'
import { useViewerId } from '@/hooks/useViewerId'
interface Room {
id: string
code: string
name: string
gameName: string
status: 'lobby' | 'playing' | 'finished'
createdBy: string
creatorName: string
isLocked: boolean
}
interface Member {
id: string
userId: string
displayName: string
isCreator: boolean
isOnline: boolean
joinedAt: Date
}
interface Player {
id: string
userId: string
name: string
emoji: string
color: string
isActive: boolean
}
export default function RoomDetailPage() {
const params = useParams()
const router = useRouter()
const roomId = params.roomId as string
const { data: guestId } = useViewerId()
const [room, setRoom] = useState<Room | null>(null)
const [members, setMembers] = useState<Member[]>([])
const [memberPlayers, setMemberPlayers] = useState<Record<string, Player[]>>({})
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [socket, setSocket] = useState<Socket | null>(null)
const [isConnected, setIsConnected] = useState(false)
useEffect(() => {
fetchRoom()
}, [roomId])
useEffect(() => {
if (!guestId || !roomId) return
// Connect to socket
const sock = io({ path: '/api/socket' })
setSocket(sock)
sock.on('connect', () => {
setIsConnected(true)
// Join the room
sock.emit('join-room', { roomId, userId: guestId })
})
sock.on('disconnect', () => {
setIsConnected(false)
})
sock.on('room-joined', (data) => {
console.log('Joined room:', data)
if (data.members) {
setMembers(data.members)
}
if (data.memberPlayers) {
setMemberPlayers(data.memberPlayers)
}
})
sock.on('member-joined', (data) => {
console.log('Member joined:', data)
if (data.members) {
setMembers(data.members)
}
if (data.memberPlayers) {
setMemberPlayers(data.memberPlayers)
}
})
sock.on('member-left', (data) => {
console.log('Member left:', data)
if (data.members) {
setMembers(data.members)
}
if (data.memberPlayers) {
setMemberPlayers(data.memberPlayers)
}
})
sock.on('room-error', (error) => {
console.error('Room error:', error)
setError(error.error)
})
sock.on('room-players-updated', (data) => {
console.log('Room players updated:', data)
if (data.memberPlayers) {
setMemberPlayers(data.memberPlayers)
}
})
return () => {
sock.emit('leave-room', { roomId, userId: guestId })
sock.disconnect()
}
}, [roomId, guestId])
// Notify room when window regains focus (user might have changed players in another tab)
useEffect(() => {
if (!socket || !guestId || !roomId) return
const handleFocus = () => {
console.log('Window focused, notifying room of potential player changes')
socket.emit('players-updated', { roomId, userId: guestId })
}
window.addEventListener('focus', handleFocus)
return () => window.removeEventListener('focus', handleFocus)
}, [socket, roomId, guestId])
const fetchRoom = async () => {
try {
setLoading(true)
const response = await fetch(`/api/arcade/rooms/${roomId}`)
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const data = await response.json()
setRoom(data.room)
setMembers(data.members || [])
setMemberPlayers(data.memberPlayers || {})
setError(null)
} catch (err) {
console.error('Failed to fetch room:', err)
setError('Failed to load room')
} finally {
setLoading(false)
}
}
const startGame = () => {
if (!room) return
// Navigate to the game with the room ID
router.push(`/arcade/rooms/${roomId}/${room.gameName}`)
}
const joinRoom = async () => {
try {
const response = await fetch(`/api/arcade/rooms/${roomId}/join`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ displayName: 'Player' }),
})
if (!response.ok) {
const errorData = await response.json()
// Handle specific room membership conflict
if (errorData.code === 'ROOM_MEMBERSHIP_CONFLICT') {
alert(errorData.userMessage || errorData.message)
// Refresh the page to update room state
await fetchRoom()
return
}
throw new Error(errorData.error || `HTTP ${response.status}`)
}
const data = await response.json()
// Show notification if user was auto-removed from other rooms
if (data.autoLeave) {
console.log(`[Room Join] ${data.autoLeave.message}`)
// Could show a toast notification here in the future
}
// Refresh room data to update membership UI
await fetchRoom()
} catch (err) {
console.error('Failed to join room:', err)
alert('Failed to join room')
}
}
const leaveRoom = async () => {
try {
const response = await fetch(`/api/arcade/rooms/${roomId}/leave`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || `HTTP ${response.status}`)
}
// Navigate to arcade home after successfully leaving
router.push('/arcade')
} catch (err) {
console.error('Failed to leave room:', err)
alert('Failed to leave room')
}
}
if (loading) {
return (
<PageWithNav>
<div
className={css({
minH: 'calc(100vh - 80px)',
bg: 'linear-gradient(135deg, #0f0f23 0%, #1a1a3a 50%, #2d1b69 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
fontSize: 'xl',
})}
>
Loading room...
</div>
</PageWithNav>
)
}
if (error || !room) {
return (
<PageWithNav>
<div
className={css({
minH: 'calc(100vh - 80px)',
bg: 'linear-gradient(135deg, #0f0f23 0%, #1a1a3a 50%, #2d1b69 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
p: '8',
})}
>
<div
className={css({
bg: 'rgba(255, 255, 255, 0.05)',
backdropFilter: 'blur(10px)',
border: '1px solid rgba(255, 255, 255, 0.1)',
rounded: 'lg',
p: '12',
textAlign: 'center',
maxW: '500px',
})}
>
<p className={css({ fontSize: 'xl', color: 'white', mb: '4' })}>
{error || 'Room not found'}
</p>
<button
onClick={() => router.push('/arcade/rooms')}
className={css({
px: '6',
py: '3',
bg: '#3b82f6',
color: 'white',
rounded: 'lg',
fontWeight: '600',
cursor: 'pointer',
_hover: { bg: '#2563eb' },
})}
>
Back to Rooms
</button>
</div>
</div>
</PageWithNav>
)
}
const onlineMembers = members.filter((m) => m.isOnline)
// Check if current user is a member
const isMember = members.some((m) => m.userId === guestId)
// Calculate union of all active players in the room
const allPlayers: Player[] = []
const playerIds = new Set<string>()
for (const userId in memberPlayers) {
for (const player of memberPlayers[userId]) {
if (!playerIds.has(player.id)) {
playerIds.add(player.id)
allPlayers.push(player)
}
}
}
return (
<PageWithNav>
<div
className={css({
minH: 'calc(100vh - 80px)',
bg: 'linear-gradient(135deg, #0f0f23 0%, #1a1a3a 50%, #2d1b69 100%)',
p: '8',
})}
>
<div className={css({ maxW: '1000px', mx: 'auto' })}>
{/* Header */}
<div
className={css({
bg: 'rgba(255, 255, 255, 0.05)',
backdropFilter: 'blur(10px)',
border: '1px solid rgba(255, 255, 255, 0.1)',
rounded: 'lg',
p: '8',
mb: '6',
})}
>
<div className={css({ mb: '4' })}>
<button
onClick={() => router.push('/arcade/rooms')}
className={css({
display: 'inline-flex',
alignItems: 'center',
gap: '2',
color: '#a0a0ff',
fontSize: 'sm',
cursor: 'pointer',
_hover: { color: '#60a5fa' },
mb: '3',
})}
>
Back to Rooms
</button>
</div>
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: '4',
})}
>
<div>
<h1
className={css({ fontSize: '3xl', fontWeight: 'bold', color: 'white', mb: '2' })}
>
{room.name}
</h1>
<div
className={css({ display: 'flex', gap: '4', color: '#a0a0ff', fontSize: 'sm' })}
>
<span>🎮 {room.gameName}</span>
<span>👤 Host: {room.creatorName}</span>
<span
className={css({
px: '3',
py: '1',
bg: 'rgba(255, 255, 255, 0.1)',
color: '#fbbf24',
rounded: 'full',
fontWeight: '600',
fontFamily: 'monospace',
})}
>
Code: {room.code}
</span>
</div>
</div>
<div className={css({ display: 'flex', gap: '3', alignItems: 'center' })}>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '2',
px: '3',
py: '2',
bg: isConnected ? 'rgba(16, 185, 129, 0.2)' : 'rgba(239, 68, 68, 0.2)',
border: `1px solid ${isConnected ? '#10b981' : '#ef4444'}`,
rounded: 'full',
})}
>
<div
className={css({
w: '2',
h: '2',
bg: isConnected ? '#10b981' : '#ef4444',
rounded: 'full',
})}
/>
<span
className={css({ color: isConnected ? '#10b981' : '#ef4444', fontSize: 'sm' })}
>
{isConnected ? 'Connected' : 'Disconnected'}
</span>
</div>
</div>
</div>
</div>
{/* Game Players - Union of all active players */}
<div
className={css({
bg: 'rgba(255, 255, 255, 0.05)',
backdropFilter: 'blur(10px)',
border: '1px solid rgba(255, 255, 255, 0.1)',
rounded: 'lg',
p: '8',
mb: '6',
})}
>
<h2 className={css({ fontSize: '2xl', fontWeight: 'bold', color: 'white', mb: '2' })}>
🎯 Game Players ({allPlayers.length})
</h2>
<p className={css({ color: '#a0a0ff', fontSize: 'sm', mb: '4' })}>
These players will participate when the game starts
</p>
{allPlayers.length > 0 ? (
<div className={css({ display: 'flex', gap: '2', flexWrap: 'wrap' })}>
{allPlayers.map((player) => (
<div
key={player.id}
className={css({
display: 'flex',
alignItems: 'center',
gap: '2',
px: '3',
py: '2',
bg: 'rgba(59, 130, 246, 0.15)',
border: '2px solid rgba(59, 130, 246, 0.4)',
rounded: 'lg',
color: '#60a5fa',
fontWeight: '600',
})}
>
<span className={css({ fontSize: 'xl' })}>{player.emoji}</span>
<span>{player.name}</span>
</div>
))}
</div>
) : (
<div
className={css({
color: '#6b7280',
fontStyle: 'italic',
textAlign: 'center',
py: '4',
})}
>
No active players yet. Members need to set up their players.
</div>
)}
</div>
{/* Members List */}
<div
className={css({
bg: 'rgba(255, 255, 255, 0.05)',
backdropFilter: 'blur(10px)',
border: '1px solid rgba(255, 255, 255, 0.1)',
rounded: 'lg',
p: '8',
mb: '6',
})}
>
<h2 className={css({ fontSize: '2xl', fontWeight: 'bold', color: 'white', mb: '2' })}>
👥 Room Members ({onlineMembers.length}/{members.length})
</h2>
<p className={css({ color: '#a0a0ff', fontSize: 'sm', mb: '4' })}>
Users in this room and their active players
</p>
<div className={css({ display: 'grid', gap: '3' })}>
{members.map((member) => {
const players = memberPlayers[member.userId] || []
return (
<div
key={member.id}
className={css({
display: 'flex',
flexDirection: 'column',
gap: '2',
p: '4',
bg: 'rgba(255, 255, 255, 0.05)',
border: '1px solid rgba(255, 255, 255, 0.1)',
rounded: 'lg',
opacity: member.isOnline ? 1 : 0.5,
})}
>
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
})}
>
<div className={css({ display: 'flex', alignItems: 'center', gap: '3' })}>
<div
className={css({
w: '3',
h: '3',
bg: member.isOnline ? '#10b981' : '#6b7280',
rounded: 'full',
})}
/>
<span className={css({ color: 'white', fontWeight: '600' })}>
{member.displayName}
</span>
{member.isCreator && (
<span
className={css({
px: '2',
py: '1',
bg: 'rgba(251, 191, 36, 0.2)',
color: '#fbbf24',
rounded: 'full',
fontSize: 'xs',
fontWeight: '600',
})}
>
HOST
</span>
)}
</div>
<span className={css({ color: '#a0a0ff', fontSize: 'sm' })}>
{member.isOnline ? '🟢 Online' : '⚫ Offline'}
</span>
</div>
{players.length > 0 && (
<div
className={css({ display: 'flex', gap: '2', flexWrap: 'wrap', ml: '6' })}
>
<span className={css({ color: '#a0a0ff', fontSize: 'xs', mr: '1' })}>
Players:
</span>
{players.map((player) => (
<span
key={player.id}
className={css({
px: '2',
py: '1',
bg: 'rgba(59, 130, 246, 0.2)',
color: '#60a5fa',
border: '1px solid rgba(59, 130, 246, 0.3)',
rounded: 'full',
fontSize: 'xs',
fontWeight: '600',
})}
>
{player.emoji} {player.name}
</span>
))}
</div>
)}
{players.length === 0 && (
<div
className={css({
ml: '6',
color: '#6b7280',
fontSize: 'xs',
fontStyle: 'italic',
})}
>
No active players
</div>
)}
</div>
)
})}
</div>
</div>
{/* Actions */}
<div className={css({ display: 'flex', gap: '4' })}>
{isMember ? (
<>
<button
onClick={leaveRoom}
className={css({
flex: 1,
px: '6',
py: '4',
bg: 'rgba(255, 255, 255, 0.1)',
color: 'white',
rounded: 'lg',
fontWeight: '600',
cursor: 'pointer',
_hover: { bg: 'rgba(255, 255, 255, 0.15)' },
})}
>
Leave Room
</button>
<button
onClick={startGame}
disabled={allPlayers.length < 1}
className={css({
flex: 2,
px: '6',
py: '4',
bg: allPlayers.length < 1 ? '#6b7280' : '#10b981',
color: 'white',
rounded: 'lg',
fontSize: 'xl',
fontWeight: '600',
cursor: allPlayers.length < 1 ? 'not-allowed' : 'pointer',
opacity: allPlayers.length < 1 ? 0.5 : 1,
_hover: allPlayers.length < 1 ? {} : { bg: '#059669' },
})}
>
{allPlayers.length < 1
? 'Waiting for players...'
: `🎮 Start Game (${allPlayers.length} players)`}
</button>
</>
) : (
<>
<button
onClick={() => router.push('/arcade/rooms')}
className={css({
flex: 1,
px: '6',
py: '4',
bg: 'rgba(255, 255, 255, 0.1)',
color: 'white',
rounded: 'lg',
fontWeight: '600',
cursor: 'pointer',
_hover: { bg: 'rgba(255, 255, 255, 0.15)' },
})}
>
Back to Rooms
</button>
<button
onClick={joinRoom}
disabled={room.isLocked}
className={css({
flex: 2,
px: '6',
py: '4',
bg: room.isLocked ? '#6b7280' : '#3b82f6',
color: 'white',
rounded: 'lg',
fontSize: 'xl',
fontWeight: '600',
cursor: room.isLocked ? 'not-allowed' : 'pointer',
opacity: room.isLocked ? 0.5 : 1,
_hover: room.isLocked ? {} : { bg: '#2563eb' },
})}
>
{room.isLocked ? '🔒 Room Locked' : 'Join Room'}
</button>
</>
)}
</div>
</div>
</div>
</PageWithNav>
)
}

View File

@@ -0,0 +1,468 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { css } from '../../../../styled-system/css'
import { PageWithNav } from '@/components/PageWithNav'
interface Room {
id: string
code: string
name: string
gameName: string
status: 'lobby' | 'playing' | 'finished'
createdAt: Date
creatorName: string
isLocked: boolean
memberCount?: number
playerCount?: number
isMember?: boolean
}
export default function RoomBrowserPage() {
const router = useRouter()
const [rooms, setRooms] = useState<Room[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showCreateModal, setShowCreateModal] = useState(false)
useEffect(() => {
fetchRooms()
}, [])
const fetchRooms = async () => {
try {
setLoading(true)
const response = await fetch('/api/arcade/rooms')
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const data = await response.json()
setRooms(data.rooms)
setError(null)
} catch (err) {
console.error('Failed to fetch rooms:', err)
setError('Failed to load rooms')
} finally {
setLoading(false)
}
}
const createRoom = async (name: string, gameName: string) => {
try {
const response = await fetch('/api/arcade/rooms', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
gameName,
creatorName: 'Player',
gameConfig: { difficulty: 6 },
}),
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const data = await response.json()
router.push(`/arcade/rooms/${data.room.id}`)
} catch (err) {
console.error('Failed to create room:', err)
alert('Failed to create room')
}
}
const joinRoom = async (roomId: string) => {
try {
const response = await fetch(`/api/arcade/rooms/${roomId}/join`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ displayName: 'Player' }),
})
if (!response.ok) {
const errorData = await response.json()
// Handle specific room membership conflict
if (errorData.code === 'ROOM_MEMBERSHIP_CONFLICT') {
alert(errorData.userMessage || errorData.message)
// Refresh the page to update room list state
await fetchRooms()
return
}
throw new Error(errorData.error || `HTTP ${response.status}`)
}
const data = await response.json()
// Show notification if user was auto-removed from other rooms
if (data.autoLeave) {
console.log(`[Room Join] ${data.autoLeave.message}`)
// Could show a toast notification here in the future
}
router.push(`/arcade/rooms/${roomId}`)
} catch (err) {
console.error('Failed to join room:', err)
alert('Failed to join room')
}
}
return (
<PageWithNav>
<div
className={css({
minH: 'calc(100vh - 80px)',
bg: 'linear-gradient(135deg, #0f0f23 0%, #1a1a3a 50%, #2d1b69 100%)',
p: '8',
})}
>
<div className={css({ maxW: '1200px', mx: 'auto' })}>
{/* Header */}
<div className={css({ mb: '8', textAlign: 'center' })}>
<h1
className={css({
fontSize: '4xl',
fontWeight: 'bold',
color: 'white',
mb: '4',
})}
>
🎮 Multiplayer Rooms
</h1>
<p className={css({ color: '#a0a0ff', fontSize: 'lg', mb: '6' })}>
Join a room or create your own to play with friends
</p>
<button
onClick={() => setShowCreateModal(true)}
className={css({
px: '6',
py: '3',
bg: '#10b981',
color: 'white',
rounded: 'lg',
fontSize: 'lg',
fontWeight: '600',
cursor: 'pointer',
_hover: { bg: '#059669' },
transition: 'all 0.2s',
})}
>
+ Create New Room
</button>
</div>
{/* Room List */}
{loading && (
<div className={css({ textAlign: 'center', color: 'white', py: '12' })}>
Loading rooms...
</div>
)}
{error && (
<div
className={css({
bg: '#fef2f2',
border: '1px solid #fecaca',
color: '#991b1b',
p: '4',
rounded: 'lg',
textAlign: 'center',
})}
>
{error}
</div>
)}
{!loading && !error && rooms.length === 0 && (
<div
className={css({
bg: 'rgba(255, 255, 255, 0.05)',
backdropFilter: 'blur(10px)',
border: '1px solid rgba(255, 255, 255, 0.1)',
rounded: 'lg',
p: '12',
textAlign: 'center',
color: 'white',
})}
>
<p className={css({ fontSize: 'xl', mb: '2' })}>No rooms available</p>
<p className={css({ color: '#a0a0ff' })}>Be the first to create one!</p>
</div>
)}
{!loading && !error && rooms.length > 0 && (
<div className={css({ display: 'grid', gap: '4' })}>
{rooms.map((room) => (
<div
key={room.id}
className={css({
bg: 'rgba(255, 255, 255, 0.05)',
backdropFilter: 'blur(10px)',
border: '1px solid rgba(255, 255, 255, 0.1)',
rounded: 'lg',
p: '6',
transition: 'all 0.2s',
_hover: {
bg: 'rgba(255, 255, 255, 0.08)',
borderColor: 'rgba(255, 255, 255, 0.2)',
},
})}
>
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
})}
>
<div
onClick={() => router.push(`/arcade/rooms/${room.id}`)}
className={css({ flex: 1, cursor: 'pointer' })}
>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '3',
mb: '2',
})}
>
<h3
className={css({ fontSize: '2xl', fontWeight: 'bold', color: 'white' })}
>
{room.name}
</h3>
<span
className={css({
px: '3',
py: '1',
bg: 'rgba(255, 255, 255, 0.1)',
color: '#fbbf24',
rounded: 'full',
fontSize: 'sm',
fontWeight: '600',
fontFamily: 'monospace',
})}
>
{room.code}
</span>
{room.isLocked && (
<span className={css({ color: '#f87171', fontSize: 'sm' })}>
🔒 Locked
</span>
)}
</div>
<div
className={css({
display: 'flex',
gap: '4',
color: '#a0a0ff',
fontSize: 'sm',
flexWrap: 'wrap',
})}
>
<span>👤 Host: {room.creatorName}</span>
<span>🎮 {room.gameName}</span>
{room.memberCount !== undefined && (
<span>
👥 {room.memberCount} member{room.memberCount !== 1 ? 's' : ''}
</span>
)}
{room.playerCount !== undefined && room.playerCount > 0 && (
<span>
🎯 {room.playerCount} player{room.playerCount !== 1 ? 's' : ''}
</span>
)}
<span
className={css({
color:
room.status === 'lobby'
? '#10b981'
: room.status === 'playing'
? '#fbbf24'
: '#6b7280',
})}
>
{room.status === 'lobby'
? '⏳ Waiting'
: room.status === 'playing'
? '🎮 Playing'
: '✓ Finished'}
</span>
</div>
</div>
{room.isMember ? (
<div
className={css({
px: '6',
py: '3',
bg: '#10b981',
color: 'white',
rounded: 'lg',
fontWeight: '600',
display: 'flex',
alignItems: 'center',
gap: '2',
})}
>
Joined
</div>
) : (
<button
onClick={(e) => {
e.stopPropagation()
joinRoom(room.id)
}}
disabled={room.isLocked}
className={css({
px: '6',
py: '3',
bg: room.isLocked ? '#6b7280' : '#3b82f6',
color: 'white',
rounded: 'lg',
fontWeight: '600',
cursor: room.isLocked ? 'not-allowed' : 'pointer',
opacity: room.isLocked ? 0.5 : 1,
_hover: room.isLocked ? {} : { bg: '#2563eb' },
transition: 'all 0.2s',
})}
>
Join Room
</button>
)}
</div>
</div>
))}
</div>
)}
</div>
{/* Create Room Modal */}
{showCreateModal && (
<div
className={css({
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
bg: 'rgba(0, 0, 0, 0.7)',
backdropFilter: 'blur(4px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 50,
})}
onClick={() => setShowCreateModal(false)}
>
<div
className={css({
bg: 'white',
rounded: 'xl',
p: '8',
maxW: '500px',
w: 'full',
mx: '4',
})}
onClick={(e) => e.stopPropagation()}
>
<h2 className={css({ fontSize: '2xl', fontWeight: 'bold', mb: '6' })}>
Create New Room
</h2>
<form
onSubmit={(e) => {
e.preventDefault()
const formData = new FormData(e.currentTarget)
const name = formData.get('name') as string
const gameName = formData.get('gameName') as string
if (name && gameName) {
createRoom(name, gameName)
}
}}
>
<div className={css({ mb: '4' })}>
<label className={css({ display: 'block', mb: '2', fontWeight: '600' })}>
Room Name
</label>
<input
name="name"
type="text"
required
placeholder="My Awesome Room"
className={css({
w: 'full',
px: '4',
py: '3',
border: '1px solid #d1d5db',
rounded: 'lg',
_focus: { outline: 'none', borderColor: '#3b82f6' },
})}
/>
</div>
<div className={css({ mb: '6' })}>
<label className={css({ display: 'block', mb: '2', fontWeight: '600' })}>
Game
</label>
<select
name="gameName"
required
className={css({
w: 'full',
px: '4',
py: '3',
border: '1px solid #d1d5db',
rounded: 'lg',
_focus: { outline: 'none', borderColor: '#3b82f6' },
})}
>
<option value="matching">Memory Matching</option>
<option value="memory-quiz">Memory Quiz</option>
<option value="complement-race">Complement Race</option>
</select>
</div>
<div className={css({ display: 'flex', gap: '3' })}>
<button
type="button"
onClick={() => setShowCreateModal(false)}
className={css({
flex: 1,
px: '6',
py: '3',
bg: '#e5e7eb',
color: '#374151',
rounded: 'lg',
fontWeight: '600',
cursor: 'pointer',
_hover: { bg: '#d1d5db' },
})}
>
Cancel
</button>
<button
type="submit"
className={css({
flex: 1,
px: '6',
py: '3',
bg: '#10b981',
color: 'white',
rounded: 'lg',
fontWeight: '600',
cursor: 'pointer',
_hover: { bg: '#059669' },
})}
>
Create Room
</button>
</div>
</form>
</div>
</div>
)}
</div>
</PageWithNav>
)
}

View File

@@ -2,6 +2,7 @@
import React from 'react'
import { useGameMode } from '../contexts/GameModeContext'
import { useArcadeGuard } from '../hooks/useArcadeGuard'
import { AppNavBar } from './AppNavBar'
import { GameContextNav } from './nav/GameContextNav'
import { PlayerConfigDialog } from './nav/PlayerConfigDialog'
@@ -28,6 +29,7 @@ export function PageWithNav({
children,
}: PageWithNavProps) {
const { players, activePlayers, setActive, activePlayerCount } = useGameMode()
const { hasActiveSession, activeSession } = useArcadeGuard({ enabled: false }) // Don't redirect, just get info
const [mounted, setMounted] = React.useState(false)
const [configurePlayerId, setConfigurePlayerId] = React.useState<string | null>(null)
@@ -76,6 +78,20 @@ export function PageWithNav({
const shouldEmphasize = emphasizeGameContext && mounted
const showFullscreenSelection = shouldEmphasize && activePlayerCount === 0
// Compute arcade session info for display
const roomInfo =
hasActiveSession && activeSession
? {
gameName: activeSession.currentGame,
playerCount: activePlayerCount, // TODO: Get actual player count from session when available
}
: undefined
// Compute network players (other players in the arcade session)
// For now, we don't have this info in activeSession, so return empty array
// TODO: When arcade room system is implemented, fetch other players from session
const networkPlayers: Array<{ id: string; emoji?: string; name?: string }> = []
// Create nav content if title is provided
const navContent = navTitle ? (
<GameContextNav
@@ -93,6 +109,8 @@ export function PageWithNav({
onSetup={onSetup}
onNewGame={onNewGame}
canModifyPlayers={canModifyPlayers}
roomInfo={roomInfo}
networkPlayers={networkPlayers}
/>
) : null

View File

@@ -4,6 +4,8 @@ import { AddPlayerButton } from './AddPlayerButton'
import { FullscreenPlayerSelection } from './FullscreenPlayerSelection'
import { GameControlButtons } from './GameControlButtons'
import { GameModeIndicator } from './GameModeIndicator'
import { NetworkPlayerIndicator } from './NetworkPlayerIndicator'
import { RoomInfo } from './RoomInfo'
type GameMode = 'none' | 'single' | 'battle' | 'tournament'
@@ -13,6 +15,17 @@ interface Player {
emoji: string
}
interface NetworkPlayer {
id: string
emoji?: string
name?: string
}
interface ArcadeRoomInfo {
gameName: string
playerCount: number
}
interface GameContextNavProps {
navTitle: string
navEmoji?: string
@@ -28,6 +41,9 @@ interface GameContextNavProps {
onSetup?: () => void
onNewGame?: () => void
canModifyPlayers?: boolean
// Arcade session info
networkPlayers?: NetworkPlayer[]
roomInfo?: ArcadeRoomInfo
}
export function GameContextNav({
@@ -45,6 +61,8 @@ export function GameContextNav({
onSetup,
onNewGame,
canModifyPlayers = true,
networkPlayers = [],
roomInfo,
}: GameContextNavProps) {
const [_isTransitioning, setIsTransitioning] = React.useState(false)
const [layoutMode, setLayoutMode] = React.useState<'column' | 'row'>(
@@ -113,6 +131,34 @@ export function GameContextNav({
showFullscreenSelection={showFullscreenSelection}
/>
{/* Room Info - show when in arcade session */}
{roomInfo && !showFullscreenSelection && (
<RoomInfo
gameName={roomInfo.gameName}
playerCount={roomInfo.playerCount}
shouldEmphasize={shouldEmphasize}
/>
)}
{/* Network Players - show other players in the room */}
{networkPlayers.length > 0 && !showFullscreenSelection && (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: shouldEmphasize ? '12px' : '6px',
}}
>
{networkPlayers.map((player) => (
<NetworkPlayerIndicator
key={player.id}
player={player}
shouldEmphasize={shouldEmphasize}
/>
))}
</div>
)}
{/* Game Control Buttons - only show during active game */}
{!showFullscreenSelection && !canModifyPlayers && (
<GameControlButtons onSetup={onSetup} onNewGame={onNewGame} onQuit={onExitSession} />

View File

@@ -0,0 +1,121 @@
import React from 'react'
interface NetworkPlayer {
id: string
emoji?: string
name?: string
}
interface NetworkPlayerIndicatorProps {
player: NetworkPlayer
shouldEmphasize: boolean
}
/**
* Displays a network player with a special "network" frame border
* to distinguish them from local players
*/
export function NetworkPlayerIndicator({ player, shouldEmphasize }: NetworkPlayerIndicatorProps) {
const [isHovered, setIsHovered] = React.useState(false)
return (
<div
style={{
position: 'relative',
fontSize: shouldEmphasize ? '48px' : '20px',
lineHeight: 1,
transition: 'all 0.4s cubic-bezier(0.4, 0, 0.2, 1)',
cursor: 'default',
}}
title={player.name || `Network Player ${player.id.slice(0, 8)}`}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* Network frame border */}
<div
style={{
position: 'absolute',
inset: '-6px',
borderRadius: '8px',
background: `
linear-gradient(135deg,
rgba(59, 130, 246, 0.4),
rgba(147, 51, 234, 0.4),
rgba(236, 72, 153, 0.4))
`,
opacity: isHovered ? 1 : 0.7,
transition: 'opacity 0.2s ease',
zIndex: -1,
}}
/>
{/* Animated network signal indicator */}
<div
style={{
position: 'absolute',
top: '-8px',
right: '-8px',
width: '12px',
height: '12px',
borderRadius: '50%',
background: 'rgba(34, 197, 94, 0.9)',
boxShadow: '0 0 8px rgba(34, 197, 94, 0.6)',
animation: 'networkPulse 2s ease-in-out infinite',
zIndex: 1,
}}
title="Connected"
/>
{/* Player emoji or fallback */}
<div
style={{
position: 'relative',
filter: shouldEmphasize ? 'drop-shadow(0 4px 8px rgba(0,0,0,0.25))' : 'none',
}}
>
{player.emoji || '🌐'}
</div>
{/* Network icon badge */}
<div
style={{
position: 'absolute',
bottom: '-4px',
left: '-4px',
width: '16px',
height: '16px',
borderRadius: '50%',
border: '2px solid white',
background: 'linear-gradient(135deg, #3b82f6, #8b5cf6)',
color: 'white',
fontSize: '8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 2px 6px rgba(0,0,0,0.3)',
zIndex: 1,
}}
title="Network Player"
>
📡
</div>
<style
dangerouslySetInnerHTML={{
__html: `
@keyframes networkPulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.5;
transform: scale(1.2);
}
}
`,
}}
/>
</div>
)
}

View File

@@ -0,0 +1,87 @@
interface RoomInfoProps {
gameName: string
playerCount: number
shouldEmphasize: boolean
}
/**
* Displays current arcade room/session information
*/
export function RoomInfo({ gameName, playerCount, shouldEmphasize }: RoomInfoProps) {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: shouldEmphasize ? '8px 16px' : '4px 12px',
background: 'linear-gradient(135deg, rgba(59, 130, 246, 0.2), rgba(147, 51, 234, 0.2))',
borderRadius: '12px',
border: '2px solid rgba(59, 130, 246, 0.4)',
fontSize: shouldEmphasize ? '16px' : '14px',
fontWeight: '600',
color: 'rgba(255, 255, 255, 0.95)',
transition: 'all 0.4s cubic-bezier(0.4, 0, 0.2, 1)',
boxShadow: '0 4px 12px rgba(59, 130, 246, 0.2)',
}}
title="Active Arcade Session"
>
{/* Room icon */}
<div
style={{
fontSize: shouldEmphasize ? '20px' : '16px',
display: 'flex',
alignItems: 'center',
}}
>
🎮
</div>
{/* Room details */}
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '2px',
}}
>
<div
style={{
fontSize: shouldEmphasize ? '14px' : '12px',
opacity: 0.8,
textTransform: 'uppercase',
letterSpacing: '0.5px',
}}
>
Arcade Session
</div>
<div
style={{
fontSize: shouldEmphasize ? '16px' : '14px',
fontWeight: 'bold',
}}
>
{gameName}
</div>
</div>
{/* Player count badge */}
<div
style={{
marginLeft: '8px',
padding: '4px 8px',
background: 'rgba(255, 255, 255, 0.2)',
borderRadius: '8px',
fontSize: shouldEmphasize ? '14px' : '12px',
fontWeight: 'bold',
display: 'flex',
alignItems: 'center',
gap: '4px',
}}
>
<span>👥</span>
<span>{playerCount}</span>
</div>
</div>
)
}

View File

@@ -1,29 +1,36 @@
import { createId } from '@paralleldrive/cuid2'
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
import { integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'
import { arcadeRooms } from './arcade-rooms'
export const roomMembers = sqliteTable('room_members', {
id: text('id')
.primaryKey()
.$defaultFn(() => createId()),
export const roomMembers = sqliteTable(
'room_members',
{
id: text('id')
.primaryKey()
.$defaultFn(() => createId()),
roomId: text('room_id')
.notNull()
.references(() => arcadeRooms.id, { onDelete: 'cascade' }),
roomId: text('room_id')
.notNull()
.references(() => arcadeRooms.id, { onDelete: 'cascade' }),
userId: text('user_id').notNull(), // User/guest ID
displayName: text('display_name', { length: 50 }).notNull(),
userId: text('user_id').notNull(), // User/guest ID - UNIQUE: one room per user (enforced by index below)
displayName: text('display_name', { length: 50 }).notNull(),
isCreator: integer('is_creator', { mode: 'boolean' }).notNull().default(false),
isCreator: integer('is_creator', { mode: 'boolean' }).notNull().default(false),
joinedAt: integer('joined_at', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date()),
lastSeen: integer('last_seen', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date()),
isOnline: integer('is_online', { mode: 'boolean' }).notNull().default(true),
})
joinedAt: integer('joined_at', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date()),
lastSeen: integer('last_seen', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date()),
isOnline: integer('is_online', { mode: 'boolean' }).notNull().default(true),
},
(table) => ({
// Explicit unique index for clarity and database-level enforcement
userIdIdx: uniqueIndex('idx_room_members_user_id_unique').on(table.userId),
})
)
export type RoomMember = typeof roomMembers.$inferSelect
export type NewRoomMember = typeof roomMembers.$inferInsert

View File

@@ -8,6 +8,7 @@ import {
deleteArcadeSession,
getArcadeSession,
} from '../session-manager'
import { createRoom, deleteRoom } from '../room-manager'
/**
* Integration test for the full arcade session flow
@@ -16,6 +17,7 @@ import {
describe('Arcade Session Integration', () => {
const testUserId = 'integration-test-user'
const testGuestId = 'integration-test-guest'
let testRoomId: string
beforeEach(async () => {
// Create test user
@@ -27,11 +29,25 @@ describe('Arcade Session Integration', () => {
createdAt: new Date(),
})
.onConflictDoNothing()
// Create test room
const room = await createRoom({
name: 'Test Room',
createdBy: testGuestId,
creatorName: 'Test User',
gameName: 'matching',
gameConfig: { difficulty: 6, gameType: 'abacus-numeral', turnTimer: 30 },
ttlMinutes: 60,
})
testRoomId = room.id
})
afterEach(async () => {
// Clean up
await deleteArcadeSession(testUserId)
await deleteArcadeSession(testGuestId)
if (testRoomId) {
await deleteRoom(testRoomId)
}
await db.delete(schema.users).where(eq(schema.users.id, testUserId))
})
@@ -45,12 +61,12 @@ describe('Arcade Session Integration', () => {
difficulty: 6,
turnTimer: 30,
gamePhase: 'setup',
currentPlayer: "1",
currentPlayer: '1',
matchedPairs: 0,
totalPairs: 6,
moves: 0,
scores: {},
activePlayers: ["1"],
activePlayers: ['1'],
consecutiveMatches: {},
gameStartTime: null,
gameEndTime: null,
@@ -63,11 +79,12 @@ describe('Arcade Session Integration', () => {
}
const session = await createArcadeSession({
userId: testUserId,
userId: testGuestId,
gameName: 'matching',
gameUrl: '/arcade/matching',
initialState,
activePlayers: ["1"],
activePlayers: ['1'],
roomId: testRoomId,
})
expect(session).toBeDefined()
@@ -86,7 +103,7 @@ describe('Arcade Session Integration', () => {
playerId: testUserId,
timestamp: Date.now(),
data: {
activePlayers: ["1"],
activePlayers: ['1'],
},
}
@@ -147,12 +164,12 @@ describe('Arcade Session Integration', () => {
difficulty: 6,
turnTimer: 30,
gamePhase: 'playing',
currentPlayer: "1",
currentPlayer: '1',
matchedPairs: 0,
totalPairs: 6,
moves: 0,
scores: { 1: 0 },
activePlayers: ["1"],
activePlayers: ['1'],
consecutiveMatches: { 1: 0 },
gameStartTime: Date.now(),
gameEndTime: null,
@@ -165,11 +182,12 @@ describe('Arcade Session Integration', () => {
}
await createArcadeSession({
userId: testUserId,
userId: testGuestId,
gameName: 'matching',
gameUrl: '/arcade/matching',
initialState: playingState,
activePlayers: ["1"],
activePlayers: ['1'],
roomId: testRoomId,
})
// First move: flip card 1

View File

@@ -0,0 +1,281 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { eq } from 'drizzle-orm'
import { db, schema } from '@/db'
import { addRoomMember, getRoomMember, getUserRooms } from '../room-membership'
import { createRoom, deleteRoom } from '../room-manager'
/**
* Integration tests for modal room enforcement
*
* Tests the database-level unique constraint combined with application-level
* auto-leave logic to ensure users can only be in one room at a time.
*/
describe('Modal Room Enforcement', () => {
const testGuestId1 = 'modal-test-guest-1'
const testGuestId2 = 'modal-test-guest-2'
const testUserId1 = 'modal-test-user-1'
const testUserId2 = 'modal-test-user-2'
let room1Id: string
let room2Id: string
let room3Id: string
beforeEach(async () => {
// Create test users
await db
.insert(schema.users)
.values([
{
id: testUserId1,
guestId: testGuestId1,
createdAt: new Date(),
},
{
id: testUserId2,
guestId: testGuestId2,
createdAt: new Date(),
},
])
.onConflictDoNothing()
// Create test rooms
const room1 = await createRoom({
name: 'Modal Test Room 1',
createdBy: testGuestId1,
creatorName: 'User 1',
gameName: 'matching',
gameConfig: { difficulty: 6 },
ttlMinutes: 60,
})
room1Id = room1.id
const room2 = await createRoom({
name: 'Modal Test Room 2',
createdBy: testGuestId1,
creatorName: 'User 1',
gameName: 'matching',
gameConfig: { difficulty: 8 },
ttlMinutes: 60,
})
room2Id = room2.id
const room3 = await createRoom({
name: 'Modal Test Room 3',
createdBy: testGuestId1,
creatorName: 'User 1',
gameName: 'memory-quiz',
gameConfig: {},
ttlMinutes: 60,
})
room3Id = room3.id
})
afterEach(async () => {
// Clean up
await db.delete(schema.roomMembers).where(eq(schema.roomMembers.userId, testGuestId1))
await db.delete(schema.roomMembers).where(eq(schema.roomMembers.userId, testGuestId2))
try {
await deleteRoom(room1Id)
await deleteRoom(room2Id)
await deleteRoom(room3Id)
} catch {
// Rooms may have been deleted in test
}
await db.delete(schema.users).where(eq(schema.users.id, testUserId1))
await db.delete(schema.users).where(eq(schema.users.id, testUserId2))
})
it('should allow user to join their first room', async () => {
const result = await addRoomMember({
roomId: room1Id,
userId: testGuestId1,
displayName: 'Test User',
isCreator: false,
})
expect(result.member).toBeDefined()
expect(result.member.roomId).toBe(room1Id)
expect(result.member.userId).toBe(testGuestId1)
expect(result.autoLeaveResult).toBeUndefined()
const userRooms = await getUserRooms(testGuestId1)
expect(userRooms).toHaveLength(1)
expect(userRooms[0]).toBe(room1Id)
})
it('should automatically leave previous room when joining new one', async () => {
// Join room 1
await addRoomMember({
roomId: room1Id,
userId: testGuestId1,
displayName: 'Test User',
isCreator: false,
})
let userRooms = await getUserRooms(testGuestId1)
expect(userRooms).toHaveLength(1)
expect(userRooms[0]).toBe(room1Id)
// Join room 2 (should auto-leave room 1)
const result = await addRoomMember({
roomId: room2Id,
userId: testGuestId1,
displayName: 'Test User',
isCreator: false,
})
expect(result.autoLeaveResult).toBeDefined()
expect(result.autoLeaveResult?.leftRooms).toHaveLength(1)
expect(result.autoLeaveResult?.leftRooms[0]).toBe(room1Id)
expect(result.autoLeaveResult?.previousRoomMembers).toHaveLength(1)
userRooms = await getUserRooms(testGuestId1)
expect(userRooms).toHaveLength(1)
expect(userRooms[0]).toBe(room2Id)
// Verify user is no longer in room 1
const room1Member = await getRoomMember(room1Id, testGuestId1)
expect(room1Member).toBeUndefined()
// Verify user is in room 2
const room2Member = await getRoomMember(room2Id, testGuestId1)
expect(room2Member).toBeDefined()
})
it('should handle rejoining the same room without auto-leave', async () => {
// Join room 1
const firstJoin = await addRoomMember({
roomId: room1Id,
userId: testGuestId1,
displayName: 'Test User',
isCreator: false,
})
expect(firstJoin.autoLeaveResult).toBeUndefined()
// "Rejoin" room 1 (should just update status)
const secondJoin = await addRoomMember({
roomId: room1Id,
userId: testGuestId1,
displayName: 'Test User Updated',
isCreator: false,
})
expect(secondJoin.autoLeaveResult).toBeUndefined()
expect(secondJoin.member.roomId).toBe(room1Id)
const userRooms = await getUserRooms(testGuestId1)
expect(userRooms).toHaveLength(1)
expect(userRooms[0]).toBe(room1Id)
})
it('should allow different users in different rooms simultaneously', async () => {
// User 1 joins room 1
await addRoomMember({
roomId: room1Id,
userId: testGuestId1,
displayName: 'User 1',
isCreator: false,
})
// User 2 joins room 2
await addRoomMember({
roomId: room2Id,
userId: testGuestId2,
displayName: 'User 2',
isCreator: false,
})
const user1Rooms = await getUserRooms(testGuestId1)
const user2Rooms = await getUserRooms(testGuestId2)
expect(user1Rooms).toHaveLength(1)
expect(user1Rooms[0]).toBe(room1Id)
expect(user2Rooms).toHaveLength(1)
expect(user2Rooms[0]).toBe(room2Id)
})
it('should auto-leave when switching between multiple rooms', async () => {
// Join room 1
await addRoomMember({
roomId: room1Id,
userId: testGuestId1,
displayName: 'Test User',
})
// Join room 2 (auto-leave room 1)
const result2 = await addRoomMember({
roomId: room2Id,
userId: testGuestId1,
displayName: 'Test User',
})
expect(result2.autoLeaveResult?.leftRooms).toContain(room1Id)
// Join room 3 (auto-leave room 2)
const result3 = await addRoomMember({
roomId: room3Id,
userId: testGuestId1,
displayName: 'Test User',
})
expect(result3.autoLeaveResult?.leftRooms).toContain(room2Id)
// Verify only in room 3
const userRooms = await getUserRooms(testGuestId1)
expect(userRooms).toHaveLength(1)
expect(userRooms[0]).toBe(room3Id)
})
it('should provide correct auto-leave metadata', async () => {
// Join room 1
await addRoomMember({
roomId: room1Id,
userId: testGuestId1,
displayName: 'Original Name',
})
// Join room 2 and check metadata
const result = await addRoomMember({
roomId: room2Id,
userId: testGuestId1,
displayName: 'New Name',
})
expect(result.autoLeaveResult).toBeDefined()
expect(result.autoLeaveResult?.previousRoomMembers).toHaveLength(1)
const previousMember = result.autoLeaveResult?.previousRoomMembers[0]
expect(previousMember?.roomId).toBe(room1Id)
expect(previousMember?.member.userId).toBe(testGuestId1)
expect(previousMember?.member.displayName).toBe('Original Name')
})
it('should enforce unique constraint at database level', async () => {
// This test verifies the database constraint catches issues even if
// application logic fails
// Join room 1
await addRoomMember({
roomId: room1Id,
userId: testGuestId1,
displayName: 'Test User',
})
// Try to directly insert a second membership (bypassing auto-leave logic)
const directInsert = async () => {
await db.insert(schema.roomMembers).values({
roomId: room2Id,
userId: testGuestId1,
displayName: 'Test User',
isCreator: false,
joinedAt: new Date(),
lastSeen: new Date(),
isOnline: true,
})
}
// Should fail due to unique constraint
await expect(directInsert()).rejects.toThrow()
})
})

View File

@@ -0,0 +1,199 @@
import { eq } from 'drizzle-orm'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { db, schema } from '@/db'
import { createArcadeSession, deleteArcadeSession, getArcadeSession } from '../session-manager'
import { createRoom, deleteRoom } from '../room-manager'
/**
* Integration tests for orphaned session cleanup
*
* These tests ensure that sessions without valid rooms are properly
* cleaned up to prevent the bug where users get redirected to
* non-existent games when rooms have been TTL deleted.
*/
describe('Orphaned Session Cleanup', () => {
const testUserId = 'orphan-test-user-id'
const testGuestId = 'orphan-test-guest-id'
let testRoomId: string
beforeEach(async () => {
// Create test user
await db
.insert(schema.users)
.values({
id: testUserId,
guestId: testGuestId,
createdAt: new Date(),
})
.onConflictDoNothing()
// Create test room
const room = await createRoom({
name: 'Orphan Test Room',
createdBy: testGuestId,
creatorName: 'Test User',
gameName: 'matching',
gameConfig: { difficulty: 6, gameType: 'abacus-numeral', turnTimer: 30 },
ttlMinutes: 60,
})
testRoomId = room.id
})
afterEach(async () => {
// Clean up
await deleteArcadeSession(testGuestId)
if (testRoomId) {
try {
await deleteRoom(testRoomId)
} catch {
// Room may have been deleted in test
}
}
await db.delete(schema.users).where(eq(schema.users.id, testUserId))
})
it('should return undefined when session has no roomId', async () => {
// Create a session with a valid room
const session = await createArcadeSession({
userId: testGuestId,
gameName: 'matching',
gameUrl: '/arcade/matching',
initialState: { gamePhase: 'setup' },
activePlayers: ['player-1'],
roomId: testRoomId,
})
expect(session).toBeDefined()
expect(session.roomId).toBe(testRoomId)
// Manually set roomId to null to simulate orphaned session
await db
.update(schema.arcadeSessions)
.set({ roomId: null })
.where(eq(schema.arcadeSessions.userId, testUserId))
// Getting the session should auto-delete it and return undefined
const result = await getArcadeSession(testGuestId)
expect(result).toBeUndefined()
// Verify session was actually deleted
const [directCheck] = await db
.select()
.from(schema.arcadeSessions)
.where(eq(schema.arcadeSessions.userId, testUserId))
.limit(1)
expect(directCheck).toBeUndefined()
})
it('should return undefined when session room has been deleted', async () => {
// Create a session with a valid room
const session = await createArcadeSession({
userId: testGuestId,
gameName: 'matching',
gameUrl: '/arcade/matching',
initialState: { gamePhase: 'setup' },
activePlayers: ['player-1'],
roomId: testRoomId,
})
expect(session).toBeDefined()
expect(session.roomId).toBe(testRoomId)
// Delete the room (simulating TTL expiration)
await deleteRoom(testRoomId)
// Getting the session should detect missing room and auto-delete
const result = await getArcadeSession(testGuestId)
expect(result).toBeUndefined()
// Verify session was actually deleted
const [directCheck] = await db
.select()
.from(schema.arcadeSessions)
.where(eq(schema.arcadeSessions.userId, testUserId))
.limit(1)
expect(directCheck).toBeUndefined()
})
it('should return valid session when room exists', async () => {
// Create a session with a valid room
const session = await createArcadeSession({
userId: testGuestId,
gameName: 'matching',
gameUrl: '/arcade/matching',
initialState: { gamePhase: 'setup' },
activePlayers: ['player-1'],
roomId: testRoomId,
})
expect(session).toBeDefined()
// Getting the session should work fine when room exists
const result = await getArcadeSession(testGuestId)
expect(result).toBeDefined()
expect(result?.roomId).toBe(testRoomId)
expect(result?.currentGame).toBe('matching')
})
it('should handle multiple getArcadeSession calls idempotently', async () => {
// Create a session with a valid room
await createArcadeSession({
userId: testGuestId,
gameName: 'matching',
gameUrl: '/arcade/matching',
initialState: { gamePhase: 'setup' },
activePlayers: ['player-1'],
roomId: testRoomId,
})
// Delete the room
await deleteRoom(testRoomId)
// Multiple calls should all return undefined and not error
const result1 = await getArcadeSession(testGuestId)
const result2 = await getArcadeSession(testGuestId)
const result3 = await getArcadeSession(testGuestId)
expect(result1).toBeUndefined()
expect(result2).toBeUndefined()
expect(result3).toBeUndefined()
})
it('should prevent orphaned sessions from causing redirect loops', async () => {
/**
* Regression test for the specific bug:
* - Room gets TTL deleted
* - Session persists with null/invalid roomId
* - User visits /arcade
* - useArcadeRedirect finds the orphaned session
* - User gets redirected to /arcade/matching
* - But there's no valid game to play
*
* Fix: getArcadeSession should auto-delete orphaned sessions
*/
// 1. Create session with room
await createArcadeSession({
userId: testGuestId,
gameName: 'matching',
gameUrl: '/arcade/matching',
initialState: { gamePhase: 'setup' },
activePlayers: ['player-1'],
roomId: testRoomId,
})
// 2. Room gets TTL deleted
await deleteRoom(testRoomId)
// 3. User's client checks for active session (like useArcadeRedirect does)
const activeSession = await getArcadeSession(testGuestId)
// 4. Should return undefined, preventing redirect
expect(activeSession).toBeUndefined()
// 5. User can now proceed to arcade lobby normally
// (no redirect to non-existent game)
})
})

View File

@@ -0,0 +1,473 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { db, type schema } from '@/db'
import {
cleanupExpiredRooms,
createRoom,
deleteRoom,
getRoomByCode,
getRoomById,
isRoomCreator,
listActiveRooms,
touchRoom,
updateRoom,
type CreateRoomOptions,
} from '../room-manager'
import * as roomCode from '../room-code'
// Mock the database
vi.mock('@/db', () => ({
db: {
query: {
arcadeRooms: {
findFirst: vi.fn(),
findMany: vi.fn(),
},
},
insert: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
schema: {
arcadeRooms: {
id: 'id',
code: 'code',
name: 'name',
gameName: 'gameName',
isLocked: 'isLocked',
status: 'status',
lastActivity: 'lastActivity',
},
arcadeSessions: {
userId: 'userId',
roomId: 'roomId',
},
},
}))
// Mock room-code module
vi.mock('../room-code', () => ({
generateRoomCode: vi.fn(),
}))
describe('Room Manager', () => {
const mockRoom: schema.ArcadeRoom = {
id: 'room-123',
code: 'ABC123',
name: 'Test Room',
createdBy: 'user-1',
creatorName: 'Test User',
createdAt: new Date(),
lastActivity: new Date(),
ttlMinutes: 60,
isLocked: false,
gameName: 'matching',
gameConfig: { difficulty: 6 },
status: 'lobby',
currentSessionId: null,
totalGamesPlayed: 0,
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('createRoom', () => {
it('creates a room with generated code', async () => {
const options: CreateRoomOptions = {
name: 'Test Room',
createdBy: 'user-1',
creatorName: 'Test User',
gameName: 'matching',
gameConfig: { difficulty: 6 },
}
// Mock code generation
vi.mocked(roomCode.generateRoomCode).mockReturnValue('ABC123')
// Mock code uniqueness check
vi.mocked(db.query.arcadeRooms.findFirst).mockResolvedValue(undefined)
// Mock insert
const mockInsert = {
values: vi.fn().mockReturnThis(),
returning: vi.fn().mockResolvedValue([mockRoom]),
}
vi.mocked(db.insert).mockReturnValue(mockInsert as any)
const room = await createRoom(options)
expect(room).toEqual(mockRoom)
expect(roomCode.generateRoomCode).toHaveBeenCalled()
expect(db.insert).toHaveBeenCalled()
})
it('retries code generation on collision', async () => {
const options: CreateRoomOptions = {
name: 'Test Room',
createdBy: 'user-1',
creatorName: 'Test User',
gameName: 'matching',
gameConfig: { difficulty: 6 },
}
// First code collides, second is unique
vi.mocked(roomCode.generateRoomCode)
.mockReturnValueOnce('ABC123')
.mockReturnValueOnce('XYZ789')
// First check finds collision, second check is unique
vi.mocked(db.query.arcadeRooms.findFirst)
.mockResolvedValueOnce(mockRoom) // Collision
.mockResolvedValueOnce(undefined) // Unique
const mockInsert = {
values: vi.fn().mockReturnThis(),
returning: vi.fn().mockResolvedValue([{ ...mockRoom, code: 'XYZ789' }]),
}
vi.mocked(db.insert).mockReturnValue(mockInsert as any)
const room = await createRoom(options)
expect(room.code).toBe('XYZ789')
expect(roomCode.generateRoomCode).toHaveBeenCalledTimes(2)
})
it('throws error after max collision attempts', async () => {
const options: CreateRoomOptions = {
name: 'Test Room',
createdBy: 'user-1',
creatorName: 'Test User',
gameName: 'matching',
gameConfig: { difficulty: 6 },
}
// All codes collide
vi.mocked(roomCode.generateRoomCode).mockReturnValue('ABC123')
vi.mocked(db.query.arcadeRooms.findFirst).mockResolvedValue(mockRoom)
await expect(createRoom(options)).rejects.toThrow('Failed to generate unique room code')
})
it('sets default TTL to 60 minutes', async () => {
const options: CreateRoomOptions = {
name: 'Test Room',
createdBy: 'user-1',
creatorName: 'Test User',
gameName: 'matching',
gameConfig: { difficulty: 6 },
}
vi.mocked(roomCode.generateRoomCode).mockReturnValue('ABC123')
vi.mocked(db.query.arcadeRooms.findFirst).mockResolvedValue(undefined)
const mockInsert = {
values: vi.fn().mockReturnThis(),
returning: vi.fn().mockResolvedValue([mockRoom]),
}
vi.mocked(db.insert).mockReturnValue(mockInsert as any)
const room = await createRoom(options)
expect(room.ttlMinutes).toBe(60)
})
it('respects custom TTL', async () => {
const options: CreateRoomOptions = {
name: 'Test Room',
createdBy: 'user-1',
creatorName: 'Test User',
gameName: 'matching',
gameConfig: { difficulty: 6 },
ttlMinutes: 120,
}
vi.mocked(roomCode.generateRoomCode).mockReturnValue('ABC123')
vi.mocked(db.query.arcadeRooms.findFirst).mockResolvedValue(undefined)
const mockInsert = {
values: vi.fn().mockReturnThis(),
returning: vi.fn().mockResolvedValue([{ ...mockRoom, ttlMinutes: 120 }]),
}
vi.mocked(db.insert).mockReturnValue(mockInsert as any)
const room = await createRoom(options)
expect(room.ttlMinutes).toBe(120)
})
})
describe('getRoomById', () => {
it('returns room when found', async () => {
vi.mocked(db.query.arcadeRooms.findFirst).mockResolvedValue(mockRoom)
const room = await getRoomById('room-123')
expect(room).toEqual(mockRoom)
expect(db.query.arcadeRooms.findFirst).toHaveBeenCalled()
})
it('returns undefined when not found', async () => {
vi.mocked(db.query.arcadeRooms.findFirst).mockResolvedValue(undefined)
const room = await getRoomById('nonexistent')
expect(room).toBeUndefined()
})
})
describe('getRoomByCode', () => {
it('returns room when found', async () => {
vi.mocked(db.query.arcadeRooms.findFirst).mockResolvedValue(mockRoom)
const room = await getRoomByCode('ABC123')
expect(room).toEqual(mockRoom)
})
it('converts code to uppercase', async () => {
vi.mocked(db.query.arcadeRooms.findFirst).mockResolvedValue(mockRoom)
await getRoomByCode('abc123')
// Check that the where clause used uppercase
const call = vi.mocked(db.query.arcadeRooms.findFirst).mock.calls[0][0]
expect(call).toBeDefined()
})
it('returns undefined when not found', async () => {
vi.mocked(db.query.arcadeRooms.findFirst).mockResolvedValue(undefined)
const room = await getRoomByCode('NONEXISTENT')
expect(room).toBeUndefined()
})
})
describe('updateRoom', () => {
it('updates room and returns updated data', async () => {
const updates = { name: 'Updated Room', isLocked: true }
const mockUpdate = {
set: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
returning: vi.fn().mockResolvedValue([{ ...mockRoom, ...updates }]),
}
vi.mocked(db.update).mockReturnValue(mockUpdate as any)
const room = await updateRoom('room-123', updates)
expect(room?.name).toBe('Updated Room')
expect(room?.isLocked).toBe(true)
expect(db.update).toHaveBeenCalled()
})
it('updates lastActivity timestamp', async () => {
const mockUpdate = {
set: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
returning: vi.fn().mockResolvedValue([mockRoom]),
}
vi.mocked(db.update).mockReturnValue(mockUpdate as any)
await updateRoom('room-123', { name: 'Updated' })
const setCall = mockUpdate.set.mock.calls[0][0]
expect(setCall).toHaveProperty('lastActivity')
expect(setCall.lastActivity).toBeInstanceOf(Date)
})
})
describe('touchRoom', () => {
it('updates lastActivity timestamp', async () => {
const mockUpdate = {
set: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
}
vi.mocked(db.update).mockReturnValue(mockUpdate as any)
await touchRoom('room-123')
expect(db.update).toHaveBeenCalled()
const setCall = mockUpdate.set.mock.calls[0][0]
expect(setCall).toHaveProperty('lastActivity')
expect(setCall.lastActivity).toBeInstanceOf(Date)
})
})
describe('deleteRoom', () => {
it('deletes room from database', async () => {
const mockDelete = {
where: vi.fn().mockReturnThis(),
}
vi.mocked(db.delete).mockReturnValue(mockDelete as any)
await deleteRoom('room-123')
expect(db.delete).toHaveBeenCalled()
})
})
describe('listActiveRooms', () => {
const activeRooms = [mockRoom, { ...mockRoom, id: 'room-456', name: 'Another Room' }]
it('returns active rooms', async () => {
vi.mocked(db.query.arcadeRooms.findMany).mockResolvedValue(activeRooms)
const rooms = await listActiveRooms()
expect(rooms).toEqual(activeRooms)
expect(rooms).toHaveLength(2)
})
it('filters by game name when provided', async () => {
vi.mocked(db.query.arcadeRooms.findMany).mockResolvedValue([mockRoom])
const rooms = await listActiveRooms('matching')
expect(rooms).toHaveLength(1)
expect(db.query.arcadeRooms.findMany).toHaveBeenCalled()
})
it('excludes locked rooms', async () => {
vi.mocked(db.query.arcadeRooms.findMany).mockResolvedValue(activeRooms)
await listActiveRooms()
// Verify the where clause excludes locked rooms
const call = vi.mocked(db.query.arcadeRooms.findMany).mock.calls[0][0]
expect(call).toBeDefined()
})
it('limits results to 50 rooms', async () => {
vi.mocked(db.query.arcadeRooms.findMany).mockResolvedValue(activeRooms)
await listActiveRooms()
const call = vi.mocked(db.query.arcadeRooms.findMany).mock.calls[0][0]
expect(call?.limit).toBe(50)
})
})
describe('cleanupExpiredRooms', () => {
it('deletes expired rooms', async () => {
const now = new Date()
const expiredRoom = {
...mockRoom,
lastActivity: new Date(now.getTime() - 2 * 60 * 60 * 1000), // 2 hours ago
ttlMinutes: 60, // 1 hour TTL = expired
}
vi.mocked(db.query.arcadeRooms.findMany).mockResolvedValue([expiredRoom])
const mockUpdate = {
set: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
}
vi.mocked(db.update).mockReturnValue(mockUpdate as any)
const mockDelete = {
where: vi.fn().mockReturnThis(),
}
vi.mocked(db.delete).mockReturnValue(mockDelete as any)
const count = await cleanupExpiredRooms()
expect(count).toBe(1)
expect(db.update).toHaveBeenCalled() // Should clear roomId from sessions first
expect(db.delete).toHaveBeenCalled()
})
it('does not delete active rooms', async () => {
const now = new Date()
const activeRoom = {
...mockRoom,
lastActivity: new Date(now.getTime() - 30 * 60 * 1000), // 30 min ago
ttlMinutes: 60, // 1 hour TTL = still active
}
vi.mocked(db.query.arcadeRooms.findMany).mockResolvedValue([activeRoom])
const count = await cleanupExpiredRooms()
expect(count).toBe(0)
expect(db.delete).not.toHaveBeenCalled()
})
it('handles mixed expired and active rooms', async () => {
const now = new Date()
const mockUpdate = {
set: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
}
vi.mocked(db.update).mockReturnValue(mockUpdate as any)
const rooms = [
{
...mockRoom,
id: 'expired-1',
lastActivity: new Date(now.getTime() - 2 * 60 * 60 * 1000),
ttlMinutes: 60,
},
{
...mockRoom,
id: 'active-1',
lastActivity: new Date(now.getTime() - 30 * 60 * 1000),
ttlMinutes: 60,
},
{
...mockRoom,
id: 'expired-2',
lastActivity: new Date(now.getTime() - 3 * 60 * 60 * 1000),
ttlMinutes: 120,
},
]
vi.mocked(db.query.arcadeRooms.findMany).mockResolvedValue(rooms)
const mockDelete = {
where: vi.fn().mockReturnThis(),
}
vi.mocked(db.delete).mockReturnValue(mockDelete as any)
const count = await cleanupExpiredRooms()
expect(count).toBe(2) // Only 2 expired rooms
expect(db.delete).toHaveBeenCalled()
})
it('returns 0 when no rooms exist', async () => {
vi.mocked(db.query.arcadeRooms.findMany).mockResolvedValue([])
const count = await cleanupExpiredRooms()
expect(count).toBe(0)
expect(db.delete).not.toHaveBeenCalled()
})
})
describe('isRoomCreator', () => {
it('returns true for room creator', async () => {
vi.mocked(db.query.arcadeRooms.findFirst).mockResolvedValue(mockRoom)
const isCreator = await isRoomCreator('room-123', 'user-1')
expect(isCreator).toBe(true)
})
it('returns false for non-creator', async () => {
vi.mocked(db.query.arcadeRooms.findFirst).mockResolvedValue(mockRoom)
const isCreator = await isRoomCreator('room-123', 'user-2')
expect(isCreator).toBe(false)
})
it('returns false when room not found', async () => {
vi.mocked(db.query.arcadeRooms.findFirst).mockResolvedValue(undefined)
const isCreator = await isRoomCreator('nonexistent', 'user-1')
expect(isCreator).toBe(false)
})
})
})

View File

@@ -0,0 +1,377 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { db, type schema } from '@/db'
import {
addRoomMember,
getOnlineMemberCount,
getOnlineRoomMembers,
getRoomMember,
getRoomMembers,
getUserRooms,
isMember,
removeAllMembers,
removeMember,
setMemberOnline,
touchMember,
type AddMemberOptions,
} from '../room-membership'
// Mock the database
vi.mock('@/db', () => ({
db: {
query: {
roomMembers: {
findFirst: vi.fn(),
findMany: vi.fn(),
},
},
insert: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
schema: {
roomMembers: {
id: 'id',
roomId: 'roomId',
userId: 'userId',
isOnline: 'isOnline',
joinedAt: 'joinedAt',
},
},
}))
describe('Room Membership', () => {
const mockMember: schema.RoomMember = {
id: 'member-123',
roomId: 'room-123',
userId: 'user-1',
displayName: 'Test User',
isCreator: false,
joinedAt: new Date(),
lastSeen: new Date(),
isOnline: true,
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('addRoomMember', () => {
it('adds new member to room', async () => {
const options: AddMemberOptions = {
roomId: 'room-123',
userId: 'user-1',
displayName: 'Test User',
}
// No existing member
vi.mocked(db.query.roomMembers.findFirst).mockResolvedValue(undefined)
// Mock insert
const mockInsert = {
values: vi.fn().mockReturnThis(),
returning: vi.fn().mockResolvedValue([mockMember]),
}
vi.mocked(db.insert).mockReturnValue(mockInsert as any)
// Mock getUserRooms to return empty array (no existing rooms)
vi.mocked(db.query.roomMembers.findMany).mockResolvedValue([])
const result = await addRoomMember(options)
expect(result.member).toEqual(mockMember)
expect(result.autoLeaveResult).toBeUndefined()
expect(db.insert).toHaveBeenCalled()
})
it('updates existing member instead of creating duplicate', async () => {
const options: AddMemberOptions = {
roomId: 'room-123',
userId: 'user-1',
displayName: 'Test User',
}
// Existing member found
const existingMember = { ...mockMember, isOnline: false }
vi.mocked(db.query.roomMembers.findFirst).mockResolvedValue(existingMember)
// Mock update
const mockUpdate = {
set: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
returning: vi.fn().mockResolvedValue([{ ...existingMember, isOnline: true }]),
}
vi.mocked(db.update).mockReturnValue(mockUpdate as any)
const result = await addRoomMember(options)
expect(result.member.isOnline).toBe(true)
expect(result.autoLeaveResult).toBeUndefined()
expect(db.update).toHaveBeenCalled()
expect(db.insert).not.toHaveBeenCalled()
})
it('sets isCreator flag when specified', async () => {
const options: AddMemberOptions = {
roomId: 'room-123',
userId: 'user-1',
displayName: 'Test User',
isCreator: true,
}
vi.mocked(db.query.roomMembers.findFirst).mockResolvedValue(undefined)
const mockInsert = {
values: vi.fn().mockReturnThis(),
returning: vi.fn().mockResolvedValue([{ ...mockMember, isCreator: true }]),
}
vi.mocked(db.insert).mockReturnValue(mockInsert as any)
// Mock getUserRooms to return empty array
vi.mocked(db.query.roomMembers.findMany).mockResolvedValue([])
const result = await addRoomMember(options)
expect(result.member.isCreator).toBe(true)
})
it('sets isOnline to true by default', async () => {
const options: AddMemberOptions = {
roomId: 'room-123',
userId: 'user-1',
displayName: 'Test User',
}
vi.mocked(db.query.roomMembers.findFirst).mockResolvedValue(undefined)
const mockInsert = {
values: vi.fn().mockReturnThis(),
returning: vi.fn().mockResolvedValue([mockMember]),
}
vi.mocked(db.insert).mockReturnValue(mockInsert as any)
// Mock getUserRooms to return empty array
vi.mocked(db.query.roomMembers.findMany).mockResolvedValue([])
const result = await addRoomMember(options)
expect(result.member.isOnline).toBe(true)
})
})
describe('getRoomMember', () => {
it('returns member when found', async () => {
vi.mocked(db.query.roomMembers.findFirst).mockResolvedValue(mockMember)
const member = await getRoomMember('room-123', 'user-1')
expect(member).toEqual(mockMember)
})
it('returns undefined when not found', async () => {
vi.mocked(db.query.roomMembers.findFirst).mockResolvedValue(undefined)
const member = await getRoomMember('room-123', 'user-999')
expect(member).toBeUndefined()
})
})
describe('getRoomMembers', () => {
const members = [
mockMember,
{ ...mockMember, id: 'member-456', userId: 'user-2', displayName: 'User 2' },
]
it('returns all members in room', async () => {
vi.mocked(db.query.roomMembers.findMany).mockResolvedValue(members)
const result = await getRoomMembers('room-123')
expect(result).toEqual(members)
expect(result).toHaveLength(2)
})
it('returns empty array when no members', async () => {
vi.mocked(db.query.roomMembers.findMany).mockResolvedValue([])
const result = await getRoomMembers('room-123')
expect(result).toEqual([])
})
})
describe('getOnlineRoomMembers', () => {
const onlineMembers = [
mockMember,
{ ...mockMember, id: 'member-456', userId: 'user-2', isOnline: true },
]
it('returns only online members', async () => {
vi.mocked(db.query.roomMembers.findMany).mockResolvedValue(onlineMembers)
const result = await getOnlineRoomMembers('room-123')
expect(result).toEqual(onlineMembers)
expect(result.every((m) => m.isOnline)).toBe(true)
})
it('returns empty array when no online members', async () => {
vi.mocked(db.query.roomMembers.findMany).mockResolvedValue([])
const result = await getOnlineRoomMembers('room-123')
expect(result).toEqual([])
})
})
describe('setMemberOnline', () => {
it('updates member online status to true', async () => {
const mockUpdate = {
set: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
}
vi.mocked(db.update).mockReturnValue(mockUpdate as any)
await setMemberOnline('room-123', 'user-1', true)
expect(db.update).toHaveBeenCalled()
const setCall = mockUpdate.set.mock.calls[0][0]
expect(setCall.isOnline).toBe(true)
expect(setCall.lastSeen).toBeInstanceOf(Date)
})
it('updates member online status to false', async () => {
const mockUpdate = {
set: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
}
vi.mocked(db.update).mockReturnValue(mockUpdate as any)
await setMemberOnline('room-123', 'user-1', false)
const setCall = mockUpdate.set.mock.calls[0][0]
expect(setCall.isOnline).toBe(false)
})
it('updates lastSeen timestamp', async () => {
const mockUpdate = {
set: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
}
vi.mocked(db.update).mockReturnValue(mockUpdate as any)
await setMemberOnline('room-123', 'user-1', true)
const setCall = mockUpdate.set.mock.calls[0][0]
expect(setCall.lastSeen).toBeInstanceOf(Date)
})
})
describe('touchMember', () => {
it('updates lastSeen timestamp', async () => {
const mockUpdate = {
set: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
}
vi.mocked(db.update).mockReturnValue(mockUpdate as any)
await touchMember('room-123', 'user-1')
expect(db.update).toHaveBeenCalled()
const setCall = mockUpdate.set.mock.calls[0][0]
expect(setCall.lastSeen).toBeInstanceOf(Date)
})
})
describe('removeMember', () => {
it('removes member from room', async () => {
const mockDelete = {
where: vi.fn().mockReturnThis(),
}
vi.mocked(db.delete).mockReturnValue(mockDelete as any)
await removeMember('room-123', 'user-1')
expect(db.delete).toHaveBeenCalled()
})
})
describe('removeAllMembers', () => {
it('removes all members from room', async () => {
const mockDelete = {
where: vi.fn().mockReturnThis(),
}
vi.mocked(db.delete).mockReturnValue(mockDelete as any)
await removeAllMembers('room-123')
expect(db.delete).toHaveBeenCalled()
})
})
describe('getOnlineMemberCount', () => {
it('returns count of online members', async () => {
const onlineMembers = [
mockMember,
{ ...mockMember, id: 'member-456', userId: 'user-2' },
{ ...mockMember, id: 'member-789', userId: 'user-3' },
]
vi.mocked(db.query.roomMembers.findMany).mockResolvedValue(onlineMembers)
const count = await getOnlineMemberCount('room-123')
expect(count).toBe(3)
})
it('returns 0 when no online members', async () => {
vi.mocked(db.query.roomMembers.findMany).mockResolvedValue([])
const count = await getOnlineMemberCount('room-123')
expect(count).toBe(0)
})
})
describe('isMember', () => {
it('returns true when user is member', async () => {
vi.mocked(db.query.roomMembers.findFirst).mockResolvedValue(mockMember)
const result = await isMember('room-123', 'user-1')
expect(result).toBe(true)
})
it('returns false when user is not member', async () => {
vi.mocked(db.query.roomMembers.findFirst).mockResolvedValue(undefined)
const result = await isMember('room-123', 'user-999')
expect(result).toBe(false)
})
})
describe('getUserRooms', () => {
it('returns list of room IDs user is member of', async () => {
const memberships = [
{ ...mockMember, roomId: 'room-1' },
{ ...mockMember, roomId: 'room-2' },
{ ...mockMember, roomId: 'room-3' },
]
vi.mocked(db.query.roomMembers.findMany).mockResolvedValue(memberships)
const rooms = await getUserRooms('user-1')
expect(rooms).toEqual(['room-1', 'room-2', 'room-3'])
})
it('returns empty array when user has no rooms', async () => {
vi.mocked(db.query.roomMembers.findMany).mockResolvedValue([])
const rooms = await getUserRooms('user-1')
expect(rooms).toEqual([])
})
})
})

View File

@@ -103,7 +103,8 @@ describe('session-manager', () => {
gameName: 'matching',
gameUrl: '/arcade/matching',
initialState: {},
activePlayers: ["1"],
activePlayers: ['1'],
roomId: 'test-room-id',
})
// Verify user lookup by guestId
@@ -159,7 +160,8 @@ describe('session-manager', () => {
gameName: 'matching',
gameUrl: '/arcade/matching',
initialState: {},
activePlayers: ["1"],
activePlayers: ['1'],
roomId: 'test-room-id',
})
// Verify user was created

View File

@@ -0,0 +1,108 @@
/**
* Player manager for arcade rooms
* Handles fetching and validating player participation in rooms
*/
import { and, eq } from 'drizzle-orm'
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
* @param viewerId - The guestId from the cookie (same as what getViewerId() returns)
*/
export async function getActivePlayers(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 players by the actual user.id
return await db.query.players.findMany({
where: and(eq(schema.players.userId, user.id), eq(schema.players.isActive, true)),
orderBy: schema.players.createdAt,
})
}
/**
* Get all active players for all members in a room
* Returns a map of userId -> Player[]
*/
export async function getRoomActivePlayers(roomId: string): Promise<Map<string, Player[]>> {
// Get all room members
const members = await db.query.roomMembers.findMany({
where: eq(schema.roomMembers.roomId, roomId),
})
// Fetch active players for each member
const playerMap = new Map<string, Player[]>()
for (const member of members) {
const players = await getActivePlayers(member.userId)
playerMap.set(member.userId, players)
}
return playerMap
}
/**
* Get all player IDs that should participate in a room game
* Flattens the player lists from all room members
*/
export async function getRoomPlayerIds(roomId: string): Promise<string[]> {
const playerMap = await getRoomActivePlayers(roomId)
const allPlayers: string[] = []
for (const players of playerMap.values()) {
allPlayers.push(...players.map((p) => p.id))
}
return allPlayers
}
/**
* Validate that a player ID belongs to a user who is a member of a room
*/
export async function validatePlayerInRoom(playerId: string, roomId: string): Promise<boolean> {
// Get the player
const player = await db.query.players.findFirst({
where: eq(schema.players.id, playerId),
})
if (!player) return false
// Check if the player's user is a member of the room
const member = await db.query.roomMembers.findFirst({
where: and(eq(schema.roomMembers.roomId, roomId), eq(schema.roomMembers.userId, player.userId)),
})
return !!member
}
/**
* Get player details by ID
*/
export async function getPlayer(playerId: string): Promise<Player | undefined> {
return await db.query.players.findFirst({
where: eq(schema.players.id, playerId),
})
}
/**
* Get multiple players by IDs
*/
export async function getPlayers(playerIds: string[]): Promise<Player[]> {
if (playerIds.length === 0) return []
const players: Player[] = []
for (const id of playerIds) {
const player = await getPlayer(id)
if (player) players.push(player)
}
return players
}

View File

@@ -13,16 +13,25 @@ export interface AddMemberOptions {
isCreator?: boolean
}
export interface AutoLeaveResult {
leftRooms: string[] // Room IDs user was removed from
previousRoomMembers: Array<{ roomId: string; member: schema.RoomMember }>
}
/**
* Add a member to a room
* Automatically removes user from any other rooms they're in (modal room enforcement)
* Returns the new membership and info about rooms that were auto-left
*/
export async function addRoomMember(options: AddMemberOptions): Promise<schema.RoomMember> {
export async function addRoomMember(
options: AddMemberOptions
): Promise<{ member: schema.RoomMember; autoLeaveResult?: AutoLeaveResult }> {
const now = new Date()
// Check if member already exists
// Check if member already exists in THIS room
const existing = await getRoomMember(options.roomId, options.userId)
if (existing) {
// Update lastSeen and isOnline
// Already in this room - just update status (no auto-leave needed)
const [updated] = await db
.update(schema.roomMembers)
.set({
@@ -31,9 +40,35 @@ export async function addRoomMember(options: AddMemberOptions): Promise<schema.R
})
.where(eq(schema.roomMembers.id, existing.id))
.returning()
return updated
return { member: updated }
}
// AUTO-LEAVE LOGIC: Remove from all other rooms before joining this one
const currentRooms = await getUserRooms(options.userId)
const autoLeaveResult: AutoLeaveResult = {
leftRooms: [],
previousRoomMembers: [],
}
for (const roomId of currentRooms) {
if (roomId !== options.roomId) {
// Get member info before removing (for socket events)
const memberToRemove = await getRoomMember(roomId, options.userId)
if (memberToRemove) {
autoLeaveResult.previousRoomMembers.push({
roomId,
member: memberToRemove,
})
}
// Remove from room
await removeMember(roomId, options.userId)
autoLeaveResult.leftRooms.push(roomId)
console.log(`[Room Membership] Auto-left room ${roomId} for user ${options.userId}`)
}
}
// Now add to new room
const newMember: schema.NewRoomMember = {
roomId: options.roomId,
userId: options.userId,
@@ -44,9 +79,29 @@ export async function addRoomMember(options: AddMemberOptions): Promise<schema.R
isOnline: true,
}
const [member] = await db.insert(schema.roomMembers).values(newMember).returning()
console.log('[Room Membership] Added member:', member.userId, 'to room:', member.roomId)
return member
try {
const [member] = await db.insert(schema.roomMembers).values(newMember).returning()
console.log('[Room Membership] Added member:', member.userId, 'to room:', member.roomId)
return {
member,
autoLeaveResult: autoLeaveResult.leftRooms.length > 0 ? autoLeaveResult : undefined,
}
} catch (error: any) {
// Handle unique constraint violation
// This should rarely happen due to auto-leave logic above, but catch it for safety
if (
error.code === 'SQLITE_CONSTRAINT' ||
error.message?.includes('UNIQUE') ||
error.message?.includes('unique')
) {
console.error('[Room Membership] Unique constraint violation:', error.message)
throw new Error(
'ROOM_MEMBERSHIP_CONFLICT: User is already in another room. This should have been handled by auto-leave logic.'
)
}
throw error
}
}
/**

View File

@@ -0,0 +1,58 @@
/**
* Room TTL Cleanup Scheduler
* Periodically cleans up expired rooms
*/
import { cleanupExpiredRooms } from './room-manager'
// Cleanup interval: run every 5 minutes
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000
let cleanupInterval: NodeJS.Timeout | null = null
/**
* Start the TTL cleanup scheduler
* Runs cleanup every 5 minutes
*/
export function startRoomTTLCleanup() {
if (cleanupInterval) {
console.log('[Room TTL] Cleanup scheduler already running')
return
}
console.log('[Room TTL] Starting cleanup scheduler (every 5 minutes)')
// Run immediately on start
cleanupExpiredRooms()
.then((count) => {
if (count > 0) {
console.log(`[Room TTL] Initial cleanup removed ${count} expired rooms`)
}
})
.catch((error) => {
console.error('[Room TTL] Initial cleanup failed:', error)
})
// Then run periodically
cleanupInterval = setInterval(async () => {
try {
const count = await cleanupExpiredRooms()
if (count > 0) {
console.log(`[Room TTL] Cleanup removed ${count} expired rooms`)
}
} catch (error) {
console.error('[Room TTL] Cleanup failed:', error)
}
}, CLEANUP_INTERVAL_MS)
}
/**
* Stop the TTL cleanup scheduler
*/
export function stopRoomTTLCleanup() {
if (cleanupInterval) {
clearInterval(cleanupInterval)
cleanupInterval = null
console.log('[Room TTL] Cleanup scheduler stopped')
}
}

View File

@@ -13,6 +13,7 @@ export interface CreateSessionOptions {
gameUrl: string
initialState: unknown
activePlayers: string[] // Player IDs (UUIDs)
roomId: string // Required - sessions must be associated with a room
}
export interface SessionUpdateResult {
@@ -71,6 +72,7 @@ export async function createArcadeSession(
gameUrl: options.gameUrl,
gameState: options.initialState as any,
activePlayers: options.activePlayers as any,
roomId: options.roomId, // Associate session with room
startedAt: now,
lastActivityAt: now,
expiresAt,
@@ -96,8 +98,29 @@ export async function getArcadeSession(guestId: string): Promise<schema.ArcadeSe
.where(eq(schema.arcadeSessions.userId, userId))
.limit(1)
if (!session) return undefined
// Check if session has expired
if (session && session.expiresAt < new Date()) {
if (session.expiresAt < new Date()) {
await deleteArcadeSession(guestId)
return undefined
}
// Check if session has a valid room association
// Sessions without rooms are orphaned and should be cleaned up
if (!session.roomId) {
console.log('[Session Manager] Deleting orphaned session without room:', session.userId)
await deleteArcadeSession(guestId)
return undefined
}
// Verify the room still exists
const room = await db.query.arcadeRooms.findFirst({
where: eq(schema.arcadeRooms.id, session.roomId),
})
if (!room) {
console.log('[Session Manager] Deleting session with non-existent room:', session.roomId)
await deleteArcadeSession(guestId)
return undefined
}

View File

@@ -0,0 +1,40 @@
/**
* Socket.IO server instance accessor for API routes
* This module provides a way for API routes to access the socket.io server
* to broadcast real-time updates.
*/
import type { Server as SocketIOServerType } from 'socket.io'
// Cache for the socket server module
let socketServerModule: any = null
/**
* Get the socket.io server instance
* Returns null if not initialized or if called on client-side
*/
export async function getSocketIO(): Promise<SocketIOServerType | null> {
// Client-side: return null
if (typeof window !== 'undefined') {
return null
}
// Lazy-load the socket server module on first call
if (!socketServerModule) {
try {
// Dynamic import to avoid bundling issues
socketServerModule = await import('../../socket-server')
} catch (error) {
console.error('[Socket IO] Failed to load socket server:', error)
return null
}
}
// Call the exported getSocketIO function from the module
if (socketServerModule && typeof socketServerModule.getSocketIO === 'function') {
return socketServerModule.getSocketIO()
}
console.warn('[Socket IO] getSocketIO function not found in socket-server module')
return null
}

View File

@@ -49,9 +49,8 @@ const ProgressiveTestComponent: React.FC<{
stepIndices.forEach((stepIndex, i) => {
const description = fullInstruction.multiStepInstructions?.[i] || `Step ${i + 1}`
const stepBeads = fullInstruction.stepBeadHighlights?.filter(
(bead) => bead.stepIndex === stepIndex
) || []
const stepBeads =
fullInstruction.stepBeadHighlights?.filter((bead) => bead.stepIndex === stepIndex) || []
// Calculate the value change for this step by applying all bead movements
let valueChange = 0

View File

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