feat(server): layout-aware room.create + resolved layout echoes (Phase C)

Extends the WebSocket protocol so clients can request a specific
starting layout when creating a room, and receive the resolved
layout echoed back on room.created / room.joined.

Protocol additions (Zod):
- room.create.payload.layout: optional discriminated union of
  { kind: "premade", id } | { kind: "fen", fen, name? }
                           | { kind: "custom", pieces, name? }
- room.created / room.joined: new optional 'layout' field with
  resolved { id, name, pieces } — present on new servers, absent
  on legacy ones (backward compat).
- LAYOUT_INVALID error code for validation failures.
- PiecePlacement + ResolvedLayout schemas.

Server implementation:
- New layouts.ts: resolveLayoutRequest() handles premade lookup,
  FEN parse, custom pieces. Chess960 picks a random seed server-
  side (the ultimate authority on 'which position'). Always runs
  validateLayout — invalid input returns LAYOUT_INVALID, never
  mutates room state.
- rooms.ts: Room.layout field + RoomRegistry.createRoom/joinRoom
  accept/return resolved layouts. Default is CLASSIC_LAYOUT.
- game-session.ts: GameSession + GameSessionRegistry.create
  accept StartingLayout; constructs ChessEngine({ layout }).
- broadcast.ts: handleRoomCreate resolves+validates first, rejects
  with LAYOUT_INVALID toast, otherwise threads layout into the
  room + session + response. handleRoomJoin echoes the room's
  stored layout to the joiner.

Chess package:
- packages/chess/src/index.ts exports LAYOUT_REGISTRY, all premade
  layouts, buildChess960Layout, toFen/fromFen, validateLayout,
  and the related types — so server can pull them without
  importing internal module paths.

Tests: 20 new tests (11 in protocol.test.ts for the layout union,
12 in layouts.test.ts for server-side resolution).

1011 tests passing; bun run check clean.

PROTOCOL.md updated with examples of all three layout kinds.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-18 20:01:01 -06:00
commit 174cad6ae7
No known key found for this signature in database
9 changed files with 595 additions and 13 deletions

View file

@ -38,3 +38,25 @@ export {
type PresetActivation,
type PresetScope,
} from "./presets/active-set.js";
// Starting Layouts — piece-placement abstractions orthogonal to
// preset rules. Consumed by the server for room.create layout
// resolution and by the client Lobby for premade selection.
export {
LAYOUT_REGISTRY,
CLASSIC_LAYOUT,
EMPTY_LAYOUT,
DUNSANY_LAYOUT,
MONSTER_LAYOUT,
PAWNS_ONLY_LAYOUT,
HORDE_LAYOUT,
KNIGHTMATE_LAYOUT,
CHESS960_SHIM,
buildChess960Layout,
toFen,
fromFen,
type PiecePlacement,
type StartingLayout,
type LayoutValidationResult,
} from "./layouts/index.js";
export { validateLayout } from "./layouts/validate.js";

View file

