diff --git a/packages/chess/src/index.ts b/packages/chess/src/index.ts index 6f18817..ac31124 100644 --- a/packages/chess/src/index.ts +++ b/packages/chess/src/index.ts @@ -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"; diff --git a/packages/server/src/broadcast.ts b/packages/server/src/broadcast.ts index 7a3d416..5fd4807 100644 --- a/packages/server/src/broadcast.ts +++ b/packages/server/src/broadcast.ts @@ -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, 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 } : {}), }), ); diff --git a/packages/server/src/game-session.ts b/packages/server/src/game-session.ts index 5991491..353cb48 100644 --- a/packages/server/src/game-session.ts +++ b/packages/server/src/game-session.ts @@ -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; } diff --git a/packages/server/src/protocol.ts b/packages/server/src/protocol.ts index b939982..93f586c 100644 --- a/packages/server/src/protocol.ts +++ b/packages/server/src/protocol.ts @@ -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; @@ -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; diff --git a/packages/server/src/room.create-profile.test.ts b/packages/server/src/room.create-profile.test.ts new file mode 100644 index 0000000..abcc094 --- /dev/null +++ b/packages/server/src/room.create-profile.test.ts @@ -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 { + 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 } { + 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 }; + 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"); + }); +}); diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index 74bac1f..f42c6e5 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -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 } : {}), }; }