From 7d07bb78baba53e86bdc66d8aba935ed4800a439 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Thu, 16 Apr 2026 17:09:43 -0600 Subject: [PATCH] feat(server): add room registry with codes + tokens (P4.3) --- packages/server/src/rooms.test.ts | 192 ++++++++++++++++++++++++++++++ packages/server/src/rooms.ts | 185 ++++++++++++++++++++++++++++ 2 files changed, 377 insertions(+) create mode 100644 packages/server/src/rooms.test.ts create mode 100644 packages/server/src/rooms.ts diff --git a/packages/server/src/rooms.test.ts b/packages/server/src/rooms.test.ts new file mode 100644 index 0000000..62f92ae --- /dev/null +++ b/packages/server/src/rooms.test.ts @@ -0,0 +1,192 @@ +import { describe, it, expect } from "vitest"; +import { RoomRegistry, __test__ } from "./rooms.js"; + +const CODE_RE = /^[A-Z0-9]{6}$/; +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +describe("RoomRegistry.createRoom", () => { + it("returns a 6-char uppercase alphanumeric code", () => { + const reg = new RoomRegistry(); + const { code } = reg.createRoom(); + expect(code).toMatch(CODE_RE); + }); + + it("assigns white to the creator and returns a UUID v4 token", () => { + const reg = new RoomRegistry(); + const { code, token, color } = reg.createRoom(); + expect(color).toBe("white"); + expect(token).toMatch(UUID_RE); + const player = reg.getPlayerByToken(code, token); + expect(player).toBeDefined(); + expect(player?.color).toBe("white"); + expect(player?.connected).toBe(true); + expect(player?.lastSeq).toBe(0); + }); + + it("stores rulesetIds defensively (caller mutation does not leak)", () => { + const reg = new RoomRegistry(); + const rules = ["pawns-move-backward"]; + const { code } = reg.createRoom(rules); + rules.push("piece-hp"); + const room = reg.getRoom(code); + expect(room?.rulesetIds).toEqual(["pawns-move-backward"]); + }); + + it("defaults rulesetIds to an empty array when omitted", () => { + const reg = new RoomRegistry(); + const { code } = reg.createRoom(); + expect(reg.getRoom(code)?.rulesetIds).toEqual([]); + }); +}); + +describe("RoomRegistry.joinRoom", () => { + it("assigns black to the second player and echoes active rules", () => { + const reg = new RoomRegistry(); + const { code } = reg.createRoom(["pawns-move-backward"]); + const result = reg.joinRoom(code); + if ("error" in result) { + throw new Error(`expected success, got ${result.error}`); + } + expect(result.color).toBe("black"); + expect(result.token).toMatch(UUID_RE); + expect(result.activeRules).toEqual(["pawns-move-backward"]); + const player = reg.getPlayerByToken(code, result.token); + expect(player?.color).toBe("black"); + expect(player?.connected).toBe(true); + }); + + it("returns ROOM_NOT_FOUND for an unknown code", () => { + const reg = new RoomRegistry(); + const result = reg.joinRoom("ZZZZZZ"); + expect(result).toEqual({ error: "ROOM_NOT_FOUND" }); + }); + + it("returns ROOM_FULL when a third player tries to join", () => { + const reg = new RoomRegistry(); + const { code } = reg.createRoom(); + const second = reg.joinRoom(code); + expect("error" in second).toBe(false); + const third = reg.joinRoom(code); + expect(third).toEqual({ error: "ROOM_FULL" }); + }); + + it("issues distinct tokens for white and black", () => { + const reg = new RoomRegistry(); + const { code, token: whiteToken } = reg.createRoom(); + const join = reg.joinRoom(code); + if ("error" in join) throw new Error(join.error); + expect(join.token).not.toBe(whiteToken); + }); +}); + +describe("RoomRegistry.leaveRoom", () => { + it("removes the player and returns true", () => { + const reg = new RoomRegistry(); + const { code, token } = reg.createRoom(); + const join = reg.joinRoom(code); + if ("error" in join) throw new Error(join.error); + expect(reg.leaveRoom(code, join.token)).toBe(true); + expect(reg.getPlayerByToken(code, join.token)).toBeUndefined(); + // White is still here, so the room lives on. + expect(reg.getRoom(code)).toBeDefined(); + expect(reg.getPlayerByToken(code, token)).toBeDefined(); + }); + + it("destroys the room once the last player leaves", () => { + const reg = new RoomRegistry(); + const { code, token } = reg.createRoom(); + expect(reg.leaveRoom(code, token)).toBe(true); + expect(reg.getRoom(code)).toBeUndefined(); + }); + + it("returns false when the room does not exist", () => { + const reg = new RoomRegistry(); + expect(reg.leaveRoom("ZZZZZZ", "nope")).toBe(false); + }); + + it("returns false when the token does not match any player", () => { + const reg = new RoomRegistry(); + const { code } = reg.createRoom(); + expect(reg.leaveRoom(code, "not-a-real-token")).toBe(false); + }); +}); + +describe("RoomRegistry.getPlayerByToken", () => { + it("returns the matching player", () => { + const reg = new RoomRegistry(); + const { code, token } = reg.createRoom(); + const player = reg.getPlayerByToken(code, token); + expect(player?.token).toBe(token); + expect(player?.color).toBe("white"); + }); + + it("returns undefined for an unknown room", () => { + const reg = new RoomRegistry(); + expect(reg.getPlayerByToken("ZZZZZZ", "any")).toBeUndefined(); + }); + + it("returns undefined for an unknown token", () => { + const reg = new RoomRegistry(); + const { code } = reg.createRoom(); + expect(reg.getPlayerByToken(code, "not-a-real-token")).toBeUndefined(); + }); +}); + +describe("RoomRegistry connection state", () => { + it("markDisconnected flips connected to false", () => { + const reg = new RoomRegistry(); + const { code, token } = reg.createRoom(); + reg.markDisconnected(code, token); + expect(reg.getPlayerByToken(code, token)?.connected).toBe(false); + }); + + it("markConnected flips connected back to true", () => { + const reg = new RoomRegistry(); + const { code, token } = reg.createRoom(); + reg.markDisconnected(code, token); + reg.markConnected(code, token); + expect(reg.getPlayerByToken(code, token)?.connected).toBe(true); + }); + + it("no-ops silently on unknown rooms/tokens", () => { + const reg = new RoomRegistry(); + expect(() => { + reg.markDisconnected("ZZZZZZ", "x"); + }).not.toThrow(); + expect(() => { + reg.markConnected("ZZZZZZ", "x"); + }).not.toThrow(); + }); +}); + +describe("RoomRegistry code generator", () => { + it("produces 1000 unique codes (fuzz)", () => { + const codes = new Set(); + for (let i = 0; i < 1000; i++) { + codes.add(__test__.generateCode()); + } + expect(codes.size).toBe(1000); + }); + + it("only emits characters from the declared alphabet", () => { + const alphabet = new Set(__test__.CODE_ALPHABET); + for (let i = 0; i < 200; i++) { + const code = __test__.generateCode(); + expect(code.length).toBe(__test__.CODE_LENGTH); + for (const ch of code) { + expect(alphabet.has(ch)).toBe(true); + } + } + }); + + it("createRoom never returns duplicate codes across 500 rooms", () => { + const reg = new RoomRegistry(); + const codes = new Set(); + for (let i = 0; i < 500; i++) { + const { code } = reg.createRoom(); + codes.add(code); + } + expect(codes.size).toBe(500); + }); +}); diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts new file mode 100644 index 0000000..5e5e9ae --- /dev/null +++ b/packages/server/src/rooms.ts @@ -0,0 +1,185 @@ +// In-memory room registry for the chess server. +// +// Rooms hold up to two players identified by opaque UUID v4 tokens. Codes are +// six-character upper-case alphanumeric strings ([A-Z0-9]) drawn from a CSPRNG. +// Nothing here persists across restarts — PROTOCOL.md §Auth & Rooms mandates +// in-memory-only storage. +import type { Color } from "./protocol.js"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface RoomPlayer { + /** UUID v4 assigned at room.create / room.join; authenticates this slot. */ + token: string; + color: Color; + /** False during the 60-second reconnect grace window. */ + connected: boolean; + /** Highest server `seq` the client has acknowledged (for reconnect deltas). */ + lastSeq: number; +} + +export interface Room { + /** 6-char uppercase [A-Z0-9]. */ + code: string; + /** token → player. Using a Map keeps lookup O(1) and keyed on the secret. */ + players: Map; + /** Unix milliseconds the room was created — used for TTL/eviction later. */ + createdAt: number; + /** Rule presets activated for this game (from room.create payload). */ + rulesetIds: string[]; +} + +export type JoinResult = + | { token: string; color: "black"; activeRules: string[] } + | { error: "ROOM_NOT_FOUND" | "ROOM_FULL" }; + +// --------------------------------------------------------------------------- +// Code generation +// --------------------------------------------------------------------------- + +const CODE_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; +const CODE_LENGTH = 6; +/** Upper bound on how many times we'll retry a collision before bailing. */ +const CODE_COLLISION_BUDGET = 16; + +/** + * Generate a 6-char [A-Z0-9] code by drawing bytes from crypto.getRandomValues. + * + * We intentionally avoid slicing UUID hex because that alphabet is only 16 + * chars (0-9a-f) — narrower than our 36-char spec and thus more collision + * prone at 6 chars. Using rejection sampling on 8-bit bytes against a 36-char + * alphabet keeps the distribution uniform (36 divides evenly into 252). + */ +function generateCode(): string { + const out: string[] = []; + // Request extra bytes up front so a few rejections don't force another + // syscall. 16 bytes yields ~14 usable draws on average. + const buf = new Uint8Array(16); + let i = 0; + crypto.getRandomValues(buf); + while (out.length < CODE_LENGTH) { + if (i >= buf.length) { + crypto.getRandomValues(buf); + i = 0; + } + const byte = buf[i++]!; + // 252 = 36 * 7; any byte >= 252 is rejected to keep the mapping uniform. + if (byte >= 252) continue; + out.push(CODE_ALPHABET[byte % CODE_ALPHABET.length]!); + } + return out.join(""); +} + +// --------------------------------------------------------------------------- +// RoomRegistry +// --------------------------------------------------------------------------- + +export class RoomRegistry { + private readonly rooms = new Map(); + + /** + * Create a fresh room. The caller becomes white and receives a UUID v4 + * token that authenticates every subsequent message. + */ + createRoom( + rulesetIds: string[] = [], + ): { code: string; token: string; color: "white" } { + const code = this.allocateCode(); + const token = crypto.randomUUID(); + const player: RoomPlayer = { + token, + color: "white", + connected: true, + lastSeq: 0, + }; + const room: Room = { + code, + players: new Map([[token, player]]), + createdAt: Date.now(), + // Defensive copy — callers shouldn't be able to mutate our state. + rulesetIds: [...rulesetIds], + }; + this.rooms.set(code, room); + return { code, token, color: "white" }; + } + + /** + * Join an existing room as black. Returns a discriminated result; callers + * map the error codes onto the protocol's `ROOM_NOT_FOUND` / `ROOM_FULL`. + */ + joinRoom(code: string): JoinResult { + const room = this.rooms.get(code); + if (!room) return { error: "ROOM_NOT_FOUND" }; + if (room.players.size >= 2) return { error: "ROOM_FULL" }; + const token = crypto.randomUUID(); + const player: RoomPlayer = { + token, + color: "black", + connected: true, + lastSeq: 0, + }; + room.players.set(token, player); + return { + token, + color: "black", + // Defensive copy so joiners can't mutate the room's ruleset list. + activeRules: [...room.rulesetIds], + }; + } + + /** + * Remove a player from a room. Returns true iff both room and player + * existed. If the room becomes empty it is destroyed immediately — the + * 60-second grace window is a *connection* concern handled elsewhere via + * markDisconnected, not an empty-room concern. + */ + leaveRoom(code: string, token: string): boolean { + const room = this.rooms.get(code); + if (!room) return false; + const removed = room.players.delete(token); + if (removed && room.players.size === 0) { + this.rooms.delete(code); + } + return removed; + } + + getRoom(code: string): Room | undefined { + return this.rooms.get(code); + } + + getPlayerByToken(code: string, token: string): RoomPlayer | undefined { + return this.rooms.get(code)?.players.get(token); + } + + /** Mark a slot disconnected — starts the reconnect grace window. */ + markDisconnected(code: string, token: string): void { + const player = this.getPlayerByToken(code, token); + if (player) player.connected = false; + } + + /** Mark a slot reconnected after the client re-auths with its token. */ + markConnected(code: string, token: string): void { + const player = this.getPlayerByToken(code, token); + if (player) player.connected = true; + } + + /** + * Draw codes until we find one not currently in use. Collisions are + * astronomically rare (36^6 ≈ 2.2B) but we still bound the loop so a + * pathological RNG can't wedge the server. + */ + private allocateCode(): string { + for (let attempt = 0; attempt < CODE_COLLISION_BUDGET; attempt++) { + const code = generateCode(); + if (!this.rooms.has(code)) return code; + } + throw new Error( + `RoomRegistry: exhausted ${String(CODE_COLLISION_BUDGET)} code allocation attempts`, + ); + } +} + +// Exported for tests that want to assert on the raw generator. +export const __test__ = { generateCode, CODE_ALPHABET, CODE_LENGTH };