@ -54,11 +54,23 @@ Request payload:
```json
{
"rulesetIds": ["pawns-move-backward", "piece-hp"]
"rulesetIds": ["pawns-move-backward", "piece-hp"],
"layout": { "kind": "premade", "id": "dunsany" }
}
```
- `rulesetIds`: optional array of preset rule IDs to activate for this game
- `layout`: optional starting-layout selector. Discriminated union:
- `{ "kind": "premade", "id": "dunsany" }` — look up by registered id
(`classic`, `dunsany`, `monster`, `pawns-only`, `horde`,
`knightmate`, `chess960`, `empty`).
- `{ "kind": "fen", "fen": "rnbqkbnr/...", "name": "optional" }` — parse
the piece-placement field of a FEN string. Extra FEN fields
(side-to-move, castling, en-passant, clocks) are ignored.
- `{ "kind": "custom", "pieces": [...], "name": "optional" }` — direct
placements; each piece is `{ type, color, square, hasMoved? }`.
`square` is 0..63. Capped at 128 pieces.
- When `layout` is omitted, the server uses the Classic (FIDE) layout.
Response (Server → Client, type `room.created`):
@ -69,14 +81,26 @@ Response (Server → Client, type `room.created`):
"payload": {
"code": "ABC123",
"token": "550e8400-e29b-41d4-a716-446655440000",
"color": "white"
"color": "white",
"layout": {
"id": "dunsany",
"name": "Dunsany's Chess",
"pieces": [{ "type": "pawn", "color": "white", "square": 0 }, ...]
}
}
}
```
- `layout`: resolved starting layout echoed back. Present on all
`room.created` and `room.joined` responses from new servers; may be
absent on legacy (pre-layouts) servers.
Error cases:
- Server at capacity (too many rooms): `error` code `SERVER_FULL`
- Layout fails validation (bad king count, duplicate squares,
unknown premade id, malformed FEN): `error` code `LAYOUT_INVALID`.
Message field carries the human-readable reason.
### Message: room.join
@ -101,11 +125,19 @@ Response (Server → Client, type `room.joined`):
"code": "ABC123",
"token": "661f9500-f30c-52e5-b827-557766550111",
"color": "black",
"activeRules": ["pawns-move-backward"]
"activeRules": ["pawns-move-backward"],
"layout": {
"id": "dunsany",
"name": "Dunsany's Chess",
"pieces": [{ "type": "pawn", "color": "white", "square": 0 }, ...]
}
}
}
```
- `layout`: resolved layout used when the room was created. Late
joiners render the board from this. Optional for backward compat.
When second player joins, server broadcasts `game.state` to BOTH players (initial board state).
Error cases:

View file

@ -34,6 +34,7 @@ import {
} from "./protocol.js";
import { DEFAULT_GRACE_MS, reconnectManager } from "./reconnect.js";
import { RoomRegistry } from "./rooms.js";
import { resolveLayoutRequest, toResolvedLayout } from "./layouts.js";
// ---------------------------------------------------------------------------
// Per-connection data carried on ws.data
@ -271,16 +272,38 @@ function handleRoomCreate(
);
return;
}
// Resolve the layout request (premade lookup / FEN parse / custom
// pieces) and run validateLayout against it. Invalid layouts fail
// CREATE before any state is written.
const layoutResult = resolveLayoutRequest(payload.layout);
if (!layoutResult.ok) {
sendTo(ws, errorMessage("LAYOUT_INVALID", layoutResult.error, false));
return;
}
const resolvedLayout = layoutResult.layout;
const rulesetIds = payload.rulesetIds ?? [];
const { code, token, color } = roomRegistry.createRoom([...rulesetIds]);
sessionRegistry.create(code, rulesetIds);
const { code, token, color } = roomRegistry.createRoom(
[...rulesetIds],
resolvedLayout,
);
sessionRegistry.create(code, rulesetIds, resolvedLayout);
ws.data.roomCode = code;
ws.data.token = token;
setActiveRooms(roomRegistry.getRoomCount());
logger
.child({ clientId: ws.data.clientId, roomCode: code })
.info("room.create");
sendTo(ws, envelope("room.created", { code, token, color }));
sendTo(
ws,
envelope("room.created", {
code,
token,
color,
layout: toResolvedLayout(resolvedLayout),
}),
);
}
function handleRoomJoin(
@ -342,6 +365,7 @@ function handleRoomJoin(
token: result.token,
color: result.color,
activeRules: result.activeRules,
layout: toResolvedLayout(result.layout),
}),
);

View file

