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,
|
type LayoutValidationResult,
|
||||||
} from "./layouts/index.js";
|
} from "./layouts/index.js";
|
||||||
export { validateLayout } from "./layouts/validate.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"
|
| "MSG_TOO_LARGE"
|
||||||
| "BAD_TOKEN"
|
| "BAD_TOKEN"
|
||||||
| "INVALID_MESSAGE"
|
| "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)
|
// Starting layout wire shapes (mirrors server/src/protocol.ts)
|
||||||
|
|
@ -66,6 +71,50 @@ export interface Fact {
|
||||||
value: unknown;
|
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
|
// Server → Client payloads
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -124,6 +173,9 @@ export interface RoomCreatedPayload {
|
||||||
/** Resolved starting layout. Optional on the wire for backward
|
/** Resolved starting layout. Optional on the wire for backward
|
||||||
* compat with pre-layouts servers. */
|
* compat with pre-layouts servers. */
|
||||||
layout?: ResolvedLayoutWire;
|
layout?: ResolvedLayoutWire;
|
||||||
|
/** Modifier profile active at room creation. Optional for backward
|
||||||
|
* compat with pre-modifiers servers. */
|
||||||
|
profile?: ModifierProfileWire;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RoomJoinedPayload {
|
export interface RoomJoinedPayload {
|
||||||
|
|
@ -133,6 +185,21 @@ export interface RoomJoinedPayload {
|
||||||
activeRules: string[];
|
activeRules: string[];
|
||||||
/** Resolved starting layout (see RoomCreatedPayload.layout). */
|
/** Resolved starting layout (see RoomCreatedPayload.layout). */
|
||||||
layout?: ResolvedLayoutWire;
|
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 {
|
export interface ErrorPayload {
|
||||||
|
|
@ -150,6 +217,21 @@ export interface RoomCreatePayload {
|
||||||
/** Optional starting-layout selector. When omitted the server
|
/** Optional starting-layout selector. When omitted the server
|
||||||
* opens the room with the FIDE classic layout. */
|
* opens the room with the FIDE classic layout. */
|
||||||
layout?: LayoutRequest;
|
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 {
|
export interface RoomJoinPayload {
|
||||||
|
|
@ -182,6 +264,7 @@ export type ServerMessage =
|
||||||
| MessageEnvelope<"game.presets", GamePresetsPayload>
|
| MessageEnvelope<"game.presets", GamePresetsPayload>
|
||||||
| MessageEnvelope<"room.created", RoomCreatedPayload>
|
| MessageEnvelope<"room.created", RoomCreatedPayload>
|
||||||
| MessageEnvelope<"room.joined", RoomJoinedPayload>
|
| MessageEnvelope<"room.joined", RoomJoinedPayload>
|
||||||
|
| MessageEnvelope<"modifier-profile.updated", ModifierProfileUpdatedPayload>
|
||||||
| MessageEnvelope<"error", ErrorPayload>;
|
| MessageEnvelope<"error", ErrorPayload>;
|
||||||
|
|
||||||
export type ClientMessage =
|
export type ClientMessage =
|
||||||
|
|
@ -189,4 +272,5 @@ export type ClientMessage =
|
||||||
| MessageEnvelope<"room.join", RoomJoinPayload>
|
| MessageEnvelope<"room.join", RoomJoinPayload>
|
||||||
| MessageEnvelope<"room.leave", Record<string, never>>
|
| MessageEnvelope<"room.leave", Record<string, never>>
|
||||||
| MessageEnvelope<"game.move", GameMovePayload>
|
| 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
|
- `layout`: resolved starting layout echoed back. Present on all
|
||||||
`room.created` and `room.joined` responses from new servers; may be
|
`room.created` and `room.joined` responses from new servers; may be
|
||||||
absent on legacy (pre-layouts) servers.
|
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:
|
Error cases:
|
||||||
|
|
||||||
|
|
@ -101,6 +104,14 @@ Error cases:
|
||||||
- Layout fails validation (bad king count, duplicate squares,
|
- Layout fails validation (bad king count, duplicate squares,
|
||||||
unknown premade id, malformed FEN): `error` code `LAYOUT_INVALID`.
|
unknown premade id, malformed FEN): `error` code `LAYOUT_INVALID`.
|
||||||
Message field carries the human-readable reason.
|
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
|
### 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
|
### Message: error
|
||||||
|
|
||||||
Direction: Server → Client
|
Direction: Server → Client
|
||||||
|
|
@ -321,3 +421,8 @@ Messages counted: all messages from client including heartbeats.
|
||||||
| `MSG_TOO_LARGE` | Yes | Message exceeds 64KB |
|
| `MSG_TOO_LARGE` | Yes | Message exceeds 64KB |
|
||||||
| `BAD_TOKEN` | Yes | Token missing or invalid for room |
|
| `BAD_TOKEN` | Yes | Token missing or invalid for room |
|
||||||
| `INVALID_MESSAGE` | Yes | JSON parse failure or schema validation failure |
|
| `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,
|
PROTOCOL_VERSION,
|
||||||
ClientMessageSchema,
|
ClientMessageSchema,
|
||||||
ServerMessageSchema,
|
ServerMessageSchema,
|
||||||
|
ModifierProfileSchema,
|
||||||
|
ModifierProfileUpdatePayloadSchema,
|
||||||
|
RoomCreatePayloadSchema,
|
||||||
|
MODIFIER_PROFILE_INVALID,
|
||||||
|
MODIFIER_PROFILE_NO_KING,
|
||||||
|
MODIFIER_PROFILE_INVULN_KING,
|
||||||
|
MODIFIER_PROFILE_DEADLOCK,
|
||||||
type AnyMessage,
|
type AnyMessage,
|
||||||
type ClientMessage,
|
type ClientMessage,
|
||||||
type ServerMessage,
|
type ServerMessage,
|
||||||
|
|
@ -620,3 +627,240 @@ describe("room.create layout payload", () => {
|
||||||
expect(r.ok).toBe(true);
|
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.
|
// Chess server WebSocket protocol v1 — Zod schemas & validation.
|
||||||
// See PROTOCOL.md for the full spec.
|
// See PROTOCOL.md for the full spec.
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import type { ModifierProfile } from "@paratype/chess";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Primitives
|
// Primitives
|
||||||
|
|
@ -53,9 +54,26 @@ export const ErrorCodeSchema = z.enum([
|
||||||
// failed validation (bad king count, duplicate squares, etc.) or
|
// failed validation (bad king count, duplicate squares, etc.) or
|
||||||
// specified an unknown premade id / malformed FEN.
|
// specified an unknown premade id / malformed FEN.
|
||||||
"LAYOUT_INVALID",
|
"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>;
|
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([
|
export const GameEndReasonSchema = z.enum([
|
||||||
"checkmate",
|
"checkmate",
|
||||||
"stalemate",
|
"stalemate",
|
||||||
|
|
@ -157,12 +175,149 @@ export const ResolvedLayoutSchema = z.object({
|
||||||
});
|
});
|
||||||
export type ResolvedLayout = z.infer<typeof ResolvedLayoutSchema>;
|
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({
|
export const RoomCreatePayloadSchema = z.object({
|
||||||
rulesetIds: z.array(z.string()).optional(),
|
rulesetIds: z.array(z.string()).optional(),
|
||||||
layout: LayoutRequestSchema.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>;
|
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({
|
export const RoomJoinPayloadSchema = z.object({
|
||||||
code: RoomCodeSchema,
|
code: RoomCodeSchema,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue