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:
@@ -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))
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
655
apps/web/drizzle/meta/0005_snapshot.json
Normal file
655
apps/web/drizzle/meta/0005_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user