feat(server): add room registry with codes + tokens (P4.3)
This commit is contained in:
parent
817b4d95f3
commit
7d07bb78ba
2 changed files with 377 additions and 0 deletions
192
packages/server/src/rooms.test.ts
Normal file
192
packages/server/src/rooms.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
185
packages/server/src/rooms.ts
Normal file
185
packages/server/src/rooms.ts
Normal 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 };
|
||||
Loading…
Add table
Add a link
Reference in a new issue