From 7cc9617f3e62f718bfb854839ce96e4363a79f66 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Mon, 20 Apr 2026 16:43:38 -0600 Subject: [PATCH] fix(server): widen ModifierKindIdSchema to accept custom modifier ids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T3 audit gap 1 (BLOCKER). The server's ModifierKindIdSchema was still z.enum([built-ins]) even after the client-side mirror was widened during T29. Effect: any multiplayer room.create or modifier-profile.update carrying a profile whose perType[i].kind is a user-authored CustomModifierId (e.g. 'custom:shield') was rejected server-side as INVALID_MESSAGE. Symmetric fix: accept any non-empty string, defer kind validity to the engine's registry-dispatch fallback at apply time (MODIFIER_REGISTRY → engine.customModifiers → warn-and-skip). The built-in id list is preserved as a doc comment + void-referenced constant so it can't silently drift. Test updated: 'rejects a profile with unknown modifier kind' inverted to 'accepts a profile with arbitrary modifier kind (T3 widening)' + new 'rejects empty-string modifier kind' to preserve the min(1) guard. --- packages/server/src/protocol.test.ts | 23 +++++++++++++++++++++-- packages/server/src/protocol.ts | 22 ++++++++++++++++++++-- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/packages/server/src/protocol.test.ts b/packages/server/src/protocol.test.ts index 695f1d6..e372372 100644 --- a/packages/server/src/protocol.test.ts +++ b/packages/server/src/protocol.test.ts @@ -681,12 +681,31 @@ describe("ModifierProfileSchema", () => { expect(r.success).toBe(false); }); - it("rejects a profile with unknown modifier kind", () => { + it("accepts a profile with arbitrary modifier kind (T3 widening for custom ids)", () => { + // Pre-T3 the wire enum rejected unknown kinds. The widening was + // forced by user-authored CustomModifierIds (arbitrary strings like + // "custom:shield"); kind validity is now enforced by the engine's + // registry-dispatch fallback at apply time, not at the wire. const r = ModifierProfileSchema.safeParse({ ...validProfile, perType: [ { - kind: "teleport", + kind: "custom:teleport", + pieceType: "pawn", + color: "white", + value: 1, + }, + ], + }); + expect(r.success).toBe(true); + }); + + it("rejects a profile with empty-string modifier kind", () => { + const r = ModifierProfileSchema.safeParse({ + ...validProfile, + perType: [ + { + kind: "", pieceType: "pawn", color: "white", value: 1, diff --git a/packages/server/src/protocol.ts b/packages/server/src/protocol.ts index d9b97f6..9f431d0 100644 --- a/packages/server/src/protocol.ts +++ b/packages/server/src/protocol.ts @@ -195,14 +195,32 @@ export type ResolvedLayout = z.infer; // assertion below. // --------------------------------------------------------------------------- -const ModifierKindIdSchema = z.enum([ +/** + * Built-in modifier ids — documented here for reference; the actual + * parse schema below accepts ANY non-empty string. + * + * T3 widens `kind` away from a literal enum because user-authored + * custom modifier descriptors use arbitrary ids (e.g. "custom:shield"). + * Rejecting unknown kinds server-side would drop every profile that + * references a custom descriptor, breaking multiplayer end-to-end. + * + * Validity of the kind is enforced ELSEWHERE: the chess engine's + * apply pipeline looks the id up in MODIFIER_REGISTRY (built-ins) + * then engine.customModifiers (user-authored). Unknown kinds are + * warned and skipped — forwards-compat with a new built-in by + * design. + */ +const BUILTIN_MODIFIER_KINDS = [ "hp-bonus", "range-bonus", "direction-additions", "capture-flags", "promotion-override", "damage-resistance", -]); +] as const; +void BUILTIN_MODIFIER_KINDS; // documentation anchor; server parse accepts any string + +const ModifierKindIdSchema = z.string().min(1); const ModifierColorExtSchema = z.enum(["white", "black", "both"]);