@ -18,6 +18,7 @@ import {
type GameResult,
type PieceColor,
type PieceType,
type StartingLayout,
} from "@paratype/chess";
// ---------------------------------------------------------------------------
@ -96,9 +97,16 @@ export class GameSession {
* as `scope=both, turnsRemaining=null` (permanent) on the engine's
* private ActivePresetSet. Invalid ids are silently skipped here
* the server validates them earlier at the protocol layer.
* @param layout optional starting layout. When undefined, engine
* defaults to CLASSIC_LAYOUT (FIDE). The server has already
* resolved and validated the layout before constructing the
* session, so this value is trusted.
*/
constructor(rulesetIds: readonly string[] = []) {
this.engine = new ChessEngine();
constructor(
rulesetIds: readonly string[] = [],
layout?: StartingLayout,
) {
this.engine = layout !== undefined ? new ChessEngine({ layout }) : new ChessEngine();
if (rulesetIds.length > 0) {
try {
this.engine.setActivePresets(
@ -255,14 +263,21 @@ export class GameSessionRegistry {
* Create and store a new session for `code`. Throws if a session for
* that code already exists callers should call delete() first if they
* intend to recycle a code (in practice room codes never recycle).
*
* `layout` is optional; when provided, the engine opens from it
* instead of the FIDE default.
*/
create(code: string, rulesetIds?: readonly string[]): GameSession {
create(
code: string,
rulesetIds?: readonly string[],
layout?: StartingLayout,
): GameSession {
if (this.sessions.has(code)) {
throw new Error(
`GameSessionRegistry: session already exists for code "${code}"`,
);
}
const session = new GameSession(rulesetIds ?? []);
const session = new GameSession(rulesetIds ?? [], layout);
this.sessions.set(code, session);
return session;
}

View file

@ -0,0 +1,143 @@
/**
* Server-side layout resolution tests.
*
* Covers the `resolveLayoutRequest` entry point that `handleRoomCreate`
* uses: premade lookup, FEN parse, custom pieces, error branches.
*/
import { describe, it, expect } from "vitest";
import { resolveLayoutRequest, toResolvedLayout } from "./layouts.js";
import "@paratype/chess"; // trigger layout registration side-effects
describe("resolveLayoutRequest()", () => {
it("undefined request → returns CLASSIC_LAYOUT", () => {
const result = resolveLayoutRequest(undefined);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.layout.id).toBe("classic");
expect(result.layout.pieces).toHaveLength(32);
}
});
it("premade id resolves to registered layout", () => {
const result = resolveLayoutRequest({ kind: "premade", id: "dunsany" });
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.layout.id).toBe("dunsany");
expect(result.layout.pieces).toHaveLength(48);
}
});
it("chess960 premade resolves to a valid Chess960 position", () => {
const result = resolveLayoutRequest({ kind: "premade", id: "chess960" });
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.layout.id).toBe("chess960");
expect(result.layout.pieces).toHaveLength(32);
}
});
it("unknown premade id errors", () => {
const result = resolveLayoutRequest({
kind: "premade",
id: "never-registered",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/unknown.*never-registered/i);
}
});
it("FEN kind parses FIDE starting position", () => {
const result = resolveLayoutRequest({
kind: "fen",
fen: "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR",
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.layout.pieces).toHaveLength(32);
expect(result.layout.source).toBe("custom");
}
});
it("invalid FEN errors", () => {
const result = resolveLayoutRequest({ kind: "fen", fen: "totally-not-a-fen" });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/invalid fen/i);
}
});
it("FEN without kings errors at validation step", () => {
const result = resolveLayoutRequest({ kind: "fen", fen: "8/8/8/8/8/8/8/8" });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/king/i);
}
});
it("custom pieces with king per side succeeds", () => {
const result = resolveLayoutRequest({
kind: "custom",
pieces: [
{ type: "king", color: "white", square: 4 },
{ type: "king", color: "black", square: 60 },
],
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.layout.pieces).toHaveLength(2);
}
});
it("custom pieces missing kings errors", () => {
const result = resolveLayoutRequest({
kind: "custom",
pieces: [{ type: "rook", color: "white", square: 0 }],
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/king/i);
}
});
it("custom pieces with duplicate squares errors", () => {
const result = resolveLayoutRequest({
kind: "custom",
pieces: [
{ type: "king", color: "white", square: 4 },
{ type: "king", color: "black", square: 60 },
{ type: "rook", color: "white", square: 0 },
{ type: "rook", color: "white", square: 0 },
],
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/duplicate/i);
}
});
});
describe("toResolvedLayout()", () => {
it("produces the wire shape for a resolved layout", () => {
const resolved = resolveLayoutRequest({ kind: "premade", id: "dunsany" });
expect(resolved.ok).toBe(true);
if (!resolved.ok) return;
const wire = toResolvedLayout(resolved.layout);
expect(wire.id).toBe("dunsany");
expect(wire.name).toBe("Dunsany's Chess");
expect(wire.pieces).toHaveLength(48);
});
it("produces a mutable pieces array (defensive copy)", () => {
const resolved = resolveLayoutRequest(undefined);
expect(resolved.ok).toBe(true);
if (!resolved.ok) return;
const wire = toResolvedLayout(resolved.layout);
// Should be able to push without mutating the original.
wire.pieces.push({ type: "queen", color: "white", square: 0 });
expect(resolved.layout.pieces).toHaveLength(32);
expect(wire.pieces).toHaveLength(33);
});
});

