Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9aadd1f81 | ||
|
|
4686f59d24 | ||
|
|
1219539585 | ||
|
|
87cc0b64fb | ||
|
|
c640a79a44 | ||
|
|
0d85331652 | ||
|
|
28a2e7d651 | ||
|
|
a27c36193e | ||
|
|
cc80a1454b | ||
|
|
8175c43533 | ||
|
|
b49630f3cb | ||
|
|
c4d8032d02 | ||
|
|
01ff114258 | ||
|
|
d173a178bc | ||
|
|
431668729c | ||
|
|
b5d0bee120 | ||
|
|
9dac431c1f | ||
|
|
5bbb212da9 | ||
|
|
c30f585810 | ||
|
|
0a768c65fb | ||
|
|
5ed2ab21ca | ||
|
|
1cb175982a | ||
|
|
ee6094d59d | ||
|
|
9d0c488f2b | ||
|
|
e7d2a73ddf | ||
|
|
63517cf45d | ||
|
|
5e3261f3be | ||
|
|
1e43e6945b | ||
|
|
94a1d9b110 | ||
|
|
9fa5652173 | ||
|
|
3fa6cce17a | ||
|
|
8f3dd9ec92 | ||
|
|
30abf33ee8 | ||
|
|
caa2bea7a8 | ||
|
|
12c3c37ff8 | ||
|
|
5c32209a2c | ||
|
|
ebfc88c5ea |
122
CHANGELOG.md
122
CHANGELOG.md
@@ -1,3 +1,125 @@
|
||||
## [2.8.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.2...v2.8.3) (2025-10-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* remove ArcadeGuardedPage from room page to prevent redirect loop ([4686f59](https://github.com/antialias/soroban-abacus-flashcards/commit/4686f59d245b2b502dc0764c223a5ce84bf1af44))
|
||||
|
||||
## [2.8.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.1...v2.8.2) (2025-10-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* revert to showing only active players in room games ([87cc0b6](https://github.com/antialias/soroban-abacus-flashcards/commit/87cc0b64fb5f3debaf1d2f122aecfefc62922fed))
|
||||
|
||||
## [2.8.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.0...v2.8.1) (2025-10-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* include all players from room members in room games ([28a2e7d](https://github.com/antialias/soroban-abacus-flashcards/commit/28a2e7d6511e70b83adf7d0465789a91026bc1f7))
|
||||
|
||||
## [2.8.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.7.4...v2.8.0) (2025-10-08)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* implement room-wide multi-user game state synchronization ([8175c43](https://github.com/antialias/soroban-abacus-flashcards/commit/8175c43533c474fff48eb128c97747033bfb434a))
|
||||
|
||||
|
||||
### Tests
|
||||
|
||||
* add comprehensive tests for arcade guard and room navigation ([b49630f](https://github.com/antialias/soroban-abacus-flashcards/commit/b49630f3cb02ebbac75b4680948bbface314dccb))
|
||||
|
||||
## [2.7.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.7.3...v2.7.4) (2025-10-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* respect enabled flag in useArcadeGuard WebSocket redirects ([01ff114](https://github.com/antialias/soroban-abacus-flashcards/commit/01ff114258ff7ab43ef2bd79b41c7035fe02ac70))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* move room management pages to /arcade-rooms ([4316687](https://github.com/antialias/soroban-abacus-flashcards/commit/431668729cfb145d6e0c13947de2a82f27fa400d))
|
||||
|
||||
## [2.7.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.7.2...v2.7.3) (2025-10-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* set room sessions to use /arcade/room URL ([9dac431](https://github.com/antialias/soroban-abacus-flashcards/commit/9dac431c1f91c246f67a059cda3cff6cbef40a43))
|
||||
|
||||
## [2.7.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.7.1...v2.7.2) (2025-10-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add hasAttemptedFetch flag to prevent premature redirect ([c30f585](https://github.com/antialias/soroban-abacus-flashcards/commit/c30f58581028878350282cad5231d614590d9f2b))
|
||||
|
||||
## [2.7.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.7.0...v2.7.1) (2025-10-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* resolve race condition in /arcade/room redirect ([5ed2ab2](https://github.com/antialias/soroban-abacus-flashcards/commit/5ed2ab21cab408147081a493c8dd6b1de48b2d01))
|
||||
|
||||
## [2.7.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.6.0...v2.7.0) (2025-10-08)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* extend GameModeContext to support room-based multiplayer ([ee6094d](https://github.com/antialias/soroban-abacus-flashcards/commit/ee6094d59d26a9e80ba5d023ca6dc13143bea308))
|
||||
|
||||
## [2.6.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.5.0...v2.6.0) (2025-10-08)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* refactor room addressing to /arcade/room ([e7d2a73](https://github.com/antialias/soroban-abacus-flashcards/commit/e7d2a73ddf2048691325a18e3d71a7ece444c131))
|
||||
|
||||
## [2.5.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.4.6...v2.5.0) (2025-10-08)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* display room info and network players in mini app nav ([5e3261f](https://github.com/antialias/soroban-abacus-flashcards/commit/5e3261f3bec8c19ec88c9a35a7e6ef8eda88a55e))
|
||||
|
||||
## [2.4.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.4.5...v2.4.6) (2025-10-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* real-time room member updates via globalThis socket.io sharing ([94a1d9b](https://github.com/antialias/soroban-abacus-flashcards/commit/94a1d9b11058bfb4b54a4753e143cf85f215e913))
|
||||
|
||||
## [2.4.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.4.4...v2.4.5) (2025-10-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* send all members (not just online) in socket broadcasts ([3fa6cce](https://github.com/antialias/soroban-abacus-flashcards/commit/3fa6cce17a7acd940cf5a9e6433bf6c4b497540c))
|
||||
|
||||
## [2.4.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.4.3...v2.4.4) (2025-10-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* correctly access getSocketIO from dynamic import ([30abf33](https://github.com/antialias/soroban-abacus-flashcards/commit/30abf33ee86b36f2a98014e5b017fa8e466a2107))
|
||||
|
||||
## [2.4.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.4.2...v2.4.3) (2025-10-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* resolve socket-server import path for Next.js build ([12c3c37](https://github.com/antialias/soroban-abacus-flashcards/commit/12c3c37ff8e1d3df71d72e527c08fa975043c504))
|
||||
|
||||
## [2.4.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.4.1...v2.4.2) (2025-10-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* broadcast member join/leave events immediately via API ([ebfc88c](https://github.com/antialias/soroban-abacus-flashcards/commit/ebfc88c5ea0a8a0fdda039fa129e1054b9c42e65))
|
||||
|
||||
## [2.4.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.4.0...v2.4.1) (2025-10-08)
|
||||
|
||||
|
||||
|
||||
@@ -1,39 +1,6 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npm run build:*)",
|
||||
"Bash(npx tsc:*)",
|
||||
"Bash(curl:*)",
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit -m \"$(cat <<''EOF''\nfix: lazy-load database connection to prevent build-time access\n\nRefactor db/index.ts to use lazy initialization via Proxy pattern.\nThis prevents the database from being accessed at module import time,\nwhich was causing Next.js build failures in CI/CD environments where\nno database file exists.\n\nThe database connection is now created only when first accessed at\nruntime, allowing static site generation to complete successfully.\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude <noreply@anthropic.com>\nEOF\n)\")",
|
||||
"Bash(git push:*)",
|
||||
"Read(//Users/antialias/projects/soroban-abacus-flashcards/**)",
|
||||
"Bash(npm install:*)",
|
||||
"Bash(cat:*)",
|
||||
"Bash(pnpm add:*)",
|
||||
"Bash(npx biome check:*)",
|
||||
"Bash(npx:*)",
|
||||
"Bash(eslint:*)",
|
||||
"Bash(npm run lint:fix:*)",
|
||||
"Bash(npm run format:*)",
|
||||
"Bash(npm run lint:*)",
|
||||
"Bash(pnpm install:*)",
|
||||
"Bash(pnpm run:*)",
|
||||
"Bash(rm:*)",
|
||||
"Bash(lsof:*)",
|
||||
"Bash(xargs kill:*)",
|
||||
"Bash(tee:*)",
|
||||
"Bash(for file in src/app/arcade/complement-race/components/RaceTrack/CircularTrack.tsx src/app/arcade/complement-race/components/RaceTrack/LinearTrack.tsx src/app/games/complement-race/components/RaceTrack/CircularTrack.tsx src/app/games/complement-race/components/RaceTrack/LinearTrack.tsx)",
|
||||
"Bash(do)",
|
||||
"Bash(done)",
|
||||
"Bash(for file in src/app/arcade/complement-race/components/RaceTrack/SteamTrainJourney.tsx src/app/games/complement-race/components/RaceTrack/SteamTrainJourney.tsx)",
|
||||
"Bash(for file in src/app/arcade/complement-race/hooks/useTrackManagement.ts src/app/games/complement-race/hooks/useTrackManagement.ts)",
|
||||
"Bash(echo \"EXIT CODE: $?\")",
|
||||
"Bash(git commit -m \"$(cat <<''EOF''\nfeat: add Biome + ESLint linting setup\n\nAdd Biome for formatting and general linting, with minimal ESLint\nconfiguration for React Hooks rules only. This provides:\n\n- Fast formatting via Biome (10-100x faster than Prettier)\n- General JS/TS linting via Biome\n- React Hooks validation via ESLint (rules-of-hooks)\n- Import organization via Biome\n\nConfiguration files:\n- biome.jsonc: Biome config with custom rule overrides\n- eslint.config.js: Minimal flat config for React Hooks only\n- .gitignore: Added Biome cache exclusion\n- LINTING.md: Documentation for the setup\n\nScripts added to package.json:\n- npm run lint: Check all files\n- npm run lint:fix: Auto-fix issues\n- npm run format: Format all files\n- npm run check: Full Biome check\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude <noreply@anthropic.com>\nEOF\n)\")",
|
||||
"Bash(git commit:*)",
|
||||
"Bash(npm run pre-commit:*)",
|
||||
"Bash(npm run:*)"
|
||||
],
|
||||
"allow": ["Bash(npm test:*)"],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
}
|
||||
|
||||
371
apps/web/__tests__/room-realtime-updates.e2e.test.ts
Normal file
371
apps/web/__tests__/room-realtime-updates.e2e.test.ts
Normal file
@@ -0,0 +1,371 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { createServer } from 'http'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { io as ioClient, type Socket } from 'socket.io-client'
|
||||
import { afterEach, beforeEach, describe, expect, it, afterAll, beforeAll } from 'vitest'
|
||||
import { db, schema } from '../src/db'
|
||||
import { createRoom } from '../src/lib/arcade/room-manager'
|
||||
import { addRoomMember } from '../src/lib/arcade/room-membership'
|
||||
import { initializeSocketServer } from '../socket-server'
|
||||
import type { Server as SocketIOServerType } from 'socket.io'
|
||||
|
||||
/**
|
||||
* Real-time Room Updates E2E Tests
|
||||
*
|
||||
* Tests that socket broadcasts work correctly when users join/leave rooms.
|
||||
* Simulates multiple connected users and verifies they receive real-time updates.
|
||||
*/
|
||||
|
||||
describe('Room Real-time Updates', () => {
|
||||
let testUserId1: string
|
||||
let testUserId2: string
|
||||
let testGuestId1: string
|
||||
let testGuestId2: string
|
||||
let testRoomId: string
|
||||
let socket1: Socket
|
||||
let httpServer: any
|
||||
let io: SocketIOServerType
|
||||
let serverPort: number
|
||||
|
||||
beforeAll(async () => {
|
||||
// Create HTTP server and initialize Socket.IO for testing
|
||||
httpServer = createServer()
|
||||
io = initializeSocketServer(httpServer)
|
||||
|
||||
// Find an available port
|
||||
await new Promise<void>((resolve) => {
|
||||
httpServer.listen(0, () => {
|
||||
serverPort = (httpServer.address() as any).port
|
||||
console.log(`Test socket server listening on port ${serverPort}`)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
// Close all socket connections
|
||||
if (io) {
|
||||
io.close()
|
||||
}
|
||||
if (httpServer) {
|
||||
await new Promise<void>((resolve) => {
|
||||
httpServer.close(() => resolve())
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
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
|
||||
|
||||
// Create a test room
|
||||
const room = await createRoom({
|
||||
name: 'Realtime Test Room',
|
||||
createdBy: testGuestId1,
|
||||
creatorName: 'User 1',
|
||||
gameName: 'matching',
|
||||
gameConfig: { difficulty: 6 },
|
||||
ttlMinutes: 60,
|
||||
})
|
||||
testRoomId = room.id
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
// Disconnect sockets
|
||||
if (socket1?.connected) {
|
||||
socket1.disconnect()
|
||||
}
|
||||
|
||||
// Clean up room members
|
||||
await db.delete(schema.roomMembers).where(eq(schema.roomMembers.roomId, testRoomId))
|
||||
|
||||
// Clean up rooms
|
||||
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))
|
||||
})
|
||||
|
||||
it('should broadcast member-joined when a user joins via API', async () => {
|
||||
// User 1 joins the room via API first (this is what happens when they click "Join Room")
|
||||
await addRoomMember({
|
||||
roomId: testRoomId,
|
||||
userId: testGuestId1,
|
||||
displayName: 'User 1',
|
||||
isCreator: false,
|
||||
})
|
||||
|
||||
// User 1 connects to socket
|
||||
socket1 = ioClient(`http://localhost:${serverPort}`, {
|
||||
path: '/api/socket',
|
||||
transports: ['websocket'],
|
||||
})
|
||||
|
||||
// Wait for socket to connect
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket1.on('connect', () => resolve())
|
||||
socket1.on('connect_error', (err) => reject(err))
|
||||
setTimeout(() => reject(new Error('Connection timeout')), 2000)
|
||||
})
|
||||
|
||||
// Small delay to ensure event handlers are set up
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
// Set up listener for room-joined BEFORE emitting
|
||||
const roomJoinedPromise = new Promise<void>((resolve, reject) => {
|
||||
socket1.on('room-joined', () => resolve())
|
||||
socket1.on('room-error', (err) => reject(new Error(err.error)))
|
||||
setTimeout(() => reject(new Error('Room-joined timeout')), 3000)
|
||||
})
|
||||
|
||||
// Now emit the join-room event
|
||||
socket1.emit('join-room', { roomId: testRoomId, userId: testGuestId1 })
|
||||
|
||||
// Wait for confirmation
|
||||
await roomJoinedPromise
|
||||
|
||||
// Set up listener for member-joined event BEFORE User 2 joins
|
||||
const memberJoinedPromise = new Promise<any>((resolve, reject) => {
|
||||
socket1.on('member-joined', (data) => {
|
||||
resolve(data)
|
||||
})
|
||||
setTimeout(() => reject(new Error('Timeout waiting for member-joined event')), 3000)
|
||||
})
|
||||
|
||||
// User 2 joins the room via addRoomMember
|
||||
const { member: newMember } = await addRoomMember({
|
||||
roomId: testRoomId,
|
||||
userId: testGuestId2,
|
||||
displayName: 'User 2',
|
||||
isCreator: false,
|
||||
})
|
||||
|
||||
// Manually trigger the broadcast (this is what the API route SHOULD do)
|
||||
const { getRoomMembers } = await import('../src/lib/arcade/room-membership')
|
||||
const { getRoomActivePlayers } = await import('../src/lib/arcade/player-manager')
|
||||
|
||||
const members = await getRoomMembers(testRoomId)
|
||||
const memberPlayers = await getRoomActivePlayers(testRoomId)
|
||||
|
||||
const memberPlayersObj: Record<string, any[]> = {}
|
||||
for (const [uid, players] of memberPlayers.entries()) {
|
||||
memberPlayersObj[uid] = players
|
||||
}
|
||||
|
||||
io.to(`room:${testRoomId}`).emit('member-joined', {
|
||||
roomId: testRoomId,
|
||||
userId: testGuestId2,
|
||||
members,
|
||||
memberPlayers: memberPlayersObj,
|
||||
})
|
||||
|
||||
// Wait for the socket broadcast with timeout
|
||||
const data = await memberJoinedPromise
|
||||
|
||||
// Verify the broadcast data
|
||||
expect(data).toBeDefined()
|
||||
expect(data.roomId).toBe(testRoomId)
|
||||
expect(data.userId).toBe(testGuestId2)
|
||||
expect(data.members).toBeDefined()
|
||||
expect(Array.isArray(data.members)).toBe(true)
|
||||
|
||||
// Verify both users are in the members list
|
||||
const memberUserIds = data.members.map((m: any) => m.userId)
|
||||
expect(memberUserIds).toContain(testGuestId1)
|
||||
expect(memberUserIds).toContain(testGuestId2)
|
||||
|
||||
// Verify the new member details
|
||||
const addedMember = data.members.find((m: any) => m.userId === testGuestId2)
|
||||
expect(addedMember).toBeDefined()
|
||||
expect(addedMember.displayName).toBe('User 2')
|
||||
expect(addedMember.roomId).toBe(testRoomId)
|
||||
})
|
||||
|
||||
it('should broadcast member-left when a user leaves via API', async () => {
|
||||
// User 1 joins the room first
|
||||
await addRoomMember({
|
||||
roomId: testRoomId,
|
||||
userId: testGuestId1,
|
||||
displayName: 'User 1',
|
||||
isCreator: false,
|
||||
})
|
||||
|
||||
// User 2 joins the room
|
||||
await addRoomMember({
|
||||
roomId: testRoomId,
|
||||
userId: testGuestId2,
|
||||
displayName: 'User 2',
|
||||
isCreator: false,
|
||||
})
|
||||
|
||||
// User 1 connects to socket
|
||||
socket1 = ioClient(`http://localhost:${serverPort}`, {
|
||||
path: '/api/socket',
|
||||
transports: ['websocket'],
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
socket1.on('connect', () => resolve())
|
||||
})
|
||||
|
||||
socket1.emit('join-room', { roomId: testRoomId, userId: testGuestId1 })
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
socket1.on('room-joined', () => resolve())
|
||||
})
|
||||
|
||||
// Set up listener for member-left event
|
||||
const memberLeftPromise = new Promise<any>((resolve) => {
|
||||
socket1.on('member-left', (data) => {
|
||||
resolve(data)
|
||||
})
|
||||
})
|
||||
|
||||
// User 2 leaves the room via API
|
||||
await db.delete(schema.roomMembers).where(eq(schema.roomMembers.userId, testGuestId2))
|
||||
|
||||
// Manually trigger the leave broadcast (simulating what the API does)
|
||||
const { getSocketIO } = await import('../src/lib/socket-io')
|
||||
const io = await getSocketIO()
|
||||
if (io) {
|
||||
const { getRoomMembers } = await import('../src/lib/arcade/room-membership')
|
||||
const { getRoomActivePlayers } = await import('../src/lib/arcade/player-manager')
|
||||
|
||||
const members = await getRoomMembers(testRoomId)
|
||||
const memberPlayers = await getRoomActivePlayers(testRoomId)
|
||||
|
||||
const memberPlayersObj: Record<string, any[]> = {}
|
||||
for (const [uid, players] of memberPlayers.entries()) {
|
||||
memberPlayersObj[uid] = players
|
||||
}
|
||||
|
||||
io.to(`room:${testRoomId}`).emit('member-left', {
|
||||
roomId: testRoomId,
|
||||
userId: testGuestId2,
|
||||
members,
|
||||
memberPlayers: memberPlayersObj,
|
||||
})
|
||||
}
|
||||
|
||||
// Wait for the socket broadcast with timeout
|
||||
const data = await Promise.race([
|
||||
memberLeftPromise,
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Timeout waiting for member-left event')), 2000)
|
||||
),
|
||||
])
|
||||
|
||||
// Verify the broadcast data
|
||||
expect(data).toBeDefined()
|
||||
expect(data.roomId).toBe(testRoomId)
|
||||
expect(data.userId).toBe(testGuestId2)
|
||||
expect(data.members).toBeDefined()
|
||||
expect(Array.isArray(data.members)).toBe(true)
|
||||
|
||||
// Verify User 2 is no longer in the members list
|
||||
const memberUserIds = data.members.map((m: any) => m.userId)
|
||||
expect(memberUserIds).toContain(testGuestId1)
|
||||
expect(memberUserIds).not.toContain(testGuestId2)
|
||||
})
|
||||
|
||||
it('should update both members and players lists in member-joined broadcast', async () => {
|
||||
// Create an active player for User 2
|
||||
const [player2] = await db
|
||||
.insert(schema.players)
|
||||
.values({
|
||||
userId: testUserId2,
|
||||
name: 'Player 2',
|
||||
emoji: '🎮',
|
||||
color: '#3b82f6',
|
||||
isActive: true,
|
||||
})
|
||||
.returning()
|
||||
|
||||
// User 1 connects and joins room
|
||||
socket1 = ioClient(`http://localhost:${serverPort}`, {
|
||||
path: '/api/socket',
|
||||
transports: ['websocket'],
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
socket1.on('connect', () => resolve())
|
||||
})
|
||||
|
||||
socket1.emit('join-room', { roomId: testRoomId, userId: testGuestId1 })
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
socket1.on('room-joined', () => resolve())
|
||||
})
|
||||
|
||||
const memberJoinedPromise = new Promise<any>((resolve) => {
|
||||
socket1.on('member-joined', (data) => {
|
||||
resolve(data)
|
||||
})
|
||||
})
|
||||
|
||||
// User 2 joins via API
|
||||
await addRoomMember({
|
||||
roomId: testRoomId,
|
||||
userId: testGuestId2,
|
||||
displayName: 'User 2',
|
||||
isCreator: false,
|
||||
})
|
||||
|
||||
// Manually trigger the broadcast (simulating what the API does)
|
||||
const { getRoomMembers: getRoomMembers3 } = await import('../src/lib/arcade/room-membership')
|
||||
const { getRoomActivePlayers: getRoomActivePlayers3 } = await import(
|
||||
'../src/lib/arcade/player-manager'
|
||||
)
|
||||
|
||||
const members2 = await getRoomMembers3(testRoomId)
|
||||
const memberPlayers2 = await getRoomActivePlayers3(testRoomId)
|
||||
|
||||
const memberPlayersObj2: Record<string, any[]> = {}
|
||||
for (const [uid, players] of memberPlayers2.entries()) {
|
||||
memberPlayersObj2[uid] = players
|
||||
}
|
||||
|
||||
io.to(`room:${testRoomId}`).emit('member-joined', {
|
||||
roomId: testRoomId,
|
||||
userId: testGuestId2,
|
||||
members: members2,
|
||||
memberPlayers: memberPlayersObj2,
|
||||
})
|
||||
|
||||
const data = await Promise.race([
|
||||
memberJoinedPromise,
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), 2000)),
|
||||
])
|
||||
|
||||
// Verify members list is updated
|
||||
expect(data.members).toBeDefined()
|
||||
const memberUserIds = data.members.map((m: any) => m.userId)
|
||||
expect(memberUserIds).toContain(testGuestId2)
|
||||
|
||||
// Verify players list is updated
|
||||
expect(data.memberPlayers).toBeDefined()
|
||||
expect(data.memberPlayers[testGuestId2]).toBeDefined()
|
||||
expect(Array.isArray(data.memberPlayers[testGuestId2])).toBe(true)
|
||||
|
||||
// User 2's players should include the active player we created
|
||||
const user2Players = data.memberPlayers[testGuestId2]
|
||||
expect(user2Players.length).toBeGreaterThan(0)
|
||||
expect(user2Players.some((p: any) => p.id === player2.id)).toBe(true)
|
||||
|
||||
// Clean up player
|
||||
await db.delete(schema.players).where(eq(schema.players.id, player2.id))
|
||||
})
|
||||
})
|
||||
490
apps/web/docs/MULTIPLAYER_SYNC_ARCHITECTURE.md
Normal file
490
apps/web/docs/MULTIPLAYER_SYNC_ARCHITECTURE.md
Normal file
@@ -0,0 +1,490 @@
|
||||
# Multiplayer Synchronization Architecture
|
||||
|
||||
## Current State: Single-User Multi-Tab Sync
|
||||
|
||||
### How it Works
|
||||
|
||||
**Client-Side Flow:**
|
||||
|
||||
1. User opens game in Tab A and Tab B
|
||||
2. Both tabs create WebSocket connections via `useArcadeSocket()`
|
||||
3. Both emit `join-arcade-session` with `userId`
|
||||
4. Server adds both sockets to `arcade:${userId}` room
|
||||
|
||||
**When User Makes a Move (from Tab A):**
|
||||
|
||||
```typescript
|
||||
// Client (Tab A)
|
||||
sendMove({ type: 'FLIP_CARD', playerId: 'player-1', data: { cardId: 'card-5' } })
|
||||
|
||||
// Optimistic update applied locally
|
||||
state = applyMoveOptimistically(state, move)
|
||||
|
||||
// Socket emits to server
|
||||
socket.emit('game-move', { userId, move })
|
||||
```
|
||||
|
||||
**Server Processing:**
|
||||
|
||||
```typescript
|
||||
// socket-server.ts line 71
|
||||
socket.on('game-move', async (data) => {
|
||||
// Validate move
|
||||
const result = await applyGameMove(data.userId, data.move)
|
||||
|
||||
if (result.success) {
|
||||
// ✅ Broadcast to ALL tabs of this user
|
||||
io.to(`arcade:${data.userId}`).emit('move-accepted', {
|
||||
gameState: result.session.gameState,
|
||||
version: result.session.version,
|
||||
move: data.move
|
||||
})
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**Both Tabs Receive Update:**
|
||||
|
||||
```typescript
|
||||
// Client (Tab A and Tab B)
|
||||
socket.on('move-accepted', (data) => {
|
||||
// Update server state
|
||||
optimistic.handleMoveAccepted(data.gameState, data.version, data.move)
|
||||
|
||||
// Tab A: Remove from pending queue (was optimistic)
|
||||
// Tab B: Just sync with server state (wasn't expecting it)
|
||||
})
|
||||
```
|
||||
|
||||
### Key Components
|
||||
|
||||
1. **`useOptimisticGameState`** - Manages optimistic updates
|
||||
- Keeps `serverState` (last confirmed by server)
|
||||
- Keeps `pendingMoves[]` (not yet confirmed)
|
||||
- Current state = serverState + all pending moves applied
|
||||
|
||||
2. **`useArcadeSession`** - Combines socket + optimistic state
|
||||
- Connects socket
|
||||
- Applies moves optimistically
|
||||
- Sends moves to server
|
||||
- Handles server responses
|
||||
|
||||
3. **Socket Rooms** - Server-side broadcast channels
|
||||
- `arcade:${userId}` - All tabs of one user
|
||||
- Each socket can be in multiple rooms
|
||||
- `io.to(room).emit()` broadcasts to all sockets in that room
|
||||
|
||||
4. **Session Storage** - Database
|
||||
- One session per user (userId is unique key)
|
||||
- Contains `gameState`, `version`, `roomId`
|
||||
- Optimistic locking via version number
|
||||
|
||||
---
|
||||
|
||||
## Required: Room-Based Multi-User Sync
|
||||
|
||||
### The Goal
|
||||
|
||||
Multiple users in the same room at `/arcade/room` should all see synchronized game state:
|
||||
|
||||
- User A (2 tabs): Tab A1, Tab A2
|
||||
- User B (1 tab): Tab B1
|
||||
- User C (2 tabs): Tab C1, Tab C2
|
||||
|
||||
When User A makes a move in Tab A1:
|
||||
- **All of User A's tabs** see the move (Tab A1, Tab A2)
|
||||
- **All of User B's tabs** see the move (Tab B1)
|
||||
- **All of User C's tabs** see the move (Tab C1, Tab C2)
|
||||
|
||||
### The Challenge
|
||||
|
||||
Current architecture only broadcasts within one user:
|
||||
```typescript
|
||||
// ❌ Only reaches User A's tabs
|
||||
io.to(`arcade:${userA}`).emit('move-accepted', ...)
|
||||
```
|
||||
|
||||
We need to broadcast to the entire room:
|
||||
```typescript
|
||||
// ✅ Reaches all users in the room
|
||||
io.to(`game:${roomId}`).emit('move-accepted', ...)
|
||||
```
|
||||
|
||||
### The Solution
|
||||
|
||||
#### 1. Add Room-Based Game Socket Room
|
||||
|
||||
When a user joins `/arcade/room`, they join TWO socket rooms:
|
||||
|
||||
```typescript
|
||||
// socket-server.ts - extend join-arcade-session
|
||||
socket.on('join-arcade-session', async ({ userId, roomId }) => {
|
||||
// Join user's personal room (for multi-tab sync)
|
||||
socket.join(`arcade:${userId}`)
|
||||
|
||||
// If this session is part of a room, also join the game room
|
||||
if (roomId) {
|
||||
socket.join(`game:${roomId}`)
|
||||
console.log(`🎮 User ${userId} joined game room ${roomId}`)
|
||||
}
|
||||
|
||||
// Send current session state...
|
||||
})
|
||||
```
|
||||
|
||||
#### 2. Broadcast to Both Rooms
|
||||
|
||||
When processing moves for room-based sessions:
|
||||
|
||||
```typescript
|
||||
// socket-server.ts - modify game-move handler
|
||||
socket.on('game-move', async (data) => {
|
||||
const result = await applyGameMove(data.userId, data.move)
|
||||
|
||||
if (result.success && result.session) {
|
||||
const moveAcceptedData = {
|
||||
gameState: result.session.gameState,
|
||||
version: result.session.version,
|
||||
move: data.move,
|
||||
}
|
||||
|
||||
// Broadcast to user's own tabs (for optimistic update reconciliation)
|
||||
io.to(`arcade:${data.userId}`).emit('move-accepted', moveAcceptedData)
|
||||
|
||||
// If this is a room-based session, ALSO broadcast to all room members
|
||||
if (result.session.roomId) {
|
||||
io.to(`game:${result.session.roomId}`).emit('move-accepted', moveAcceptedData)
|
||||
console.log(`📢 Broadcasted move to room ${result.session.roomId}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**Why broadcast to both?**
|
||||
- `arcade:${userId}` - So the acting user's tabs can reconcile their optimistic updates
|
||||
- `game:${roomId}` - So all other users in the room receive the update
|
||||
|
||||
#### 3. Client Handles Own vs. Other Moves
|
||||
|
||||
The client already handles this correctly via optimistic updates:
|
||||
|
||||
```typescript
|
||||
// User A (Tab A1) - Makes move
|
||||
sendMove({ type: 'FLIP_CARD', ... })
|
||||
// → Applies optimistically immediately
|
||||
// → Sends to server
|
||||
// → Receives move-accepted
|
||||
// → Reconciles: removes from pending queue
|
||||
|
||||
// User B (Tab B1) - Sees move from User A
|
||||
// → Receives move-accepted (unexpected)
|
||||
// → Reconciles: clears pending queue, syncs with server state
|
||||
// → Result: sees User A's move immediately
|
||||
```
|
||||
|
||||
The beauty is that `handleMoveAccepted()` already handles both cases:
|
||||
- **Own move**: Remove from pending queue
|
||||
- **Other's move**: Clear pending queue (since server state is now ahead)
|
||||
|
||||
#### 4. Pass roomId in join-arcade-session
|
||||
|
||||
Client needs to send roomId when joining:
|
||||
|
||||
```typescript
|
||||
// hooks/useArcadeSocket.ts
|
||||
const joinSession = useCallback((userId: string, roomId?: string) => {
|
||||
if (!socket) return
|
||||
socket.emit('join-arcade-session', { userId, roomId })
|
||||
}, [socket])
|
||||
|
||||
// hooks/useArcadeSession.ts
|
||||
useEffect(() => {
|
||||
if (connected && autoJoin && userId) {
|
||||
// Get roomId from session or room context
|
||||
const roomId = getRoomId() // Need to provide this
|
||||
joinSession(userId, roomId)
|
||||
}
|
||||
}, [connected, autoJoin, userId, joinSession])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Server-Side Changes
|
||||
|
||||
**File: `socket-server.ts`**
|
||||
|
||||
1. ✅ Accept `roomId` in `join-arcade-session` event
|
||||
```typescript
|
||||
socket.on('join-arcade-session', async ({ userId, roomId }) => {
|
||||
socket.join(`arcade:${userId}`)
|
||||
|
||||
// Join game room if session is room-based
|
||||
if (roomId) {
|
||||
socket.join(`game:${roomId}`)
|
||||
}
|
||||
|
||||
// Rest of logic...
|
||||
})
|
||||
```
|
||||
|
||||
2. ✅ Broadcast to room in `game-move` handler
|
||||
```typescript
|
||||
if (result.success && result.session) {
|
||||
const moveData = {
|
||||
gameState: result.session.gameState,
|
||||
version: result.session.version,
|
||||
move: data.move,
|
||||
}
|
||||
|
||||
// Broadcast to user's tabs
|
||||
io.to(`arcade:${data.userId}`).emit('move-accepted', moveData)
|
||||
|
||||
// ALSO broadcast to room if room-based session
|
||||
if (result.session.roomId) {
|
||||
io.to(`game:${result.session.roomId}`).emit('move-accepted', moveData)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. ✅ Handle room disconnects
|
||||
```typescript
|
||||
socket.on('disconnect', () => {
|
||||
// Leave all rooms (handled automatically by socket.io)
|
||||
// But log for debugging
|
||||
if (currentUserId && currentRoomId) {
|
||||
console.log(`User ${currentUserId} left game room ${currentRoomId}`)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Phase 2: Client-Side Changes
|
||||
|
||||
**File: `hooks/useArcadeSocket.ts`**
|
||||
|
||||
1. ✅ Add roomId parameter to joinSession
|
||||
```typescript
|
||||
export interface UseArcadeSocketReturn {
|
||||
// ... existing
|
||||
joinSession: (userId: string, roomId?: string) => void
|
||||
}
|
||||
|
||||
const joinSession = useCallback((userId: string, roomId?: string) => {
|
||||
if (!socket) return
|
||||
socket.emit('join-arcade-session', { userId, roomId })
|
||||
}, [socket])
|
||||
```
|
||||
|
||||
**File: `hooks/useArcadeSession.ts`**
|
||||
|
||||
2. ✅ Accept roomId in options
|
||||
```typescript
|
||||
export interface UseArcadeSessionOptions<TState> {
|
||||
userId: string
|
||||
roomId?: string // NEW
|
||||
initialState: TState
|
||||
applyMove: (state: TState, move: GameMove) => TState
|
||||
// ... rest
|
||||
}
|
||||
|
||||
export function useArcadeSession<TState>(options: UseArcadeSessionOptions<TState>) {
|
||||
const { userId, roomId, ...optimisticOptions } = options
|
||||
|
||||
// Auto-join with roomId
|
||||
useEffect(() => {
|
||||
if (connected && autoJoin && userId) {
|
||||
joinSession(userId, roomId)
|
||||
}
|
||||
}, [connected, autoJoin, userId, roomId, joinSession])
|
||||
|
||||
// ... rest
|
||||
}
|
||||
```
|
||||
|
||||
**File: `app/arcade/matching/context/ArcadeMemoryPairsContext.tsx`**
|
||||
|
||||
3. ✅ Get roomId from room data and pass to session
|
||||
```typescript
|
||||
import { useRoomData } from '@/hooks/useRoomData'
|
||||
|
||||
export function ArcadeMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
const { data: viewerId } = useViewerId()
|
||||
const { roomData } = useRoomData()
|
||||
|
||||
// Arcade session integration
|
||||
const { state, sendMove, ... } = useArcadeSession<MemoryPairsState>({
|
||||
userId: viewerId || '',
|
||||
roomId: roomData?.id, // NEW - pass room ID
|
||||
initialState,
|
||||
applyMove: applyMoveOptimistically,
|
||||
})
|
||||
|
||||
// ... rest stays the same
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 3: Testing
|
||||
|
||||
1. **Multi-Tab Test (Single User)**
|
||||
- Open `/arcade/room` in 2 tabs as User A
|
||||
- Make move in Tab 1
|
||||
- Verify Tab 2 updates immediately
|
||||
|
||||
2. **Multi-User Test (Different Users)**
|
||||
- User A opens `/arcade/room` in 1 tab
|
||||
- User B opens `/arcade/room` in 1 tab (same room)
|
||||
- User A makes move
|
||||
- Verify User B sees move immediately
|
||||
|
||||
3. **Multi-User Multi-Tab Test**
|
||||
- User A: 2 tabs (Tab A1, Tab A2)
|
||||
- User B: 2 tabs (Tab B1, Tab B2)
|
||||
- User A makes move in Tab A1
|
||||
- Verify all 4 tabs update
|
||||
|
||||
4. **Rapid Move Test**
|
||||
- User A and User B both make moves rapidly
|
||||
- Verify no conflicts
|
||||
- Verify all moves are processed in order
|
||||
|
||||
---
|
||||
|
||||
## Edge Cases to Handle
|
||||
|
||||
### 1. User Leaves Room Mid-Game
|
||||
|
||||
**Current behavior:** Session persists, user can rejoin
|
||||
|
||||
**Required behavior:**
|
||||
- If user leaves room (HTTP POST to `/api/arcade/rooms/[roomId]/leave`):
|
||||
- Delete their session
|
||||
- Emit `session-ended` to their tabs
|
||||
- Other users continue playing
|
||||
|
||||
### 2. Version Conflicts
|
||||
|
||||
**Already handled** by optimistic locking:
|
||||
- Each move increments version
|
||||
- Client tracks server version
|
||||
- If conflict detected, reconciliation happens automatically
|
||||
|
||||
### 3. Session Without Room
|
||||
|
||||
**Already handled** by session-manager.ts:
|
||||
- Sessions without `roomId` are considered orphaned
|
||||
- They're cleaned up on next access (lines 111-115)
|
||||
|
||||
### 4. Multiple Users Same Move
|
||||
|
||||
**Handled by server validation:**
|
||||
- Server processes moves sequentially
|
||||
- First valid move wins
|
||||
- Second move gets rejected if it's now invalid
|
||||
- Client rolls back rejected move
|
||||
|
||||
---
|
||||
|
||||
## Benefits of This Architecture
|
||||
|
||||
1. **Reuses existing optimistic update system**
|
||||
- No changes needed to client-side optimistic logic
|
||||
- Already handles own vs. others' moves
|
||||
|
||||
2. **Minimal changes required**
|
||||
- Add `roomId` parameter (3 places)
|
||||
- Add one `io.to()` broadcast (1 place)
|
||||
- Wire up roomId from context (1 place)
|
||||
|
||||
3. **Backward compatible**
|
||||
- Non-room sessions still work (roomId is optional)
|
||||
- Solo play unaffected
|
||||
|
||||
4. **Scalable**
|
||||
- Socket.io handles multiple rooms efficiently
|
||||
- No N² broadcasting (room-based is O(N))
|
||||
|
||||
5. **Already tested pattern**
|
||||
- Multi-tab sync proves the broadcast pattern works
|
||||
- Just extending to more sockets (different users)
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### 1. Validate Room Membership
|
||||
|
||||
Before processing moves, verify user is in the room:
|
||||
|
||||
```typescript
|
||||
// session-manager.ts - in applyGameMove()
|
||||
const session = await getArcadeSession(userId)
|
||||
|
||||
if (session.roomId) {
|
||||
// Verify user is a member of this room
|
||||
const membership = await getRoomMember(session.roomId, userId)
|
||||
if (!membership) {
|
||||
return { success: false, error: 'User not in room' }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Verify Player Ownership
|
||||
|
||||
Ensure users can only make moves for their own players:
|
||||
|
||||
```typescript
|
||||
// Already handled in validator
|
||||
// move.playerId must be in session.activePlayers
|
||||
// activePlayers are owned by the userId making the move
|
||||
```
|
||||
|
||||
This is already enforced by how activePlayers are set up in the room.
|
||||
|
||||
---
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### 1. Broadcasting Overhead
|
||||
|
||||
- **Current**: 1 user × N tabs = N broadcasts per move
|
||||
- **New**: M users × N tabs each = (M×N) broadcasts per move
|
||||
- **Impact**: Linear with room size, not quadratic
|
||||
- **Acceptable**: Socket.io is optimized for this
|
||||
|
||||
### 2. Database Queries
|
||||
|
||||
- No change: Still 1 database write per move
|
||||
- Session is stored per-user, not per-room
|
||||
- Room data is separate (cached, not updated per move)
|
||||
|
||||
### 3. Memory
|
||||
|
||||
- Each socket joins 2 rooms instead of 1
|
||||
- Negligible: Socket.io uses efficient room data structures
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
### Unit Tests
|
||||
|
||||
- [ ] `useArcadeSocket` accepts and passes roomId
|
||||
- [ ] `useArcadeSession` accepts and passes roomId
|
||||
- [ ] Server joins `game:${roomId}` room when roomId provided
|
||||
|
||||
### Integration Tests
|
||||
|
||||
- [ ] Single user, 2 tabs: both tabs sync
|
||||
- [ ] 2 users, 1 tab each: both users sync
|
||||
- [ ] 2 users, 2 tabs each: all 4 tabs sync
|
||||
- [ ] User leaves room: session deleted, others continue
|
||||
- [ ] Rapid concurrent moves: all processed correctly
|
||||
|
||||
### Manual Tests
|
||||
|
||||
- [ ] Open room in 2 browsers (different users)
|
||||
- [ ] Play full game to completion
|
||||
- [ ] Verify scores sync correctly
|
||||
- [ ] Verify turn changes sync correctly
|
||||
- [ ] Verify game completion syncs correctly
|
||||
@@ -1,29 +0,0 @@
|
||||
const { Server } = require('socket.io')
|
||||
|
||||
function initializeSocketServer(httpServer) {
|
||||
const io = new Server(httpServer, {
|
||||
path: '/api/socket',
|
||||
cors: {
|
||||
origin: process.env.NEXT_PUBLIC_URL || 'http://localhost:3000',
|
||||
credentials: true,
|
||||
},
|
||||
})
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
console.log('🔌 Client connected:', socket.id)
|
||||
|
||||
socket.on('join-arcade-session', ({ userId }) => {
|
||||
socket.join(`arcade:${userId}`)
|
||||
console.log(`👤 User ${userId} joined arcade room`)
|
||||
})
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
console.log('🔌 Client disconnected:', socket.id)
|
||||
})
|
||||
})
|
||||
|
||||
console.log('✅ Socket.IO initialized on /api/socket')
|
||||
return io
|
||||
}
|
||||
|
||||
module.exports = { initializeSocketServer }
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Server as HTTPServer } from 'http'
|
||||
import { Server as SocketIOServer } from 'socket.io'
|
||||
import type { Server as SocketIOServerType } from 'socket.io'
|
||||
import {
|
||||
applyGameMove,
|
||||
createArcadeSession,
|
||||
@@ -8,16 +9,25 @@ import {
|
||||
updateSessionActivity,
|
||||
} from './src/lib/arcade/session-manager'
|
||||
import { createRoom, getRoomById } from './src/lib/arcade/room-manager'
|
||||
import {
|
||||
getOnlineRoomMembers,
|
||||
getRoomMembers,
|
||||
getUserRooms,
|
||||
setMemberOnline,
|
||||
} from './src/lib/arcade/room-membership'
|
||||
import { getRoomMembers, getUserRooms, setMemberOnline } from './src/lib/arcade/room-membership'
|
||||
import { getRoomActivePlayers } from './src/lib/arcade/player-manager'
|
||||
import type { GameMove, GameName } from './src/lib/arcade/validation'
|
||||
import { matchingGameValidator } from './src/lib/arcade/validation/MatchingGameValidator'
|
||||
|
||||
// Use globalThis to store socket.io instance to avoid module isolation issues
|
||||
// This ensures the same instance is accessible across dynamic imports
|
||||
declare global {
|
||||
var __socketIO: SocketIOServerType | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the socket.io server instance
|
||||
* Returns null if not initialized
|
||||
*/
|
||||
export function getSocketIO(): SocketIOServerType | null {
|
||||
return globalThis.__socketIO || null
|
||||
}
|
||||
|
||||
export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
const io = new SocketIOServer(httpServer, {
|
||||
path: '/api/socket',
|
||||
@@ -32,30 +42,39 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
let currentUserId: string | null = null
|
||||
|
||||
// Join arcade session room
|
||||
socket.on('join-arcade-session', async ({ userId }: { userId: string }) => {
|
||||
currentUserId = userId
|
||||
socket.join(`arcade:${userId}`)
|
||||
console.log(`👤 User ${userId} joined arcade room`)
|
||||
socket.on(
|
||||
'join-arcade-session',
|
||||
async ({ userId, roomId }: { userId: string; roomId?: string }) => {
|
||||
currentUserId = userId
|
||||
socket.join(`arcade:${userId}`)
|
||||
console.log(`👤 User ${userId} joined arcade room`)
|
||||
|
||||
// Send current session state if exists
|
||||
try {
|
||||
const session = await getArcadeSession(userId)
|
||||
if (session) {
|
||||
socket.emit('session-state', {
|
||||
gameState: session.gameState,
|
||||
currentGame: session.currentGame,
|
||||
gameUrl: session.gameUrl,
|
||||
activePlayers: session.activePlayers,
|
||||
version: session.version,
|
||||
})
|
||||
} else {
|
||||
socket.emit('no-active-session')
|
||||
// If this session is part of a room, also join the game room for multi-user sync
|
||||
if (roomId) {
|
||||
socket.join(`game:${roomId}`)
|
||||
console.log(`🎮 User ${userId} joined game room ${roomId}`)
|
||||
}
|
||||
|
||||
// Send current session state if exists
|
||||
try {
|
||||
const session = await getArcadeSession(userId)
|
||||
if (session) {
|
||||
socket.emit('session-state', {
|
||||
gameState: session.gameState,
|
||||
currentGame: session.currentGame,
|
||||
gameUrl: session.gameUrl,
|
||||
activePlayers: session.activePlayers,
|
||||
version: session.version,
|
||||
})
|
||||
} else {
|
||||
socket.emit('no-active-session')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching session:', error)
|
||||
socket.emit('session-error', { error: 'Failed to fetch session' })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching session:', error)
|
||||
socket.emit('session-error', { error: 'Failed to fetch session' })
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
// Handle game moves
|
||||
socket.on('game-move', async (data: { userId: string; move: GameMove }) => {
|
||||
@@ -132,7 +151,7 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
await createArcadeSession({
|
||||
userId: data.userId,
|
||||
gameName: 'matching',
|
||||
gameUrl: '/arcade/matching',
|
||||
gameUrl: '/arcade/room', // Room-based sessions use /arcade/room
|
||||
initialState,
|
||||
activePlayers,
|
||||
roomId: room.id,
|
||||
@@ -143,7 +162,7 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
// Notify all connected clients about the new session
|
||||
const newSession = await getArcadeSession(data.userId)
|
||||
if (newSession) {
|
||||
io.to(`arcade:${data.userId}`).emit('session-state', {
|
||||
io!.to(`arcade:${data.userId}`).emit('session-state', {
|
||||
gameState: newSession.gameState,
|
||||
currentGame: newSession.currentGame,
|
||||
gameUrl: newSession.gameUrl,
|
||||
@@ -158,12 +177,20 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
const result = await applyGameMove(data.userId, data.move)
|
||||
|
||||
if (result.success && result.session) {
|
||||
// Broadcast the updated state to all devices for this user
|
||||
io.to(`arcade:${data.userId}`).emit('move-accepted', {
|
||||
const moveAcceptedData = {
|
||||
gameState: result.session.gameState,
|
||||
version: result.session.version,
|
||||
move: data.move,
|
||||
})
|
||||
}
|
||||
|
||||
// Broadcast the updated state to all devices for this user
|
||||
io!.to(`arcade:${data.userId}`).emit('move-accepted', moveAcceptedData)
|
||||
|
||||
// If this is a room-based session, ALSO broadcast to all users in the room
|
||||
if (result.session.roomId) {
|
||||
io!.to(`game:${result.session.roomId}`).emit('move-accepted', moveAcceptedData)
|
||||
console.log(`📢 Broadcasted move to game room ${result.session.roomId}`)
|
||||
}
|
||||
|
||||
// Update activity timestamp
|
||||
await updateSessionActivity(data.userId)
|
||||
@@ -190,7 +217,7 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
|
||||
try {
|
||||
await deleteArcadeSession(userId)
|
||||
io.to(`arcade:${userId}`).emit('session-ended')
|
||||
io!.to(`arcade:${userId}`).emit('session-ended')
|
||||
} catch (error) {
|
||||
console.error('Error ending session:', error)
|
||||
socket.emit('session-error', { error: 'Failed to end session' })
|
||||
@@ -220,7 +247,6 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
|
||||
// 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
|
||||
@@ -233,7 +259,6 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
socket.emit('room-joined', {
|
||||
roomId,
|
||||
members,
|
||||
onlineMembers,
|
||||
memberPlayers: memberPlayersObj,
|
||||
})
|
||||
|
||||
@@ -241,7 +266,7 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
socket.to(`room:${roomId}`).emit('member-joined', {
|
||||
roomId,
|
||||
userId,
|
||||
onlineMembers,
|
||||
members,
|
||||
memberPlayers: memberPlayersObj,
|
||||
})
|
||||
|
||||
@@ -263,8 +288,8 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
// Mark member as offline
|
||||
await setMemberOnline(roomId, userId, false)
|
||||
|
||||
// Get updated online members
|
||||
const onlineMembers = await getOnlineRoomMembers(roomId)
|
||||
// Get updated members
|
||||
const members = await getRoomMembers(roomId)
|
||||
const memberPlayers = await getRoomActivePlayers(roomId)
|
||||
|
||||
// Convert memberPlayers Map to object
|
||||
@@ -274,10 +299,10 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
}
|
||||
|
||||
// Notify remaining members
|
||||
io.to(`room:${roomId}`).emit('member-left', {
|
||||
io!.to(`room:${roomId}`).emit('member-left', {
|
||||
roomId,
|
||||
userId,
|
||||
onlineMembers,
|
||||
members,
|
||||
memberPlayers: memberPlayersObj,
|
||||
})
|
||||
|
||||
@@ -302,7 +327,7 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
}
|
||||
|
||||
// Broadcast to all members in the room (including sender)
|
||||
io.to(`room:${roomId}`).emit('room-players-updated', {
|
||||
io!.to(`room:${roomId}`).emit('room-players-updated', {
|
||||
roomId,
|
||||
memberPlayers: memberPlayersObj,
|
||||
})
|
||||
@@ -323,6 +348,8 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
})
|
||||
})
|
||||
|
||||
// Store in globalThis to make accessible across module boundaries
|
||||
globalThis.__socketIO = io
|
||||
console.log('✅ Socket.IO initialized on /api/socket')
|
||||
return io
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
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 { addRoomMember, getRoomMembers } from '@/lib/arcade/room-membership'
|
||||
import { getActivePlayers, getRoomActivePlayers } from '@/lib/arcade/player-manager'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
import { getSocketIO } from '@/lib/socket-io'
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ roomId: string }>
|
||||
@@ -56,6 +57,34 @@ export async function POST(req: NextRequest, context: RouteContext) {
|
||||
// Update room activity to refresh TTL
|
||||
await touchRoom(roomId)
|
||||
|
||||
// Broadcast to all users in the room via socket
|
||||
const io = await getSocketIO()
|
||||
if (io) {
|
||||
try {
|
||||
const members = await getRoomMembers(roomId)
|
||||
const memberPlayers = await getRoomActivePlayers(roomId)
|
||||
|
||||
// Convert memberPlayers Map to object for JSON serialization
|
||||
const memberPlayersObj: Record<string, any[]> = {}
|
||||
for (const [uid, players] of memberPlayers.entries()) {
|
||||
memberPlayersObj[uid] = players
|
||||
}
|
||||
|
||||
// Broadcast to all users in this room
|
||||
io.to(`room:${roomId}`).emit('member-joined', {
|
||||
roomId,
|
||||
userId: viewerId,
|
||||
members,
|
||||
memberPlayers: memberPlayersObj,
|
||||
})
|
||||
|
||||
console.log(`[Join API] Broadcasted member-joined for user ${viewerId} in room ${roomId}`)
|
||||
} catch (socketError) {
|
||||
// Log but don't fail the request if socket broadcast fails
|
||||
console.error('[Join API] Failed to broadcast member-joined:', socketError)
|
||||
}
|
||||
}
|
||||
|
||||
// Build response with auto-leave info if applicable
|
||||
return NextResponse.json(
|
||||
{
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getRoomById } from '@/lib/arcade/room-manager'
|
||||
import { isMember, removeMember } from '@/lib/arcade/room-membership'
|
||||
import { getRoomMembers, isMember, removeMember } from '@/lib/arcade/room-membership'
|
||||
import { getRoomActivePlayers } from '@/lib/arcade/player-manager'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
import { getSocketIO } from '@/lib/socket-io'
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ roomId: string }>
|
||||
@@ -31,6 +33,34 @@ export async function POST(_req: NextRequest, context: RouteContext) {
|
||||
// Remove member
|
||||
await removeMember(roomId, viewerId)
|
||||
|
||||
// Broadcast to all remaining users in the room via socket
|
||||
const io = await getSocketIO()
|
||||
if (io) {
|
||||
try {
|
||||
const members = await getRoomMembers(roomId)
|
||||
const memberPlayers = await getRoomActivePlayers(roomId)
|
||||
|
||||
// Convert memberPlayers Map to object for JSON serialization
|
||||
const memberPlayersObj: Record<string, any[]> = {}
|
||||
for (const [uid, players] of memberPlayers.entries()) {
|
||||
memberPlayersObj[uid] = players
|
||||
}
|
||||
|
||||
// Broadcast to all users in this room
|
||||
io.to(`room:${roomId}`).emit('member-left', {
|
||||
roomId,
|
||||
userId: viewerId,
|
||||
members,
|
||||
memberPlayers: memberPlayersObj,
|
||||
})
|
||||
|
||||
console.log(`[Leave API] Broadcasted member-left for user ${viewerId} in room ${roomId}`)
|
||||
} catch (socketError) {
|
||||
// Log but don't fail the request if socket broadcast fails
|
||||
console.error('[Leave API] Failed to broadcast member-left:', socketError)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Failed to leave room:', error)
|
||||
|
||||
52
apps/web/src/app/api/arcade/rooms/current/route.ts
Normal file
52
apps/web/src/app/api/arcade/rooms/current/route.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getUserRooms } from '@/lib/arcade/room-membership'
|
||||
import { getRoomById } from '@/lib/arcade/room-manager'
|
||||
import { getRoomMembers } from '@/lib/arcade/room-membership'
|
||||
import { getRoomActivePlayers } from '@/lib/arcade/player-manager'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
|
||||
/**
|
||||
* GET /api/arcade/rooms/current
|
||||
* Returns the user's current room (if any)
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const userId = await getViewerId()
|
||||
|
||||
// Get all rooms user is in (should be at most 1 due to modal room enforcement)
|
||||
const roomIds = await getUserRooms(userId)
|
||||
|
||||
if (roomIds.length === 0) {
|
||||
return NextResponse.json({ room: null }, { status: 200 })
|
||||
}
|
||||
|
||||
const roomId = roomIds[0]
|
||||
|
||||
// Get room data
|
||||
const room = await getRoomById(roomId)
|
||||
if (!room) {
|
||||
return NextResponse.json({ error: 'Room not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Get members
|
||||
const members = await getRoomMembers(roomId)
|
||||
|
||||
// Get active players for all members
|
||||
const memberPlayers = await getRoomActivePlayers(roomId)
|
||||
|
||||
// Convert Map to object for JSON serialization
|
||||
const memberPlayersObj: Record<string, any[]> = {}
|
||||
for (const [uid, players] of memberPlayers.entries()) {
|
||||
memberPlayersObj[uid] = players
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
room,
|
||||
members,
|
||||
memberPlayers: memberPlayersObj,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[Current Room API] Error:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch current room' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { io, type Socket } from 'socket.io-client'
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { useViewerId } from '@/hooks/useViewerId'
|
||||
|
||||
@@ -83,8 +83,8 @@ export default function RoomDetailPage() {
|
||||
|
||||
sock.on('member-joined', (data) => {
|
||||
console.log('Member joined:', data)
|
||||
if (data.onlineMembers) {
|
||||
setMembers(data.onlineMembers)
|
||||
if (data.members) {
|
||||
setMembers(data.members)
|
||||
}
|
||||
if (data.memberPlayers) {
|
||||
setMemberPlayers(data.memberPlayers)
|
||||
@@ -93,8 +93,8 @@ export default function RoomDetailPage() {
|
||||
|
||||
sock.on('member-left', (data) => {
|
||||
console.log('Member left:', data)
|
||||
if (data.onlineMembers) {
|
||||
setMembers(data.onlineMembers)
|
||||
if (data.members) {
|
||||
setMembers(data.members)
|
||||
}
|
||||
if (data.memberPlayers) {
|
||||
setMemberPlayers(data.memberPlayers)
|
||||
@@ -154,8 +154,8 @@ export default function RoomDetailPage() {
|
||||
|
||||
const startGame = () => {
|
||||
if (!room) return
|
||||
// Navigate to the game with the room ID
|
||||
router.push(`/arcade/rooms/${roomId}/${room.gameName}`)
|
||||
// Navigate to the room game page
|
||||
router.push('/arcade/room')
|
||||
}
|
||||
|
||||
const joinRoom = async () => {
|
||||
@@ -264,7 +264,7 @@ export default function RoomDetailPage() {
|
||||
{error || 'Room not found'}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.push('/arcade/rooms')}
|
||||
onClick={() => router.push('/arcade-rooms')}
|
||||
className={css({
|
||||
px: '6',
|
||||
py: '3',
|
||||
@@ -325,7 +325,7 @@ export default function RoomDetailPage() {
|
||||
>
|
||||
<div className={css({ mb: '4' })}>
|
||||
<button
|
||||
onClick={() => router.push('/arcade/rooms')}
|
||||
onClick={() => router.push('/arcade-rooms')}
|
||||
className={css({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
@@ -621,7 +621,7 @@ export default function RoomDetailPage() {
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => router.push('/arcade/rooms')}
|
||||
onClick={() => router.push('/arcade-rooms')}
|
||||
className={css({
|
||||
flex: 1,
|
||||
px: '6',
|
||||
311
apps/web/src/app/arcade-rooms/__tests__/room-navigation.test.tsx
Normal file
311
apps/web/src/app/arcade-rooms/__tests__/room-navigation.test.tsx
Normal file
@@ -0,0 +1,311 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import * as nextNavigation from 'next/navigation'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as arcadeGuard from '@/hooks/useArcadeGuard'
|
||||
import * as roomData from '@/hooks/useRoomData'
|
||||
import * as viewerId from '@/hooks/useViewerId'
|
||||
|
||||
// Mock Next.js navigation
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: vi.fn(),
|
||||
usePathname: vi.fn(),
|
||||
useParams: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock hooks
|
||||
vi.mock('@/hooks/useArcadeGuard')
|
||||
vi.mock('@/hooks/useRoomData')
|
||||
vi.mock('@/hooks/useViewerId')
|
||||
vi.mock('@/hooks/useUserPlayers', () => ({
|
||||
useUserPlayers: () => ({ data: [], isLoading: false }),
|
||||
useCreatePlayer: () => ({ mutate: vi.fn() }),
|
||||
useUpdatePlayer: () => ({ mutate: vi.fn() }),
|
||||
useDeletePlayer: () => ({ mutate: vi.fn() }),
|
||||
}))
|
||||
vi.mock('@/hooks/useArcadeSocket', () => ({
|
||||
useArcadeSocket: () => ({
|
||||
connected: false,
|
||||
joinSession: vi.fn(),
|
||||
socket: null,
|
||||
sendMove: vi.fn(),
|
||||
exitSession: vi.fn(),
|
||||
pingSession: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock styled-system
|
||||
vi.mock('../../../../styled-system/css', () => ({
|
||||
css: () => '',
|
||||
}))
|
||||
|
||||
// Mock components
|
||||
vi.mock('@/components/PageWithNav', () => ({
|
||||
PageWithNav: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
// Import pages after mocks
|
||||
import RoomBrowserPage from '../page'
|
||||
|
||||
describe('Room Navigation with Active Sessions', () => {
|
||||
const mockRouter = {
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
back: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.spyOn(nextNavigation, 'useRouter').mockReturnValue(mockRouter as any)
|
||||
vi.spyOn(nextNavigation, 'usePathname').mockReturnValue('/arcade-rooms')
|
||||
vi.spyOn(viewerId, 'useViewerId').mockReturnValue({
|
||||
data: 'test-user',
|
||||
isLoading: false,
|
||||
isPending: false,
|
||||
error: null,
|
||||
} as any)
|
||||
global.fetch = vi.fn()
|
||||
})
|
||||
|
||||
describe('RoomBrowserPage', () => {
|
||||
it('should render room browser without redirecting when user has active game session', async () => {
|
||||
// User has an active game session
|
||||
vi.spyOn(arcadeGuard, 'useArcadeGuard').mockReturnValue({
|
||||
hasActiveSession: true,
|
||||
loading: false,
|
||||
activeSession: {
|
||||
gameUrl: '/arcade/room',
|
||||
currentGame: 'matching',
|
||||
},
|
||||
})
|
||||
|
||||
// User is in a room
|
||||
vi.spyOn(roomData, 'useRoomData').mockReturnValue({
|
||||
roomData: {
|
||||
id: 'room-1',
|
||||
name: 'Test Room',
|
||||
code: 'ABC123',
|
||||
gameName: 'matching',
|
||||
members: [],
|
||||
memberPlayers: {},
|
||||
},
|
||||
isLoading: false,
|
||||
isInRoom: true,
|
||||
})
|
||||
|
||||
// Mock rooms API
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
rooms: [
|
||||
{
|
||||
id: 'room-1',
|
||||
code: 'ABC123',
|
||||
name: 'Test Room',
|
||||
gameName: 'matching',
|
||||
status: 'lobby',
|
||||
createdAt: new Date(),
|
||||
creatorName: 'Test User',
|
||||
isLocked: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
render(<RoomBrowserPage />)
|
||||
|
||||
// Should render the page
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('🎮 Multiplayer Rooms')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Should NOT redirect to /arcade/room
|
||||
expect(mockRouter.push).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should NOT redirect when PageWithNav uses arcade guard with enabled=false', async () => {
|
||||
// Simulate PageWithNav calling useArcadeGuard with enabled=false
|
||||
const arcadeGuardSpy = vi.spyOn(arcadeGuard, 'useArcadeGuard')
|
||||
|
||||
// User has an active game session
|
||||
arcadeGuardSpy.mockReturnValue({
|
||||
hasActiveSession: true,
|
||||
loading: false,
|
||||
activeSession: {
|
||||
gameUrl: '/arcade/room',
|
||||
currentGame: 'matching',
|
||||
},
|
||||
})
|
||||
|
||||
vi.spyOn(roomData, 'useRoomData').mockReturnValue({
|
||||
roomData: null,
|
||||
isLoading: false,
|
||||
isInRoom: false,
|
||||
})
|
||||
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ rooms: [] }),
|
||||
})
|
||||
|
||||
render(<RoomBrowserPage />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('🎮 Multiplayer Rooms')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// PageWithNav should have called useArcadeGuard with enabled=false
|
||||
// This is tested in PageWithNav's own tests, but we verify no redirect happened
|
||||
expect(mockRouter.push).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should allow navigation to room detail even with active session', async () => {
|
||||
vi.spyOn(arcadeGuard, 'useArcadeGuard').mockReturnValue({
|
||||
hasActiveSession: true,
|
||||
loading: false,
|
||||
activeSession: {
|
||||
gameUrl: '/arcade/room',
|
||||
currentGame: 'matching',
|
||||
},
|
||||
})
|
||||
|
||||
vi.spyOn(roomData, 'useRoomData').mockReturnValue({
|
||||
roomData: null,
|
||||
isLoading: false,
|
||||
isInRoom: false,
|
||||
})
|
||||
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
rooms: [
|
||||
{
|
||||
id: 'room-1',
|
||||
code: 'ABC123',
|
||||
name: 'Test Room',
|
||||
gameName: 'matching',
|
||||
status: 'lobby',
|
||||
createdAt: new Date(),
|
||||
creatorName: 'Test User',
|
||||
isLocked: false,
|
||||
isMember: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
render(<RoomBrowserPage />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Room')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Click on the room card
|
||||
const roomCard = screen.getByText('Test Room').parentElement
|
||||
roomCard?.click()
|
||||
|
||||
// Should navigate to room detail, not to /arcade/room
|
||||
await waitFor(() => {
|
||||
expect(mockRouter.push).toHaveBeenCalledWith('/arcade-rooms/room-1')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Room navigation edge cases', () => {
|
||||
it('should handle rapid navigation between room pages without redirect loops', async () => {
|
||||
vi.spyOn(arcadeGuard, 'useArcadeGuard').mockReturnValue({
|
||||
hasActiveSession: true,
|
||||
loading: false,
|
||||
activeSession: {
|
||||
gameUrl: '/arcade/room',
|
||||
currentGame: 'matching',
|
||||
},
|
||||
})
|
||||
|
||||
vi.spyOn(roomData, 'useRoomData').mockReturnValue({
|
||||
roomData: null,
|
||||
isLoading: false,
|
||||
isInRoom: false,
|
||||
})
|
||||
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ rooms: [] }),
|
||||
})
|
||||
|
||||
const { rerender } = render(<RoomBrowserPage />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('🎮 Multiplayer Rooms')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Simulate pathname changes (navigating between room pages)
|
||||
vi.spyOn(nextNavigation, 'usePathname').mockReturnValue('/arcade-rooms/room-1')
|
||||
rerender(<RoomBrowserPage />)
|
||||
|
||||
vi.spyOn(nextNavigation, 'usePathname').mockReturnValue('/arcade-rooms')
|
||||
rerender(<RoomBrowserPage />)
|
||||
|
||||
// Should never redirect to game page
|
||||
expect(mockRouter.push).not.toHaveBeenCalledWith('/arcade/room')
|
||||
})
|
||||
|
||||
it('should allow user to leave room and browse other rooms during active game', async () => {
|
||||
// User is in a room with an active game
|
||||
vi.spyOn(arcadeGuard, 'useArcadeGuard').mockReturnValue({
|
||||
hasActiveSession: true,
|
||||
loading: false,
|
||||
activeSession: {
|
||||
gameUrl: '/arcade/room',
|
||||
currentGame: 'matching',
|
||||
},
|
||||
})
|
||||
|
||||
vi.spyOn(roomData, 'useRoomData').mockReturnValue({
|
||||
roomData: {
|
||||
id: 'room-1',
|
||||
name: 'Current Room',
|
||||
code: 'ABC123',
|
||||
gameName: 'matching',
|
||||
members: [],
|
||||
memberPlayers: {},
|
||||
},
|
||||
isLoading: false,
|
||||
isInRoom: true,
|
||||
})
|
||||
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
rooms: [
|
||||
{
|
||||
id: 'room-1',
|
||||
name: 'Current Room',
|
||||
code: 'ABC123',
|
||||
gameName: 'matching',
|
||||
status: 'playing',
|
||||
isMember: true,
|
||||
},
|
||||
{
|
||||
id: 'room-2',
|
||||
name: 'Other Room',
|
||||
code: 'DEF456',
|
||||
gameName: 'memory-quiz',
|
||||
status: 'lobby',
|
||||
isMember: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
render(<RoomBrowserPage />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Current Room')).toBeInTheDocument()
|
||||
expect(screen.getByText('Other Room')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Should be able to view both rooms without redirect
|
||||
expect(mockRouter.push).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { css } from '../../../styled-system/css'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
|
||||
interface Room {
|
||||
@@ -66,7 +66,7 @@ export default function RoomBrowserPage() {
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
router.push(`/arcade/rooms/${data.room.id}`)
|
||||
router.push(`/arcade-rooms/${data.room.id}`)
|
||||
} catch (err) {
|
||||
console.error('Failed to create room:', err)
|
||||
alert('Failed to create room')
|
||||
@@ -103,7 +103,7 @@ export default function RoomBrowserPage() {
|
||||
// Could show a toast notification here in the future
|
||||
}
|
||||
|
||||
router.push(`/arcade/rooms/${roomId}`)
|
||||
router.push(`/arcade-rooms/${roomId}`)
|
||||
} catch (err) {
|
||||
console.error('Failed to join room:', err)
|
||||
alert('Failed to join room')
|
||||
@@ -219,7 +219,7 @@ export default function RoomBrowserPage() {
|
||||
})}
|
||||
>
|
||||
<div
|
||||
onClick={() => router.push(`/arcade/rooms/${room.id}`)}
|
||||
onClick={() => router.push(`/arcade-rooms/${room.id}`)}
|
||||
className={css({ flex: 1, cursor: 'pointer' })}
|
||||
>
|
||||
<div
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo } from 'react'
|
||||
import { useArcadeSession } from '@/hooks/useArcadeSession'
|
||||
import { useRoomData } from '@/hooks/useRoomData'
|
||||
import { useViewerId } from '@/hooks/useViewerId'
|
||||
import type { GameMove } from '@/lib/arcade/validation'
|
||||
import { useGameMode } from '../../../../contexts/GameModeContext'
|
||||
@@ -104,6 +105,7 @@ const ArcadeMemoryPairsContext = createContext<MemoryPairsContextValue | null>(n
|
||||
// Provider component
|
||||
export function ArcadeMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
const { data: viewerId } = useViewerId()
|
||||
const { roomData } = useRoomData()
|
||||
const { activePlayerCount, activePlayers: activePlayerIds } = useGameMode()
|
||||
|
||||
// Get active player IDs directly as strings (UUIDs)
|
||||
@@ -112,7 +114,7 @@ export function ArcadeMemoryPairsProvider({ children }: { children: ReactNode })
|
||||
// Derive game mode from active player count
|
||||
const gameMode = activePlayerCount > 1 ? 'multiplayer' : 'single'
|
||||
|
||||
// Arcade session integration
|
||||
// Arcade session integration with room-wide sync
|
||||
const {
|
||||
state,
|
||||
sendMove,
|
||||
@@ -120,6 +122,7 @@ export function ArcadeMemoryPairsProvider({ children }: { children: ReactNode })
|
||||
exitSession,
|
||||
} = useArcadeSession<MemoryPairsState>({
|
||||
userId: viewerId || '',
|
||||
roomId: roomData?.id, // Enable multi-user sync for room-based games
|
||||
initialState,
|
||||
applyMove: applyMoveOptimistically,
|
||||
})
|
||||
@@ -195,7 +198,7 @@ export function ArcadeMemoryPairsProvider({ children }: { children: ReactNode })
|
||||
activePlayers,
|
||||
},
|
||||
})
|
||||
}, [state.gameType, state.difficulty, activePlayers, sendMove])
|
||||
}, [state.gameType, state.difficulty, activePlayers, sendMove, roomData])
|
||||
|
||||
const flipCard = useCallback(
|
||||
(cardId: string) => {
|
||||
|
||||
75
apps/web/src/app/arcade/room/page.tsx
Normal file
75
apps/web/src/app/arcade/room/page.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect } from 'react'
|
||||
import { useRoomData } from '@/hooks/useRoomData'
|
||||
import { MemoryPairsGame } from '../matching/components/MemoryPairsGame'
|
||||
import { ArcadeMemoryPairsProvider } from '../matching/context/ArcadeMemoryPairsContext'
|
||||
|
||||
/**
|
||||
* /arcade/room - Renders the game for the user's current room
|
||||
* Since users can only be in one room at a time, this is a simple singular route
|
||||
*/
|
||||
export default function RoomPage() {
|
||||
const router = useRouter()
|
||||
const { roomData, isLoading } = useRoomData()
|
||||
|
||||
// Redirect to arcade if no room
|
||||
useEffect(() => {
|
||||
if (!isLoading && !roomData) {
|
||||
router.push('/arcade')
|
||||
}
|
||||
}, [isLoading, roomData, router])
|
||||
|
||||
// Show loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
fontSize: '18px',
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
Loading room...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show nothing while redirecting
|
||||
if (!roomData) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Render the appropriate game based on room's gameName
|
||||
// Note: We don't use ArcadeGuardedPage here because room-based games
|
||||
// have their own navigation logic via useRoomData
|
||||
switch (roomData.gameName) {
|
||||
case 'matching':
|
||||
return (
|
||||
<ArcadeMemoryPairsProvider>
|
||||
<MemoryPairsGame />
|
||||
</ArcadeMemoryPairsProvider>
|
||||
)
|
||||
|
||||
// TODO: Add other games (complement-race, memory-quiz, etc.)
|
||||
default:
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
fontSize: '18px',
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
Game "{roomData.gameName}" not yet supported
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useParams } from 'next/navigation'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { ComplementRaceGame } from '@/app/arcade/complement-race/components/ComplementRaceGame'
|
||||
import { ComplementRaceProvider } from '@/app/arcade/complement-race/context/ComplementRaceContext'
|
||||
|
||||
export default function RoomComplementRacePage() {
|
||||
const params = useParams()
|
||||
const roomId = params.roomId as string
|
||||
|
||||
// TODO Phase 4: Integrate room context with game state
|
||||
// - Connect to room socket events
|
||||
// - Sync game state across players
|
||||
// - Handle multiplayer race dynamics
|
||||
|
||||
return (
|
||||
<PageWithNav navTitle="Speed Complement Race" navEmoji="🏁">
|
||||
<ComplementRaceProvider>
|
||||
<ComplementRaceGame />
|
||||
</ComplementRaceProvider>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useParams } from 'next/navigation'
|
||||
import { ArcadeGuardedPage } from '@/components/ArcadeGuardedPage'
|
||||
import { MemoryPairsGame } from '@/app/arcade/matching/components/MemoryPairsGame'
|
||||
import { ArcadeMemoryPairsProvider } from '@/app/arcade/matching/context/ArcadeMemoryPairsContext'
|
||||
|
||||
export default function RoomMatchingPage() {
|
||||
const params = useParams()
|
||||
const roomId = params.roomId as string
|
||||
|
||||
// TODO Phase 4: Integrate room context with game state
|
||||
// - Connect to room socket events
|
||||
// - Sync game state across players
|
||||
// - Handle multiplayer moves
|
||||
|
||||
return (
|
||||
<ArcadeGuardedPage>
|
||||
<ArcadeMemoryPairsProvider>
|
||||
<MemoryPairsGame />
|
||||
</ArcadeMemoryPairsProvider>
|
||||
</ArcadeGuardedPage>
|
||||
)
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useParams } from 'next/navigation'
|
||||
|
||||
// Temporarily redirect to solo arcade version
|
||||
// TODO Phase 4: Implement room-aware memory quiz with multiplayer sync
|
||||
export default function RoomMemoryQuizPage() {
|
||||
const params = useParams()
|
||||
const roomId = params.roomId as string
|
||||
|
||||
// Import and use the arcade version for now
|
||||
// This prevents 404s while we work on full multiplayer integration
|
||||
const MemoryQuizGame = require('@/app/arcade/memory-quiz/page').default
|
||||
|
||||
return <MemoryQuizGame />
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
import React from 'react'
|
||||
import { useGameMode } from '../contexts/GameModeContext'
|
||||
import { useArcadeGuard } from '../hooks/useArcadeGuard'
|
||||
import { useRoomData } from '../hooks/useRoomData'
|
||||
import { useViewerId } from '../hooks/useViewerId'
|
||||
import { AppNavBar } from './AppNavBar'
|
||||
import { GameContextNav } from './nav/GameContextNav'
|
||||
import { PlayerConfigDialog } from './nav/PlayerConfigDialog'
|
||||
@@ -30,6 +32,8 @@ export function PageWithNav({
|
||||
}: PageWithNavProps) {
|
||||
const { players, activePlayers, setActive, activePlayerCount } = useGameMode()
|
||||
const { hasActiveSession, activeSession } = useArcadeGuard({ enabled: false }) // Don't redirect, just get info
|
||||
const { roomData, isInRoom } = useRoomData()
|
||||
const { data: viewerId } = useViewerId()
|
||||
const [mounted, setMounted] = React.useState(false)
|
||||
const [configurePlayerId, setConfigurePlayerId] = React.useState<string | null>(null)
|
||||
|
||||
@@ -80,17 +84,33 @@ export function PageWithNav({
|
||||
|
||||
// Compute arcade session info for display
|
||||
const roomInfo =
|
||||
hasActiveSession && activeSession
|
||||
isInRoom && roomData
|
||||
? {
|
||||
gameName: activeSession.currentGame,
|
||||
playerCount: activePlayerCount, // TODO: Get actual player count from session when available
|
||||
roomName: roomData.name,
|
||||
gameName: roomData.gameName,
|
||||
playerCount: roomData.members.length,
|
||||
}
|
||||
: undefined
|
||||
: hasActiveSession && activeSession
|
||||
? {
|
||||
gameName: activeSession.currentGame,
|
||||
playerCount: activePlayerCount,
|
||||
}
|
||||
: 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 }> = []
|
||||
// Compute network players (other players in the room, excluding current user)
|
||||
const networkPlayers: Array<{ id: string; emoji?: string; name?: string }> =
|
||||
isInRoom && roomData
|
||||
? roomData.members
|
||||
.filter((member) => member.userId !== viewerId)
|
||||
.flatMap((member) => {
|
||||
const memberPlayerList = roomData.memberPlayers[member.userId] || []
|
||||
return memberPlayerList.map((player) => ({
|
||||
id: player.id,
|
||||
emoji: player.emoji,
|
||||
name: `${player.name} (${member.displayName})`,
|
||||
}))
|
||||
})
|
||||
: []
|
||||
|
||||
// Create nav content if title is provided
|
||||
const navContent = navTitle ? (
|
||||
|
||||
@@ -22,6 +22,7 @@ interface NetworkPlayer {
|
||||
}
|
||||
|
||||
interface ArcadeRoomInfo {
|
||||
roomName?: string
|
||||
gameName: string
|
||||
playerCount: number
|
||||
}
|
||||
@@ -134,6 +135,7 @@ export function GameContextNav({
|
||||
{/* Room Info - show when in arcade session */}
|
||||
{roomInfo && !showFullscreenSelection && (
|
||||
<RoomInfo
|
||||
roomName={roomInfo.roomName}
|
||||
gameName={roomInfo.gameName}
|
||||
playerCount={roomInfo.playerCount}
|
||||
shouldEmphasize={shouldEmphasize}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
interface RoomInfoProps {
|
||||
roomName?: string
|
||||
gameName: string
|
||||
playerCount: number
|
||||
shouldEmphasize: boolean
|
||||
@@ -7,7 +8,7 @@ interface RoomInfoProps {
|
||||
/**
|
||||
* Displays current arcade room/session information
|
||||
*/
|
||||
export function RoomInfo({ gameName, playerCount, shouldEmphasize }: RoomInfoProps) {
|
||||
export function RoomInfo({ roomName, gameName, playerCount, shouldEmphasize }: RoomInfoProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -53,7 +54,7 @@ export function RoomInfo({ gameName, playerCount, shouldEmphasize }: RoomInfoPro
|
||||
letterSpacing: '0.5px',
|
||||
}}
|
||||
>
|
||||
Arcade Session
|
||||
{roomName ? 'Room' : 'Arcade Session'}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
@@ -61,7 +62,7 @@ export function RoomInfo({ gameName, playerCount, shouldEmphasize }: RoomInfoPro
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
{gameName}
|
||||
{roomName || gameName}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
useUpdatePlayer,
|
||||
useUserPlayers,
|
||||
} from '@/hooks/useUserPlayers'
|
||||
import { useRoomData } from '@/hooks/useRoomData'
|
||||
import { useViewerId } from '@/hooks/useViewerId'
|
||||
import { getNextPlayerColor } from '../types/player'
|
||||
|
||||
// Client-side Player type (compatible with old type)
|
||||
@@ -66,28 +68,72 @@ export function GameModeProvider({ children }: { children: ReactNode }) {
|
||||
const { mutate: createPlayer } = useCreatePlayer()
|
||||
const { mutate: updatePlayerMutation } = useUpdatePlayer()
|
||||
const { mutate: deletePlayer } = useDeletePlayer()
|
||||
const { roomData } = useRoomData()
|
||||
const { data: viewerId } = useViewerId()
|
||||
|
||||
const [isInitialized, setIsInitialized] = useState(false)
|
||||
|
||||
// Convert DB players to Map
|
||||
const players = useMemo(() => {
|
||||
// Convert DB players to Map (local players)
|
||||
const localPlayers = useMemo(() => {
|
||||
const map = new Map<string, Player>()
|
||||
dbPlayers.forEach((dbPlayer) => {
|
||||
map.set(dbPlayer.id, toClientPlayer(dbPlayer))
|
||||
map.set(dbPlayer.id, {
|
||||
...toClientPlayer(dbPlayer),
|
||||
isLocal: true,
|
||||
})
|
||||
})
|
||||
return map
|
||||
}, [dbPlayers])
|
||||
|
||||
// Track active players from DB isActive status
|
||||
// When in a room, merge all players from all room members
|
||||
const players = useMemo(() => {
|
||||
const map = new Map<string, Player>(localPlayers)
|
||||
|
||||
if (roomData) {
|
||||
// Add players from other room members (marked as remote)
|
||||
Object.entries(roomData.memberPlayers).forEach(([userId, memberPlayers]) => {
|
||||
// Skip the current user's players (already in localPlayers)
|
||||
if (userId === viewerId) return
|
||||
|
||||
memberPlayers.forEach((roomPlayer) => {
|
||||
map.set(roomPlayer.id, {
|
||||
id: roomPlayer.id,
|
||||
name: roomPlayer.name,
|
||||
emoji: roomPlayer.emoji,
|
||||
color: roomPlayer.color,
|
||||
createdAt: Date.now(),
|
||||
isActive: true, // Players in memberPlayers are active
|
||||
isLocal: false, // Remote player
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return map
|
||||
}, [localPlayers, roomData, viewerId])
|
||||
|
||||
// Track active players (local + room members when in a room)
|
||||
const activePlayers = useMemo(() => {
|
||||
const set = new Set<string>()
|
||||
dbPlayers.forEach((player) => {
|
||||
if (player.isActive) {
|
||||
set.add(player.id)
|
||||
}
|
||||
})
|
||||
|
||||
if (roomData) {
|
||||
// In room mode: all players from all members are active
|
||||
Object.values(roomData.memberPlayers).forEach((memberPlayers) => {
|
||||
memberPlayers.forEach((player) => {
|
||||
set.add(player.id)
|
||||
})
|
||||
})
|
||||
} else {
|
||||
// Solo mode: only local active players
|
||||
dbPlayers.forEach((player) => {
|
||||
if (player.isActive) {
|
||||
set.add(player.id)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return set
|
||||
}, [dbPlayers])
|
||||
}, [dbPlayers, roomData])
|
||||
|
||||
// Initialize with default players if none exist
|
||||
useEffect(() => {
|
||||
@@ -125,15 +171,33 @@ export function GameModeProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
const updatePlayer = (id: string, updates: Partial<Player>) => {
|
||||
updatePlayerMutation({ id, updates })
|
||||
const player = players.get(id)
|
||||
// Only allow updating local players
|
||||
if (player?.isLocal) {
|
||||
updatePlayerMutation({ id, updates })
|
||||
} else {
|
||||
console.warn('[GameModeContext] Cannot update remote player:', id)
|
||||
}
|
||||
}
|
||||
|
||||
const removePlayer = (id: string) => {
|
||||
deletePlayer(id)
|
||||
const player = players.get(id)
|
||||
// Only allow removing local players
|
||||
if (player?.isLocal) {
|
||||
deletePlayer(id)
|
||||
} else {
|
||||
console.warn('[GameModeContext] Cannot remove remote player:', id)
|
||||
}
|
||||
}
|
||||
|
||||
const setActive = (id: string, active: boolean) => {
|
||||
updatePlayerMutation({ id, updates: { isActive: active } })
|
||||
const player = players.get(id)
|
||||
// Only allow changing active status of local players
|
||||
if (player?.isLocal) {
|
||||
updatePlayerMutation({ id, updates: { isActive: active } })
|
||||
} else {
|
||||
console.warn('[GameModeContext] Cannot change active status of remote player:', id)
|
||||
}
|
||||
}
|
||||
|
||||
const getActivePlayers = (): Player[] => {
|
||||
|
||||
@@ -65,9 +65,11 @@ describe('useArcadeGuard', () => {
|
||||
|
||||
it('should fetch active session on mount', async () => {
|
||||
const mockSession = {
|
||||
gameUrl: '/arcade/matching',
|
||||
currentGame: 'matching',
|
||||
gameState: {},
|
||||
session: {
|
||||
gameUrl: '/arcade/matching',
|
||||
currentGame: 'matching',
|
||||
gameState: {},
|
||||
},
|
||||
}
|
||||
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
@@ -91,9 +93,11 @@ describe('useArcadeGuard', () => {
|
||||
|
||||
it('should redirect to active session if on different page', async () => {
|
||||
const mockSession = {
|
||||
gameUrl: '/arcade/memory-quiz',
|
||||
currentGame: 'memory-quiz',
|
||||
gameState: {},
|
||||
session: {
|
||||
gameUrl: '/arcade/memory-quiz',
|
||||
currentGame: 'memory-quiz',
|
||||
gameState: {},
|
||||
},
|
||||
}
|
||||
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
@@ -112,9 +116,11 @@ describe('useArcadeGuard', () => {
|
||||
|
||||
it('should NOT redirect if already on active session page', async () => {
|
||||
const mockSession = {
|
||||
gameUrl: '/arcade/matching',
|
||||
currentGame: 'matching',
|
||||
gameState: {},
|
||||
session: {
|
||||
gameUrl: '/arcade/matching',
|
||||
currentGame: 'matching',
|
||||
gameState: {},
|
||||
},
|
||||
}
|
||||
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
@@ -152,9 +158,11 @@ describe('useArcadeGuard', () => {
|
||||
it('should call onRedirect callback when redirecting', async () => {
|
||||
const onRedirect = vi.fn()
|
||||
const mockSession = {
|
||||
gameUrl: '/arcade/memory-quiz',
|
||||
currentGame: 'memory-quiz',
|
||||
gameState: {},
|
||||
session: {
|
||||
gameUrl: '/arcade/memory-quiz',
|
||||
currentGame: 'memory-quiz',
|
||||
gameState: {},
|
||||
},
|
||||
}
|
||||
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
@@ -248,9 +256,11 @@ describe('useArcadeGuard', () => {
|
||||
})
|
||||
|
||||
const mockSession = {
|
||||
gameUrl: '/arcade/matching',
|
||||
currentGame: 'matching',
|
||||
gameState: {},
|
||||
session: {
|
||||
gameUrl: '/arcade/matching',
|
||||
currentGame: 'matching',
|
||||
gameState: {},
|
||||
},
|
||||
}
|
||||
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
@@ -285,4 +295,136 @@ describe('useArcadeGuard', () => {
|
||||
// Should not crash, just set loading to false
|
||||
expect(result.current.hasActiveSession).toBe(false)
|
||||
})
|
||||
|
||||
describe('enabled flag behavior', () => {
|
||||
it('should NOT redirect from HTTP check when enabled=false', async () => {
|
||||
const mockSession = {
|
||||
session: {
|
||||
gameUrl: '/arcade/memory-quiz',
|
||||
currentGame: 'memory-quiz',
|
||||
gameState: {},
|
||||
},
|
||||
}
|
||||
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockSession,
|
||||
})
|
||||
|
||||
vi.spyOn(nextNavigation, 'usePathname').mockReturnValue('/arcade-rooms')
|
||||
|
||||
renderHook(() => useArcadeGuard({ enabled: false }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Should NOT redirect
|
||||
expect(mockRouter.push).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should NOT redirect from WebSocket when enabled=false', async () => {
|
||||
let onSessionStateCallback: ((data: any) => void) | null = null
|
||||
|
||||
vi.spyOn(arcadeSocket, 'useArcadeSocket').mockImplementation((events) => {
|
||||
onSessionStateCallback = events?.onSessionState || null
|
||||
return mockUseArcadeSocket
|
||||
})
|
||||
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
})
|
||||
|
||||
vi.spyOn(nextNavigation, 'usePathname').mockReturnValue('/arcade-rooms')
|
||||
|
||||
const { result } = renderHook(() => useArcadeGuard({ enabled: false }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
|
||||
// Simulate session-state event from WebSocket
|
||||
onSessionStateCallback?.({
|
||||
gameUrl: '/arcade/room',
|
||||
currentGame: 'matching',
|
||||
gameState: {},
|
||||
activePlayers: [1],
|
||||
version: 1,
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
// Should track the session
|
||||
expect(result.current.hasActiveSession).toBe(true)
|
||||
expect(result.current.activeSession).toEqual({
|
||||
gameUrl: '/arcade/room',
|
||||
currentGame: 'matching',
|
||||
})
|
||||
})
|
||||
|
||||
// But should NOT redirect since enabled=false
|
||||
expect(mockRouter.push).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should STILL redirect from WebSocket when enabled=true', async () => {
|
||||
let onSessionStateCallback: ((data: any) => void) | null = null
|
||||
|
||||
vi.spyOn(arcadeSocket, 'useArcadeSocket').mockImplementation((events) => {
|
||||
onSessionStateCallback = events?.onSessionState || null
|
||||
return mockUseArcadeSocket
|
||||
})
|
||||
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
})
|
||||
|
||||
vi.spyOn(nextNavigation, 'usePathname').mockReturnValue('/arcade-rooms')
|
||||
|
||||
renderHook(() => useArcadeGuard({ enabled: true }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUseArcadeSocket.joinSession).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Simulate session-state event from WebSocket
|
||||
onSessionStateCallback?.({
|
||||
gameUrl: '/arcade/room',
|
||||
currentGame: 'matching',
|
||||
gameState: {},
|
||||
activePlayers: [1],
|
||||
version: 1,
|
||||
})
|
||||
|
||||
// Should redirect when enabled=true
|
||||
await waitFor(() => {
|
||||
expect(mockRouter.push).toHaveBeenCalledWith('/arcade/room')
|
||||
})
|
||||
})
|
||||
|
||||
it('should track session state even when enabled=false', async () => {
|
||||
const mockSession = {
|
||||
session: {
|
||||
gameUrl: '/arcade/room',
|
||||
currentGame: 'matching',
|
||||
gameState: {},
|
||||
},
|
||||
}
|
||||
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockSession,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useArcadeGuard({ enabled: false }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
|
||||
// Should still provide session info even without redirects
|
||||
expect(result.current.hasActiveSession).toBe(false) // No fetch happened
|
||||
expect(result.current.activeSession).toBe(null)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -73,8 +73,8 @@ export function useArcadeGuard(options: UseArcadeGuardOptions = {}): UseArcadeGu
|
||||
currentGame: data.currentGame,
|
||||
})
|
||||
|
||||
// Redirect if we're not already on the active game page
|
||||
if (pathname !== data.gameUrl) {
|
||||
// Redirect if we're not already on the active game page (only if enabled)
|
||||
if (enabled && pathname !== data.gameUrl) {
|
||||
console.log('[ArcadeGuard] Redirecting to active session:', data.gameUrl)
|
||||
onRedirect?.(data.gameUrl)
|
||||
router.push(data.gameUrl)
|
||||
@@ -126,8 +126,8 @@ export function useArcadeGuard(options: UseArcadeGuardOptions = {}): UseArcadeGu
|
||||
currentGame: session.currentGame,
|
||||
})
|
||||
|
||||
// Redirect if we're not already on the active game page
|
||||
if (pathname !== session.gameUrl) {
|
||||
// Redirect if we're not already on the active game page (only if enabled)
|
||||
if (enabled && pathname !== session.gameUrl) {
|
||||
console.log('[ArcadeGuard] Redirecting to active session:', session.gameUrl)
|
||||
onRedirect?.(session.gameUrl)
|
||||
router.push(session.gameUrl)
|
||||
|
||||
@@ -12,6 +12,12 @@ export interface UseArcadeSessionOptions<TState> extends UseOptimisticGameStateO
|
||||
*/
|
||||
userId: string
|
||||
|
||||
/**
|
||||
* Room ID for multi-user sync (optional)
|
||||
* If provided, game state will sync across all users in the room
|
||||
*/
|
||||
roomId?: string
|
||||
|
||||
/**
|
||||
* Auto-join session on mount
|
||||
* @default true
|
||||
@@ -76,7 +82,7 @@ export interface UseArcadeSessionReturn<TState> {
|
||||
export function useArcadeSession<TState>(
|
||||
options: UseArcadeSessionOptions<TState>
|
||||
): UseArcadeSessionReturn<TState> {
|
||||
const { userId, autoJoin = true, ...optimisticOptions } = options
|
||||
const { userId, roomId, autoJoin = true, ...optimisticOptions } = options
|
||||
|
||||
// Optimistic state management
|
||||
const optimistic = useOptimisticGameState<TState>(optimisticOptions)
|
||||
@@ -122,9 +128,9 @@ export function useArcadeSession<TState>(
|
||||
// Auto-join session when connected
|
||||
useEffect(() => {
|
||||
if (connected && autoJoin && userId) {
|
||||
joinSession(userId)
|
||||
joinSession(userId, roomId)
|
||||
}
|
||||
}, [connected, autoJoin, userId, joinSession])
|
||||
}, [connected, autoJoin, userId, roomId, joinSession])
|
||||
|
||||
// Send move with optimistic update
|
||||
const sendMove = useCallback(
|
||||
@@ -156,9 +162,9 @@ export function useArcadeSession<TState>(
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
if (connected && userId) {
|
||||
joinSession(userId)
|
||||
joinSession(userId, roomId)
|
||||
}
|
||||
}, [connected, userId, joinSession])
|
||||
}, [connected, userId, roomId, joinSession])
|
||||
|
||||
return {
|
||||
state: optimistic.state,
|
||||
|
||||
@@ -20,7 +20,7 @@ export interface ArcadeSocketEvents {
|
||||
export interface UseArcadeSocketReturn {
|
||||
socket: Socket | null
|
||||
connected: boolean
|
||||
joinSession: (userId: string) => void
|
||||
joinSession: (userId: string, roomId?: string) => void
|
||||
sendMove: (userId: string, move: GameMove) => void
|
||||
exitSession: (userId: string) => void
|
||||
pingSession: (userId: string) => void
|
||||
@@ -103,13 +103,17 @@ export function useArcadeSocket(events: ArcadeSocketEvents = {}): UseArcadeSocke
|
||||
}, [])
|
||||
|
||||
const joinSession = useCallback(
|
||||
(userId: string) => {
|
||||
(userId: string, roomId?: string) => {
|
||||
if (!socket) {
|
||||
console.warn('[ArcadeSocket] Cannot join session - socket not connected')
|
||||
return
|
||||
}
|
||||
console.log('[ArcadeSocket] Joining session for user:', userId)
|
||||
socket.emit('join-arcade-session', { userId })
|
||||
console.log(
|
||||
'[ArcadeSocket] Joining session for user:',
|
||||
userId,
|
||||
roomId ? `in room ${roomId}` : '(solo)'
|
||||
)
|
||||
socket.emit('join-arcade-session', { userId, roomId })
|
||||
},
|
||||
[socket]
|
||||
)
|
||||
|
||||
205
apps/web/src/hooks/useRoomData.ts
Normal file
205
apps/web/src/hooks/useRoomData.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { io, type Socket } from 'socket.io-client'
|
||||
import { useViewerId } from './useViewerId'
|
||||
|
||||
export interface RoomMember {
|
||||
id: string
|
||||
userId: string
|
||||
displayName: string
|
||||
isOnline: boolean
|
||||
isCreator: boolean
|
||||
}
|
||||
|
||||
export interface RoomPlayer {
|
||||
id: string
|
||||
name: string
|
||||
emoji: string
|
||||
color: string
|
||||
}
|
||||
|
||||
export interface RoomData {
|
||||
id: string
|
||||
name: string
|
||||
code: string
|
||||
gameName: string
|
||||
members: RoomMember[]
|
||||
memberPlayers: Record<string, RoomPlayer[]> // userId -> players
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch and subscribe to the user's current room data
|
||||
* Returns null if user is not in any room
|
||||
*/
|
||||
export function useRoomData() {
|
||||
const { data: userId, isPending: isUserIdPending } = useViewerId()
|
||||
const [socket, setSocket] = useState<Socket | null>(null)
|
||||
const [roomData, setRoomData] = useState<RoomData | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [hasAttemptedFetch, setHasAttemptedFetch] = useState(false)
|
||||
|
||||
// Fetch the user's current room
|
||||
useEffect(() => {
|
||||
if (!userId) {
|
||||
setRoomData(null)
|
||||
setHasAttemptedFetch(false)
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
setHasAttemptedFetch(false)
|
||||
|
||||
// Fetch current room data
|
||||
fetch('/api/arcade/rooms/current')
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error('Failed to fetch current room')
|
||||
return res.json()
|
||||
})
|
||||
.then((data) => {
|
||||
if (data.room) {
|
||||
const roomData = {
|
||||
id: data.room.id,
|
||||
name: data.room.name,
|
||||
code: data.room.code,
|
||||
gameName: data.room.gameName,
|
||||
members: data.members || [],
|
||||
memberPlayers: data.memberPlayers || {},
|
||||
}
|
||||
setRoomData(roomData)
|
||||
} else {
|
||||
setRoomData(null)
|
||||
}
|
||||
setIsLoading(false)
|
||||
setHasAttemptedFetch(true)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('[useRoomData] Failed to fetch room data:', error)
|
||||
setRoomData(null)
|
||||
setIsLoading(false)
|
||||
setHasAttemptedFetch(true)
|
||||
})
|
||||
}, [userId])
|
||||
|
||||
// Initialize socket connection when user has a room
|
||||
useEffect(() => {
|
||||
if (!roomData?.id || !userId) {
|
||||
if (socket) {
|
||||
socket.disconnect()
|
||||
setSocket(null)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const sock = io({ path: '/api/socket' })
|
||||
|
||||
sock.on('connect', () => {
|
||||
// Join the room to receive updates
|
||||
sock.emit('join-room', { roomId: roomData.id, userId })
|
||||
})
|
||||
|
||||
sock.on('disconnect', () => {
|
||||
// Socket disconnected
|
||||
})
|
||||
|
||||
setSocket(sock)
|
||||
|
||||
return () => {
|
||||
if (sock.connected) {
|
||||
// Leave the room before disconnecting
|
||||
sock.emit('leave-room', { roomId: roomData.id, userId })
|
||||
sock.disconnect()
|
||||
}
|
||||
}
|
||||
}, [roomData?.id, userId])
|
||||
|
||||
// Subscribe to real-time updates via socket
|
||||
useEffect(() => {
|
||||
if (!socket || !roomData?.id) return
|
||||
|
||||
const handleRoomJoined = (data: {
|
||||
roomId: string
|
||||
members: RoomMember[]
|
||||
memberPlayers: Record<string, RoomPlayer[]>
|
||||
}) => {
|
||||
if (data.roomId === roomData.id) {
|
||||
setRoomData((prev) => {
|
||||
if (!prev) return null
|
||||
return {
|
||||
...prev,
|
||||
members: data.members,
|
||||
memberPlayers: data.memberPlayers,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleMemberJoined = (data: {
|
||||
roomId: string
|
||||
userId: string
|
||||
members: RoomMember[]
|
||||
memberPlayers: Record<string, RoomPlayer[]>
|
||||
}) => {
|
||||
if (data.roomId === roomData.id) {
|
||||
setRoomData((prev) => {
|
||||
if (!prev) return null
|
||||
return {
|
||||
...prev,
|
||||
members: data.members,
|
||||
memberPlayers: data.memberPlayers,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleMemberLeft = (data: {
|
||||
roomId: string
|
||||
userId: string
|
||||
members: RoomMember[]
|
||||
memberPlayers: Record<string, RoomPlayer[]>
|
||||
}) => {
|
||||
if (data.roomId === roomData.id) {
|
||||
setRoomData((prev) => {
|
||||
if (!prev) return null
|
||||
return {
|
||||
...prev,
|
||||
members: data.members,
|
||||
memberPlayers: data.memberPlayers,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleRoomPlayersUpdated = (data: {
|
||||
roomId: string
|
||||
memberPlayers: Record<string, RoomPlayer[]>
|
||||
}) => {
|
||||
if (data.roomId === roomData.id) {
|
||||
setRoomData((prev) => {
|
||||
if (!prev) return null
|
||||
return {
|
||||
...prev,
|
||||
memberPlayers: data.memberPlayers,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
socket.on('room-joined', handleRoomJoined)
|
||||
socket.on('member-joined', handleMemberJoined)
|
||||
socket.on('member-left', handleMemberLeft)
|
||||
socket.on('room-players-updated', handleRoomPlayersUpdated)
|
||||
|
||||
return () => {
|
||||
socket.off('room-joined', handleRoomJoined)
|
||||
socket.off('member-joined', handleMemberJoined)
|
||||
socket.off('member-left', handleMemberLeft)
|
||||
socket.off('room-players-updated', handleRoomPlayersUpdated)
|
||||
}
|
||||
}, [socket, roomData?.id])
|
||||
|
||||
return {
|
||||
roomData,
|
||||
// Loading if: userId is pending, currently fetching, or have userId but haven't tried fetching yet
|
||||
isLoading: isUserIdPending || isLoading || (!!userId && !hasAttemptedFetch),
|
||||
isInRoom: !!roomData,
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,29 @@ import { db, schema } from '@/db'
|
||||
import type { Player } from '@/db/schema/players'
|
||||
|
||||
/**
|
||||
* Get a user's active players
|
||||
* These are the players that will participate when the user joins a game
|
||||
* Get all players for a user (regardless of isActive status)
|
||||
* @param viewerId - The guestId from the cookie (same as what getViewerId() returns)
|
||||
*/
|
||||
export async function getAllPlayers(viewerId: string): Promise<Player[]> {
|
||||
// First get the user record by guestId
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(schema.users.guestId, viewerId),
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Now query all players by the actual user.id (no isActive filter)
|
||||
return await db.query.players.findMany({
|
||||
where: eq(schema.players.userId, user.id),
|
||||
orderBy: schema.players.createdAt,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user's active players (solo mode)
|
||||
* These are the players that will participate when the user joins a solo game
|
||||
* @param viewerId - The guestId from the cookie (same as what getViewerId() returns)
|
||||
*/
|
||||
export async function getActivePlayers(viewerId: string): Promise<Player[]> {
|
||||
@@ -30,7 +51,8 @@ export async function getActivePlayers(viewerId: string): Promise<Player[]> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active players for all members in a room
|
||||
* Get active players for all members in a room
|
||||
* Returns only players marked isActive=true from each room member
|
||||
* Returns a map of userId -> Player[]
|
||||
*/
|
||||
export async function getRoomActivePlayers(roomId: string): Promise<Map<string, Player[]>> {
|
||||
@@ -39,7 +61,7 @@ export async function getRoomActivePlayers(roomId: string): Promise<Map<string,
|
||||
where: eq(schema.roomMembers.roomId, roomId),
|
||||
})
|
||||
|
||||
// Fetch active players for each member
|
||||
// Fetch active players for each member (respects isActive flag)
|
||||
const playerMap = new Map<string, Player[]>()
|
||||
for (const member of members) {
|
||||
const players = await getActivePlayers(member.userId)
|
||||
|
||||
40
apps/web/src/lib/socket-io.ts
Normal file
40
apps/web/src/lib/socket-io.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Socket.IO server instance accessor for API routes
|
||||
* This module provides a way for API routes to access the socket.io server
|
||||
* to broadcast real-time updates.
|
||||
*/
|
||||
|
||||
import type { Server as SocketIOServerType } from 'socket.io'
|
||||
|
||||
// Cache for the socket server module
|
||||
let socketServerModule: any = null
|
||||
|
||||
/**
|
||||
* Get the socket.io server instance
|
||||
* Returns null if not initialized or if called on client-side
|
||||
*/
|
||||
export async function getSocketIO(): Promise<SocketIOServerType | null> {
|
||||
// Client-side: return null
|
||||
if (typeof window !== 'undefined') {
|
||||
return null
|
||||
}
|
||||
|
||||
// Lazy-load the socket server module on first call
|
||||
if (!socketServerModule) {
|
||||
try {
|
||||
// Dynamic import to avoid bundling issues
|
||||
socketServerModule = await import('../../socket-server')
|
||||
} catch (error) {
|
||||
console.error('[Socket IO] Failed to load socket server:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Call the exported getSocketIO function from the module
|
||||
if (socketServerModule && typeof socketServerModule.getSocketIO === 'function') {
|
||||
return socketServerModule.getSocketIO()
|
||||
}
|
||||
|
||||
console.warn('[Socket IO] getSocketIO function not found in socket-server module')
|
||||
return null
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "soroban-monorepo",
|
||||
"version": "2.4.1",
|
||||
"version": "2.8.3",
|
||||
"private": true,
|
||||
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
|
||||
"workspaces": [
|
||||
|
||||
Reference in New Issue
Block a user