Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4c8cfaad2 | ||
|
|
f005fbbb77 | ||
|
|
5751dfef5c | ||
|
|
7ebb2be392 | ||
|
|
bc219c2ad6 | ||
|
|
3c002ab29d | ||
|
|
6800747f80 | ||
|
|
99906ae53d |
57
.claude/terminology.md
Normal file
57
.claude/terminology.md
Normal 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
|
||||
16
CHANGELOG.md
16
CHANGELOG.md
@@ -1,3 +1,19 @@
|
||||
## [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)
|
||||
|
||||
|
||||
|
||||
@@ -30,7 +30,9 @@
|
||||
"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:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
455
apps/web/__tests__/api-arcade-rooms.e2e.test.ts
Normal file
455
apps/web/__tests__/api-arcade-rooms.e2e.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
203
apps/web/__tests__/orphaned-session.e2e.test.ts
Normal file
203
apps/web/__tests__/orphaned-session.e2e.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
169
apps/web/docs/FIXES-APPLIED.md
Normal file
169
apps/web/docs/FIXES-APPLIED.md
Normal 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
|
||||
189
apps/web/docs/INCONSISTENCIES.md
Normal file
189
apps/web/docs/INCONSISTENCIES.md
Normal 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
|
||||
```
|
||||
153
apps/web/docs/terminology-user-player-room.md
Normal file
153
apps/web/docs/terminology-user-player-room.md
Normal 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
|
||||
15
apps/web/drizzle/0004_shiny_madelyne_pryor.sql
Normal file
15
apps/web/drizzle/0004_shiny_madelyne_pryor.sql
Normal 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`);
|
||||
660
apps/web/drizzle/meta/0004_snapshot.json
Normal file
660
apps/web/drizzle/meta/0004_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -7,7 +7,15 @@ 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 {
|
||||
getOnlineRoomMembers,
|
||||
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'
|
||||
|
||||
export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
@@ -85,15 +93,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 +207,113 @@ 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 onlineMembers = await getOnlineRoomMembers(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,
|
||||
onlineMembers,
|
||||
memberPlayers: memberPlayersObj,
|
||||
})
|
||||
|
||||
// Notify all other members in the room
|
||||
socket.to(`room:${roomId}`).emit('member-joined', {
|
||||
roomId,
|
||||
userId,
|
||||
onlineMembers,
|
||||
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 online members
|
||||
const onlineMembers = await getOnlineRoomMembers(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,
|
||||
onlineMembers,
|
||||
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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
96
apps/web/src/app/api/arcade/rooms/[roomId]/join/route.ts
Normal file
96
apps/web/src/app/api/arcade/rooms/[roomId]/join/route.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getRoomById, touchRoom } from '@/lib/arcade/room-manager'
|
||||
import { addRoomMember } from '@/lib/arcade/room-membership'
|
||||
import { getActivePlayers } from '@/lib/arcade/player-manager'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
|
||||
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)
|
||||
|
||||
// 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 })
|
||||
}
|
||||
}
|
||||
39
apps/web/src/app/api/arcade/rooms/[roomId]/leave/route.ts
Normal file
39
apps/web/src/app/api/arcade/rooms/[roomId]/leave/route.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getRoomById } from '@/lib/arcade/room-manager'
|
||||
import { isMember, removeMember } from '@/lib/arcade/room-membership'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
|
||||
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)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Failed to leave room:', error)
|
||||
return NextResponse.json({ error: 'Failed to leave room' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
35
apps/web/src/app/api/arcade/rooms/[roomId]/members/route.ts
Normal file
35
apps/web/src/app/api/arcade/rooms/[roomId]/members/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
132
apps/web/src/app/api/arcade/rooms/[roomId]/route.ts
Normal file
132
apps/web/src/app/api/arcade/rooms/[roomId]/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
39
apps/web/src/app/api/arcade/rooms/code/[code]/route.ts
Normal file
39
apps/web/src/app/api/arcade/rooms/code/[code]/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
126
apps/web/src/app/api/arcade/rooms/route.ts
Normal file
126
apps/web/src/app/api/arcade/rooms/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
57
apps/web/src/app/api/debug/active-players/route.ts
Normal file
57
apps/web/src/app/api/debug/active-players/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
24
apps/web/src/app/arcade/rooms/[roomId]/matching/page.tsx
Normal file
24
apps/web/src/app/arcade/rooms/[roomId]/matching/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
16
apps/web/src/app/arcade/rooms/[roomId]/memory-quiz/page.tsx
Normal file
16
apps/web/src/app/arcade/rooms/[roomId]/memory-quiz/page.tsx
Normal 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 />
|
||||
}
|
||||
649
apps/web/src/app/arcade/rooms/[roomId]/page.tsx
Normal file
649
apps/web/src/app/arcade/rooms/[roomId]/page.tsx
Normal file
@@ -0,0 +1,649 @@
|
||||
'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.onlineMembers) {
|
||||
setMembers(data.onlineMembers)
|
||||
}
|
||||
if (data.memberPlayers) {
|
||||
setMemberPlayers(data.memberPlayers)
|
||||
}
|
||||
})
|
||||
|
||||
sock.on('member-left', (data) => {
|
||||
console.log('Member left:', data)
|
||||
if (data.onlineMembers) {
|
||||
setMembers(data.onlineMembers)
|
||||
}
|
||||
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 = () => {
|
||||
router.push('/arcade/rooms')
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
468
apps/web/src/app/arcade/rooms/page.tsx
Normal file
468
apps/web/src/app/arcade/rooms/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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} />
|
||||
|
||||
121
apps/web/src/components/nav/NetworkPlayerIndicator.tsx
Normal file
121
apps/web/src/components/nav/NetworkPlayerIndicator.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
87
apps/web/src/components/nav/RoomInfo.tsx
Normal file
87
apps/web/src/components/nav/RoomInfo.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
281
apps/web/src/lib/arcade/__tests__/modal-rooms.test.ts
Normal file
281
apps/web/src/lib/arcade/__tests__/modal-rooms.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
473
apps/web/src/lib/arcade/__tests__/room-manager.test.ts
Normal file
473
apps/web/src/lib/arcade/__tests__/room-manager.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
377
apps/web/src/lib/arcade/__tests__/room-membership.test.ts
Normal file
377
apps/web/src/lib/arcade/__tests__/room-membership.test.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
108
apps/web/src/lib/arcade/player-manager.ts
Normal file
108
apps/web/src/lib/arcade/player-manager.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
58
apps/web/src/lib/arcade/room-ttl-cleanup.ts
Normal file
58
apps/web/src/lib/arcade/room-ttl-cleanup.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "soroban-monorepo",
|
||||
"version": "2.3.1",
|
||||
"version": "2.4.0",
|
||||
"private": true,
|
||||
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
|
||||
"workspaces": [
|
||||
|
||||
Reference in New Issue
Block a user