View file

@ -0,0 +1,135 @@
/**
* Server-side layout resolution + validation.
*
* Translates a protocol `LayoutRequest` (discriminated union of
* premade id / FEN / custom pieces) into a concrete
* `StartingLayout` and runs `validateLayout` against it. Returns
* either the layout or a human-readable error string.
*
* The server is AUTHORITATIVE on layout validation. Even if the
* client already ran validation locally (via the editor) we re-
* validate here a client could be running tampered code, or
* racing an older server with stricter rules.
*/
import {
LAYOUT_REGISTRY,
fromFen,
validateLayout,
type StartingLayout,
type PiecePlacement,
buildChess960Layout,
CLASSIC_LAYOUT,
} from "@paratype/chess";
import type { LayoutRequest } from "./protocol.js";
export type ResolveResult =
| { ok: true; layout: StartingLayout }
| { ok: false; error: string };
/**
* Resolve a wire-format layout request to a concrete StartingLayout
* and validate it.
*
* Returns `{ ok: false, error }` on any failure. The error string is
* formatted for inclusion in a `LAYOUT_INVALID` error message to the
* client keep it human-readable.
*/
export function resolveLayoutRequest(
request: LayoutRequest | undefined,
): ResolveResult {
if (request === undefined) {
return { ok: true, layout: CLASSIC_LAYOUT };
}
let resolved: StartingLayout;
switch (request.kind) {
case "premade": {
// Chess960 is a shim: the registry entry is FIDE, but selecting
// it implies "generate a fresh random position". We pick a seed
// here so the server — not the client — decides the layout. If
// a client wants a specific seed they should use {kind:"custom"}
// with buildChess960Layout output instead.
if (request.id === "chess960") {
resolved = buildChess960Layout(
Math.floor(Math.random() * 960),
);
break;
}
const layout = LAYOUT_REGISTRY.get(request.id);
if (layout === undefined) {
return {
ok: false,
error: `Unknown premade layout id: "${request.id}"`,
};
}
resolved = layout;
break;
}
case "fen": {
const { pieces, errors } = fromFen(request.fen);
if (errors.length > 0) {
return {
ok: false,
error: `Invalid FEN: ${errors.join("; ")}`,
};
}
resolved = {
id: "custom",
name: request.name ?? "Custom (FEN)",
description: "Imported from FEN.",
pieces,
source: "custom",
};
break;
}
case "custom": {
resolved = {
id: "custom",
name: request.name ?? "Custom Layout",
description: "Client-authored layout.",
pieces: request.pieces as readonly PiecePlacement[],
source: "custom",
};
break;
}
default: {
// Exhaustiveness guard — protocol schema enforces kind union,
// but belt-and-suspenders.
const _never: never = request;
return {
ok: false,
error: `Unknown layout request kind: ${JSON.stringify(_never)}`,
};
}
}
const { errors } = validateLayout(resolved);
if (errors.length > 0) {
return {
ok: false,
error: `Layout failed validation: ${errors.join("; ")}`,
};
}
return { ok: true, layout: resolved };
}
/**
* Convert a StartingLayout to the wire-format `ResolvedLayout`
* shape used in `room.created` / `room.joined` echoes.
*
* Strips the description (saves bytes; clients fetch from the
* registry if they need rich text) and hoists pieces into a mutable
* array.
*/
export function toResolvedLayout(layout: StartingLayout): {
id: string;
name: string;
pieces: PiecePlacement[];
} {
return {
id: layout.id,
name: layout.name,
pieces: [...layout.pieces],
};
}

View file

