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:
parent
c34c11af92
commit
4c8e8467b7
6 changed files with 453 additions and 7 deletions
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
||||
|
|
|
|||
297
packages/server/src/room.create-profile.test.ts
Normal file
297
packages/server/src/room.create-profile.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue