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:
parent
f7099d754d
commit
174cad6ae7
9 changed files with 595 additions and 13 deletions
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
143
packages/server/src/layouts.test.ts
Normal file
143
packages/server/src/layouts.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
135
packages/server/src/layouts.ts
Normal file
135
packages/server/src/layouts.ts
Normal 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],
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue