feat(server): add room registry with codes + tokens (P4.3)

This commit is contained in:
Joey Yakimowich-Payne 2026-04-16 17:09:43 -06:00
commit 7d07bb78ba
No known key found for this signature in database
2 changed files with 377 additions and 0 deletions

View file

@ -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<string>();
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<string>();
for (let i = 0; i < 500; i++) {
const { code } = reg.createRoom();
codes.add(code);
}
expect(codes.size).toBe(500);
});
});

View file

@ -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<string, RoomPlayer>;
/** 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<string, Room>();
/**
* 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 };