feat(net): client subscriber + send method for custom-modifier broadcast

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.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-19 21:38:32 -06:00
commit 63c46a3f9e
No known key found for this signature in database
3 changed files with 80 additions and 1 deletions

View file

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

View file

@ -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 / …)

View file

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