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