implement foundation + room system (Plan 1 of 5)

Bun workspace monorepo with shared, server, client packages.
Server: Hono + @hono/node-ws, Drizzle + PostgreSQL, in-memory room manager
with WebSocket broadcasting, HTTP room creation, DB persistence layer.
Client: React 19 + Vite + Tailwind v4 + shadcn/ui, TanStack Router with
landing/display/host/player routes, Zustand store, WebSocket connection hook.
20 tests passing (room manager unit + WS integration).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 12:52:32 +01:00
parent cd993e032d
commit ff311b5fac
54 changed files with 3964 additions and 0 deletions
+175
View File
@@ -0,0 +1,175 @@
import { describe, expect, it, beforeEach } from "vitest"
import { RoomManager } from "../src/rooms/room-manager"
import type { Act } from "@celebrate-esc/shared"
describe("RoomManager", () => {
let manager: RoomManager
beforeEach(() => {
manager = new RoomManager()
})
describe("createRoom", () => {
it("returns a 4-character room code and session ID", () => {
const result = manager.createRoom("Host")
expect(result.code).toMatch(/^[A-Z0-9]{4}$/)
expect(result.sessionId).toBeDefined()
expect(result.sessionId.length).toBe(36) // UUID
})
it("creates the host as a player in the room", () => {
const { code, sessionId } = manager.createRoom("Host")
const room = manager.getRoom(code)
expect(room).toBeDefined()
expect(room!.players).toHaveLength(1)
expect(room!.players[0]!.displayName).toBe("Host")
expect(room!.players[0]!.isHost).toBe(true)
expect(room!.players[0]!.sessionId).toBe(sessionId)
})
it("starts in lobby state", () => {
const { code } = manager.createRoom("Host")
const room = manager.getRoom(code)
expect(room!.currentAct).toBe("lobby")
})
it("generates unique room codes", () => {
const codes = new Set<string>()
for (let i = 0; i < 50; i++) {
const { code } = manager.createRoom(`Host ${i}`)
codes.add(code)
}
expect(codes.size).toBe(50)
})
})
describe("joinRoom", () => {
it("adds a player to an existing room", () => {
const { code } = manager.createRoom("Host")
const result = manager.joinRoom(code, "Player 1")
expect("sessionId" in result).toBe(true)
if ("sessionId" in result) {
const room = manager.getRoom(code)
expect(room!.players).toHaveLength(2)
expect(room!.players[1]!.displayName).toBe("Player 1")
expect(room!.players[1]!.isHost).toBe(false)
}
})
it("rejects join if room not found", () => {
const result = manager.joinRoom("ZZZZ", "Player")
expect(result).toEqual({ error: "Room not found" })
})
it("rejects join if room has ended", () => {
const { code } = manager.createRoom("Host")
// Force room to ended state
manager.advanceAct(code, manager.getRoom(code)!.hostSessionId)
manager.advanceAct(code, manager.getRoom(code)!.hostSessionId)
manager.advanceAct(code, manager.getRoom(code)!.hostSessionId)
manager.advanceAct(code, manager.getRoom(code)!.hostSessionId)
const result = manager.joinRoom(code, "Late Player")
expect(result).toEqual({ error: "Room has ended" })
})
it("rejects join if display name is taken", () => {
const { code } = manager.createRoom("Host")
manager.joinRoom(code, "Player 1")
const result = manager.joinRoom(code, "Player 1")
expect(result).toEqual({ error: "Name already taken" })
})
it("rejects join if room is full (10 players)", () => {
const { code } = manager.createRoom("Host")
for (let i = 1; i <= 9; i++) {
manager.joinRoom(code, `Player ${i}`)
}
const result = manager.joinRoom(code, "Player 10")
expect(result).toEqual({ error: "Room is full" })
})
})
describe("advanceAct", () => {
it("advances through acts in order", () => {
const { code } = manager.createRoom("Host")
const room = manager.getRoom(code)!
const hostSession = room.hostSessionId
const expectedSequence: Act[] = ["act1", "act2", "act3", "ended"]
for (const expected of expectedSequence) {
const result = manager.advanceAct(code, hostSession)
expect(result).toEqual({ newAct: expected })
expect(manager.getRoom(code)!.currentAct).toBe(expected)
}
})
it("cannot advance past ended", () => {
const { code } = manager.createRoom("Host")
const room = manager.getRoom(code)!
// Advance to ended
for (let i = 0; i < 4; i++) {
manager.advanceAct(code, room.hostSessionId)
}
const result = manager.advanceAct(code, room.hostSessionId)
expect(result).toEqual({ error: "Room has already ended" })
})
it("rejects advance from non-host", () => {
const { code } = manager.createRoom("Host")
const joinResult = manager.joinRoom(code, "Player")
if ("sessionId" in joinResult) {
const result = manager.advanceAct(code, joinResult.sessionId)
expect(result).toEqual({ error: "Only the host can advance acts" })
}
})
})
describe("endRoom", () => {
it("sets room to ended state", () => {
const { code } = manager.createRoom("Host")
const room = manager.getRoom(code)!
const result = manager.endRoom(code, room.hostSessionId)
expect(result).toEqual({ success: true })
expect(manager.getRoom(code)!.currentAct).toBe("ended")
})
it("rejects end from non-host", () => {
const { code } = manager.createRoom("Host")
const joinResult = manager.joinRoom(code, "Player")
if ("sessionId" in joinResult) {
const result = manager.endRoom(code, joinResult.sessionId)
expect(result).toEqual({ error: "Only the host can end the room" })
}
})
})
describe("getRoom", () => {
it("returns null for non-existent room", () => {
expect(manager.getRoom("ZZZZ")).toBeNull()
})
it("returns serialized room state", () => {
const { code } = manager.createRoom("Host")
const room = manager.getRoom(code)
expect(room).toBeDefined()
expect(room!.code).toBe(code)
expect(room!.currentAct).toBe("lobby")
expect(Array.isArray(room!.players)).toBe(true)
})
})
describe("reconnect", () => {
it("re-identifies an existing player by session ID", () => {
const { code, sessionId } = manager.createRoom("Host")
const result = manager.reconnectPlayer(code, sessionId)
expect(result).toEqual({ success: true, playerId: expect.any(String) })
})
it("rejects reconnect with unknown session ID", () => {
const { code } = manager.createRoom("Host")
const result = manager.reconnectPlayer(code, "00000000-0000-0000-0000-000000000000")
expect(result).toEqual({ error: "Session not found in this room" })
})
})
})
+108
View File
@@ -0,0 +1,108 @@
import { describe, expect, it, afterEach, beforeEach } from "vitest"
import { serve } from "@hono/node-server"
import { app, injectWebSocket } from "../src/app"
import { registerWebSocketRoutes } from "../src/ws/handler"
import { roomManager } from "../src/rooms/index"
// Register WS routes once
registerWebSocketRoutes()
let server: ReturnType<typeof serve>
function waitForMessage(ws: WebSocket): Promise<unknown> {
return new Promise((resolve) => {
ws.addEventListener(
"message",
(event) => {
resolve(JSON.parse(event.data as string))
},
{ once: true },
)
})
}
function waitForOpen(ws: WebSocket): Promise<void> {
return new Promise((resolve) => {
if (ws.readyState === WebSocket.OPEN) {
resolve()
} else {
ws.addEventListener("open", () => resolve(), { once: true })
}
})
}
describe("WebSocket handler", () => {
let port: number
beforeEach(async () => {
roomManager.reset()
port = 3100 + Math.floor(Math.random() * 900)
server = serve({ fetch: app.fetch, port })
injectWebSocket(server)
})
afterEach(() => {
server.close()
})
it("creates a room via HTTP and connects via WebSocket", async () => {
// Create room via HTTP
const res = await fetch(`http://localhost:${port}/rooms`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ displayName: "Host" }),
})
const { data } = (await res.json()) as { data: { code: string; sessionId: string } }
expect(data.code).toMatch(/^[A-Z0-9]{4}$/)
// Connect as host via WebSocket
const ws = new WebSocket(`ws://localhost:${port}/ws/${data.code}?sessionId=${data.sessionId}`)
await waitForOpen(ws)
const msg = (await waitForMessage(ws)) as { type: string; room: { code: string } }
expect(msg.type).toBe("room_state")
expect(msg.room.code).toBe(data.code)
ws.close()
})
it("player joins room via WebSocket", async () => {
// Create room
const res = await fetch(`http://localhost:${port}/rooms`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ displayName: "Host" }),
})
const { data } = (await res.json()) as { data: { code: string; sessionId: string } }
// Connect host
const hostWs = new WebSocket(`ws://localhost:${port}/ws/${data.code}?sessionId=${data.sessionId}`)
await waitForOpen(hostWs)
await waitForMessage(hostWs) // room_state
// Connect player (no sessionId)
const playerWs = new WebSocket(`ws://localhost:${port}/ws/${data.code}`)
await waitForOpen(playerWs)
await waitForMessage(playerWs) // initial room_state
// Set up listeners BEFORE sending to avoid race conditions
const playerMsgPromise = waitForMessage(playerWs)
const hostMsgPromise = waitForMessage(hostWs)
// Player sends join_room
playerWs.send(JSON.stringify({ type: "join_room", displayName: "Player 1" }))
// Player receives room_state with sessionId
const playerMsg = (await playerMsgPromise) as { type: string; sessionId?: string }
expect(playerMsg.type).toBe("room_state")
expect(playerMsg.sessionId).toBeDefined()
// Host receives player_joined broadcast
const hostMsg = (await hostMsgPromise) as { type: string; player: { displayName: string } }
expect(hostMsg.type).toBe("player_joined")
expect(hostMsg.player.displayName).toBe("Player 1")
hostWs.close()
playerWs.close()
})
})