From 174cad6ae751abdfa129b782072c5cc80d05a63b Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Sat, 18 Apr 2026 20:01:01 -0600 Subject: [PATCH] feat(server): layout-aware room.create + resolved layout echoes (Phase C) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/chess/src/index.ts | 22 +++++ packages/server/PROTOCOL.md | 38 ++++++- packages/server/src/broadcast.ts | 30 +++++- packages/server/src/game-session.ts | 23 ++++- packages/server/src/layouts.test.ts | 143 +++++++++++++++++++++++++++ packages/server/src/layouts.ts | 135 +++++++++++++++++++++++++ packages/server/src/protocol.test.ts | 117 ++++++++++++++++++++++ packages/server/src/protocol.ts | 71 +++++++++++++ packages/server/src/rooms.ts | 29 +++++- 9 files changed, 595 insertions(+), 13 deletions(-) create mode 100644 packages/server/src/layouts.test.ts create mode 100644 packages/server/src/layouts.ts diff --git a/packages/chess/src/index.ts b/packages/chess/src/index.ts index 6e1cda3..5b98442 100644 --- a/packages/chess/src/index.ts +++ b/packages/chess/src/index.ts @@ -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"; diff --git a/packages/server/PROTOCOL.md b/packages/server/PROTOCOL.md index fe45e73..ec0c451 100644 --- a/packages/server/PROTOCOL.md +++ b/packages/server/PROTOCOL.md @@ -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: diff --git a/packages/server/src/broadcast.ts b/packages/server/src/broadcast.ts index 56711ce..7a3d416 100644 --- a/packages/server/src/broadcast.ts +++ b/packages/server/src/broadcast.ts @@ -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), }), ); diff --git a/packages/server/src/game-session.ts b/packages/server/src/game-session.ts index 724a81c..5991491 100644 --- a/packages/server/src/game-session.ts +++ b/packages/server/src/game-session.ts @@ -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; } diff --git a/packages/server/src/layouts.test.ts b/packages/server/src/layouts.test.ts new file mode 100644 index 0000000..46b1493 --- /dev/null +++ b/packages/server/src/layouts.test.ts @@ -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); + }); +}); diff --git a/packages/server/src/layouts.ts b/packages/server/src/layouts.ts new file mode 100644 index 0000000..456fb7c --- /dev/null +++ b/packages/server/src/layouts.ts @@ -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], + }; +} diff --git a/packages/server/src/protocol.test.ts b/packages/server/src/protocol.test.ts index dd8c066..6bc113b 100644 --- a/packages/server/src/protocol.test.ts +++ b/packages/server/src/protocol.test.ts @@ -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); + }); +}); diff --git a/packages/server/src/protocol.ts b/packages/server/src/protocol.ts index 1e218cc..cf6af23 100644 --- a/packages/server/src/protocol.ts +++ b/packages/server/src/protocol.ts @@ -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; @@ -93,8 +97,69 @@ export type Envelope = z.infer; // 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; + +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; + +/** + * 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; + export const RoomCreatePayloadSchema = z.object({ rulesetIds: z.array(z.string()).optional(), + layout: LayoutRequestSchema.optional(), }); export type RoomCreatePayload = z.infer; @@ -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; @@ -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; diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index e09e83f..74bac1f 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -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, }; }