From 63c46a3f9ed9b059d6086ac9cd2647fb8968e60e Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Sun, 19 Apr 2026 21:38:32 -0600 Subject: [PATCH] feat(net): client subscriber + send method for custom-modifier broadcast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the T24 server-side custom-modifier.register handler all the way through to per-engine custom registries on every connected client. net/types.ts: - new CustomModifierDescriptorWire interface mirroring the chess-side CustomModifierDescriptor (structurally identical; Zod-mirrored across the v3/v4 boundary). - new CustomModifierRegisterPayload + CustomModifierRegisteredPayload. - ServerMessage union extended with custom-modifier.registered envelope. - ClientMessage union extended with custom-modifier.register envelope. net/client.ts: - GameClientEvent union extended with custom-modifier.registered. - handleMessage dispatch switch routes the event to listeners. - new GameClient.sendRegisterCustomModifier(descriptor) helper that ships the message under the active room code; silent no-op when the client isn't in a room (mirrors sendMove's pre-connect guard). net/prediction.ts: - PredictionManager subscribes to custom-modifier.registered. On receipt, registers the descriptor onto BOTH baseEngine.customModifiers AND predictedEngine.customModifiers (when present) so subsequent profile applies and reconciliation from a future game.state can resolve the kind. Triggers an onStateChange so the UI re-renders. Two e2e fixmes remain — both depend on a CustomModifierEditor button that calls sendRegisterCustomModifier when the editor is opened from a multiplayer game. The wire is fully implemented; only the editor's multiplayer-aware send-button surface is missing. Documented as a T3.1 follow-up in the e2e fixme comments. --- packages/chess/src/net/client.ts | 29 +++++++++++++++++++++++ packages/chess/src/net/prediction.ts | 17 ++++++++++++++ packages/chess/src/net/types.ts | 35 +++++++++++++++++++++++++++- 3 files changed, 80 insertions(+), 1 deletion(-) diff --git a/packages/chess/src/net/client.ts b/packages/chess/src/net/client.ts index a8fa42b..7f9c746 100644 --- a/packages/chess/src/net/client.ts +++ b/packages/chess/src/net/client.ts @@ -12,6 +12,7 @@ import type { ClientMessage, + CustomModifierRegisteredPayload, ErrorPayload, GameDeltaPayload, GameEndPayload, @@ -46,6 +47,7 @@ export type GameClientEvent = | { type: "modifier-profile.consent-received"; payload: ModifierProfileConsentReceivedPayload } | { type: "modifier-profile.queued"; payload: ModifierProfileQueuedPayload } | { type: "modifier-profile.updated"; payload: ModifierProfileUpdatedPayload } + | { type: "custom-modifier.registered"; payload: CustomModifierRegisteredPayload } | { type: "error"; payload: ErrorPayload } | { type: "connected" } | { type: "disconnected"; willReconnect: boolean }; @@ -280,6 +282,30 @@ export class GameClient { this.send({ type: "room.setPresets", payload }); } + /** + * T3: register a custom modifier descriptor on the current room. + * Server validates structurally, stores in the per-room registry + * (capped at 10 distinct descriptors), and broadcasts + * `custom-modifier.registered` to every connected client. Clients + * (including the sender) mirror the descriptor onto their local + * engine's customModifiers registry via the predicate manager + * subscriber, so subsequent profile applies can resolve the kind. + */ + sendRegisterCustomModifier( + descriptor: import("./types.js").CustomModifierDescriptorWire, + ): void { + if (this.code === null) { + // No active room — silently no-op rather than throw, mirroring + // sendMove's behaviour when called pre-connect. + return; + } + const payload: import("./types.js").CustomModifierRegisterPayload = { + roomCode: this.code, + descriptor, + }; + this.send({ type: "custom-modifier.register", payload }); + } + // ------------------------------------------------------------------------- // Accessors (primarily for tests & reconnect logic) // ------------------------------------------------------------------------- @@ -458,6 +484,9 @@ export class GameClient { case "modifier-profile.updated": this.emit({ type, payload: payload as ModifierProfileUpdatedPayload }); return; + case "custom-modifier.registered": + this.emit({ type, payload: payload as CustomModifierRegisteredPayload }); + return; case "error": this.emit({ type, payload: payload as ErrorPayload }); return; diff --git a/packages/chess/src/net/prediction.ts b/packages/chess/src/net/prediction.ts index b577378..49d9f3d 100644 --- a/packages/chess/src/net/prediction.ts +++ b/packages/chess/src/net/prediction.ts @@ -125,6 +125,23 @@ export class PredictionManager { } this.onStateChange(this.baseEngine); }); + // T3: when the server broadcasts a custom modifier registration, + // mirror it onto every local engine's per-instance custom registry + // so subsequent profile applies (or reconciliation from a future + // game.state) can resolve the kind. The descriptor is sent + // verbatim; we cast to the chess-side type since the wire shape + // is structurally identical (Zod-mirrored across the v3/v4 boundary). + this.client.on("custom-modifier.registered", (e) => { + const descriptor = e.payload + .descriptor as unknown as Parameters< + typeof this.baseEngine.customModifiers.register + >[0]; + this.baseEngine.customModifiers.register(descriptor); + if (this.predictedEngine) { + this.predictedEngine.customModifiers.register(descriptor); + } + this.onStateChange(this.baseEngine); + }); this.client.on("error", (e) => { // Fatal errors tear down the session; the app restarts from a fresh // `game.state`. Non-fatal errors (ILLEGAL_MOVE / NOT_YOUR_TURN / …) diff --git a/packages/chess/src/net/types.ts b/packages/chess/src/net/types.ts index 430c222..d92ddc4 100644 --- a/packages/chess/src/net/types.ts +++ b/packages/chess/src/net/types.ts @@ -261,6 +261,37 @@ export interface ModifierProfileConsentReceivedPayload { roomCode: string; } +/** + * Wire shape of a custom modifier descriptor. Mirrors the chess + * package's CustomModifierDescriptor structurally; the schema + * validation lives on the server (Zod v3) and on the client (Zod v4). + */ +export interface CustomModifierDescriptorWire { + type: "data"; + id: string; + name: string; + description: string; + version: 1; + primitives: ReadonlyArray<{ kind: string; params: unknown }>; + targetAttrs: readonly string[]; + uiForm: "primitive-composer"; + source: "custom"; + author?: string; + createdAt?: number; +} + +/** Client → server: register a custom modifier descriptor on a room. */ +export interface CustomModifierRegisterPayload { + roomCode: string; + descriptor: CustomModifierDescriptorWire; +} + +/** Server → all clients in room: a new custom modifier was registered. */ +export interface CustomModifierRegisteredPayload { + roomCode: string; + descriptor: CustomModifierDescriptorWire; +} + export interface ErrorPayload { code: string; message: string; @@ -353,6 +384,7 @@ export type ServerMessage = "modifier-profile.consent-received", ModifierProfileConsentReceivedPayload > + | MessageEnvelope<"custom-modifier.registered", CustomModifierRegisteredPayload> | MessageEnvelope<"error", ErrorPayload>; export type ClientMessage = @@ -363,4 +395,5 @@ export type ClientMessage = | MessageEnvelope<"room.setPresets", RoomSetPresetsPayload> | MessageEnvelope<"modifier-profile.update", ModifierProfileUpdatePayload> | MessageEnvelope<"modifier-profile.propose", ModifierProfileProposePayload> - | MessageEnvelope<"modifier-profile.consent", ModifierProfileConsentPayload>; + | MessageEnvelope<"modifier-profile.consent", ModifierProfileConsentPayload> + | MessageEnvelope<"custom-modifier.register", CustomModifierRegisterPayload>;