feat(server): modifier profile protocol schemas + error codes

Adds wire schemas and error codes for the modifier-profile feature:

- ModifierProfileSchema mirrored in server/protocol.ts (server pins a different zod major, so the chess-side schema cannot be re-exported directly). A keyof parity check guards against drift.

- RoomCreatePayloadSchema gains an optional 'profile' field — additive, existing callers unaffected.

- modifier-profile.update (client->server) payload schema with roomCode, newProfile, and version (for optimistic-concurrency checks).

- modifier-profile.updated (server->client) broadcast payload interface, typed for future promotion to the discriminated union when the broadcast is wired through rooms.

- Four new error codes: MODIFIER_PROFILE_INVALID / NO_KING / INVULN_KING / DEADLOCK, exported as const literals alongside the enum.

- Client wire types (packages/chess/src/net/types.ts) mirror all of the above: ModifierProfileWire shape, optional 'profile' on Room{Create,Created,Joined} payloads, and the new modifier-profile.* client/server message envelopes.

- PROTOCOL.md documents the new request/response flow and extends the error-code table.

Tests: 20 new cases across ModifierProfileSchema, RoomCreate profile integration, ModifierProfileUpdatePayloadSchema, and error-code acceptance.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-18 22:43:58 -06:00
commit 4b7d943edc
No known key found for this signature in database
5 changed files with 603 additions and 2 deletions

View file

@ -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";

View file

@ -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<string, never>>
| MessageEnvelope<"game.move", GameMovePayload>
| MessageEnvelope<"room.setPresets", RoomSetPresetsPayload>;
| MessageEnvelope<"room.setPresets", RoomSetPresetsPayload>
| MessageEnvelope<"modifier-profile.update", ModifierProfileUpdatePayload>;

View file

@ -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 |

View file

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

View file

@ -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<typeof ErrorCodeSchema>;
// 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<typeof ResolvedLayoutSchema>;
// ---------------------------------------------------------------------------
// 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<typeof TypeModifierSchema>;
export const InstanceModifierSchema = z.object({
kind: ModifierKindIdSchema,
square: ModifierAlgebraicSquareSchema,
value: z.unknown(),
});
export type InstanceModifierWire = z.infer<typeof InstanceModifierSchema>;
/**
* 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<typeof ModifierProfileSchema>;
// 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<T> = keyof T;
type _AssertKeyEq<A, B> = [A] extends [B]
? [B] extends [A]
? true
: never
: never;
type _ModifierProfileKeyCheck = _AssertKeyEq<
_Keys<ModifierProfileWire>,
_Keys<ModifierProfile>
>;
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<typeof RoomCreatePayloadSchema>;
/**
* 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,
});