houserules/packages/server/src/protocol.test.ts
Joey Yakimowich-Payne 174cad6ae7
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.
2026-04-18 20:01:01 -06:00

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);
});
});