diff --git a/packages/chess/src/index.ts b/packages/chess/src/index.ts index 5b98442..6f18817 100644 --- a/packages/chess/src/index.ts +++ b/packages/chess/src/index.ts @@ -60,3 +60,16 @@ export { type LayoutValidationResult, } from "./layouts/index.js"; export { validateLayout } from "./layouts/validate.js"; + +// Piece modifier profiles — orthogonal to layouts and presets. Exposed +// so the server can type-check wire payloads against the authoritative +// chess-side shape. The zod schema itself is NOT re-exported here +// because the server package pins a different zod major; the server +// mirrors the schema locally (same shape) for wire validation. +export type { + ModifierProfile, + TypeModifier, + InstanceModifier, + ModifierKindId, + Direction, +} from "./modifiers/types.js"; diff --git a/packages/chess/src/net/types.ts b/packages/chess/src/net/types.ts index 2efb4d6..f993241 100644 --- a/packages/chess/src/net/types.ts +++ b/packages/chess/src/net/types.ts @@ -28,7 +28,12 @@ export type ErrorCode = | "MSG_TOO_LARGE" | "BAD_TOKEN" | "INVALID_MESSAGE" - | "LAYOUT_INVALID"; + | "LAYOUT_INVALID" + // Modifier-profile rejections — mirrored from server/protocol.ts. + | "MODIFIER_PROFILE_INVALID" + | "MODIFIER_PROFILE_NO_KING" + | "MODIFIER_PROFILE_INVULN_KING" + | "MODIFIER_PROFILE_DEADLOCK"; // --------------------------------------------------------------------------- // Starting layout wire shapes (mirrors server/src/protocol.ts) @@ -66,6 +71,50 @@ export interface Fact { value: unknown; } +// --------------------------------------------------------------------------- +// Modifier profile wire shapes (mirrors server/src/protocol.ts) +// --------------------------------------------------------------------------- + +export type ModifierKindIdWire = + | "hp-bonus" + | "range-bonus" + | "direction-additions" + | "capture-flags" + | "promotion-override" + | "damage-resistance"; + +export interface TypeModifierWire { + kind: ModifierKindIdWire; + pieceType: PieceType; + color: Color | "both"; + value: unknown; +} + +export interface InstanceModifierWire { + kind: ModifierKindIdWire; + /** Algebraic notation, e.g. "b1". */ + square: string; + value: unknown; +} + +/** + * JSON-serializable shape of a ModifierProfile as it travels on the + * wire. Mirrors the chess-package `ModifierProfile` type structurally; + * kept as an independent interface here so the net-types layer doesn't + * need to import from the modifiers subsystem (and accidentally pull + * in the descriptor registry side-effects). + */ +export interface ModifierProfileWire { + id: string; + name: string; + description: string; + layoutId?: string; + perType: TypeModifierWire[]; + perInstance: InstanceModifierWire[]; + version: 1; + source: "premade" | "custom"; +} + // --------------------------------------------------------------------------- // Server → Client payloads // --------------------------------------------------------------------------- @@ -124,6 +173,9 @@ export interface RoomCreatedPayload { /** Resolved starting layout. Optional on the wire for backward * compat with pre-layouts servers. */ layout?: ResolvedLayoutWire; + /** Modifier profile active at room creation. Optional for backward + * compat with pre-modifiers servers. */ + profile?: ModifierProfileWire; } export interface RoomJoinedPayload { @@ -133,6 +185,21 @@ export interface RoomJoinedPayload { activeRules: string[]; /** Resolved starting layout (see RoomCreatedPayload.layout). */ layout?: ResolvedLayoutWire; + /** Active modifier profile when this client joined (see + * RoomCreatedPayload.profile). */ + profile?: ModifierProfileWire; +} + +/** + * Server → client broadcast: the room's active modifier profile was + * replaced. Clients apply this to their local engine at the next turn + * boundary — the server guarantees the broadcast is ordered + * immediately before the ensuing `game.state` / `game.delta`. + */ +export interface ModifierProfileUpdatedPayload { + profile: ModifierProfileWire; + version: number; + appliedAt: "turn-boundary"; } export interface ErrorPayload { @@ -150,6 +217,21 @@ export interface RoomCreatePayload { /** Optional starting-layout selector. When omitted the server * opens the room with the FIDE classic layout. */ layout?: LayoutRequest; + /** Optional inline modifier profile applied at room creation. */ + profile?: ModifierProfileWire; +} + +/** + * Client → server intent: swap the active modifier profile for the + * room. Server validates and applies at the next turn boundary, then + * broadcasts `modifier-profile.updated` to both players. `version` is + * the profile version the client last observed; stale requests are + * rejected with `MODIFIER_PROFILE_INVALID`. + */ +export interface ModifierProfileUpdatePayload { + roomCode: string; + newProfile: ModifierProfileWire; + version: number; } export interface RoomJoinPayload { @@ -182,6 +264,7 @@ export type ServerMessage = | MessageEnvelope<"game.presets", GamePresetsPayload> | MessageEnvelope<"room.created", RoomCreatedPayload> | MessageEnvelope<"room.joined", RoomJoinedPayload> + | MessageEnvelope<"modifier-profile.updated", ModifierProfileUpdatedPayload> | MessageEnvelope<"error", ErrorPayload>; export type ClientMessage = @@ -189,4 +272,5 @@ export type ClientMessage = | MessageEnvelope<"room.join", RoomJoinPayload> | MessageEnvelope<"room.leave", Record> | MessageEnvelope<"game.move", GameMovePayload> - | MessageEnvelope<"room.setPresets", RoomSetPresetsPayload>; + | MessageEnvelope<"room.setPresets", RoomSetPresetsPayload> + | MessageEnvelope<"modifier-profile.update", ModifierProfileUpdatePayload>; diff --git a/packages/server/PROTOCOL.md b/packages/server/PROTOCOL.md index ec0c451..f60543f 100644 --- a/packages/server/PROTOCOL.md +++ b/packages/server/PROTOCOL.md @@ -94,6 +94,9 @@ Response (Server → Client, type `room.created`): - `layout`: resolved starting layout echoed back. Present on all `room.created` and `room.joined` responses from new servers; may be absent on legacy (pre-layouts) servers. +- `profile`: resolved modifier profile echoed back. Same shape as the + request-side `profile` field. Omitted when the room was created + without a profile, or when the server is pre-modifiers. Error cases: @@ -101,6 +104,14 @@ Error cases: - Layout fails validation (bad king count, duplicate squares, unknown premade id, malformed FEN): `error` code `LAYOUT_INVALID`. Message field carries the human-readable reason. +- Modifier profile fails validation: `error` code + `MODIFIER_PROFILE_INVALID` (schema / descriptor value-schema + failure) or one of the three carved-out invariants: + `MODIFIER_PROFILE_NO_KING`, `MODIFIER_PROFILE_INVULN_KING`, + `MODIFIER_PROFILE_DEADLOCK`. The `message` field carries a + human-readable reason; clients may use the code for targeted UI + hints (e.g. "Your profile leaves white with no king — please + adjust the setup"). ### Message: room.join @@ -248,6 +259,95 @@ Payload: } ``` +### Message: modifier-profile.update + +Direction: Client → Server +Purpose: Replace the room's active modifier profile. Applied by the +server at the NEXT TURN BOUNDARY — never mid-move. + +Request payload: + +```json +{ + "type": "modifier-profile.update", + "roomCode": "ABC123", + "newProfile": { + "id": "buff-rooks", + "name": "Rook Reach", + "description": "All rooks get +1 range.", + "layoutId": "classic", + "perType": [ + { "kind": "range-bonus", "pieceType": "rook", "color": "both", "value": 1 } + ], + "perInstance": [], + "version": 1, + "source": "custom" + }, + "version": 3 +} +``` + +- `roomCode`: the code of the room whose profile is being swapped. +- `newProfile`: the full replacement `ModifierProfile` (same shape as + the `profile` field on `room.create`). Partial updates are not + supported — the server treats the incoming profile as authoritative. +- `version`: the PROFILE version the client last observed (starts at + `0` before any update and is incremented by the server on each + successful apply). Submitting a stale version triggers an `error` + with code `MODIFIER_PROFILE_INVALID` so concurrent edits from both + players never silently overwrite. + +On successful apply, the server broadcasts `modifier-profile.updated` +to both players (see below), followed by the usual `game.state` / +`game.delta` reflecting any piece-attribute changes. + +Error cases: + +- Profile fails schema / value-schema validation: + `MODIFIER_PROFILE_INVALID` +- Profile would leave a side with no king: `MODIFIER_PROFILE_NO_KING` +- Profile would make a king invulnerable (damage-resistance ≥ 1.0): + `MODIFIER_PROFILE_INVULN_KING` +- Profile's combined effects make the current position a legal-move + deadlock (neither side has any move): `MODIFIER_PROFILE_DEADLOCK` +- Room not found / client not a member: `ROOM_NOT_FOUND` + +### Message: modifier-profile.updated + +Direction: Server → Client +Purpose: Broadcast to both players after a successful +`modifier-profile.update`. Clients apply the new profile to their +local engine at the next turn boundary. + +Payload: + +```json +{ + "type": "modifier-profile.updated", + "profile": { + "id": "buff-rooks", + "name": "Rook Reach", + "description": "All rooks get +1 range.", + "layoutId": "classic", + "perType": [ + { "kind": "range-bonus", "pieceType": "rook", "color": "both", "value": 1 } + ], + "perInstance": [], + "version": 1, + "source": "custom" + }, + "version": 4, + "appliedAt": "turn-boundary" +} +``` + +- `profile`: the newly-active modifier profile (same shape as + `room.create.profile`). +- `version`: the monotonically-incremented profile version. Clients + echo this on subsequent `modifier-profile.update` requests. +- `appliedAt`: always `"turn-boundary"` in v1. Reserved for future + policies (e.g. `"immediate"` for settings that apply mid-move). + ### Message: error Direction: Server → Client @@ -321,3 +421,8 @@ Messages counted: all messages from client including heartbeats. | `MSG_TOO_LARGE` | Yes | Message exceeds 64KB | | `BAD_TOKEN` | Yes | Token missing or invalid for room | | `INVALID_MESSAGE` | Yes | JSON parse failure or schema validation failure | +| `LAYOUT_INVALID` | No | Starting layout failed validation | +| `MODIFIER_PROFILE_INVALID` | No | Modifier profile failed schema / descriptor validation, or stale `version` on an update | +| `MODIFIER_PROFILE_NO_KING` | No | Profile would leave a side without a king | +| `MODIFIER_PROFILE_INVULN_KING` | No | Profile would make a king invulnerable | +| `MODIFIER_PROFILE_DEADLOCK` | No | Profile would make the current position an unplayable deadlock | diff --git a/packages/server/src/protocol.test.ts b/packages/server/src/protocol.test.ts index 6bc113b..695f1d6 100644 --- a/packages/server/src/protocol.test.ts +++ b/packages/server/src/protocol.test.ts @@ -5,6 +5,13 @@ import { PROTOCOL_VERSION, ClientMessageSchema, ServerMessageSchema, + ModifierProfileSchema, + ModifierProfileUpdatePayloadSchema, + RoomCreatePayloadSchema, + MODIFIER_PROFILE_INVALID, + MODIFIER_PROFILE_NO_KING, + MODIFIER_PROFILE_INVULN_KING, + MODIFIER_PROFILE_DEADLOCK, type AnyMessage, type ClientMessage, type ServerMessage, @@ -620,3 +627,240 @@ describe("room.create layout payload", () => { expect(r.ok).toBe(true); }); }); + +// --------------------------------------------------------------------------- +// Modifier profile — schemas, RoomCreate integration, error codes +// --------------------------------------------------------------------------- + +/** + * A minimally-valid ModifierProfile fixture. Uses one perType entry + * (a +1 HP bonus on white pawns) and one perInstance entry so both + * arrays are exercised by parsing. + */ +const validProfile = { + id: "test-profile", + name: "Test Profile", + description: "For protocol.test coverage.", + layoutId: "classic", + perType: [ + { + kind: "hp-bonus", + pieceType: "pawn", + color: "white", + value: 1, + }, + ], + perInstance: [ + { + kind: "range-bonus", + square: "d1", + value: 2, + }, + ], + version: 1, + source: "custom", +} as const; + +describe("ModifierProfileSchema", () => { + it("parses a valid profile", () => { + const r = ModifierProfileSchema.safeParse(validProfile); + expect(r.success).toBe(true); + }); + + it("parses a profile with empty perType / perInstance", () => { + const r = ModifierProfileSchema.safeParse({ + ...validProfile, + perType: [], + perInstance: [], + }); + expect(r.success).toBe(true); + }); + + it("rejects a profile with wrong version literal", () => { + const r = ModifierProfileSchema.safeParse({ ...validProfile, version: 2 }); + expect(r.success).toBe(false); + }); + + it("rejects a profile with unknown modifier kind", () => { + const r = ModifierProfileSchema.safeParse({ + ...validProfile, + perType: [ + { + kind: "teleport", + pieceType: "pawn", + color: "white", + value: 1, + }, + ], + }); + expect(r.success).toBe(false); + }); + + it("rejects a perInstance entry with non-algebraic square", () => { + const r = ModifierProfileSchema.safeParse({ + ...validProfile, + perInstance: [{ kind: "hp-bonus", square: "d9", value: 1 }], + }); + expect(r.success).toBe(false); + }); +}); + +describe("RoomCreatePayloadSchema — profile field (T17)", () => { + it("accepts a room.create with a valid inline profile", () => { + const r = validateMessage({ + ...envelope, + type: "room.create", + payload: { profile: validProfile }, + }); + expect(r.ok).toBe(true); + }); + + it("accepts a room.create WITHOUT a profile (backward compat)", () => { + const r = validateMessage({ + ...envelope, + type: "room.create", + payload: {}, + }); + expect(r.ok).toBe(true); + }); + + it("accepts room.create with layout + profile + rulesetIds together", () => { + const r = validateMessage({ + ...envelope, + type: "room.create", + payload: { + rulesetIds: ["piece-hp"], + layout: { kind: "premade", id: "classic" }, + profile: validProfile, + }, + }); + expect(r.ok).toBe(true); + }); + + it("parses through the schema directly (not just the envelope path)", () => { + const r = RoomCreatePayloadSchema.safeParse({ profile: validProfile }); + expect(r.success).toBe(true); + if (r.success) expect(r.data.profile?.id).toBe("test-profile"); + }); + + it("rejects a room.create with a malformed profile", () => { + const r = validateMessage({ + ...envelope, + type: "room.create", + payload: { + profile: { ...validProfile, version: 99 }, + }, + }); + // The profile field is optional, but when present it must validate. + expect(r.ok).toBe(false); + }); +}); + +describe("ModifierProfileUpdatePayloadSchema", () => { + it("parses a well-formed update payload", () => { + const r = ModifierProfileUpdatePayloadSchema.safeParse({ + type: "modifier-profile.update", + roomCode: "ABC123", + newProfile: validProfile, + version: 3, + }); + expect(r.success).toBe(true); + }); + + it("parses with version = 0 (initial pre-update state)", () => { + const r = ModifierProfileUpdatePayloadSchema.safeParse({ + type: "modifier-profile.update", + roomCode: "ABC123", + newProfile: validProfile, + version: 0, + }); + expect(r.success).toBe(true); + }); + + it("rejects missing `version`", () => { + const r = ModifierProfileUpdatePayloadSchema.safeParse({ + type: "modifier-profile.update", + roomCode: "ABC123", + newProfile: validProfile, + }); + expect(r.success).toBe(false); + }); + + it("rejects negative `version`", () => { + const r = ModifierProfileUpdatePayloadSchema.safeParse({ + type: "modifier-profile.update", + roomCode: "ABC123", + newProfile: validProfile, + version: -1, + }); + expect(r.success).toBe(false); + }); + + it("rejects non-integer `version`", () => { + const r = ModifierProfileUpdatePayloadSchema.safeParse({ + type: "modifier-profile.update", + roomCode: "ABC123", + newProfile: validProfile, + version: 1.5, + }); + expect(r.success).toBe(false); + }); + + it("rejects bad roomCode format", () => { + const r = ModifierProfileUpdatePayloadSchema.safeParse({ + type: "modifier-profile.update", + roomCode: "abc123", // lowercase rejected + newProfile: validProfile, + version: 1, + }); + expect(r.success).toBe(false); + }); + + it("rejects wrong literal `type`", () => { + const r = ModifierProfileUpdatePayloadSchema.safeParse({ + type: "modifier-profile.nope", + roomCode: "ABC123", + newProfile: validProfile, + version: 1, + }); + expect(r.success).toBe(false); + }); +}); + +describe("Modifier profile error codes", () => { + it("const exports equal their string literal values", () => { + expect(MODIFIER_PROFILE_INVALID).toBe("MODIFIER_PROFILE_INVALID"); + expect(MODIFIER_PROFILE_NO_KING).toBe("MODIFIER_PROFILE_NO_KING"); + expect(MODIFIER_PROFILE_INVULN_KING).toBe("MODIFIER_PROFILE_INVULN_KING"); + expect(MODIFIER_PROFILE_DEADLOCK).toBe("MODIFIER_PROFILE_DEADLOCK"); + }); + + it("all four are accepted by the ErrorCodeSchema via an error payload", () => { + for (const code of [ + MODIFIER_PROFILE_INVALID, + MODIFIER_PROFILE_NO_KING, + MODIFIER_PROFILE_INVULN_KING, + MODIFIER_PROFILE_DEADLOCK, + ]) { + const r = validateMessage({ + ...envelope, + type: "error", + payload: { code, message: `reason for ${code}`, fatal: false }, + }); + expect(r.ok).toBe(true); + } + }); + + it("rejects a similar-but-unregistered modifier profile code", () => { + const r = validateMessage({ + ...envelope, + type: "error", + payload: { + code: "MODIFIER_PROFILE_UNKNOWN", + message: "x", + fatal: false, + }, + }); + expect(r.ok).toBe(false); + }); +}); diff --git a/packages/server/src/protocol.ts b/packages/server/src/protocol.ts index cf6af23..b939982 100644 --- a/packages/server/src/protocol.ts +++ b/packages/server/src/protocol.ts @@ -1,6 +1,7 @@ // Chess server WebSocket protocol v1 — Zod schemas & validation. // See PROTOCOL.md for the full spec. import { z } from "zod"; +import type { ModifierProfile } from "@paratype/chess"; // --------------------------------------------------------------------------- // Primitives @@ -53,9 +54,26 @@ export const ErrorCodeSchema = z.enum([ // failed validation (bad king count, duplicate squares, etc.) or // specified an unknown premade id / malformed FEN. "LAYOUT_INVALID", + // Modifier profile rejections. `MODIFIER_PROFILE_INVALID` is the + // generic "schema / value-schema / descriptor validation" failure. + // The three specific codes (NO_KING, INVULN_KING, DEADLOCK) are + // carved out so clients can surface targeted UI guidance without + // string-matching the message field. + "MODIFIER_PROFILE_INVALID", + "MODIFIER_PROFILE_NO_KING", + "MODIFIER_PROFILE_INVULN_KING", + "MODIFIER_PROFILE_DEADLOCK", ]); export type ErrorCode = z.infer; +// Re-export each new code as a const literal so server-side code can +// emit `code: MODIFIER_PROFILE_INVALID` without hard-coding the string +// (matches the existing `PROTOCOL_VERSION` style). +export const MODIFIER_PROFILE_INVALID = "MODIFIER_PROFILE_INVALID" as const; +export const MODIFIER_PROFILE_NO_KING = "MODIFIER_PROFILE_NO_KING" as const; +export const MODIFIER_PROFILE_INVULN_KING = "MODIFIER_PROFILE_INVULN_KING" as const; +export const MODIFIER_PROFILE_DEADLOCK = "MODIFIER_PROFILE_DEADLOCK" as const; + export const GameEndReasonSchema = z.enum([ "checkmate", "stalemate", @@ -157,12 +175,149 @@ export const ResolvedLayoutSchema = z.object({ }); export type ResolvedLayout = z.infer; +// --------------------------------------------------------------------------- +// Modifier profiles — orthogonal to layouts and presets. Attach per-type or +// per-instance rule modifiers (HP bonus, extra range, direction additions, +// etc.) that apply to pieces at game start. +// +// NOTE: the chess package is the authoritative owner of `ModifierProfile` +// and its schema. The server package pins a different major of `zod`, so +// we mirror the schema SHAPE here for wire validation rather than +// re-exporting the chess-side zod schema (which would pull its zod major +// into this compilation unit). The TS types are still imported from +// `@paratype/chess` so the two stay in structural lockstep — any drift +// surfaces as a compile error on the `z.infer<...> satisfies ModifierProfile` +// assertion below. +// --------------------------------------------------------------------------- + +const ModifierKindIdSchema = z.enum([ + "hp-bonus", + "range-bonus", + "direction-additions", + "capture-flags", + "promotion-override", + "damage-resistance", +]); + +const ModifierColorExtSchema = z.enum(["white", "black", "both"]); + +const ModifierAlgebraicSquareSchema = z + .string() + .regex(/^[a-h][1-8]$/, "square must be algebraic notation a1..h8"); + +export const TypeModifierSchema = z.object({ + kind: ModifierKindIdSchema, + pieceType: PieceTypeSchema, + color: ModifierColorExtSchema, + // `value` is per-kind; descriptor-level schemas validate it on the + // chess engine side. Keep it `unknown` on the wire so forwards-compat + // with new modifier kinds is additive (no server redeploy needed for + // a new value shape that clients negotiate separately). + value: z.unknown(), +}); +export type TypeModifierWire = z.infer; + +export const InstanceModifierSchema = z.object({ + kind: ModifierKindIdSchema, + square: ModifierAlgebraicSquareSchema, + value: z.unknown(), +}); +export type InstanceModifierWire = z.infer; + +/** + * Wire schema for a modifier profile. Shape-mirrored with + * `@paratype/chess`'s `ModifierProfileSchema` — the chess-side type is + * imported above and a TS-only compatibility check below enforces no + * drift without requiring us to export the chess-side zod schema across + * a zod major-version boundary. + */ +export const ModifierProfileSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + description: z.string(), + layoutId: z.string().optional(), + perType: z.array(TypeModifierSchema), + perInstance: z.array(InstanceModifierSchema), + version: z.literal(1), + source: z.enum(["premade", "custom"]), +}); +export type ModifierProfileWire = z.infer; + +// Compile-time drift guard: checks that the wire schema and the +// chess-side `ModifierProfile` share the same KEY set. We don't try to +// compare value types — `readonly`-vs-mutable arrays and +// `optional`-vs-`undefined` variance make symmetric assignability +// checks brittle. Key parity catches the realistic drift vector (a new +// field appears on one side and is forgotten on the other). +type _Keys = keyof T; +type _AssertKeyEq = [A] extends [B] + ? [B] extends [A] + ? true + : never + : never; +type _ModifierProfileKeyCheck = _AssertKeyEq< + _Keys, + _Keys +>; +const _modifierProfileKeyCheck: _ModifierProfileKeyCheck = true; +void _modifierProfileKeyCheck; + export const RoomCreatePayloadSchema = z.object({ rulesetIds: z.array(z.string()).optional(), layout: LayoutRequestSchema.optional(), + /** + * Optional inline modifier profile to apply at room creation. When + * omitted no per-type / per-instance modifiers are seeded. Clients + * may later swap the profile via `modifier-profile.update` at a + * turn boundary (server-enforced). + */ + profile: ModifierProfileSchema.optional(), }); export type RoomCreatePayload = z.infer; +/** + * Client → server: replace the room's modifier profile. The server + * validates the new profile (schema, descriptor value-schemas, king + * invariants) and applies it at the next turn boundary — NOT mid-move. + * Successful application is broadcast via `modifier-profile.updated`. + * + * `version` is the PROFILE version the client last observed for this + * room (monotonically incremented by the server on each successful + * update). Stale requests are rejected with `MODIFIER_PROFILE_INVALID` + * so concurrent edits from both players never silently overwrite. + */ +export const ModifierProfileUpdatePayloadSchema = z.object({ + type: z.literal("modifier-profile.update"), + roomCode: RoomCodeSchema, + newProfile: ModifierProfileSchema, + version: z.number().int().min(0), +}); +export type ModifierProfileUpdatePayload = z.infer< + typeof ModifierProfileUpdatePayloadSchema +>; + +/** + * Server → client: the room's active modifier profile was swapped. + * Clients should apply the new profile to their local engine at the + * next turn boundary (server guarantees this is AT the turn + * boundary — the broadcast is emitted immediately before the + * ensuing `game.state` / `game.delta`). + * + * Not currently dispatched through the Zod `AnyMessageSchema` union + * because the feature is still being rolled out end-to-end; once T18 + * wires broadcast through the rooms code this payload gets promoted + * to a full `msg()` entry in the union. + */ +export interface ModifierProfileUpdatedPayload { + readonly type: "modifier-profile.updated"; + readonly profile: ModifierProfile; + readonly version: number; + /** Indicates WHEN the new profile became effective — always the + * turn boundary for now; kept explicit so a future "immediate" + * policy can be added without breaking existing clients. */ + readonly appliedAt: "turn-boundary"; +} + export const RoomJoinPayloadSchema = z.object({ code: RoomCodeSchema, });