fix: populate session activePlayers from room members on join

**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 <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-10-10 15:17:45 -05:00
parent 738dd9a0d1
commit 2d00939f1b
4 changed files with 1032 additions and 234 deletions

View File

@@ -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))
}
});
});
});
})
})
})

View File

@@ -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": {}
}
}

View File

@@ -353,6 +353,56 @@ export async function updateSessionActivity(guestId: string): Promise<void> {
.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<boolean> {
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)
*/

View File

@@ -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,