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:
@@ -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" })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user