feat(server): room-create accepts profile

Wires the T17 protocol's optional modifier profile through the server's room-create flow:

- rooms.Room gains an optional profile field; RoomRegistry.createRoom/joinRoom accept and echo it.

- GameSession constructor + GameSessionRegistry.create pass the profile through to ChessEngine's EngineOptions so modifier facts get seeded and the integration preset auto-activates at game start.

- broadcast.handleRoomCreate validates an inline profile via chess's validateProfile against the resolved layout, mapping validator codes (E_PROFILE_*) onto the wire protocol's MODIFIER_PROFILE_* family. Invalid profiles produce a non-fatal error and leave room / session state untouched; the creator is not bound.

- room.created and room.joined echo the active profile when present, so late joiners render piece modifier badges on first paint.

- RoomCreatedPayloadSchema and RoomJoinedPayloadSchema gain matching optional profile fields.

- @paratype/chess barrel: re-exports CaptureFlag + validateProfile + ModifierValidation* types so the server can consume them without reaching into internals.

Tests: 5 new cases (happy path, join echo, backward compat, INVULN_KING rejection, layout-invalid precedence). Full check green (1213/1213).
This commit is contained in:
Joey Yakimowich-Payne 2026-04-18 22:55:24 -06:00
commit 4c8e8467b7
No known key found for this signature in database
6 changed files with 453 additions and 7 deletions

View file

@ -18,6 +18,7 @@ export {
export {
GAME_ENTITY,
PROMOTION_PIECES,
CaptureFlag,
oppositeColor,
chessFact,
type PieceType,
@ -73,3 +74,11 @@ export type {
ModifierKindId,
Direction,
} from "./modifiers/types.js";
export { validateProfile } from "./modifiers/validate.js";
export type {
ValidationError as ModifierValidationError,
ValidationWarning as ModifierValidationWarning,
ValidationResult as ModifierValidationResult,
ValidationErrorCode as ModifierValidationErrorCode,
ValidationWarningCode as ModifierValidationWarningCode,
} from "./modifiers/validate.js";

View file

@ -35,6 +35,26 @@ import {
import { DEFAULT_GRACE_MS, reconnectManager } from "./reconnect.js";
import { RoomRegistry } from "./rooms.js";
import { resolveLayoutRequest, toResolvedLayout } from "./layouts.js";
import {
validateProfile,
type ModifierProfile,
type ModifierValidationErrorCode,
} from "@paratype/chess";
/**
* Bridge the zod-inferred wire payload shape to the chess-side
* `ModifierProfile` type. At runtime they are structurally identical
* both are plain JSON objects with the same keys but Zod v3
* infers `z.unknown()` fields as `value?: unknown` (optional key),
* while the chess-side `TypeModifier`/`InstanceModifier` types mark
* `value: unknown` as required. The `ModifierProfileSchema` check
* that runs on inbound frames has already accepted the value (even
* when `undefined`), so the cast is safe. Keeping it in a named
* helper makes the type boundary explicit at call sites.
*/
function asChessProfile(p: unknown): ModifierProfile {
return p as ModifierProfile;
}
// ---------------------------------------------------------------------------
// Per-connection data carried on ws.data
@ -257,6 +277,36 @@ export function handleMessage(
// Handlers
// ---------------------------------------------------------------------------
/**
* Map a validator error code (from `@paratype/chess`) onto the
* protocol's modifier-profile error code family. Keeps the
* chess-package error taxonomy decoupled from the wire protocol
* validator emits semantic codes (E_PROFILE_NO_KING); the wire uses
* the MODIFIER_PROFILE_* family introduced in T17.
*
* An unknown validator code (e.g. a future attribute-limit failure
* that hasn't been carved out on the wire yet) degrades to the
* generic `MODIFIER_PROFILE_INVALID` so the client still gets a
* meaningful error instead of a schema crash.
*/
function mapProfileValidationCode(
code: ModifierValidationErrorCode,
): ErrorCode {
switch (code) {
case "E_PROFILE_NO_KING":
return "MODIFIER_PROFILE_NO_KING";
case "E_PROFILE_INVULN_KING":
return "MODIFIER_PROFILE_INVULN_KING";
case "E_PROFILE_DEADLOCK":
return "MODIFIER_PROFILE_DEADLOCK";
case "E_PROFILE_ATTR_LIMIT":
// No carved-out wire code for attribute overflow — bucket it
// under the generic invalid code; the human-readable message
// still carries the specific detail.
return "MODIFIER_PROFILE_INVALID";
}
}
function handleRoomCreate(
ws: ServerWebSocket<ClientData>,
payload: RoomCreatePayload,
@ -283,12 +333,38 @@ function handleRoomCreate(
}
const resolvedLayout = layoutResult.layout;
// Validate the modifier profile (T19). Zod has already proven the
// wire shape — here we run game-rule legality against the resolved
// layout: king presence, no invulnerable king, attribute ceiling.
// Any error prevents room creation; we surface the FIRST error so
// the client has a single actionable code to display. Warnings
// (orphan per-instance entries) are NOT surfaced on create — the
// profile is still legal, and the warning is for the editor UI.
const profile =
payload.profile !== undefined ? asChessProfile(payload.profile) : undefined;
if (profile !== undefined) {
const check = validateProfile(profile, resolvedLayout);
if (!check.valid) {
const first = check.errors[0]!;
sendTo(
ws,
errorMessage(
mapProfileValidationCode(first.code),
first.message,
false,
),
);
return;
}
}
const rulesetIds = payload.rulesetIds ?? [];
const { code, token, color } = roomRegistry.createRoom(
[...rulesetIds],
resolvedLayout,
profile,
);
sessionRegistry.create(code, rulesetIds, resolvedLayout);
sessionRegistry.create(code, rulesetIds, resolvedLayout, profile);
ws.data.roomCode = code;
ws.data.token = token;
setActiveRooms(roomRegistry.getRoomCount());
@ -302,6 +378,10 @@ function handleRoomCreate(
token,
color,
layout: toResolvedLayout(resolvedLayout),
// Echo the accepted profile so the creator's UI can reflect the
// authoritative shape (which may have been parsed/normalised on
// the way in). Omitted on the wire when no profile was supplied.
...(profile !== undefined ? { profile } : {}),
}),
);
}
@ -366,6 +446,10 @@ function handleRoomJoin(
color: result.color,
activeRules: result.activeRules,
layout: toResolvedLayout(result.layout),
// Surface the room's active modifier profile (if any) so the
// late joiner's UI can render piece modifier badges on first
// paint. Omitted when the room was created without a profile.
...(result.profile !== undefined ? { profile: result.profile } : {}),
}),
);

