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