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.
622 lines
16 KiB
TypeScript
622 lines
16 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import {
|
|
validateMessage,
|
|
validateMessageString,
|
|
PROTOCOL_VERSION,
|
|
ClientMessageSchema,
|
|
ServerMessageSchema,
|
|
type AnyMessage,
|
|
type ClientMessage,
|
|
type ServerMessage,
|
|
} from "./protocol.js";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fixtures — one minimally-valid example per message type.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const UUID = "550e8400-e29b-41d4-a716-446655440000";
|
|
const UUID2 = "661f9500-f30c-42e5-b827-557766550111";
|
|
|
|
const envelope = { v: PROTOCOL_VERSION, seq: 0, ts: 1 } as const;
|
|
|
|
const fixtures: Record<string, AnyMessage> = {
|
|
"room.create": {
|
|
...envelope,
|
|
type: "room.create",
|
|
payload: { rulesetIds: ["pawns-move-backward"] },
|
|
},
|
|
"room.create (empty payload)": {
|
|
...envelope,
|
|
type: "room.create",
|
|
payload: {},
|
|
},
|
|
"room.join": {
|
|
...envelope,
|
|
type: "room.join",
|
|
payload: { code: "ABC123" },
|
|
},
|
|
"room.leave": {
|
|
...envelope,
|
|
type: "room.leave",
|
|
payload: {},
|
|
},
|
|
"game.move": {
|
|
...envelope,
|
|
type: "game.move",
|
|
token: UUID,
|
|
payload: { from: "e2", to: "e4" },
|
|
},
|
|
"game.move (promotion)": {
|
|
...envelope,
|
|
type: "game.move",
|
|
token: UUID,
|
|
payload: { from: "a7", to: "a8", promoteTo: "queen" },
|
|
},
|
|
"room.created": {
|
|
...envelope,
|
|
seq: 1,
|
|
type: "room.created",
|
|
payload: { code: "ABC123", token: UUID, color: "white" },
|
|
},
|
|
"room.joined": {
|
|
...envelope,
|
|
seq: 1,
|
|
type: "room.joined",
|
|
payload: {
|
|
code: "ABC123",
|
|
token: UUID2,
|
|
color: "black",
|
|
activeRules: ["pawns-move-backward"],
|
|
},
|
|
},
|
|
"game.state": {
|
|
...envelope,
|
|
seq: 2,
|
|
type: "game.state",
|
|
payload: {
|
|
facts: [{ id: 1, attr: "PieceType", value: "pawn" }],
|
|
turn: "white",
|
|
lastSeq: 42,
|
|
moveHistory: ["e2-e4"],
|
|
activeRules: [],
|
|
fen: "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1",
|
|
},
|
|
},
|
|
"game.delta (ongoing)": {
|
|
...envelope,
|
|
seq: 3,
|
|
type: "game.delta",
|
|
payload: {
|
|
inserted: [{ id: 1, attr: "Position", value: 28 }],
|
|
retracted: [{ id: 1, attr: "Position", value: 12 }],
|
|
moveNotation: "e2e4",
|
|
turn: "black",
|
|
gameOver: null,
|
|
},
|
|
},
|
|
"game.delta (game over)": {
|
|
...envelope,
|
|
seq: 99,
|
|
type: "game.delta",
|
|
payload: {
|
|
inserted: [],
|
|
retracted: [],
|
|
moveNotation: "Qh4#",
|
|
turn: "black",
|
|
gameOver: { winner: "black", reason: "checkmate" },
|
|
},
|
|
},
|
|
"game.end": {
|
|
...envelope,
|
|
seq: 100,
|
|
type: "game.end",
|
|
payload: {
|
|
winner: "white",
|
|
reason: "checkmate",
|
|
finalFen: "rnb1kbnr/pppp1ppp/8/4p3/6Pq/5P2/PPPPP2P/RNBQKBNR w KQkq - 0 3",
|
|
},
|
|
},
|
|
"error (non-fatal)": {
|
|
...envelope,
|
|
seq: 5,
|
|
type: "error",
|
|
payload: {
|
|
code: "ILLEGAL_MOVE",
|
|
message: "Move e2-e5 is not legal",
|
|
fatal: false,
|
|
},
|
|
},
|
|
"error (fatal)": {
|
|
...envelope,
|
|
seq: 6,
|
|
type: "error",
|
|
payload: {
|
|
code: "VERSION_MISMATCH",
|
|
message: "expected v=1",
|
|
fatal: true,
|
|
},
|
|
},
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Round-trip: every fixture must JSON-serialise and re-validate identically.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("validateMessage — round-trip", () => {
|
|
for (const [name, message] of Object.entries(fixtures)) {
|
|
it(`round-trips ${name}`, () => {
|
|
const encoded = JSON.stringify(message);
|
|
const decoded = JSON.parse(encoded) as unknown;
|
|
const result = validateMessage(decoded);
|
|
|
|
expect(result.ok).toBe(true);
|
|
if (result.ok) {
|
|
expect(result.data).toEqual(message);
|
|
expect(result.data.type).toBe(message.type);
|
|
}
|
|
});
|
|
}
|
|
|
|
it("round-trips with an optional token present", () => {
|
|
const msg = {
|
|
...envelope,
|
|
token: UUID,
|
|
type: "room.leave" as const,
|
|
payload: {},
|
|
};
|
|
const result = validateMessage(JSON.parse(JSON.stringify(msg)));
|
|
expect(result.ok).toBe(true);
|
|
if (result.ok) expect(result.data.token).toBe(UUID);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Rejections
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("validateMessage — rejections", () => {
|
|
it("rejects non-object input (null)", () => {
|
|
const r = validateMessage(null);
|
|
expect(r.ok).toBe(false);
|
|
if (!r.ok) expect(r.error).toMatch(/INVALID_MESSAGE/);
|
|
});
|
|
|
|
it("rejects non-object input (array)", () => {
|
|
const r = validateMessage([1, 2, 3]);
|
|
expect(r.ok).toBe(false);
|
|
if (!r.ok) expect(r.error).toMatch(/INVALID_MESSAGE/);
|
|
});
|
|
|
|
it("rejects non-object input (string)", () => {
|
|
const r = validateMessage("hello");
|
|
expect(r.ok).toBe(false);
|
|
if (!r.ok) expect(r.error).toMatch(/INVALID_MESSAGE/);
|
|
});
|
|
|
|
it("rejects v=2 with VERSION_MISMATCH", () => {
|
|
const r = validateMessage({
|
|
v: 2,
|
|
seq: 0,
|
|
ts: 1,
|
|
type: "room.create",
|
|
payload: {},
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
if (!r.ok) {
|
|
expect(r.error).toMatch(/VERSION_MISMATCH/);
|
|
expect(r.error).toContain("expected v=1");
|
|
}
|
|
});
|
|
|
|
it("rejects missing v with VERSION_MISMATCH (v is undefined)", () => {
|
|
const r = validateMessage({
|
|
seq: 0,
|
|
ts: 1,
|
|
type: "room.create",
|
|
payload: {},
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
if (!r.ok) expect(r.error).toMatch(/VERSION_MISMATCH/);
|
|
});
|
|
|
|
it("rejects missing type", () => {
|
|
const r = validateMessage({ v: 1, seq: 0, ts: 1, payload: {} });
|
|
expect(r.ok).toBe(false);
|
|
if (!r.ok) expect(r.error).toMatch(/INVALID_MESSAGE/);
|
|
});
|
|
|
|
it("rejects non-string type", () => {
|
|
const r = validateMessage({
|
|
v: 1,
|
|
seq: 0,
|
|
ts: 1,
|
|
type: 42,
|
|
payload: {},
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
if (!r.ok) expect(r.error).toMatch(/non-string `type`/);
|
|
});
|
|
|
|
it("rejects unknown message type", () => {
|
|
const r = validateMessage({
|
|
v: 1,
|
|
seq: 0,
|
|
ts: 1,
|
|
type: "nope.nope",
|
|
payload: {},
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
if (!r.ok) expect(r.error).toMatch(/unknown message type "nope.nope"/);
|
|
});
|
|
|
|
it("rejects game.move with bad `from` format", () => {
|
|
const r = validateMessage({
|
|
...envelope,
|
|
type: "game.move",
|
|
payload: { from: "e22", to: "e4" },
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
if (!r.ok) expect(r.error).toMatch(/from/);
|
|
});
|
|
|
|
it("rejects game.move with bad `to` file", () => {
|
|
const r = validateMessage({
|
|
...envelope,
|
|
type: "game.move",
|
|
payload: { from: "e2", to: "z9" },
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
});
|
|
|
|
it("rejects game.move with bad promotion piece", () => {
|
|
const r = validateMessage({
|
|
...envelope,
|
|
type: "game.move",
|
|
payload: { from: "a7", to: "a8", promoteTo: "king" },
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
if (!r.ok) expect(r.error).toMatch(/promoteTo/);
|
|
});
|
|
|
|
it("rejects room.join with 5-char code", () => {
|
|
const r = validateMessage({
|
|
...envelope,
|
|
type: "room.join",
|
|
payload: { code: "ABC12" },
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
});
|
|
|
|
it("rejects room.join with lowercase code", () => {
|
|
const r = validateMessage({
|
|
...envelope,
|
|
type: "room.join",
|
|
payload: { code: "abc123" },
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
});
|
|
|
|
it("rejects negative seq", () => {
|
|
const r = validateMessage({
|
|
v: 1,
|
|
seq: -1,
|
|
ts: 1,
|
|
type: "room.leave",
|
|
payload: {},
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
});
|
|
|
|
it("rejects non-integer seq", () => {
|
|
const r = validateMessage({
|
|
v: 1,
|
|
seq: 1.5,
|
|
ts: 1,
|
|
type: "room.leave",
|
|
payload: {},
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
});
|
|
|
|
it("rejects non-positive ts", () => {
|
|
const r = validateMessage({
|
|
v: 1,
|
|
seq: 0,
|
|
ts: 0,
|
|
type: "room.leave",
|
|
payload: {},
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
});
|
|
|
|
it("rejects token that is not a UUID", () => {
|
|
const r = validateMessage({
|
|
...envelope,
|
|
token: "not-a-uuid",
|
|
type: "room.leave",
|
|
payload: {},
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
});
|
|
|
|
it("rejects room.created with non-UUID token", () => {
|
|
const r = validateMessage({
|
|
...envelope,
|
|
type: "room.created",
|
|
payload: { code: "ABC123", token: "nope", color: "white" },
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
});
|
|
|
|
it("rejects room.created with invalid color", () => {
|
|
const r = validateMessage({
|
|
...envelope,
|
|
type: "room.created",
|
|
payload: { code: "ABC123", token: UUID, color: "green" },
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
});
|
|
|
|
it("rejects game.state missing required field", () => {
|
|
const r = validateMessage({
|
|
...envelope,
|
|
type: "game.state",
|
|
payload: {
|
|
facts: [],
|
|
turn: "white",
|
|
lastSeq: 0,
|
|
moveHistory: [],
|
|
activeRules: [],
|
|
// fen missing
|
|
},
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
if (!r.ok) expect(r.error).toMatch(/fen/);
|
|
});
|
|
|
|
it("rejects error payload with unknown error code", () => {
|
|
const r = validateMessage({
|
|
...envelope,
|
|
type: "error",
|
|
payload: { code: "UNKNOWN_CODE", message: "x", fatal: false },
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
});
|
|
|
|
it("rejects game.delta gameOver with invalid reason", () => {
|
|
const r = validateMessage({
|
|
...envelope,
|
|
type: "game.delta",
|
|
payload: {
|
|
inserted: [],
|
|
retracted: [],
|
|
moveNotation: "a",
|
|
turn: "white",
|
|
gameOver: { winner: "white", reason: "???" },
|
|
},
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
});
|
|
|
|
it("rejects game.end with invalid winner", () => {
|
|
const r = validateMessage({
|
|
...envelope,
|
|
type: "game.end",
|
|
payload: { winner: "nobody", reason: "x", finalFen: "x" },
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
});
|
|
|
|
it("rejects room.leave with extra payload keys (strict)", () => {
|
|
const r = validateMessage({
|
|
...envelope,
|
|
type: "room.leave",
|
|
payload: { extra: 1 },
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// validateMessageString — raw-string entry point
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("validateMessageString", () => {
|
|
it("parses a valid JSON string frame", () => {
|
|
const msg: ClientMessage = {
|
|
...envelope,
|
|
type: "room.create",
|
|
payload: {},
|
|
};
|
|
const r = validateMessageString(JSON.stringify(msg));
|
|
expect(r.ok).toBe(true);
|
|
});
|
|
|
|
it("reports malformed JSON as INVALID_MESSAGE", () => {
|
|
const r = validateMessageString("{ this is not json");
|
|
expect(r.ok).toBe(false);
|
|
if (!r.ok) {
|
|
expect(r.error).toMatch(/INVALID_MESSAGE/);
|
|
expect(r.error).toMatch(/malformed JSON/);
|
|
}
|
|
});
|
|
|
|
it("propagates version mismatch through the string entry point", () => {
|
|
const r = validateMessageString(
|
|
JSON.stringify({ v: 99, seq: 0, ts: 1, type: "room.leave", payload: {} }),
|
|
);
|
|
expect(r.ok).toBe(false);
|
|
if (!r.ok) expect(r.error).toMatch(/VERSION_MISMATCH/);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Type-narrowing smoke test — ensures discriminated unions are usable.
|
|
// (Executed at runtime, but the value comes from the union's narrowing.)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("schema unions", () => {
|
|
it("ClientMessageSchema parses client-originating types only", () => {
|
|
const ok = ClientMessageSchema.safeParse({
|
|
...envelope,
|
|
type: "game.move",
|
|
payload: { from: "e2", to: "e4" },
|
|
});
|
|
expect(ok.success).toBe(true);
|
|
|
|
const bad = ClientMessageSchema.safeParse({
|
|
...envelope,
|
|
type: "game.end",
|
|
payload: { winner: "white", reason: "x", finalFen: "x" },
|
|
});
|
|
expect(bad.success).toBe(false);
|
|
});
|
|
|
|
it("ServerMessageSchema parses server-originating types only", () => {
|
|
const okMsg: ServerMessage = {
|
|
...envelope,
|
|
type: "error",
|
|
payload: { code: "RATE_LIMIT", message: "too fast", fatal: true },
|
|
};
|
|
const good = ServerMessageSchema.safeParse(okMsg);
|
|
expect(good.success).toBe(true);
|
|
|
|
const bad = ServerMessageSchema.safeParse({
|
|
...envelope,
|
|
type: "room.create",
|
|
payload: {},
|
|
});
|
|
expect(bad.success).toBe(false);
|
|
});
|
|
|
|
it("narrows AnyMessage by `type` discriminant", () => {
|
|
const r = validateMessage({
|
|
...envelope,
|
|
type: "game.move",
|
|
payload: { from: "e2", to: "e4" },
|
|
});
|
|
expect(r.ok).toBe(true);
|
|
if (r.ok && r.data.type === "game.move") {
|
|
// TS narrowing should let us read these fields without casts.
|
|
expect(r.data.payload.from).toBe("e2");
|
|
expect(r.data.payload.to).toBe("e4");
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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);
|
|
});
|
|
});
|