View file

@ -14,6 +14,7 @@ import {
algebraicToSquare,
PresetActivationError,
type ActivationRequest,
type ModifierProfile,
type PresetActivation,
type GameResult,
type PieceColor,
@ -101,12 +102,25 @@ export class GameSession {
* defaults to CLASSIC_LAYOUT (FIDE). The server has already
* resolved and validated the layout before constructing the
* session, so this value is trusted.
* @param profile optional ModifierProfile to apply at game start.
* Server has already run `validateProfile` against it so we can
* pass straight through to `EngineOptions.profile`, which seeds
* the modifier facts and auto-activates the
* `__modifier-profile-integration__` preset.
*/
constructor(
rulesetIds: readonly string[] = [],
layout?: StartingLayout,
profile?: ModifierProfile,
) {
this.engine = layout !== undefined ? new ChessEngine({ layout }) : new ChessEngine();
// Build the options bag once, including only the keys that were
// supplied — ChessEngine treats missing keys as "use default".
const opts: { layout?: StartingLayout; profile?: ModifierProfile } = {};
if (layout !== undefined) opts.layout = layout;
if (profile !== undefined) opts.profile = profile;
this.engine = Object.keys(opts).length > 0
? new ChessEngine(opts)
: new ChessEngine();
if (rulesetIds.length > 0) {
try {
this.engine.setActivePresets(
@ -265,19 +279,22 @@ export class GameSessionRegistry {
* 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.
* instead of the FIDE default. `profile` is optional; when provided
* the engine seeds modifier facts on piece entities and auto-
* activates the integration preset.
*/
create(
code: string,
rulesetIds?: readonly string[],
layout?: StartingLayout,
profile?: ModifierProfile,
): GameSession {
if (this.sessions.has(code)) {
throw new Error(
`GameSessionRegistry: session already exists for code "${code}"`,
);
}
const session = new GameSession(rulesetIds ?? [], layout);
const session = new GameSession(rulesetIds ?? [], layout, profile);
this.sessions.set(code, session);
return session;
}

View file

@ -379,6 +379,10 @@ export const RoomCreatedPayloadSchema = z.object({
// New servers always populate it (defaults to the CLASSIC resolved
// layout when no layout was requested at creation).
layout: ResolvedLayoutSchema.optional(),
// Active modifier profile (T17/T19). Echoed back by new servers
// when the room was created with one; omitted otherwise. Optional
// on the wire for backward compat with pre-modifiers servers.
profile: ModifierProfileSchema.optional(),
});
export type RoomCreatedPayload = z.infer<typeof RoomCreatedPayloadSchema>;
@ -389,6 +393,9 @@ export const RoomJoinedPayloadSchema = z.object({
activeRules: z.array(z.string()),
// Optional for backward compat (see RoomCreatedPayload).
layout: ResolvedLayoutSchema.optional(),
// Active modifier profile (T17/T19). Present for late joiners when
// the room was created with a profile; omitted otherwise.
profile: ModifierProfileSchema.optional(),
});
export type RoomJoinedPayload = z.infer<typeof RoomJoinedPayloadSchema>;

View file

@ -0,0 +1,297 @@
/**
* T19 integration tests: `room.create` accepts (and validates) an
* optional modifier profile.
*
* Covers:
* 1. Happy path valid profile room created, profile echoed in
* `room.created`, profile visible on the Room, also echoed to the
* second joiner via `room.joined`.
* 2. Backward compat room.create WITHOUT profile still works and
* no `profile` field appears in the response.
* 3. Invalid profile (CANNOT_BE_CAPTURED on king) error code
* `MODIFIER_PROFILE_INVULN_KING`, no room created, no session
* created, connection not bound.
*
* Driven through `handleMessage` against mock `ServerWebSocket`s so
* the exact runtime path clients will hit is exercised including
* the Zod envelope parse, layout resolution, profile validation,
* and the `room.created` envelope build.
*/
import type { ServerWebSocket } from "bun";
import { describe, expect, it } from "vitest";
import {
handleMessage,
registerConnection,
roomRegistry,
sessionRegistry,
type ClientData,
} from "./broadcast.js";
import { PROTOCOL_VERSION, type ClientMessage } from "./protocol.js";
import { CaptureFlag } from "@paratype/chess";
// ─── Mock ServerWebSocket — mirrors broadcast.test.ts harness ─────────
interface MockWs extends ServerWebSocket<ClientData> {
readonly sent: unknown[];
readonly closed: boolean;
}
function makeMockWs(clientId: string): MockWs {
const sent: unknown[] = [];
const closedFlag = { value: false };
const ws = {
data: { clientId } as ClientData,
sent,
get closed(): boolean {
return closedFlag.value;
},
send(msg: string | Buffer): number {
const str = typeof msg === "string" ? msg : msg.toString("utf8");
sent.push(JSON.parse(str));
return str.length;
},
close(): void {
closedFlag.value = true;
},
} as unknown as MockWs;
return ws;
}
function nextMsgOfType(
ws: MockWs,
type: string,
): { payload: Record<string, unknown> } {
const idx = ws.sent.findIndex(
(m) =>
typeof m === "object" &&
m !== null &&
(m as { type: unknown }).type === type,
);
if (idx < 0) {
const types = ws.sent.map((m) => (m as { type?: string }).type);
throw new Error(
`no message of type "${type}" in inbox (got ${JSON.stringify(types)})`,
);
}
const msg = ws.sent[idx] as { payload: Record<string, unknown> };
ws.sent.splice(idx, 1);
return msg;
}
function sendClient(
ws: MockWs,
type: ClientMessage["type"],
payload: unknown,
seq = 1,
): void {
handleMessage(
ws,
JSON.stringify({
v: PROTOCOL_VERSION,
seq,
ts: Date.now(),
type,
payload,
}),
);
}
// ─── Profile fixtures ────────────────────────────────────────────────
/**
* A valid profile: +1 HP to every white pawn. No king rules, so
* `validateProfile` against the FIDE classic layout returns
* `{ valid: true, errors: [] }`.
*/
const validProfile = {
id: "t19-hp-pawns",
name: "HP Pawns",
description: "All white pawns start with +1 HP (T19 test fixture).",
layoutId: "classic",
perType: [
{
kind: "hp-bonus" as const,
pieceType: "pawn" as const,
color: "white" as const,
value: 1,
},
],
perInstance: [],
version: 1 as const,
source: "custom" as const,
};
/**
* An invalid profile: grants CANNOT_BE_CAPTURED to all kings. The
* validator must flag this as `E_PROFILE_INVULN_KING`; the broadcast
* layer must translate that onto the wire's
* `MODIFIER_PROFILE_INVULN_KING` and refuse to create the room.
*/
const invulnKingProfile = {
id: "t19-invuln-kings",
name: "Invulnerable Kings",
description: "Both kings cannot be captured (T19 test fixture).",
layoutId: "classic",
perType: [
{
kind: "capture-flags" as const,
pieceType: "king" as const,
color: "both" as const,
value: CaptureFlag.CANNOT_BE_CAPTURED,
},
],
perInstance: [],
version: 1 as const,
source: "custom" as const,
};
// ─── Tests ───────────────────────────────────────────────────────────
describe("room.create — modifier profile (T19)", () => {
it("creates the room, echoes the profile, and stores it on Room + GameSession", () => {
const ws = makeMockWs(`T19-A-${crypto.randomUUID()}`);
registerConnection(ws);
sendClient(ws, "room.create", { profile: validProfile });
const created = nextMsgOfType(ws, "room.created");
const code = created.payload["code"] as string;
expect(code).toMatch(/^[A-Z0-9]{6}$/);
expect(created.payload["color"]).toBe("white");
// Profile is echoed verbatim on room.created.
const echoed = created.payload["profile"] as typeof validProfile;
expect(echoed).toBeDefined();
expect(echoed.id).toBe(validProfile.id);
expect(echoed.name).toBe(validProfile.name);
expect(echoed.perType).toEqual(validProfile.perType);
expect(echoed.version).toBe(1);
// Room registry persists the profile for subsequent lookups / joiners.
const room = roomRegistry.getRoom(code);
expect(room?.profile).toBeDefined();
expect(room?.profile?.id).toBe(validProfile.id);
// Session was created; the engine has seeded HpBonus on white pawns.
const session = sessionRegistry.get(code);
expect(session).toBeDefined();
// HpBonus=1 should be a fact on at least one entity (a pawn).
const hpBonusFacts = session!
.getAllFacts()
.filter((f) => f.attr === "HpBonus");
expect(hpBonusFacts.length).toBeGreaterThan(0);
for (const f of hpBonusFacts) expect(f.value).toBe(1);
});
it("room.joined echoes the profile to late joiners", () => {
const white = makeMockWs(`T19-J-W-${crypto.randomUUID()}`);
const black = makeMockWs(`T19-J-B-${crypto.randomUUID()}`);
registerConnection(white);
registerConnection(black);
sendClient(white, "room.create", { profile: validProfile });
const created = nextMsgOfType(white, "room.created");
const code = created.payload["code"] as string;
sendClient(black, "room.join", { code });
const joined = nextMsgOfType(black, "room.joined");
const joinedProfile = joined.payload["profile"] as typeof validProfile;
expect(joinedProfile).toBeDefined();
expect(joinedProfile.id).toBe(validProfile.id);
expect(joinedProfile.perType).toEqual(validProfile.perType);
});
it("room.create WITHOUT profile still works (backward compat); no profile field is echoed", () => {
const ws = makeMockWs(`T19-BC-${crypto.randomUUID()}`);
registerConnection(ws);
sendClient(ws, "room.create", {});
const created = nextMsgOfType(ws, "room.created");
const code = created.payload["code"] as string;
expect(code).toMatch(/^[A-Z0-9]{6}$/);
expect(created.payload["color"]).toBe("white");
// `profile` field must be ABSENT from the echo — not null, not {} —
// so downstream clients can distinguish "no profile" from "profile
// was suppressed on the wire".
expect("profile" in created.payload).toBe(false);
const room = roomRegistry.getRoom(code);
expect(room).toBeDefined();
expect(room?.profile).toBeUndefined();
});
it("rejects an invalid profile (CANNOT_BE_CAPTURED on king) with MODIFIER_PROFILE_INVULN_KING — no room or session created", () => {
const ws = makeMockWs(`T19-X-${crypto.randomUUID()}`);
registerConnection(ws);
// Snapshot counts BEFORE the attempt so we can prove no leak.
const roomsBefore = roomRegistry.getRoomCount();
const sessionsBefore = sessionRegistry.size();
sendClient(ws, "room.create", { profile: invulnKingProfile });
// Server replied with an error carrying the specific wire code.
const err = nextMsgOfType(ws, "error");
expect(err.payload["code"]).toBe("MODIFIER_PROFILE_INVULN_KING");
expect(err.payload["fatal"]).toBe(false);
// Message field should mention the reason (human readable).
expect(String(err.payload["message"])).toMatch(/CANNOT_BE_CAPTURED/);
// No `room.created` frame should have been sent.
const sawCreated = ws.sent.some(
(m) =>
typeof m === "object" &&
m !== null &&
(m as { type?: string }).type === "room.created",
);
expect(sawCreated).toBe(false);
// No state mutation: room + session counts unchanged.
expect(roomRegistry.getRoomCount()).toBe(roomsBefore);
expect(sessionRegistry.size()).toBe(sessionsBefore);
// Connection was NOT bound to a room — the socket can retry.
expect(ws.data.roomCode).toBeUndefined();
expect(ws.data.token).toBeUndefined();
// And the socket was NOT closed (non-fatal error).
expect(ws.closed).toBe(false);
});
it("rejects an invalid profile applied on top of a custom layout missing a king", () => {
const ws = makeMockWs(`T19-NOKING-${crypto.randomUUID()}`);
registerConnection(ws);
// Custom layout with only one king (black). The validator's
// E_PROFILE_NO_KING check fires against white. We ride an empty
// profile so the failure is definitely layout-tied and not
// profile-value-tied — proving validateProfile fires even when
// the profile itself is vacuous.
const emptyProfile = {
id: "t19-empty",
name: "Empty",
description: "No modifiers — used to isolate layout errors.",
perType: [],
perInstance: [],
version: 1 as const,
source: "custom" as const,
};
sendClient(ws, "room.create", {
layout: {
kind: "custom",
pieces: [{ type: "king", color: "black", square: 60 }],
},
profile: emptyProfile,
});
// The LAYOUT itself is also rejected by resolveLayoutRequest (a
// layout needs both kings), so we expect LAYOUT_INVALID before the
// profile validator even runs. This documents the precedence:
// layout validation is the FIRST gate.
const err = nextMsgOfType(ws, "error");
expect(err.payload["code"]).toBe("LAYOUT_INVALID");
});
});

View file

@ -5,7 +5,11 @@
// 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";
import {
CLASSIC_LAYOUT,
type ModifierProfile,
type StartingLayout,
} from "@paratype/chess";
// ---------------------------------------------------------------------------
// Types
@ -33,6 +37,13 @@ export interface Room {
/** Resolved starting layout used to open the game. Always present;
* defaults to CLASSIC_LAYOUT when no explicit layout was given. */
layout: StartingLayout;
/**
* Active modifier profile (T19). Optional because room creation with
* no profile field is still the common case. When set, late joiners
* receive it alongside the layout so they can render piece badges
* and the server replays it onto reconstructed engines.
*/
profile?: ModifierProfile;
}
export type JoinResult =
@ -41,6 +52,7 @@ export type JoinResult =
color: "black";
activeRules: string[];
layout: StartingLayout;
profile?: ModifierProfile;
}
| { error: "ROOM_NOT_FOUND" | "ROOM_FULL" };
@ -94,11 +106,23 @@ export class RoomRegistry {
*
* `layout` is the resolved (already-validated) starting layout.
* Callers construct this via `resolveLayoutRequest` in ./layouts.ts.
*
* `profile`, when supplied, is the modifier profile to apply at game
* start. Callers are responsible for validating it via
* `validateProfile` from @paratype/chess BEFORE handing it here
* this registry trusts its inputs.
*/
createRoom(
rulesetIds: string[] = [],
layout?: StartingLayout,
): { code: string; token: string; color: "white"; layout: StartingLayout } {
profile?: ModifierProfile,
): {
code: string;
token: string;
color: "white";
layout: StartingLayout;
profile?: ModifierProfile;
} {
const code = this.allocateCode();
const token = crypto.randomUUID();
const player: RoomPlayer = {
@ -119,9 +143,16 @@ export class RoomRegistry {
// Defensive copy — callers shouldn't be able to mutate our state.
rulesetIds: [...rulesetIds],
layout: resolvedLayout,
...(profile !== undefined ? { profile } : {}),
};
this.rooms.set(code, room);
return { code, token, color: "white", layout: resolvedLayout };
return {
code,
token,
color: "white",
layout: resolvedLayout,
...(profile !== undefined ? { profile } : {}),
};
}
/**
@ -149,6 +180,7 @@ export class RoomRegistry {
// Defensive copy so joiners can't mutate the room's ruleset list.
activeRules: [...room.rulesetIds],
layout: room.layout,
...(room.profile !== undefined ? { profile: room.profile } : {}),
};
}