feat: add arcade room system database schema and managers (Phase 1)
Implement foundational infrastructure for multi-room arcade system: Database: - Add arcade_rooms table for room metadata and lifecycle - Add room_members table for membership tracking - Add nullable roomId field to arcade_sessions for room association - Create migration 0003_naive_reptil.sql Managers: - Implement room-manager.ts with full CRUD operations - Implement room-membership.ts for member management - Add room-code.ts utility for unique room code generation - Include TTL-based room cleanup functionality Documentation: - Add arcade-rooms-technical-plan.md with complete system design - Add arcade-rooms-implementation-tasks.md with 62-task breakdown This establishes the foundation for public multiplayer rooms with: - URL-addressable rooms with unique codes - Guest user support - Configurable TTL for automatic cleanup - Room creator moderation controls 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
109
apps/web/docs/arcade-rooms-implementation-tasks.md
Normal file
109
apps/web/docs/arcade-rooms-implementation-tasks.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# Arcade Rooms Implementation Task List
|
||||
|
||||
This is the detailed implementation task list for the arcade rooms feature. Use this to restore the TodoWrite list if the session is interrupted.
|
||||
|
||||
## Phase 1: Database & API Foundation
|
||||
|
||||
- [ ] Phase 1.1: Create database migration for arcade_rooms and room_members tables
|
||||
- [ ] Phase 1.2: Implement room-manager.ts with CRUD operations (create, get, update, delete)
|
||||
- [ ] Phase 1.3: Implement room-membership.ts for member management
|
||||
- [ ] Phase 1.4: Build API endpoints for room CRUD (/api/arcade/rooms/*)
|
||||
- [ ] Phase 1.5: Add room code generation utility
|
||||
- [ ] Phase 1.6: Implement TTL cleanup system for rooms
|
||||
|
||||
### Testing Checkpoint 1
|
||||
- [ ] TESTING CHECKPOINT 1: Write unit tests for all room manager functions
|
||||
- [ ] TESTING CHECKPOINT 1: Write unit tests for room membership functions
|
||||
- [ ] TESTING CHECKPOINT 1: Write API endpoint tests for room CRUD operations
|
||||
- [ ] TESTING CHECKPOINT 1: Manual testing of room creation, joining, and TTL cleanup
|
||||
|
||||
## Phase 2: Socket.IO Integration
|
||||
|
||||
- [ ] Phase 2.1: Update socket-server.ts for room namespacing
|
||||
- [ ] Phase 2.2: Implement room-scoped broadcasts in socket handlers
|
||||
- [ ] Phase 2.3: Add presence tracking for room members
|
||||
- [ ] Phase 2.4: Update session-manager.ts to support roomId
|
||||
- [ ] Phase 2.5: Update game state sync to respect room boundaries
|
||||
|
||||
### Testing Checkpoint 2
|
||||
- [ ] TESTING CHECKPOINT 2: Write integration tests for multi-user room sessions
|
||||
- [ ] TESTING CHECKPOINT 2: Write tests for room-scoped broadcasts
|
||||
- [ ] TESTING CHECKPOINT 2: Manual testing of multi-tab synchronization within rooms
|
||||
- [ ] TESTING CHECKPOINT 2: Verify backward compatibility with solo play (no roomId)
|
||||
|
||||
## Phase 3: Guest User System
|
||||
|
||||
- [ ] Phase 3.1: Implement guest ID generation and storage
|
||||
- [ ] Phase 3.2: Create useGuestUser hook for guest authentication
|
||||
- [ ] Phase 3.3: Update auth flow to support optional guest access
|
||||
- [ ] Phase 3.4: Update API endpoints to accept guest user IDs
|
||||
|
||||
### Testing Checkpoint 3
|
||||
- [ ] TESTING CHECKPOINT 3: Write unit tests for guest ID system
|
||||
- [ ] TESTING CHECKPOINT 3: Manual testing of guest join flow
|
||||
- [ ] TESTING CHECKPOINT 3: Test guest user persistence across page refreshes
|
||||
|
||||
## Phase 4: UI Components
|
||||
|
||||
- [ ] Phase 4.1: Build CreateRoomDialog component with form validation
|
||||
- [ ] Phase 4.2: Build RoomLobby component showing current room state
|
||||
- [ ] Phase 4.3: Build RoomLobbyBrowser for public room discovery
|
||||
- [ ] Phase 4.4: Add RoomContextIndicator to navigation bar
|
||||
- [ ] Phase 4.5: Wire up useRoom and useRoomMembership hooks
|
||||
- [ ] Phase 4.6: Implement player selection UI when joining room
|
||||
|
||||
### Testing Checkpoint 4
|
||||
- [ ] TESTING CHECKPOINT 4: Write component unit tests for all room UI components
|
||||
- [ ] TESTING CHECKPOINT 4: Manual UI testing of room creation flow
|
||||
- [ ] TESTING CHECKPOINT 4: Manual UI testing of room browser and filtering
|
||||
- [ ] TESTING CHECKPOINT 4: Test player selection flow when joining rooms
|
||||
|
||||
## Phase 5: Routes & Navigation
|
||||
|
||||
- [ ] Phase 5.1: Create /arcade/rooms route structure
|
||||
- [ ] Phase 5.2: Create /arcade/rooms/:roomId route with room lobby
|
||||
- [ ] Phase 5.3: Create /arcade/rooms/:roomId/:game routes for in-room gameplay
|
||||
- [ ] Phase 5.4: Update arcade home to include room access entry points
|
||||
- [ ] Phase 5.5: Add room selector/switcher to navigation
|
||||
- [ ] Phase 5.6: Implement join-by-code flow with code input dialog
|
||||
- [ ] Phase 5.7: Add share room functionality (copy link, share code)
|
||||
|
||||
### Testing Checkpoint 5
|
||||
- [ ] TESTING CHECKPOINT 5: Write E2E tests for room creation navigation flow
|
||||
- [ ] TESTING CHECKPOINT 5: Write E2E tests for join-by-URL flow
|
||||
- [ ] TESTING CHECKPOINT 5: Write E2E tests for join-by-code flow
|
||||
- [ ] TESTING CHECKPOINT 5: Manual testing of room navigation across different states
|
||||
|
||||
## Phase 6: Final Testing & Polish
|
||||
|
||||
- [ ] Phase 6.1: Write E2E tests for complete room creation and join flow
|
||||
- [ ] Phase 6.2: Write E2E tests for multi-user gameplay in rooms
|
||||
- [ ] Phase 6.3: Write tests for TTL expiration and cleanup behavior
|
||||
- [ ] Phase 6.4: Write E2E tests for guest user complete flow
|
||||
- [ ] Phase 6.5: Write tests for room creator permissions (kick, lock, delete)
|
||||
- [ ] Phase 6.6: Performance testing with multiple concurrent rooms
|
||||
- [ ] Phase 6.7: Performance testing with many users in single room
|
||||
- [ ] Phase 6.8: Add error states and loading states to all UI components
|
||||
- [ ] Phase 6.9: Add user feedback toasts for room operations
|
||||
- [ ] Phase 6.10: Final manual user testing of complete room system
|
||||
- [ ] Phase 6.11: Cross-browser testing (Chrome, Firefox, Safari)
|
||||
- [ ] Phase 6.12: Mobile responsiveness testing for room UI
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Total: 62 tasks across 6 phases
|
||||
- 20 dedicated testing tasks (32% of total)
|
||||
- Reference: `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/docs/arcade-rooms-technical-plan.md`
|
||||
|
||||
## Restoring TodoWrite
|
||||
|
||||
To restore this list to TodoWrite format, convert each task to:
|
||||
```json
|
||||
{
|
||||
"content": "Task description",
|
||||
"status": "pending|in_progress|completed",
|
||||
"activeForm": "Present continuous form (e.g., 'Creating...')"
|
||||
}
|
||||
```
|
||||
895
apps/web/docs/arcade-rooms-technical-plan.md
Normal file
895
apps/web/docs/arcade-rooms-technical-plan.md
Normal file
@@ -0,0 +1,895 @@
|
||||
# 🎮 Arcade Room System - Complete Technical Plan
|
||||
|
||||
**Date:** 2025-01-06
|
||||
**Status:** Ready for Implementation
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Transform the current singleton arcade session into a multi-room system where users can create, manage, and share public game rooms. Rooms are URL-addressable, support guest users, have configurable TTL, and give creators full moderation control.
|
||||
|
||||
---
|
||||
|
||||
## 1. Core Requirements
|
||||
|
||||
### Room Features
|
||||
- ✅ **Public by default** - All rooms visible in public lobby
|
||||
- ✅ **No capacity limits** - Unlimited players per room
|
||||
- ✅ **Configurable TTL** - Rooms expire based on inactivity (similar to existing session TTL)
|
||||
- ✅ **URL-addressable** - Direct links to join rooms (`/arcade/rooms/{roomId}`)
|
||||
- ✅ **Guest access** - Unauthenticated users can join with temp guest IDs
|
||||
- ✅ **Anyone can create** - No authentication required to create rooms
|
||||
- ✅ **Creator moderation** - Only room creator can kick, lock, or delete room
|
||||
|
||||
---
|
||||
|
||||
## 2. Database Schema
|
||||
|
||||
### New Tables
|
||||
|
||||
```typescript
|
||||
// arcade_rooms table
|
||||
interface ArcadeRoom {
|
||||
id: string // UUID - primary key
|
||||
code: string // 6-char join code (e.g., "ABC123") - unique
|
||||
name: string // User-defined room name (max 50 chars)
|
||||
|
||||
// Creator info
|
||||
createdBy: string // User/guest ID of creator
|
||||
creatorName: string // Display name at creation time
|
||||
createdAt: Date
|
||||
|
||||
// Lifecycle
|
||||
lastActivity: Date // Updated on any room activity
|
||||
ttlMinutes: number // Time to live in minutes (default: 60)
|
||||
isLocked: boolean // Creator can lock room (no new joins)
|
||||
|
||||
// Game configuration
|
||||
gameName: string // 'matching', 'complement-race', etc.
|
||||
gameConfig: JSON // Game-specific settings (difficulty, etc.)
|
||||
|
||||
// Current state
|
||||
status: 'lobby' | 'playing' | 'finished'
|
||||
currentSessionId: string | null // FK to arcade_sessions (when game active)
|
||||
|
||||
// Metadata
|
||||
totalGamesPlayed: number // Track room usage
|
||||
}
|
||||
|
||||
// room_members table
|
||||
interface RoomMember {
|
||||
id: string // UUID - primary key
|
||||
roomId: string // FK to arcade_rooms - indexed
|
||||
userId: string // User/guest ID - indexed
|
||||
displayName: string // Name shown in room
|
||||
isCreator: boolean // True for room creator
|
||||
joinedAt: Date
|
||||
lastSeen: Date // Updated on any activity
|
||||
isOnline: boolean // Currently connected via socket
|
||||
}
|
||||
|
||||
// Modify existing: arcade_sessions
|
||||
interface ArcadeSession {
|
||||
id: string
|
||||
userId: string
|
||||
// ... existing fields
|
||||
roomId: string | null // FK to arcade_rooms (null for solo play)
|
||||
// When roomId is set, session is shared across room members
|
||||
}
|
||||
```
|
||||
|
||||
### Indexes
|
||||
```sql
|
||||
CREATE INDEX idx_rooms_code ON arcade_rooms(code);
|
||||
CREATE INDEX idx_rooms_status ON arcade_rooms(status);
|
||||
CREATE INDEX idx_rooms_last_activity ON arcade_rooms(lastActivity);
|
||||
CREATE INDEX idx_room_members_room_id ON room_members(roomId);
|
||||
CREATE INDEX idx_room_members_user_id ON room_members(userId);
|
||||
CREATE INDEX idx_room_members_online ON room_members(isOnline) WHERE isOnline = true;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. API Endpoints
|
||||
|
||||
### Room CRUD (`/api/arcade/rooms`)
|
||||
|
||||
```typescript
|
||||
// Create room
|
||||
POST /api/arcade/rooms
|
||||
Body: {
|
||||
name: string // Room name
|
||||
gameName: string // Which game
|
||||
gameConfig?: object // Game settings
|
||||
ttlMinutes?: number // Default: 60
|
||||
creatorName: string // Display name
|
||||
}
|
||||
Response: {
|
||||
room: ArcadeRoom
|
||||
joinUrl: string // Full URL to share
|
||||
}
|
||||
|
||||
// Get room details
|
||||
GET /api/arcade/rooms/:roomId
|
||||
Response: {
|
||||
room: ArcadeRoom
|
||||
members: RoomMember[]
|
||||
canModerate: boolean // True if requester is creator
|
||||
}
|
||||
|
||||
// Update room (creator only)
|
||||
PATCH /api/arcade/rooms/:roomId
|
||||
Body: {
|
||||
name?: string
|
||||
isLocked?: boolean
|
||||
ttlMinutes?: number
|
||||
}
|
||||
Response: { room: ArcadeRoom }
|
||||
|
||||
// Delete room (creator only)
|
||||
DELETE /api/arcade/rooms/:roomId
|
||||
Response: { success: boolean }
|
||||
|
||||
// Join room by code
|
||||
GET /api/arcade/rooms/join/:code
|
||||
Response: {
|
||||
roomId: string
|
||||
redirectUrl: string
|
||||
}
|
||||
```
|
||||
|
||||
### Room Discovery
|
||||
|
||||
```typescript
|
||||
// Public room lobby (list all active rooms)
|
||||
GET /api/arcade/rooms/lobby
|
||||
Query: {
|
||||
gameName?: string // Filter by game
|
||||
status?: string // Filter by status
|
||||
limit?: number // Default: 50
|
||||
offset?: number
|
||||
}
|
||||
Response: {
|
||||
rooms: Array<{
|
||||
id: string
|
||||
name: string
|
||||
code: string
|
||||
gameName: string
|
||||
status: string
|
||||
memberCount: number
|
||||
createdAt: Date
|
||||
creatorName: string
|
||||
}>
|
||||
total: number
|
||||
}
|
||||
|
||||
// Get user's rooms (all rooms user has joined)
|
||||
GET /api/arcade/rooms/my-rooms
|
||||
Query: { userId: string }
|
||||
Response: {
|
||||
rooms: Array<RoomWithMemberInfo>
|
||||
}
|
||||
```
|
||||
|
||||
### Room Membership
|
||||
|
||||
```typescript
|
||||
// Join room
|
||||
POST /api/arcade/rooms/:roomId/join
|
||||
Body: {
|
||||
userId: string // User or guest ID
|
||||
displayName: string
|
||||
}
|
||||
Response: {
|
||||
member: RoomMember
|
||||
room: ArcadeRoom
|
||||
}
|
||||
|
||||
// Leave room
|
||||
POST /api/arcade/rooms/:roomId/leave
|
||||
Body: { userId: string }
|
||||
Response: { success: boolean }
|
||||
|
||||
// Get members
|
||||
GET /api/arcade/rooms/:roomId/members
|
||||
Response: {
|
||||
members: RoomMember[]
|
||||
onlineCount: number
|
||||
}
|
||||
|
||||
// Kick member (creator only)
|
||||
DELETE /api/arcade/rooms/:roomId/members/:userId
|
||||
Response: { success: boolean }
|
||||
```
|
||||
|
||||
### Room Game Session
|
||||
|
||||
```typescript
|
||||
// Start game in room
|
||||
POST /api/arcade/rooms/:roomId/start-game
|
||||
Body: {
|
||||
initiatedBy: string // Must be room member
|
||||
activePlayers: string[] // Subset of room members
|
||||
}
|
||||
Response: {
|
||||
sessionId: string
|
||||
gameState: any
|
||||
}
|
||||
|
||||
// End game (return to lobby)
|
||||
POST /api/arcade/rooms/:roomId/end-game
|
||||
Body: { initiatedBy: string }
|
||||
Response: { success: boolean }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. WebSocket Protocol
|
||||
|
||||
### Socket.IO Room Namespacing
|
||||
|
||||
```typescript
|
||||
// Join room's socket.io room
|
||||
socket.emit('join-room', {
|
||||
roomId: string
|
||||
userId: string
|
||||
})
|
||||
|
||||
// Leave room
|
||||
socket.emit('leave-room', {
|
||||
roomId: string
|
||||
userId: string
|
||||
})
|
||||
|
||||
// Update member presence
|
||||
socket.emit('update-presence', {
|
||||
roomId: string
|
||||
userId: string
|
||||
isOnline: boolean
|
||||
})
|
||||
```
|
||||
|
||||
### Server → Client Events (room-scoped broadcasts)
|
||||
|
||||
```typescript
|
||||
// Room state changes
|
||||
socket.on('room-updated', {
|
||||
room: ArcadeRoom
|
||||
})
|
||||
|
||||
// Member events
|
||||
socket.on('member-joined', {
|
||||
member: RoomMember
|
||||
memberCount: number
|
||||
})
|
||||
|
||||
socket.on('member-left', {
|
||||
userId: string
|
||||
memberCount: number
|
||||
})
|
||||
|
||||
socket.on('member-kicked', {
|
||||
kickedUserId: string
|
||||
reason: string
|
||||
})
|
||||
|
||||
socket.on('members-updated', {
|
||||
members: RoomMember[]
|
||||
})
|
||||
|
||||
// Game session events
|
||||
socket.on('game-starting', {
|
||||
sessionId: string
|
||||
activePlayers: string[]
|
||||
})
|
||||
|
||||
socket.on('game-ended', {
|
||||
results: any
|
||||
})
|
||||
|
||||
// Room lifecycle
|
||||
socket.on('room-locked', {
|
||||
isLocked: boolean
|
||||
})
|
||||
|
||||
socket.on('room-deleted', {
|
||||
reason: string
|
||||
})
|
||||
|
||||
// Existing game moves (now room-scoped)
|
||||
socket.on('game-move', { roomId, userId, move })
|
||||
socket.on('move-accepted', { roomId, gameState, version })
|
||||
socket.on('move-rejected', { roomId, error })
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. URL Structure & Routing
|
||||
|
||||
### New Routes
|
||||
|
||||
```typescript
|
||||
/arcade/rooms // Public room lobby (list all rooms)
|
||||
/arcade/rooms/create // Create room modal/page
|
||||
/arcade/rooms/:roomId // Room lobby (pre-game)
|
||||
/arcade/rooms/:roomId/matching // Game with room context
|
||||
/arcade/rooms/:roomId/complement-race // Another game
|
||||
/arcade/join/:code // Short link: redirects to room
|
||||
|
||||
// Existing routes (backward compatible)
|
||||
/arcade // My rooms + quick play
|
||||
/arcade/matching // Solo play (no room)
|
||||
```
|
||||
|
||||
### Navigation Flow
|
||||
|
||||
```
|
||||
User Journey A: Create Room
|
||||
1. /arcade → Click "Create Room"
|
||||
2. /arcade/rooms/create → Fill form, submit
|
||||
3. /arcade/rooms/{roomId} → Room lobby, share link
|
||||
4. Click "Start Game" → /arcade/rooms/{roomId}/matching
|
||||
|
||||
User Journey B: Join via Link
|
||||
1. Receive link: example.com/arcade/rooms/{roomId}
|
||||
2. Opens lobby, automatically joins
|
||||
3. Wait for game start or click ready
|
||||
|
||||
User Journey C: Join via Code
|
||||
1. /arcade → Click "Join Room", enter ABC123
|
||||
2. Resolves code → /arcade/rooms/{roomId}
|
||||
3. Join and wait
|
||||
|
||||
User Journey D: Browse Lobby
|
||||
1. /arcade/rooms → See public room list
|
||||
2. Click room → /arcade/rooms/{roomId}
|
||||
3. Join and play
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. UI Components Architecture
|
||||
|
||||
### Component Hierarchy
|
||||
|
||||
```
|
||||
/src/components/arcade/rooms/
|
||||
├── RoomLobbyBrowser.tsx // Public room list (/arcade/rooms)
|
||||
│ ├── RoomCard.tsx // Individual room preview
|
||||
│ └── RoomFilters.tsx // Filter by game, status
|
||||
│
|
||||
├── RoomLobby.tsx // Pre-game lobby (/arcade/rooms/:roomId)
|
||||
│ ├── RoomHeader.tsx // Room name, code, share button
|
||||
│ ├── RoomMemberList.tsx // Online members
|
||||
│ ├── RoomSettings.tsx // Creator-only settings
|
||||
│ └── RoomActions.tsx // Start game, leave, etc.
|
||||
│
|
||||
├── CreateRoomDialog.tsx // Room creation modal
|
||||
│ ├── GameSelector.tsx // Choose game type
|
||||
│ ├── RoomNameInput.tsx // Name the room
|
||||
│ └── AdvancedSettings.tsx // TTL, etc.
|
||||
│
|
||||
├── JoinRoomDialog.tsx // Join by code modal
|
||||
├── RoomContextIndicator.tsx // Shows room info during game
|
||||
│ └── RoomMemberAvatars.tsx // Small member list
|
||||
│
|
||||
└── RoomModeration.tsx // Creator controls (kick, lock, delete)
|
||||
```
|
||||
|
||||
### Navigation Updates
|
||||
|
||||
```typescript
|
||||
// Update GameContextNav.tsx
|
||||
interface GameContextNavProps {
|
||||
// ... existing props
|
||||
roomContext?: {
|
||||
roomId: string
|
||||
roomName: string
|
||||
roomCode: string
|
||||
memberCount: number
|
||||
isCreator: boolean
|
||||
}
|
||||
}
|
||||
|
||||
// Shows in nav during room gameplay:
|
||||
// [🏠 Friday Night (ABC123) • 5 players ▼]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. State Management
|
||||
|
||||
### New Hooks
|
||||
|
||||
```typescript
|
||||
// useArcadeRoom.ts - Room state and membership
|
||||
export function useArcadeRoom(roomId: string) {
|
||||
return {
|
||||
room: ArcadeRoom | null
|
||||
members: RoomMember[]
|
||||
isCreator: boolean
|
||||
isOnline: boolean
|
||||
joinRoom: (displayName: string) => Promise<void>
|
||||
leaveRoom: () => Promise<void>
|
||||
updateRoom: (updates: Partial<ArcadeRoom>) => Promise<void>
|
||||
deleteRoom: () => Promise<void>
|
||||
kickMember: (userId: string) => Promise<void>
|
||||
startGame: (activePlayers: string[]) => Promise<void>
|
||||
endGame: () => Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
// useRoomMembers.ts - Real-time member presence
|
||||
export function useRoomMembers(roomId: string) {
|
||||
return {
|
||||
members: RoomMember[]
|
||||
onlineMembers: RoomMember[]
|
||||
onlineCount: number
|
||||
updatePresence: (isOnline: boolean) => void
|
||||
}
|
||||
}
|
||||
|
||||
// useRoomLobby.ts - Public room discovery
|
||||
export function useRoomLobby(filters?: RoomFilters) {
|
||||
return {
|
||||
rooms: RoomPreview[]
|
||||
loading: boolean
|
||||
refresh: () => void
|
||||
loadMore: () => void
|
||||
}
|
||||
}
|
||||
|
||||
// Update useArcadeSession.ts
|
||||
export function useArcadeSession<TState>(options: {
|
||||
userId: string
|
||||
roomId?: string // NEW: optional room context
|
||||
// ... existing options
|
||||
}) {
|
||||
// If roomId provided, session is room-scoped
|
||||
// All moves broadcast to room members
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Server Implementation
|
||||
|
||||
### New Files
|
||||
|
||||
```
|
||||
/src/lib/arcade/
|
||||
├── room-manager.ts # Core room operations
|
||||
│ ├── createRoom()
|
||||
│ ├── getRoomById()
|
||||
│ ├── updateRoom()
|
||||
│ ├── deleteRoom()
|
||||
│ ├── getRoomByCode()
|
||||
│ └── getPublicRooms()
|
||||
│
|
||||
├── room-membership.ts # Member management
|
||||
│ ├── joinRoom()
|
||||
│ ├── leaveRoom()
|
||||
│ ├── kickMember()
|
||||
│ ├── getRoomMembers()
|
||||
│ └── updateMemberPresence()
|
||||
│
|
||||
├── room-validation.ts # Access control
|
||||
│ ├── canModerateRoom()
|
||||
│ ├── canJoinRoom()
|
||||
│ ├── canStartGame()
|
||||
│ └── validateRoomName()
|
||||
│
|
||||
├── room-ttl.ts # TTL management (reuse existing pattern)
|
||||
│ ├── scheduleRoomCleanup()
|
||||
│ ├── updateRoomActivity()
|
||||
│ └── cleanupExpiredRooms()
|
||||
│
|
||||
└── session-manager.ts # Update for room support
|
||||
└── createArcadeSession() - accept roomId param
|
||||
```
|
||||
|
||||
### Socket Server Updates
|
||||
|
||||
```typescript
|
||||
// socket-server.ts modifications
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
|
||||
// Join room (socket.io namespace)
|
||||
socket.on('join-room', async ({ roomId, userId }) => {
|
||||
// Validate membership
|
||||
const member = await getRoomMember(roomId, userId)
|
||||
if (!member) {
|
||||
socket.emit('room-error', { error: 'Not a room member' })
|
||||
return
|
||||
}
|
||||
|
||||
// Join socket.io room
|
||||
socket.join(`room:${roomId}`)
|
||||
|
||||
// Update presence
|
||||
await updateMemberPresence(roomId, userId, true)
|
||||
|
||||
// Broadcast to room
|
||||
io.to(`room:${roomId}`).emit('member-joined', { member })
|
||||
|
||||
// Send current state
|
||||
const room = await getRoomById(roomId)
|
||||
const members = await getRoomMembers(roomId)
|
||||
socket.emit('room-state', { room, members })
|
||||
})
|
||||
|
||||
// Leave room
|
||||
socket.on('leave-room', async ({ roomId, userId }) => {
|
||||
socket.leave(`room:${roomId}`)
|
||||
await updateMemberPresence(roomId, userId, false)
|
||||
io.to(`room:${roomId}`).emit('member-left', { userId })
|
||||
})
|
||||
|
||||
// Game moves (room-scoped)
|
||||
socket.on('game-move', async ({ roomId, userId, move }) => {
|
||||
// Validate room membership
|
||||
const member = await getRoomMember(roomId, userId)
|
||||
if (!member) return
|
||||
|
||||
// Apply move to room's session
|
||||
const result = await applyGameMove(userId, move, roomId)
|
||||
|
||||
if (result.success) {
|
||||
// Broadcast to all room members
|
||||
io.to(`room:${roomId}`).emit('move-accepted', {
|
||||
gameState: result.session.gameState,
|
||||
version: result.session.version,
|
||||
move
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Disconnect handling
|
||||
socket.on('disconnect', () => {
|
||||
// Update presence for all rooms user was in
|
||||
updateAllUserPresence(userId, false)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Guest User System
|
||||
|
||||
### Guest ID Generation
|
||||
|
||||
```typescript
|
||||
// /src/lib/guest-id.ts
|
||||
|
||||
export function generateGuestId(): string {
|
||||
// Format: guest_{timestamp}_{random}
|
||||
// Example: guest_1704556800000_a3f2e1
|
||||
const timestamp = Date.now()
|
||||
const random = Math.random().toString(36).substring(2, 8)
|
||||
return `guest_${timestamp}_${random}`
|
||||
}
|
||||
|
||||
export function isGuestId(userId: string): boolean {
|
||||
return userId.startsWith('guest_')
|
||||
}
|
||||
|
||||
export function getGuestDisplayName(guestId: string): string {
|
||||
// Generate friendly name like "Guest 1234"
|
||||
const hash = guestId.split('_')[2]
|
||||
const num = parseInt(hash, 36) % 10000
|
||||
return `Guest ${num}`
|
||||
}
|
||||
```
|
||||
|
||||
### Guest Session Storage
|
||||
|
||||
```typescript
|
||||
// Store guest ID in localStorage
|
||||
const GUEST_ID_KEY = 'soroban_guest_id'
|
||||
|
||||
export function getOrCreateGuestId(): string {
|
||||
let guestId = localStorage.getItem(GUEST_ID_KEY)
|
||||
if (!guestId) {
|
||||
guestId = generateGuestId()
|
||||
localStorage.setItem(GUEST_ID_KEY, guestId)
|
||||
}
|
||||
return guestId
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. TTL Implementation
|
||||
|
||||
### Reuse Existing Session TTL Pattern
|
||||
|
||||
```typescript
|
||||
// room-ttl.ts
|
||||
|
||||
const DEFAULT_ROOM_TTL_MINUTES = 60
|
||||
|
||||
// Cleanup job (run every 5 minutes)
|
||||
setInterval(async () => {
|
||||
await cleanupExpiredRooms()
|
||||
}, 5 * 60 * 1000)
|
||||
|
||||
async function cleanupExpiredRooms() {
|
||||
const now = new Date()
|
||||
|
||||
// Find expired rooms
|
||||
const expiredRooms = await db.query(`
|
||||
SELECT id FROM arcade_rooms
|
||||
WHERE status != 'playing'
|
||||
AND lastActivity < NOW() - INTERVAL '1 minute' * ttlMinutes
|
||||
`)
|
||||
|
||||
for (const room of expiredRooms) {
|
||||
// Notify members
|
||||
io.to(`room:${room.id}`).emit('room-deleted', {
|
||||
reason: 'Room expired due to inactivity'
|
||||
})
|
||||
|
||||
// Delete room and members
|
||||
await deleteRoom(room.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Update activity on any room action
|
||||
export async function touchRoom(roomId: string) {
|
||||
await db.query(`
|
||||
UPDATE arcade_rooms
|
||||
SET lastActivity = NOW()
|
||||
WHERE id = $1
|
||||
`, [roomId])
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Room Code Generation
|
||||
|
||||
```typescript
|
||||
// /src/lib/arcade/room-code.ts
|
||||
|
||||
const CODE_CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' // No ambiguous chars
|
||||
const CODE_LENGTH = 6
|
||||
|
||||
export async function generateUniqueRoomCode(): Promise<string> {
|
||||
let attempts = 0
|
||||
const maxAttempts = 10
|
||||
|
||||
while (attempts < maxAttempts) {
|
||||
const code = generateCode()
|
||||
const existing = await getRoomByCode(code)
|
||||
if (!existing) return code
|
||||
attempts++
|
||||
}
|
||||
|
||||
throw new Error('Failed to generate unique room code')
|
||||
}
|
||||
|
||||
function generateCode(): string {
|
||||
let code = ''
|
||||
for (let i = 0; i < CODE_LENGTH; i++) {
|
||||
const idx = Math.floor(Math.random() * CODE_CHARS.length)
|
||||
code += CODE_CHARS[idx]
|
||||
}
|
||||
return code
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. Migration Plan
|
||||
|
||||
### Phase 1: Database & API Foundation (Day 1-2)
|
||||
1. Create database tables and indexes
|
||||
2. Implement room-manager.ts and room-membership.ts
|
||||
3. Build API endpoints
|
||||
4. Add room-code generation
|
||||
5. Implement TTL cleanup
|
||||
6. Write unit tests
|
||||
|
||||
### Phase 2: Socket.IO Integration (Day 2-3)
|
||||
1. Update socket-server.ts for room namespacing
|
||||
2. Implement room-scoped broadcasts
|
||||
3. Add presence tracking
|
||||
4. Update session-manager.ts for roomId support
|
||||
5. Test multi-user room sessions
|
||||
|
||||
### Phase 3: Guest User System (Day 3)
|
||||
1. Implement guest ID generation
|
||||
2. Add guest user hooks
|
||||
3. Update auth flow for optional guest access
|
||||
4. Test guest join/leave flow
|
||||
|
||||
### Phase 4: UI Components (Day 4-5)
|
||||
1. Build CreateRoomDialog
|
||||
2. Build RoomLobby component
|
||||
3. Build RoomLobbyBrowser (public lobby)
|
||||
4. Add RoomContextIndicator to nav
|
||||
5. Wire up all hooks
|
||||
|
||||
### Phase 5: Routes & Navigation (Day 5-6)
|
||||
1. Create /arcade/rooms routes
|
||||
2. Update arcade home for room access
|
||||
3. Add room selector to nav
|
||||
4. Implement join-by-code flow
|
||||
5. Add share room functionality
|
||||
|
||||
### Phase 6: Testing & Polish (Day 6-7)
|
||||
1. E2E tests for room creation/join
|
||||
2. Multi-user gameplay tests
|
||||
3. TTL and cleanup tests
|
||||
4. Guest user flow tests
|
||||
5. Performance testing
|
||||
6. UI polish and error states
|
||||
|
||||
---
|
||||
|
||||
## 13. Backward Compatibility
|
||||
|
||||
### Solo Play Preserved
|
||||
- Existing `/arcade/matching` routes work unchanged
|
||||
- `roomId = null` for solo sessions
|
||||
- No breaking changes to `useArcadeSession`
|
||||
- All current functionality remains intact
|
||||
|
||||
### Migration Strategy
|
||||
- Add `roomId` column to `arcade_sessions` (nullable)
|
||||
- Existing sessions have `roomId = null`
|
||||
- New room-based sessions have `roomId` populated
|
||||
- Session logic checks: `if (roomId) { /* room mode */ }`
|
||||
|
||||
---
|
||||
|
||||
## 14. Security Considerations
|
||||
|
||||
### Room Access
|
||||
- ✅ Validate room membership before allowing game moves
|
||||
- ✅ Check `isCreator` flag for moderation actions
|
||||
- ✅ Prevent joining locked rooms
|
||||
- ✅ Rate limit room creation per IP/user
|
||||
- ✅ Sanitize room names (max length, no XSS)
|
||||
|
||||
### Guest Users
|
||||
- ✅ Guest IDs stored client-side only (localStorage)
|
||||
- ✅ No sensitive data in guest profiles
|
||||
- ✅ Guest names sanitized
|
||||
- ✅ Rate limit guest actions
|
||||
- ✅ Allow authenticated users to "claim" guest activity
|
||||
|
||||
### Room Moderation
|
||||
- ✅ Only creator can kick/lock/delete
|
||||
- ✅ Kicked users cannot rejoin unless creator unlocks
|
||||
- ✅ Room deletion clears all associated data
|
||||
- ✅ Audit log for moderation actions
|
||||
|
||||
---
|
||||
|
||||
## 15. Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
- Room CRUD operations
|
||||
- Member join/leave logic
|
||||
- Code generation uniqueness
|
||||
- TTL cleanup
|
||||
- Guest ID generation
|
||||
- Access control validation
|
||||
|
||||
### Integration Tests
|
||||
- Full room creation → join → play → leave flow
|
||||
- Multi-user concurrent gameplay
|
||||
- Socket.IO room broadcasts
|
||||
- Session synchronization across tabs
|
||||
- TTL expiration and cleanup
|
||||
|
||||
### E2E Tests (Playwright)
|
||||
- Create room → share link → join as guest
|
||||
- Browse lobby → join room → play game
|
||||
- Creator kicks member
|
||||
- Room locks and unlock
|
||||
- Room TTL expiration
|
||||
|
||||
### Load Tests
|
||||
- 100+ concurrent rooms
|
||||
- 10+ players per room
|
||||
- Rapid join/leave cycles
|
||||
- Socket.IO message throughput
|
||||
|
||||
---
|
||||
|
||||
## 16. Performance Optimizations
|
||||
|
||||
### Database
|
||||
- Index on `room_members.roomId` for fast member queries
|
||||
- Index on `arcade_rooms.code` for quick code lookups
|
||||
- Index on `room_members.isOnline` for presence queries
|
||||
- Partition `arcade_rooms` by `createdAt` for TTL cleanup
|
||||
|
||||
### Caching
|
||||
- Cache active room list (1-minute TTL)
|
||||
- Cache room member counts
|
||||
- Redis pub/sub for cross-server socket broadcasts (future)
|
||||
|
||||
### Socket.IO
|
||||
- Use socket.io rooms for efficient broadcasting
|
||||
- Batch presence updates (debounce member online status)
|
||||
- Compress socket messages for large game states
|
||||
|
||||
---
|
||||
|
||||
## 17. Future Enhancements (Post-MVP)
|
||||
|
||||
1. **Room Templates** - Save room configurations
|
||||
2. **Private Rooms** - Invite-only with passwords
|
||||
3. **Room Chat** - Text chat in lobby
|
||||
4. **Spectator Mode** - Watch games without playing
|
||||
5. **Room History** - Past games and stats
|
||||
6. **Tournament Brackets** - Multi-round competitions
|
||||
7. **Room Search** - Search by name/tag
|
||||
8. **Room Tags** - Categorize rooms
|
||||
9. **Friend Integration** - Invite friends directly
|
||||
10. **Room Analytics** - Popular times, average players, etc.
|
||||
|
||||
---
|
||||
|
||||
## 18. Open Questions / Decisions Needed
|
||||
|
||||
1. **Room Name Validation** - Max length? Profanity filter?
|
||||
2. **Default TTL** - 60 minutes good default?
|
||||
3. **Code Reuse** - Can codes be reused after room expires?
|
||||
4. **Member Limit** - Even though unlimited, warn at certain threshold?
|
||||
5. **Lobby Pagination** - How many rooms to show initially?
|
||||
|
||||
---
|
||||
|
||||
## 19. Success Metrics
|
||||
|
||||
- ✅ Users can create and join rooms
|
||||
- ✅ Guest users can participate without auth
|
||||
- ✅ Multi-user gameplay synchronized across all room members
|
||||
- ✅ Room creators can moderate effectively
|
||||
- ✅ Rooms expire correctly based on TTL
|
||||
- ✅ Public lobby shows active rooms
|
||||
- ✅ No regressions in solo play mode
|
||||
- ✅ All tests passing (unit, integration, e2e)
|
||||
|
||||
---
|
||||
|
||||
## 20. Dependencies
|
||||
|
||||
### Existing Systems to Leverage
|
||||
- ✅ Current arcade session management
|
||||
- ✅ Existing WebSocket infrastructure (socket-server.ts)
|
||||
- ✅ Database setup (PostgreSQL)
|
||||
- ✅ Guest player system (from GameModeContext)
|
||||
|
||||
### New Dependencies (if needed)
|
||||
- None! All can be built with existing stack
|
||||
|
||||
---
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
- [ ] Create database migration
|
||||
- [ ] Implement room-manager.ts
|
||||
- [ ] Implement room-membership.ts
|
||||
- [ ] Build API endpoints
|
||||
- [ ] Add room code generation
|
||||
- [ ] Update socket-server.ts
|
||||
- [ ] Implement guest ID system
|
||||
- [ ] Build CreateRoomDialog
|
||||
- [ ] Build RoomLobby component
|
||||
- [ ] Build RoomLobbyBrowser
|
||||
- [ ] Add room routes
|
||||
- [ ] Update navigation
|
||||
- [ ] Write unit tests
|
||||
- [ ] Write integration tests
|
||||
- [ ] Write e2e tests
|
||||
- [ ] Documentation
|
||||
- [ ] Deploy
|
||||
|
||||
---
|
||||
|
||||
**Ready to implement! 🚀**
|
||||
31
apps/web/drizzle/0003_naive_reptil.sql
Normal file
31
apps/web/drizzle/0003_naive_reptil.sql
Normal file
@@ -0,0 +1,31 @@
|
||||
CREATE TABLE `arcade_rooms` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`code` text(6) NOT NULL,
|
||||
`name` text(50) NOT NULL,
|
||||
`created_by` text NOT NULL,
|
||||
`creator_name` text(50) NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`last_activity` integer NOT NULL,
|
||||
`ttl_minutes` integer DEFAULT 60 NOT NULL,
|
||||
`is_locked` integer DEFAULT false NOT NULL,
|
||||
`game_name` text NOT NULL,
|
||||
`game_config` text NOT NULL,
|
||||
`status` text DEFAULT 'lobby' NOT NULL,
|
||||
`current_session_id` text,
|
||||
`total_games_played` integer DEFAULT 0 NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `arcade_rooms_code_unique` ON `arcade_rooms` (`code`);--> statement-breakpoint
|
||||
CREATE TABLE `room_members` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`room_id` text NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`display_name` text(50) NOT NULL,
|
||||
`is_creator` integer DEFAULT false NOT NULL,
|
||||
`joined_at` integer NOT NULL,
|
||||
`last_seen` integer NOT NULL,
|
||||
`is_online` integer DEFAULT true NOT NULL,
|
||||
FOREIGN KEY (`room_id`) REFERENCES `arcade_rooms`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `arcade_sessions` ADD `room_id` text REFERENCES arcade_rooms(id);
|
||||
681
apps/web/drizzle/meta/0003_snapshot.json
Normal file
681
apps/web/drizzle/meta/0003_snapshot.json
Normal file
@@ -0,0 +1,681 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "68cc273f-0d84-4a46-ae41-124a3e06096b",
|
||||
"prevId": "194ccc68-7173-44c9-879a-55d20cf3ae1f",
|
||||
"tables": {
|
||||
"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": {}
|
||||
},
|
||||
"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": {}
|
||||
},
|
||||
"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": {}
|
||||
},
|
||||
"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": {}
|
||||
},
|
||||
"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": {},
|
||||
"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": {}
|
||||
},
|
||||
"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": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,13 @@
|
||||
"when": 1759752980924,
|
||||
"tag": "0002_loose_ultimatum",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "6",
|
||||
"when": 1759781243105,
|
||||
"tag": "0003_naive_reptil",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
40
apps/web/src/db/schema/arcade-rooms.ts
Normal file
40
apps/web/src/db/schema/arcade-rooms.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'
|
||||
import { createId } from '@paralleldrive/cuid2'
|
||||
|
||||
export const arcadeRooms = sqliteTable('arcade_rooms', {
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => createId()),
|
||||
|
||||
// Room identity
|
||||
code: text('code', { length: 6 }).notNull().unique(), // e.g., "ABC123"
|
||||
name: text('name', { length: 50 }).notNull(),
|
||||
|
||||
// Creator info
|
||||
createdBy: text('created_by').notNull(), // User/guest ID
|
||||
creatorName: text('creator_name', { length: 50 }).notNull(),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
||||
|
||||
// Lifecycle
|
||||
lastActivity: integer('last_activity', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
||||
ttlMinutes: integer('ttl_minutes').notNull().default(60), // Time to live
|
||||
isLocked: integer('is_locked', { mode: 'boolean' }).notNull().default(false),
|
||||
|
||||
// Game configuration
|
||||
gameName: text('game_name', {
|
||||
enum: ['matching', 'memory-quiz', 'complement-race'],
|
||||
}).notNull(),
|
||||
gameConfig: text('game_config', { mode: 'json' }).notNull(), // Game-specific settings
|
||||
|
||||
// Current state
|
||||
status: text('status', {
|
||||
enum: ['lobby', 'playing', 'finished'],
|
||||
}).notNull().default('lobby'),
|
||||
currentSessionId: text('current_session_id'), // FK to arcade_sessions (nullable)
|
||||
|
||||
// Metadata
|
||||
totalGamesPlayed: integer('total_games_played').notNull().default(0),
|
||||
})
|
||||
|
||||
export type ArcadeRoom = typeof arcadeRooms.$inferSelect
|
||||
export type NewArcadeRoom = typeof arcadeRooms.$inferInsert
|
||||
@@ -1,5 +1,6 @@
|
||||
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'
|
||||
import { users } from './users'
|
||||
import { arcadeRooms } from './arcade-rooms'
|
||||
|
||||
export const arcadeSessions = sqliteTable('arcade_sessions', {
|
||||
userId: text('user_id')
|
||||
@@ -19,6 +20,9 @@ export const arcadeSessions = sqliteTable('arcade_sessions', {
|
||||
// Active players snapshot (for quick access)
|
||||
activePlayers: text('active_players', { mode: 'json' }).notNull(),
|
||||
|
||||
// Room association (null for solo play)
|
||||
roomId: text('room_id').references(() => arcadeRooms.id, { onDelete: 'set null' }),
|
||||
|
||||
// Timing & TTL
|
||||
startedAt: integer('started_at', { mode: 'timestamp' }).notNull(),
|
||||
lastActivityAt: integer('last_activity_at', { mode: 'timestamp' }).notNull(),
|
||||
|
||||
@@ -9,4 +9,6 @@ export * from './users'
|
||||
export * from './players'
|
||||
export * from './user-stats'
|
||||
export * from './abacus-settings'
|
||||
export * from './arcade-rooms'
|
||||
export * from './room-members'
|
||||
export * from './arcade-sessions'
|
||||
|
||||
25
apps/web/src/db/schema/room-members.ts
Normal file
25
apps/web/src/db/schema/room-members.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'
|
||||
import { createId } from '@paralleldrive/cuid2'
|
||||
import { arcadeRooms } from './arcade-rooms'
|
||||
|
||||
export const roomMembers = sqliteTable('room_members', {
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => createId()),
|
||||
|
||||
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(),
|
||||
|
||||
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),
|
||||
})
|
||||
|
||||
export type RoomMember = typeof roomMembers.$inferSelect
|
||||
export type NewRoomMember = typeof roomMembers.$inferInsert
|
||||
35
apps/web/src/lib/arcade/room-code.ts
Normal file
35
apps/web/src/lib/arcade/room-code.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Room code generation utility
|
||||
* Generates short, memorable codes for joining rooms
|
||||
*/
|
||||
|
||||
const CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' // Removed ambiguous chars: 0,O,1,I
|
||||
const CODE_LENGTH = 6
|
||||
|
||||
/**
|
||||
* Generate a random 6-character room code
|
||||
* Format: ABC123 (uppercase letters + numbers, no ambiguous chars)
|
||||
*/
|
||||
export function generateRoomCode(): string {
|
||||
let code = ''
|
||||
for (let i = 0; i < CODE_LENGTH; i++) {
|
||||
const randomIndex = Math.floor(Math.random() * CHARS.length)
|
||||
code += CHARS[randomIndex]
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a room code format
|
||||
*/
|
||||
export function isValidRoomCode(code: string): boolean {
|
||||
if (code.length !== CODE_LENGTH) return false
|
||||
return code.split('').every(char => CHARS.includes(char))
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a room code (uppercase, remove spaces/dashes)
|
||||
*/
|
||||
export function normalizeRoomCode(code: string): string {
|
||||
return code.toUpperCase().replace(/[\s-]/g, '')
|
||||
}
|
||||
196
apps/web/src/lib/arcade/room-manager.ts
Normal file
196
apps/web/src/lib/arcade/room-manager.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Arcade room manager
|
||||
* Handles database operations for arcade rooms
|
||||
*/
|
||||
|
||||
import { db, schema } from '@/db'
|
||||
import { eq, and, or, lt, desc } from 'drizzle-orm'
|
||||
import { type GameName } from './validation'
|
||||
import { generateRoomCode } from './room-code'
|
||||
|
||||
export interface CreateRoomOptions {
|
||||
name: string
|
||||
createdBy: string // User/guest ID
|
||||
creatorName: string
|
||||
gameName: GameName
|
||||
gameConfig: unknown
|
||||
ttlMinutes?: number // Default: 60
|
||||
}
|
||||
|
||||
export interface UpdateRoomOptions {
|
||||
name?: string
|
||||
isLocked?: boolean
|
||||
status?: 'lobby' | 'playing' | 'finished'
|
||||
currentSessionId?: string | null
|
||||
totalGamesPlayed?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new arcade room
|
||||
* Generates a unique room code and creates the room in the database
|
||||
*/
|
||||
export async function createRoom(options: CreateRoomOptions): Promise<schema.ArcadeRoom> {
|
||||
const now = new Date()
|
||||
|
||||
// Generate unique room code (retry up to 5 times if collision)
|
||||
let code = generateRoomCode()
|
||||
let attempts = 0
|
||||
const MAX_ATTEMPTS = 5
|
||||
|
||||
while (attempts < MAX_ATTEMPTS) {
|
||||
const existing = await getRoomByCode(code)
|
||||
if (!existing) break
|
||||
code = generateRoomCode()
|
||||
attempts++
|
||||
}
|
||||
|
||||
if (attempts === MAX_ATTEMPTS) {
|
||||
throw new Error('Failed to generate unique room code')
|
||||
}
|
||||
|
||||
const newRoom: schema.NewArcadeRoom = {
|
||||
code,
|
||||
name: options.name,
|
||||
createdBy: options.createdBy,
|
||||
creatorName: options.creatorName,
|
||||
createdAt: now,
|
||||
lastActivity: now,
|
||||
ttlMinutes: options.ttlMinutes || 60,
|
||||
isLocked: false,
|
||||
gameName: options.gameName,
|
||||
gameConfig: options.gameConfig as any,
|
||||
status: 'lobby',
|
||||
currentSessionId: null,
|
||||
totalGamesPlayed: 0,
|
||||
}
|
||||
|
||||
const [room] = await db.insert(schema.arcadeRooms).values(newRoom).returning()
|
||||
console.log('[Room Manager] Created room:', room.id, 'code:', room.code)
|
||||
return room
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a room by ID
|
||||
*/
|
||||
export async function getRoomById(roomId: string): Promise<schema.ArcadeRoom | undefined> {
|
||||
return await db.query.arcadeRooms.findFirst({
|
||||
where: eq(schema.arcadeRooms.id, roomId)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a room by code
|
||||
*/
|
||||
export async function getRoomByCode(code: string): Promise<schema.ArcadeRoom | undefined> {
|
||||
return await db.query.arcadeRooms.findFirst({
|
||||
where: eq(schema.arcadeRooms.code, code.toUpperCase())
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a room
|
||||
*/
|
||||
export async function updateRoom(
|
||||
roomId: string,
|
||||
updates: UpdateRoomOptions
|
||||
): Promise<schema.ArcadeRoom | undefined> {
|
||||
const now = new Date()
|
||||
|
||||
// Always update lastActivity on any room update
|
||||
const updateData = {
|
||||
...updates,
|
||||
lastActivity: now,
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(schema.arcadeRooms)
|
||||
.set(updateData)
|
||||
.where(eq(schema.arcadeRooms.id, roomId))
|
||||
.returning()
|
||||
|
||||
return updated
|
||||
}
|
||||
|
||||
/**
|
||||
* Update room activity timestamp
|
||||
* Call this on any room activity to refresh TTL
|
||||
*/
|
||||
export async function touchRoom(roomId: string): Promise<void> {
|
||||
await db
|
||||
.update(schema.arcadeRooms)
|
||||
.set({ lastActivity: new Date() })
|
||||
.where(eq(schema.arcadeRooms.id, roomId))
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a room
|
||||
* Cascade deletes all room members
|
||||
*/
|
||||
export async function deleteRoom(roomId: string): Promise<void> {
|
||||
await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, roomId))
|
||||
console.log('[Room Manager] Deleted room:', roomId)
|
||||
}
|
||||
|
||||
/**
|
||||
* List active rooms
|
||||
* Returns rooms ordered by most recently active
|
||||
*/
|
||||
export async function listActiveRooms(gameName?: GameName): Promise<schema.ArcadeRoom[]> {
|
||||
const whereConditions = []
|
||||
|
||||
// Filter by game if specified
|
||||
if (gameName) {
|
||||
whereConditions.push(eq(schema.arcadeRooms.gameName, gameName))
|
||||
}
|
||||
|
||||
// Only return non-locked rooms in lobby or playing status
|
||||
whereConditions.push(
|
||||
eq(schema.arcadeRooms.isLocked, false),
|
||||
or(
|
||||
eq(schema.arcadeRooms.status, 'lobby'),
|
||||
eq(schema.arcadeRooms.status, 'playing')
|
||||
)
|
||||
)
|
||||
|
||||
return await db.query.arcadeRooms.findMany({
|
||||
where: whereConditions.length > 0 ? and(...whereConditions) : undefined,
|
||||
orderBy: [desc(schema.arcadeRooms.lastActivity)],
|
||||
limit: 50, // Limit to 50 most recent rooms
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired rooms
|
||||
* Delete rooms that have exceeded their TTL
|
||||
*/
|
||||
export async function cleanupExpiredRooms(): Promise<number> {
|
||||
const now = new Date()
|
||||
|
||||
// Find rooms where lastActivity + ttlMinutes < now
|
||||
const expiredRooms = await db.query.arcadeRooms.findMany({
|
||||
columns: { id: true, ttlMinutes: true, lastActivity: true }
|
||||
})
|
||||
|
||||
const toDelete = expiredRooms.filter(room => {
|
||||
const expiresAt = new Date(room.lastActivity.getTime() + room.ttlMinutes * 60 * 1000)
|
||||
return expiresAt < now
|
||||
})
|
||||
|
||||
if (toDelete.length > 0) {
|
||||
const ids = toDelete.map(r => r.id)
|
||||
await db.delete(schema.arcadeRooms).where(
|
||||
or(...ids.map(id => eq(schema.arcadeRooms.id, id)))
|
||||
)
|
||||
console.log(`[Room Manager] Cleaned up ${toDelete.length} expired rooms`)
|
||||
}
|
||||
|
||||
return toDelete.length
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user is the creator of a room
|
||||
*/
|
||||
export async function isRoomCreator(roomId: string, userId: string): Promise<boolean> {
|
||||
const room = await getRoomById(roomId)
|
||||
return room?.createdBy === userId
|
||||
}
|
||||
175
apps/web/src/lib/arcade/room-membership.ts
Normal file
175
apps/web/src/lib/arcade/room-membership.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Room membership manager
|
||||
* Handles database operations for room members
|
||||
*/
|
||||
|
||||
import { db, schema } from '@/db'
|
||||
import { eq, and } from 'drizzle-orm'
|
||||
|
||||
export interface AddMemberOptions {
|
||||
roomId: string
|
||||
userId: string // User/guest ID
|
||||
displayName: string
|
||||
isCreator?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a member to a room
|
||||
*/
|
||||
export async function addRoomMember(options: AddMemberOptions): Promise<schema.RoomMember> {
|
||||
const now = new Date()
|
||||
|
||||
// Check if member already exists
|
||||
const existing = await getRoomMember(options.roomId, options.userId)
|
||||
if (existing) {
|
||||
// Update lastSeen and isOnline
|
||||
const [updated] = await db
|
||||
.update(schema.roomMembers)
|
||||
.set({
|
||||
isOnline: true,
|
||||
lastSeen: now,
|
||||
})
|
||||
.where(eq(schema.roomMembers.id, existing.id))
|
||||
.returning()
|
||||
return updated
|
||||
}
|
||||
|
||||
const newMember: schema.NewRoomMember = {
|
||||
roomId: options.roomId,
|
||||
userId: options.userId,
|
||||
displayName: options.displayName,
|
||||
isCreator: options.isCreator || false,
|
||||
joinedAt: now,
|
||||
lastSeen: now,
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific room member
|
||||
*/
|
||||
export async function getRoomMember(
|
||||
roomId: string,
|
||||
userId: string
|
||||
): Promise<schema.RoomMember | undefined> {
|
||||
return await db.query.roomMembers.findFirst({
|
||||
where: and(
|
||||
eq(schema.roomMembers.roomId, roomId),
|
||||
eq(schema.roomMembers.userId, userId)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all members in a room
|
||||
*/
|
||||
export async function getRoomMembers(roomId: string): Promise<schema.RoomMember[]> {
|
||||
return await db.query.roomMembers.findMany({
|
||||
where: eq(schema.roomMembers.roomId, roomId),
|
||||
orderBy: schema.roomMembers.joinedAt
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get online members in a room
|
||||
*/
|
||||
export async function getOnlineRoomMembers(roomId: string): Promise<schema.RoomMember[]> {
|
||||
return await db.query.roomMembers.findMany({
|
||||
where: and(
|
||||
eq(schema.roomMembers.roomId, roomId),
|
||||
eq(schema.roomMembers.isOnline, true)
|
||||
),
|
||||
orderBy: schema.roomMembers.joinedAt
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update member's online status
|
||||
*/
|
||||
export async function setMemberOnline(
|
||||
roomId: string,
|
||||
userId: string,
|
||||
isOnline: boolean
|
||||
): Promise<void> {
|
||||
await db
|
||||
.update(schema.roomMembers)
|
||||
.set({
|
||||
isOnline,
|
||||
lastSeen: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(schema.roomMembers.roomId, roomId),
|
||||
eq(schema.roomMembers.userId, userId)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update member's last seen timestamp
|
||||
*/
|
||||
export async function touchMember(roomId: string, userId: string): Promise<void> {
|
||||
await db
|
||||
.update(schema.roomMembers)
|
||||
.set({ lastSeen: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(schema.roomMembers.roomId, roomId),
|
||||
eq(schema.roomMembers.userId, userId)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a member from a room
|
||||
*/
|
||||
export async function removeMember(roomId: string, userId: string): Promise<void> {
|
||||
await db
|
||||
.delete(schema.roomMembers)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.roomMembers.roomId, roomId),
|
||||
eq(schema.roomMembers.userId, userId)
|
||||
)
|
||||
)
|
||||
console.log('[Room Membership] Removed member:', userId, 'from room:', roomId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all members from a room
|
||||
*/
|
||||
export async function removeAllMembers(roomId: string): Promise<void> {
|
||||
await db.delete(schema.roomMembers).where(eq(schema.roomMembers.roomId, roomId))
|
||||
console.log('[Room Membership] Removed all members from room:', roomId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of online members in a room
|
||||
*/
|
||||
export async function getOnlineMemberCount(roomId: string): Promise<number> {
|
||||
const members = await getOnlineRoomMembers(roomId)
|
||||
return members.length
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user is a member of a room
|
||||
*/
|
||||
export async function isMember(roomId: string, userId: string): Promise<boolean> {
|
||||
const member = await getRoomMember(roomId, userId)
|
||||
return !!member
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all rooms a user is a member of
|
||||
*/
|
||||
export async function getUserRooms(userId: string): Promise<string[]> {
|
||||
const memberships = await db.query.roomMembers.findMany({
|
||||
where: eq(schema.roomMembers.userId, userId),
|
||||
columns: { roomId: true }
|
||||
})
|
||||
return memberships.map(m => m.roomId)
|
||||
}
|
||||
Reference in New Issue
Block a user