@ -503,3 +503,120 @@ describe("schema unions", () => {
}
});
});
// ---------------------------------------------------------------------------
// Layout field on room.create — discriminated union of premade / FEN / custom
// ---------------------------------------------------------------------------
describe("room.create layout payload", () => {
it("accepts { kind: 'premade', id } layout selection", () => {
const r = validateMessage({
...envelope,
type: "room.create",
payload: {
layout: { kind: "premade", id: "dunsany" },
},
});
expect(r.ok).toBe(true);
});
it("accepts { kind: 'fen', fen } layout selection", () => {
const r = validateMessage({
...envelope,
type: "room.create",
payload: {
layout: {
kind: "fen",
fen: "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR",
name: "Pasted FEN",
},
},
});
expect(r.ok).toBe(true);
});
it("accepts { kind: 'custom', pieces } layout selection", () => {
const r = validateMessage({
...envelope,
type: "room.create",
payload: {
layout: {
kind: "custom",
pieces: [
{ type: "king", color: "white", square: 4 },
{ type: "king", color: "black", square: 60 },
],
},
},
});
expect(r.ok).toBe(true);
});
it("rejects unknown layout kind", () => {
const r = validateMessage({
...envelope,
type: "room.create",
payload: { layout: { kind: "nonsense", id: "foo" } },
});
expect(r.ok).toBe(false);
});
it("rejects custom layout with out-of-range square", () => {
const r = validateMessage({
...envelope,
type: "room.create",
payload: {
layout: {
kind: "custom",
pieces: [{ type: "king", color: "white", square: 999 }],
},
},
});
expect(r.ok).toBe(false);
});
it("rejects custom layout with > 128 pieces (DoS cap)", () => {
const pieces = Array.from({ length: 129 }, (_, i) => ({
type: "pawn" as const,
color: "white" as const,
square: i % 64,
}));
const r = validateMessage({
...envelope,
type: "room.create",
payload: { layout: { kind: "custom", pieces } },
});
expect(r.ok).toBe(false);
});
it("room.create without layout still parses (backward compat)", () => {
const r = validateMessage({
...envelope,
type: "room.create",
payload: {},
});
expect(r.ok).toBe(true);
});
it("room.created echoes resolved layout", () => {
const r = validateMessage({
...envelope,
seq: 1,
type: "room.created",
payload: {
code: "ABC123",
token: UUID,
color: "white",
layout: {
id: "dunsany",
name: "Dunsany's Chess",
pieces: [
{ type: "king", color: "white", square: 4 },
{ type: "king", color: "black", square: 60 },
],
},
},
});
expect(r.ok).toBe(true);
});
});

View file

@ -49,6 +49,10 @@ export const ErrorCodeSchema = z.enum([
"MSG_TOO_LARGE",
"BAD_TOKEN",
"INVALID_MESSAGE",
// Room creation rejected because the requested starting layout
// failed validation (bad king count, duplicate squares, etc.) or
// specified an unknown premade id / malformed FEN.
"LAYOUT_INVALID",
]);
export type ErrorCode = z.infer<typeof ErrorCodeSchema>;
@ -93,8 +97,69 @@ export type Envelope = z.infer<typeof EnvelopeSchema>;
// Client → Server payloads
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Starting Layout payloads — orthogonal to rule presets (rulesetIds).
//
// A room may be created with a specific STARTING LAYOUT (piece placement).
// Three encodings are supported:
// - `{ kind: "premade", id }` → look up in server's LAYOUT_REGISTRY
// - `{ kind: "fen", fen, name? }` → parse piece-placement FEN
// - `{ kind: "custom", pieces, name? }` → client-authored placements
//
// If omitted, the server uses CLASSIC_LAYOUT (standard FIDE).
// ---------------------------------------------------------------------------
const PieceTypeSchema = z.enum([
"pawn",
"knight",
"bishop",
"rook",
"queen",
"king",
]);
export const PiecePlacementSchema = z.object({
type: PieceTypeSchema,
color: ColorSchema,
square: z.number().int().min(0).max(63),
hasMoved: z.boolean().optional(),
});
export type PiecePlacementWire = z.infer<typeof PiecePlacementSchema>;
export const LayoutRequestSchema = z.discriminatedUnion("kind", [
z.object({
kind: z.literal("premade"),
id: z.string().min(1),
}),
z.object({
kind: z.literal("fen"),
fen: z.string().min(1),
name: z.string().optional(),
}),
z.object({
kind: z.literal("custom"),
// Cap to 128 placements as a DoS safeguard; a real layout has ≤ 64.
pieces: z.array(PiecePlacementSchema).max(128),
name: z.string().optional(),
}),
]);
export type LayoutRequest = z.infer<typeof LayoutRequestSchema>;
/**
* Server-resolved layout echoed back to clients via `room.created`
* and `room.joined`. Always includes the concrete piece list so late
* joiners can render the board without re-requesting.
*/
export const ResolvedLayoutSchema = z.object({
id: z.string(),
name: z.string(),
pieces: z.array(PiecePlacementSchema),
});
export type ResolvedLayout = z.infer<typeof ResolvedLayoutSchema>;
export const RoomCreatePayloadSchema = z.object({
rulesetIds: z.array(z.string()).optional(),
layout: LayoutRequestSchema.optional(),
});
export type RoomCreatePayload = z.infer<typeof RoomCreatePayloadSchema>;
@ -155,6 +220,10 @@ export const RoomCreatedPayloadSchema = z.object({
code: RoomCodeSchema,
token: z.string().uuid(),
color: ColorSchema,
// Optional for backward compat — older servers don't echo layout.
// New servers always populate it (defaults to the CLASSIC resolved
// layout when no layout was requested at creation).
layout: ResolvedLayoutSchema.optional(),
});
export type RoomCreatedPayload = z.infer<typeof RoomCreatedPayloadSchema>;
@ -163,6 +232,8 @@ export const RoomJoinedPayloadSchema = z.object({
token: z.string().uuid(),
color: ColorSchema,
activeRules: z.array(z.string()),
// Optional for backward compat (see RoomCreatedPayload).
layout: ResolvedLayoutSchema.optional(),
});
export type RoomJoinedPayload = z.infer<typeof RoomJoinedPayloadSchema>;

View file

@ -5,6 +5,7 @@
// Nothing here persists across restarts — PROTOCOL.md §Auth & Rooms mandates
// in-memory-only storage.
import type { Color } from "./protocol.js";
import { CLASSIC_LAYOUT, type StartingLayout } from "@paratype/chess";
// ---------------------------------------------------------------------------
// Types
@ -29,10 +30,18 @@ export interface Room {
createdAt: number;
/** Rule presets activated for this game (from room.create payload). */
rulesetIds: string[];
/** Resolved starting layout used to open the game. Always present;
* defaults to CLASSIC_LAYOUT when no explicit layout was given. */
layout: StartingLayout;
}
export type JoinResult =
| { token: string; color: "black"; activeRules: string[] }
| {
token: string;
color: "black";
activeRules: string[];
layout: StartingLayout;
}
| { error: "ROOM_NOT_FOUND" | "ROOM_FULL" };
// ---------------------------------------------------------------------------
@ -82,10 +91,14 @@ export class RoomRegistry {
/**
* Create a fresh room. The caller becomes white and receives a UUID v4
* token that authenticates every subsequent message.
*
* `layout` is the resolved (already-validated) starting layout.
* Callers construct this via `resolveLayoutRequest` in ./layouts.ts.
*/
createRoom(
rulesetIds: string[] = [],
): { code: string; token: string; color: "white" } {
layout?: StartingLayout,
): { code: string; token: string; color: "white"; layout: StartingLayout } {
const code = this.allocateCode();
const token = crypto.randomUUID();
const player: RoomPlayer = {
@ -94,20 +107,29 @@ export class RoomRegistry {
connected: true,
lastSeq: 0,
};
// Default to FIDE when the caller omits a layout. The layouts
// barrel has already registered every premade by the time the
// server is up; falling through to CLASSIC_LAYOUT is a cheap
// lookup, not a side-effect trigger.
const resolvedLayout = layout ?? CLASSIC_LAYOUT;
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],
layout: resolvedLayout,
};
this.rooms.set(code, room);
return { code, token, color: "white" };
return { code, token, color: "white", layout: resolvedLayout };
}
/**
* Join an existing room as black. Returns a discriminated result; callers
* map the error codes onto the protocol's `ROOM_NOT_FOUND` / `ROOM_FULL`.
*
* The returned `layout` is the same resolved layout that was used
* to construct the room's GameSession late joiners render from it.
*/
joinRoom(code: string): JoinResult {
const room = this.rooms.get(code);
@ -126,6 +148,7 @@ export class RoomRegistry {
color: "black",
// Defensive copy so joiners can't mutate the room's ruleset list.
activeRules: [...room.rulesetIds],
layout: room.layout,
};
}