From 2d00939f1b59a10d271f82098c1b88acb2245ce1 Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Fri, 10 Oct 2025 15:17:45 -0500 Subject: [PATCH] fix: populate session activePlayers from room members on join MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Problem:** When users joined rooms, the arcade session was created with empty activePlayers array, causing the game to start in single-player mode even though multiple users had joined the room. **Root Cause:** Initial session creation in `join-arcade-session` handler set `activePlayers: []` without fetching the actual room members' players. **Solution:** 1. **Session Creation**: When creating initial session, fetch all room members' active players using `getRoomPlayerIds()` and populate `activePlayers` field. 2. **Dynamic Updates**: When members join room (`join-room` event) or toggle players (`players-updated` event), update session's `activePlayers` dynamically using new `updateSessionActivePlayers()` function. 3. **Protection**: Only update `activePlayers` if game is in 'setup' phase - prevents disrupting games in progress. **Key Changes:** - `socket-server.ts`: Import `getRoomPlayerIds`, use it to populate activePlayers on session creation, update session when room membership changes - `session-manager.ts`: Add `updateSessionActivePlayers()` function to safely update activePlayers during setup phase - Test fixes: Updated test files to match new schema (roomId PRIMARY KEY) **Testing:** - Session correctly populated with all room members' players on creation - New members' players added to session when they join (if in setup) - Player toggle updates session activePlayers in real-time - Games in progress protected from activePlayers modifications đŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- apps/web/__tests__/api-players.e2e.test.ts | 503 ++++++++-------- apps/web/drizzle/meta/0005_snapshot.json | 655 +++++++++++++++++++++ apps/web/src/lib/arcade/session-manager.ts | 50 ++ apps/web/src/socket-server.ts | 58 +- 4 files changed, 1032 insertions(+), 234 deletions(-) create mode 100644 apps/web/drizzle/meta/0005_snapshot.json diff --git a/apps/web/__tests__/api-players.e2e.test.ts b/apps/web/__tests__/api-players.e2e.test.ts index cfcb5909..bd768d67 100644 --- a/apps/web/__tests__/api-players.e2e.test.ts +++ b/apps/web/__tests__/api-players.e2e.test.ts @@ -2,9 +2,9 @@ * @vitest-environment node */ -import { eq } from "drizzle-orm"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { db, schema } from "../src/db"; +import { eq } from 'drizzle-orm' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { db, schema } from '../src/db' /** * API Players E2E Tests @@ -13,33 +13,30 @@ import { db, schema } from "../src/db"; * They use the actual database and test the full request/response cycle. */ -describe("Players API", () => { - let testUserId: string; - let testGuestId: string; +describe('Players API', () => { + let testUserId: string + let testGuestId: string beforeEach(async () => { // Create a test user with unique guest ID - testGuestId = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}`; - const [user] = await db - .insert(schema.users) - .values({ guestId: testGuestId }) - .returning(); - testUserId = user.id; - }); + testGuestId = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}` + const [user] = await db.insert(schema.users).values({ guestId: testGuestId }).returning() + testUserId = user.id + }) afterEach(async () => { // Clean up: delete test user (cascade deletes players) - await db.delete(schema.users).where(eq(schema.users.id, testUserId)); - }); + await db.delete(schema.users).where(eq(schema.users.id, testUserId)) + }) - describe("POST /api/players", () => { - it("creates a player with valid data", async () => { + describe('POST /api/players', () => { + it('creates a player with valid data', async () => { const playerData = { - name: "Test Player", - emoji: "😀", - color: "#3b82f6", + name: 'Test Player', + emoji: '😀', + color: '#3b82f6', isActive: true, - }; + } // Simulate creating via DB (API would do this) const [player] = await db @@ -48,377 +45,422 @@ describe("Players API", () => { userId: testUserId, ...playerData, }) - .returning(); + .returning() - expect(player).toBeDefined(); - expect(player.name).toBe(playerData.name); - expect(player.emoji).toBe(playerData.emoji); - expect(player.color).toBe(playerData.color); - expect(player.isActive).toBe(true); - expect(player.userId).toBe(testUserId); - }); + expect(player).toBeDefined() + expect(player.name).toBe(playerData.name) + expect(player.emoji).toBe(playerData.emoji) + expect(player.color).toBe(playerData.color) + expect(player.isActive).toBe(true) + expect(player.userId).toBe(testUserId) + }) - it("sets isActive to false by default", async () => { + it('sets isActive to false by default', async () => { const [player] = await db .insert(schema.players) .values({ userId: testUserId, - name: "Inactive Player", - emoji: "😮", - color: "#999999", + name: 'Inactive Player', + emoji: '😮', + color: '#999999', }) - .returning(); + .returning() - expect(player.isActive).toBe(false); - }); - }); + expect(player.isActive).toBe(false) + }) + }) - describe("GET /api/players", () => { - it("returns all players for a user", async () => { + describe('GET /api/players', () => { + it('returns all players for a user', async () => { // Create multiple players await db.insert(schema.players).values([ { userId: testUserId, - name: "Player 1", - emoji: "😀", - color: "#3b82f6", + name: 'Player 1', + emoji: '😀', + color: '#3b82f6', }, { userId: testUserId, - name: "Player 2", - emoji: "😎", - color: "#8b5cf6", + name: 'Player 2', + emoji: '😎', + color: '#8b5cf6', }, - ]); + ]) const players = await db.query.players.findMany({ where: eq(schema.players.userId, testUserId), - }); + }) - expect(players).toHaveLength(2); - expect(players[0].name).toBe("Player 1"); - expect(players[1].name).toBe("Player 2"); - }); + expect(players).toHaveLength(2) + expect(players[0].name).toBe('Player 1') + expect(players[1].name).toBe('Player 2') + }) - it("returns empty array for user with no players", async () => { + it('returns empty array for user with no players', async () => { const players = await db.query.players.findMany({ where: eq(schema.players.userId, testUserId), - }); + }) - expect(players).toHaveLength(0); - }); - }); + expect(players).toHaveLength(0) + }) + }) - describe("PATCH /api/players/[id]", () => { - it("updates player fields", async () => { + describe('PATCH /api/players/[id]', () => { + it('updates player fields', async () => { const [player] = await db .insert(schema.players) .values({ userId: testUserId, - name: "Original Name", - emoji: "😀", - color: "#3b82f6", + name: 'Original Name', + emoji: '😀', + color: '#3b82f6', }) - .returning(); + .returning() const [updated] = await db .update(schema.players) .set({ - name: "Updated Name", - emoji: "🎉", + name: 'Updated Name', + emoji: '🎉', }) .where(eq(schema.players.id, player.id)) - .returning(); + .returning() - expect(updated.name).toBe("Updated Name"); - expect(updated.emoji).toBe("🎉"); - expect(updated.color).toBe("#3b82f6"); // unchanged - }); + expect(updated.name).toBe('Updated Name') + expect(updated.emoji).toBe('🎉') + expect(updated.color).toBe('#3b82f6') // unchanged + }) - it("toggles isActive status", async () => { + it('toggles isActive status', async () => { const [player] = await db .insert(schema.players) .values({ userId: testUserId, - name: "Test Player", - emoji: "😀", - color: "#3b82f6", + name: 'Test Player', + emoji: '😀', + color: '#3b82f6', isActive: false, }) - .returning(); + .returning() const [updated] = await db .update(schema.players) .set({ isActive: true }) .where(eq(schema.players.id, player.id)) - .returning(); + .returning() - expect(updated.isActive).toBe(true); - }); - }); + expect(updated.isActive).toBe(true) + }) + }) - describe("DELETE /api/players/[id]", () => { - it("deletes a player", async () => { + describe('DELETE /api/players/[id]', () => { + it('deletes a player', async () => { const [player] = await db .insert(schema.players) .values({ userId: testUserId, - name: "To Delete", - emoji: "👋", - color: "#ef4444", + name: 'To Delete', + emoji: '👋', + color: '#ef4444', }) - .returning(); + .returning() const [deleted] = await db .delete(schema.players) .where(eq(schema.players.id, player.id)) - .returning(); + .returning() - expect(deleted).toBeDefined(); - expect(deleted.id).toBe(player.id); + expect(deleted).toBeDefined() + expect(deleted.id).toBe(player.id) // Verify it's gone const found = await db.query.players.findFirst({ where: eq(schema.players.id, player.id), - }); - expect(found).toBeUndefined(); - }); - }); + }) + expect(found).toBeUndefined() + }) + }) - describe("Cascade delete behavior", () => { - it("deletes players when user is deleted", async () => { + describe('Cascade delete behavior', () => { + it('deletes players when user is deleted', async () => { // Create players await db.insert(schema.players).values([ { userId: testUserId, - name: "Player 1", - emoji: "😀", - color: "#3b82f6", + name: 'Player 1', + emoji: '😀', + color: '#3b82f6', }, { userId: testUserId, - name: "Player 2", - emoji: "😎", - color: "#8b5cf6", + name: 'Player 2', + emoji: '😎', + color: '#8b5cf6', }, - ]); + ]) // Verify players exist let players = await db.query.players.findMany({ where: eq(schema.players.userId, testUserId), - }); - expect(players).toHaveLength(2); + }) + expect(players).toHaveLength(2) // Delete user - await db.delete(schema.users).where(eq(schema.users.id, testUserId)); + await db.delete(schema.users).where(eq(schema.users.id, testUserId)) // Verify players are gone players = await db.query.players.findMany({ where: eq(schema.players.userId, testUserId), - }); - expect(players).toHaveLength(0); - }); - }); + }) + expect(players).toHaveLength(0) + }) + }) - describe("Arcade Session: isActive Modification Restrictions", () => { - it("prevents isActive changes when user has an active arcade session", async () => { + describe('Arcade Session: isActive Modification Restrictions', () => { + it('prevents isActive changes when user has an active arcade session', async () => { // Create a player const [player] = await db .insert(schema.players) .values({ userId: testUserId, - name: "Test Player", - emoji: "😀", - color: "#3b82f6", + name: 'Test Player', + emoji: '😀', + color: '#3b82f6', isActive: false, }) - .returning(); + .returning() + + // Create a test room for the session + const [testRoom] = await db + .insert(schema.arcadeRooms) + .values({ + code: `TEST-${Date.now()}`, + name: 'Test Room', + gameName: 'matching', + gameConfig: JSON.stringify({}), + status: 'lobby', + createdBy: testUserId, + creatorName: 'Test User', + ttlMinutes: 60, + createdAt: new Date(), + }) + .returning() // Create an active arcade session - const now = new Date(); + const now = new Date() await db.insert(schema.arcadeSessions).values({ - userId: testGuestId, - currentGame: "matching", - gameUrl: "/arcade/matching", + roomId: testRoom.id, + userId: testUserId, + currentGame: 'matching', + gameUrl: '/arcade/matching', gameState: JSON.stringify({}), activePlayers: JSON.stringify([player.id]), startedAt: now, lastActivityAt: now, expiresAt: new Date(now.getTime() + 3600000), // 1 hour from now version: 1, - }); + }) // Attempt to update isActive should be prevented at API level // This test validates the logic that the API route implements const activeSession = await db.query.arcadeSessions.findFirst({ - where: eq(schema.arcadeSessions.userId, testGuestId), - }); + where: eq(schema.arcadeSessions.roomId, testRoom.id), + }) - expect(activeSession).toBeDefined(); - expect(activeSession?.currentGame).toBe("matching"); + expect(activeSession).toBeDefined() + expect(activeSession?.currentGame).toBe('matching') // Clean up session - await db - .delete(schema.arcadeSessions) - .where(eq(schema.arcadeSessions.userId, testGuestId)); - }); + await db.delete(schema.arcadeSessions).where(eq(schema.arcadeSessions.roomId, testRoom.id)) + }) - it("allows isActive changes when user has no active arcade session", async () => { + it('allows isActive changes when user has no active arcade session', async () => { // Create a player const [player] = await db .insert(schema.players) .values({ userId: testUserId, - name: "Test Player", - emoji: "😀", - color: "#3b82f6", + name: 'Test Player', + emoji: '😀', + color: '#3b82f6', isActive: false, }) - .returning(); + .returning() - // Verify no active session + // Verify no active session for this user const activeSession = await db.query.arcadeSessions.findFirst({ - where: eq(schema.arcadeSessions.userId, testGuestId), - }); + where: eq(schema.arcadeSessions.userId, testUserId), + }) - expect(activeSession).toBeUndefined(); + expect(activeSession).toBeUndefined() // Should be able to update isActive const [updated] = await db .update(schema.players) .set({ isActive: true }) .where(eq(schema.players.id, player.id)) - .returning(); + .returning() - expect(updated.isActive).toBe(true); - }); + expect(updated.isActive).toBe(true) + }) - it("allows non-isActive changes even with active session", async () => { + it('allows non-isActive changes even with active session', async () => { // Create a player const [player] = await db .insert(schema.players) .values({ userId: testUserId, - name: "Test Player", - emoji: "😀", - color: "#3b82f6", + name: 'Test Player', + emoji: '😀', + color: '#3b82f6', isActive: true, }) - .returning(); + .returning() + + // Create a test room for the session + const [testRoom] = await db + .insert(schema.arcadeRooms) + .values({ + code: `TEST-${Date.now()}`, + name: 'Test Room', + gameName: 'matching', + gameConfig: JSON.stringify({}), + status: 'lobby', + createdBy: testUserId, + creatorName: 'Test User', + ttlMinutes: 60, + createdAt: new Date(), + }) + .returning() // Create an active arcade session - const now = new Date(); + const now = new Date() await db.insert(schema.arcadeSessions).values({ - userId: testGuestId, - currentGame: "matching", - gameUrl: "/arcade/matching", + roomId: testRoom.id, + userId: testUserId, + currentGame: 'matching', + gameUrl: '/arcade/matching', gameState: JSON.stringify({}), activePlayers: JSON.stringify([player.id]), startedAt: now, lastActivityAt: now, expiresAt: new Date(now.getTime() + 3600000), // 1 hour from now version: 1, - }); + }) try { // Should be able to update name, emoji, color (non-isActive fields) const [updated] = await db .update(schema.players) .set({ - name: "Updated Name", - emoji: "🎉", - color: "#ff0000", + name: 'Updated Name', + emoji: '🎉', + color: '#ff0000', }) .where(eq(schema.players.id, player.id)) - .returning(); + .returning() - expect(updated.name).toBe("Updated Name"); - expect(updated.emoji).toBe("🎉"); - expect(updated.color).toBe("#ff0000"); - expect(updated.isActive).toBe(true); // Unchanged + expect(updated.name).toBe('Updated Name') + expect(updated.emoji).toBe('🎉') + expect(updated.color).toBe('#ff0000') + expect(updated.isActive).toBe(true) // Unchanged } finally { // Clean up session - await db - .delete(schema.arcadeSessions) - .where(eq(schema.arcadeSessions.userId, testGuestId)); + await db.delete(schema.arcadeSessions).where(eq(schema.arcadeSessions.roomId, testRoom.id)) } - }); + }) - it("session ends, then isActive changes are allowed again", async () => { + it('session ends, then isActive changes are allowed again', async () => { // Create a player const [player] = await db .insert(schema.players) .values({ userId: testUserId, - name: "Test Player", - emoji: "😀", - color: "#3b82f6", + name: 'Test Player', + emoji: '😀', + color: '#3b82f6', isActive: true, }) - .returning(); + .returning() + + // Create a test room for the session + const [testRoom] = await db + .insert(schema.arcadeRooms) + .values({ + code: `TEST-${Date.now()}`, + name: 'Test Room', + gameName: 'matching', + gameConfig: JSON.stringify({}), + status: 'lobby', + createdBy: testUserId, + creatorName: 'Test User', + ttlMinutes: 60, + createdAt: new Date(), + }) + .returning() // Create an active arcade session - const now = new Date(); + const now = new Date() await db.insert(schema.arcadeSessions).values({ - userId: testGuestId, - currentGame: "matching", - gameUrl: "/arcade/matching", + roomId: testRoom.id, + userId: testUserId, + currentGame: 'matching', + gameUrl: '/arcade/matching', gameState: JSON.stringify({}), activePlayers: JSON.stringify([player.id]), startedAt: now, lastActivityAt: now, expiresAt: new Date(now.getTime() + 3600000), // 1 hour from now version: 1, - }); + }) // Verify session exists let activeSession = await db.query.arcadeSessions.findFirst({ - where: eq(schema.arcadeSessions.userId, testGuestId), - }); - expect(activeSession).toBeDefined(); + where: eq(schema.arcadeSessions.roomId, testRoom.id), + }) + expect(activeSession).toBeDefined() // End the session - await db - .delete(schema.arcadeSessions) - .where(eq(schema.arcadeSessions.userId, testGuestId)); + await db.delete(schema.arcadeSessions).where(eq(schema.arcadeSessions.roomId, testRoom.id)) // Verify session is gone activeSession = await db.query.arcadeSessions.findFirst({ - where: eq(schema.arcadeSessions.userId, testGuestId), - }); - expect(activeSession).toBeUndefined(); + where: eq(schema.arcadeSessions.roomId, testRoom.id), + }) + expect(activeSession).toBeUndefined() // Now should be able to update isActive const [updated] = await db .update(schema.players) .set({ isActive: false }) .where(eq(schema.players.id, player.id)) - .returning(); + .returning() - expect(updated.isActive).toBe(false); - }); - }); + expect(updated.isActive).toBe(false) + }) + }) - describe("Security: userId injection prevention", () => { - it("rejects creating player with non-existent userId", async () => { + describe('Security: userId injection prevention', () => { + it('rejects creating player with non-existent userId', async () => { // Attempt to create a player with a fake userId await expect(async () => { await db.insert(schema.players).values({ - userId: "HACKER_ID_NON_EXISTENT", - name: "Hacker Player", - emoji: "đŸŠč", - color: "#ff0000", - }); - }).rejects.toThrow(/FOREIGN KEY constraint failed/); - }); + userId: 'HACKER_ID_NON_EXISTENT', + name: 'Hacker Player', + emoji: 'đŸŠč', + color: '#ff0000', + }) + }).rejects.toThrow(/FOREIGN KEY constraint failed/) + }) it("prevents modifying another user's player via userId injection (DB layer alone is insufficient)", async () => { // Create victim user and their player - const victimGuestId = `victim-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const victimGuestId = `victim-${Date.now()}-${Math.random().toString(36).slice(2)}` const [victimUser] = await db .insert(schema.users) .values({ guestId: victimGuestId }) - .returning(); + .returning() try { // Create attacker's player @@ -426,22 +468,22 @@ describe("Players API", () => { .insert(schema.players) .values({ userId: testUserId, - name: "Attacker Player", - emoji: "😈", - color: "#ff0000", + name: 'Attacker Player', + emoji: '😈', + color: '#ff0000', }) - .returning(); + .returning() const [_victimPlayer] = await db .insert(schema.players) .values({ userId: victimUser.id, - name: "Victim Player", - emoji: "đŸ‘€", - color: "#00ff00", + name: 'Victim Player', + emoji: 'đŸ‘€', + color: '#00ff00', isActive: true, }) - .returning(); + .returning() // IMPORTANT: At the DB level, changing userId to another valid userId SUCCEEDS // This is why API layer MUST filter userId from request body! @@ -449,64 +491,61 @@ describe("Players API", () => { .update(schema.players) .set({ userId: victimUser.id, // This WILL succeed at DB level! - name: "Stolen Player", + name: 'Stolen Player', }) .where(eq(schema.players.id, attackerPlayer.id)) - .returning(); + .returning() // The update succeeded - the player now belongs to victim! - expect(updated.userId).toBe(victimUser.id); - expect(updated.name).toBe("Stolen Player"); + expect(updated.userId).toBe(victimUser.id) + expect(updated.name).toBe('Stolen Player') // This test demonstrates why the API route MUST: // 1. Strip userId from request body // 2. Derive userId from session cookie // 3. Use WHERE clause to scope updates to current user's data only } finally { - await db.delete(schema.users).where(eq(schema.users.id, victimUser.id)); + await db.delete(schema.users).where(eq(schema.users.id, victimUser.id)) } - }); + }) - it("ensures players are isolated per user", async () => { + it('ensures players are isolated per user', async () => { // Create another user - const user2GuestId = `user2-${Date.now()}-${Math.random().toString(36).slice(2)}`; - const [user2] = await db - .insert(schema.users) - .values({ guestId: user2GuestId }) - .returning(); + const user2GuestId = `user2-${Date.now()}-${Math.random().toString(36).slice(2)}` + const [user2] = await db.insert(schema.users).values({ guestId: user2GuestId }).returning() try { // Create players for both users await db.insert(schema.players).values({ userId: testUserId, - name: "User 1 Player", - emoji: "🎼", - color: "#0000ff", - }); + name: 'User 1 Player', + emoji: '🎼', + color: '#0000ff', + }) await db.insert(schema.players).values({ userId: user2.id, - name: "User 2 Player", - emoji: "🎯", - color: "#ff00ff", - }); + name: 'User 2 Player', + emoji: '🎯', + color: '#ff00ff', + }) // Verify each user only sees their own players const user1Players = await db.query.players.findMany({ where: eq(schema.players.userId, testUserId), - }); + }) const user2Players = await db.query.players.findMany({ where: eq(schema.players.userId, user2.id), - }); + }) - expect(user1Players).toHaveLength(1); - expect(user1Players[0].name).toBe("User 1 Player"); + expect(user1Players).toHaveLength(1) + expect(user1Players[0].name).toBe('User 1 Player') - expect(user2Players).toHaveLength(1); - expect(user2Players[0].name).toBe("User 2 Player"); + expect(user2Players).toHaveLength(1) + expect(user2Players[0].name).toBe('User 2 Player') } finally { - await db.delete(schema.users).where(eq(schema.users.id, user2.id)); + await db.delete(schema.users).where(eq(schema.users.id, user2.id)) } - }); - }); -}); + }) + }) +}) diff --git a/apps/web/drizzle/meta/0005_snapshot.json b/apps/web/drizzle/meta/0005_snapshot.json new file mode 100644 index 00000000..7d03f5b4 --- /dev/null +++ b/apps/web/drizzle/meta/0005_snapshot.json @@ -0,0 +1,655 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "840cc055-2f32-4ae4-81ff-255641cbbd1c", + "prevId": "cbd94d51-1454-467c-a471-ccbfca886a1a", + "tables": { + "abacus_settings": { + "name": "abacus_settings", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "color_scheme": { + "name": "color_scheme", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'place-value'" + }, + "bead_shape": { + "name": "bead_shape", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'diamond'" + }, + "color_palette": { + "name": "color_palette", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "hide_inactive_beads": { + "name": "hide_inactive_beads", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "colored_numerals": { + "name": "colored_numerals", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "scale_factor": { + "name": "scale_factor", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "show_numbers": { + "name": "show_numbers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "animated": { + "name": "animated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "interactive": { + "name": "interactive", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "gestures": { + "name": "gestures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sound_enabled": { + "name": "sound_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "sound_volume": { + "name": "sound_volume", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0.8 + } + }, + "indexes": {}, + "foreignKeys": { + "abacus_settings_user_id_users_id_fk": { + "name": "abacus_settings_user_id_users_id_fk", + "tableFrom": "abacus_settings", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "arcade_rooms": { + "name": "arcade_rooms", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "code": { + "name": "code", + "type": "text(6)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "creator_name": { + "name": "creator_name", + "type": "text(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_activity": { + "name": "last_activity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ttl_minutes": { + "name": "ttl_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 60 + }, + "is_locked": { + "name": "is_locked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "game_name": { + "name": "game_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "game_config": { + "name": "game_config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'lobby'" + }, + "current_session_id": { + "name": "current_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "total_games_played": { + "name": "total_games_played", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "arcade_rooms_code_unique": { + "name": "arcade_rooms_code_unique", + "columns": ["code"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "arcade_sessions": { + "name": "arcade_sessions", + "columns": { + "room_id": { + "name": "room_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "current_game": { + "name": "current_game", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "game_url": { + "name": "game_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "game_state": { + "name": "game_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active_players": { + "name": "active_players", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + } + }, + "indexes": {}, + "foreignKeys": { + "arcade_sessions_room_id_arcade_rooms_id_fk": { + "name": "arcade_sessions_room_id_arcade_rooms_id_fk", + "tableFrom": "arcade_sessions", + "tableTo": "arcade_rooms", + "columnsFrom": ["room_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "arcade_sessions_user_id_users_id_fk": { + "name": "arcade_sessions_user_id_users_id_fk", + "tableFrom": "arcade_sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "players": { + "name": "players", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emoji": { + "name": "emoji", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "players_user_id_idx": { + "name": "players_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "players_user_id_users_id_fk": { + "name": "players_user_id_users_id_fk", + "tableFrom": "players", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "room_members": { + "name": "room_members", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "room_id": { + "name": "room_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_creator": { + "name": "is_creator", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "joined_at": { + "name": "joined_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_seen": { + "name": "last_seen", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_online": { + "name": "is_online", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": { + "idx_room_members_user_id_unique": { + "name": "idx_room_members_user_id_unique", + "columns": ["user_id"], + "isUnique": true + } + }, + "foreignKeys": { + "room_members_room_id_arcade_rooms_id_fk": { + "name": "room_members_room_id_arcade_rooms_id_fk", + "tableFrom": "room_members", + "tableTo": "arcade_rooms", + "columnsFrom": ["room_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_stats": { + "name": "user_stats", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "games_played": { + "name": "games_played", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "total_wins": { + "name": "total_wins", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "favorite_game_type": { + "name": "favorite_game_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "best_time": { + "name": "best_time", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "highest_accuracy": { + "name": "highest_accuracy", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_users_id_fk": { + "name": "user_stats_user_id_users_id_fk", + "tableFrom": "user_stats", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "guest_id": { + "name": "guest_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "upgraded_at": { + "name": "upgraded_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "users_guest_id_unique": { + "name": "users_guest_id_unique", + "columns": ["guest_id"], + "isUnique": true + }, + "users_email_unique": { + "name": "users_email_unique", + "columns": ["email"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/apps/web/src/lib/arcade/session-manager.ts b/apps/web/src/lib/arcade/session-manager.ts index b1d7ff15..c67382be 100644 --- a/apps/web/src/lib/arcade/session-manager.ts +++ b/apps/web/src/lib/arcade/session-manager.ts @@ -353,6 +353,56 @@ export async function updateSessionActivity(guestId: string): Promise { .where(eq(schema.arcadeSessions.roomId, session.roomId)) } +/** + * Update session's active players (only if game hasn't started) + * Used when new members join a room + * @param roomId - The room ID (PRIMARY KEY) + * @param playerIds - Array of player IDs to set as active players + * @returns true if updated, false if game already started or session not found + */ +export async function updateSessionActivePlayers( + roomId: string, + playerIds: string[] +): Promise { + const session = await getArcadeSessionByRoom(roomId) + if (!session) return false + + // Only update if game is in setup phase (not started yet) + const gameState = session.gameState as any + if (gameState.gamePhase !== 'setup') { + console.log('[Session Manager] Cannot update activePlayers - game already started:', { + roomId, + gamePhase: gameState.gamePhase, + }) + return false + } + + // Update both the session's activePlayers field AND the game state + const updatedGameState = { + ...gameState, + activePlayers: playerIds, + } + + const now = new Date() + await db + .update(schema.arcadeSessions) + .set({ + activePlayers: playerIds as any, + gameState: updatedGameState as any, + lastActivityAt: now, + version: session.version + 1, + }) + .where(eq(schema.arcadeSessions.roomId, roomId)) + + console.log('[Session Manager] Updated session activePlayers:', { + roomId, + playerIds, + count: playerIds.length, + }) + + return true +} + /** * Clean up expired sessions (should be called periodically) */ diff --git a/apps/web/src/socket-server.ts b/apps/web/src/socket-server.ts index 63a584ec..be39ffb2 100644 --- a/apps/web/src/socket-server.ts +++ b/apps/web/src/socket-server.ts @@ -8,10 +8,11 @@ import { getArcadeSession, getArcadeSessionByRoom, updateSessionActivity, + updateSessionActivePlayers, } from './lib/arcade/session-manager' import { createRoom, getRoomById } from './lib/arcade/room-manager' import { getRoomMembers, getUserRooms, setMemberOnline } from './lib/arcade/room-membership' -import { getRoomActivePlayers } from './lib/arcade/player-manager' +import { getRoomActivePlayers, getRoomPlayerIds } from './lib/arcade/player-manager' import type { GameMove, GameName } from './lib/arcade/validation' import { matchingGameValidator } from './lib/arcade/validation/MatchingGameValidator' @@ -71,6 +72,10 @@ export function initializeSocketServer(httpServer: HTTPServer) { // Get the room to determine game type and config const room = await getRoomById(roomId) if (room) { + // Fetch all active player IDs from room members (respects isActive flag) + const roomPlayerIds = await getRoomPlayerIds(roomId) + console.log('[join-arcade-session] Room active players:', roomPlayerIds) + // Get initial state from validator (starts in "setup" phase) const initialState = matchingGameValidator.getInitialState({ difficulty: (room.gameConfig as any)?.difficulty || 6, @@ -83,7 +88,7 @@ export function initializeSocketServer(httpServer: HTTPServer) { gameName: room.gameName as GameName, gameUrl: '/arcade/room', initialState, - activePlayers: [], // No active players yet (setup phase) + activePlayers: roomPlayerIds, // Include all room members' active players roomId: room.id, }) @@ -91,6 +96,7 @@ export function initializeSocketServer(httpServer: HTTPServer) { roomId, sessionId: session.userId, gamePhase: (session.gameState as any).gamePhase, + activePlayersCount: roomPlayerIds.length, }) } } @@ -307,6 +313,30 @@ export function initializeSocketServer(httpServer: HTTPServer) { memberPlayersObj[uid] = players } + // Update session's activePlayers if game hasn't started yet + // This ensures new members' players are included in the session + const roomPlayerIds = await getRoomPlayerIds(roomId) + const sessionUpdated = await updateSessionActivePlayers(roomId, roomPlayerIds) + + if (sessionUpdated) { + console.log(`🎼 Updated session activePlayers for room ${roomId}:`, { + playerCount: roomPlayerIds.length, + }) + + // Broadcast updated session state to all users in the game room + const updatedSession = await getArcadeSessionByRoom(roomId) + if (updatedSession) { + io!.to(`game:${roomId}`).emit('session-state', { + gameState: updatedSession.gameState, + currentGame: updatedSession.currentGame, + gameUrl: updatedSession.gameUrl, + activePlayers: updatedSession.activePlayers, + version: updatedSession.version, + }) + console.log(`📱 Broadcasted updated session state to game room ${roomId}`) + } + } + // Send current room state to the joining user socket.emit('room-joined', { roomId, @@ -378,6 +408,30 @@ export function initializeSocketServer(httpServer: HTTPServer) { memberPlayersObj[uid] = players } + // Update session's activePlayers if game hasn't started yet + const roomPlayerIds = await getRoomPlayerIds(roomId) + const sessionUpdated = await updateSessionActivePlayers(roomId, roomPlayerIds) + + if (sessionUpdated) { + console.log(`🎼 Updated session activePlayers after player toggle:`, { + roomId, + playerCount: roomPlayerIds.length, + }) + + // Broadcast updated session state to all users in the game room + const updatedSession = await getArcadeSessionByRoom(roomId) + if (updatedSession) { + io!.to(`game:${roomId}`).emit('session-state', { + gameState: updatedSession.gameState, + currentGame: updatedSession.currentGame, + gameUrl: updatedSession.gameUrl, + activePlayers: updatedSession.activePlayers, + version: updatedSession.version, + }) + console.log(`📱 Broadcasted updated session state to game room ${roomId}`) + } + } + // Broadcast to all members in the room (including sender) io!.to(`room:${roomId}`).emit('room-players-updated', { roomId,