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:
parent
0e9809007d
commit
4b7d943edc
5 changed files with 603 additions and 2 deletions
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue