feat(server): custom-modifier.register WS handler
T3 Wave 4 (T24). Adds wire protocol + server handler for registering
user-authored custom modifier descriptors on a room's per-engine
custom registry (ADR-4).
Protocol:
- CustomModifierDescriptorSchema (Zod v3 mirror of the chess-side
schema; structural validation only — primitive-kind semantics stay
client-side to avoid duplicating the primitive catalog across the
zod v3/v4 boundary).
- 'custom-modifier.register' client message carrying { roomCode,
descriptor }.
- 'custom-modifier.registered' server broadcast mirroring the same
shape out to every connected client in the room.
- New error codes: CUSTOM_MODIFIER_INVALID, CUSTOM_MODIFIER_LIMIT.
Handler:
- Auth + room-match checks matching the rest of the WS surface.
- Lazy per-room Map<id, CustomModifierDescriptorWire> created on first
register.
- Capacity cap at 10 distinct ids per room (T3 DoS guard). Re-register
with the same id REPLACES without consuming a new slot.
- On success: broadcasts to every connected client (proposer included
— confirms server-authoritative state).
6 integration tests (mock ServerWebSocket harness; same pattern as
ws.modifier-profile-update.test.ts): happy-path accept+broadcast,
same-id replace, 11th-distinct rejection, roomCode mismatch, no-auth
rejection, malformed descriptor (schema layer). All green; 1369 unit
tests pass. Engine-side wire-up (late-joiner mirroring, client client.ts
subscriber) deferred to T25/T26 UI work.
This commit is contained in:
parent
2b641c78bb
commit
109be25be6
4 changed files with 483 additions and 1 deletions
|
|
@ -29,6 +29,7 @@ import { RateLimiter } from "./middleware.js";
|
|||
import {
|
||||
PROTOCOL_VERSION,
|
||||
validateMessageString,
|
||||
type CustomModifierRegisterPayload,
|
||||
type ErrorCode,
|
||||
type Fact as WireFact,
|
||||
type GameMovePayload,
|
||||
|
|
@ -273,6 +274,9 @@ export function handleMessage(
|
|||
case "modifier-profile.consent":
|
||||
handleModifierProfileConsent(ws, msg.payload);
|
||||
break;
|
||||
case "custom-modifier.register":
|
||||
handleCustomModifierRegister(ws, msg.payload);
|
||||
break;
|
||||
case "room.created":
|
||||
case "room.joined":
|
||||
case "game.state":
|
||||
|
|
@ -284,6 +288,7 @@ export function handleMessage(
|
|||
case "modifier-profile.proposal-pending":
|
||||
case "modifier-profile.rejected":
|
||||
case "modifier-profile.consent-received":
|
||||
case "custom-modifier.registered":
|
||||
case "error":
|
||||
sendTo(
|
||||
ws,
|
||||
|
|
@ -1451,6 +1456,94 @@ function bufferDeltaForDisconnected(
|
|||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T3 — custom-modifier.register handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Maximum custom modifier descriptors per room (T3 DoS guard). */
|
||||
const CUSTOM_MODIFIER_ROOM_CAP = 10;
|
||||
|
||||
/**
|
||||
* Register a user-authored custom modifier descriptor on this room.
|
||||
* Structural validation already happened via `validateMessageString`
|
||||
* before dispatch — here we only enforce the per-room capacity,
|
||||
* store into the room's `customModifiers` map (creating it on first
|
||||
* call), and broadcast to every connected client so opponents'
|
||||
* engines mirror the same custom registry.
|
||||
*
|
||||
* Updates are idempotent keyed on descriptor id: re-registering with
|
||||
* the same id REPLACES the existing entry and broadcasts the new
|
||||
* shape; the capacity check counts distinct ids only.
|
||||
*/
|
||||
function handleCustomModifierRegister(
|
||||
ws: ServerWebSocket<ClientData>,
|
||||
payload: CustomModifierRegisterPayload,
|
||||
): void {
|
||||
const { roomCode, token } = ws.data;
|
||||
if (roomCode === undefined || token === undefined) {
|
||||
sendTo(
|
||||
ws,
|
||||
errorMessage("BAD_TOKEN", "not authenticated into a room", false),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.roomCode !== roomCode) {
|
||||
sendTo(
|
||||
ws,
|
||||
errorMessage(
|
||||
"BAD_TOKEN",
|
||||
"custom-modifier.register roomCode does not match authenticated room",
|
||||
false,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const room = roomRegistry.getRoom(roomCode);
|
||||
if (!room) {
|
||||
sendTo(
|
||||
ws,
|
||||
errorMessage("ROOM_NOT_FOUND", `room ${roomCode} not found`, false),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Lazily create the per-room custom registry.
|
||||
if (room.customModifiers === undefined) {
|
||||
room.customModifiers = new Map();
|
||||
}
|
||||
|
||||
const existing = room.customModifiers.has(payload.descriptor.id);
|
||||
if (!existing && room.customModifiers.size >= CUSTOM_MODIFIER_ROOM_CAP) {
|
||||
sendTo(
|
||||
ws,
|
||||
errorMessage(
|
||||
"CUSTOM_MODIFIER_LIMIT",
|
||||
`custom modifier cap (${CUSTOM_MODIFIER_ROOM_CAP}) reached for room ${roomCode}`,
|
||||
false,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
room.customModifiers.set(payload.descriptor.id, payload.descriptor);
|
||||
|
||||
logger
|
||||
.child({ roomCode, clientId: ws.data.clientId })
|
||||
.info({ descriptorId: payload.descriptor.id }, "custom-modifier.register");
|
||||
|
||||
// Broadcast to every client (proposer included — confirms the
|
||||
// server-authoritative state matches what was sent).
|
||||
broadcastToRoom(
|
||||
roomCode,
|
||||
envelope("custom-modifier.registered", {
|
||||
roomCode,
|
||||
descriptor: payload.descriptor,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast a game.end to everyone in `code` EXCEPT the player whose
|
||||
* token is `leaverToken`. Used when a player disconnects or leaves
|
||||
|
|
|
|||
|
|
@ -63,6 +63,9 @@ export const ErrorCodeSchema = z.enum([
|
|||
"MODIFIER_PROFILE_NO_KING",
|
||||
"MODIFIER_PROFILE_INVULN_KING",
|
||||
"MODIFIER_PROFILE_DEADLOCK",
|
||||
// T3 custom modifier registration failures.
|
||||
"CUSTOM_MODIFIER_INVALID",
|
||||
"CUSTOM_MODIFIER_LIMIT",
|
||||
]);
|
||||
export type ErrorCode = z.infer<typeof ErrorCodeSchema>;
|
||||
|
||||
|
|
@ -73,6 +76,8 @@ 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 CUSTOM_MODIFIER_INVALID = "CUSTOM_MODIFIER_INVALID" as const;
|
||||
export const CUSTOM_MODIFIER_LIMIT = "CUSTOM_MODIFIER_LIMIT" as const;
|
||||
|
||||
export const GameEndReasonSchema = z.enum([
|
||||
"checkmate",
|
||||
|
|
@ -453,6 +458,87 @@ export type ModifierProfileConsentReceivedPayload = z.infer<
|
|||
typeof ModifierProfileConsentReceivedPayloadSchema
|
||||
>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T3 — Custom modifier descriptor registration
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Users compose custom modifier descriptors from the 15 engine-shipped
|
||||
// effect primitives (T3-ADR-2). Each descriptor is a structured JSON
|
||||
// document that travels over the wire as-is and registers into the
|
||||
// room's per-engine custom registry (T3-ADR-4) — never globally, so
|
||||
// two rooms can own descriptors with the same id without collision.
|
||||
//
|
||||
// The server performs STRUCTURAL validation here (Zod shape + count
|
||||
// caps). Semantic validation (primitive-kind-in-registry, paramsSchema
|
||||
// satisfaction, recursion depth ≤ 3) happens on the client via
|
||||
// `validateCustomDescriptor` before send — rejecting malformed
|
||||
// descriptors server-side would require mirroring the primitive
|
||||
// catalog across the Zod v3/v4 boundary and isn't worth the duplication.
|
||||
// Clients that skip their own validation get the engine's degradation
|
||||
// behaviour: unknown primitives are silently skipped at apply time.
|
||||
|
||||
const EffectPrimitiveNodeWireSchema = z.lazy(() =>
|
||||
z.object({
|
||||
kind: z.string().min(1),
|
||||
// z.unknown() is used rather than z.any() for parity with the client-side
|
||||
// schema; Zod v3 infers `unknown` fields as optional at the type level,
|
||||
// but runtime parsing accepts any value (including undefined).
|
||||
params: z.unknown(),
|
||||
}),
|
||||
);
|
||||
|
||||
/** Wire shape for a user-authored custom modifier descriptor. */
|
||||
export const CustomModifierDescriptorSchema = z.object({
|
||||
type: z.literal("data"),
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1).max(40),
|
||||
description: z.string().max(200),
|
||||
version: z.literal(1),
|
||||
// DoS guard: cap the primitive array length. Deep nesting is the
|
||||
// client validator's responsibility — the server sees flat arrays
|
||||
// because nested children are inside `params`.
|
||||
primitives: z.array(EffectPrimitiveNodeWireSchema).max(50),
|
||||
targetAttrs: z.array(z.string()).max(32),
|
||||
uiForm: z.literal("primitive-composer"),
|
||||
source: z.literal("custom"),
|
||||
author: z.string().max(80).optional(),
|
||||
createdAt: z.number().int().nonnegative().optional(),
|
||||
});
|
||||
export type CustomModifierDescriptorWire = z.infer<
|
||||
typeof CustomModifierDescriptorSchema
|
||||
>;
|
||||
|
||||
/**
|
||||
* Client → server: register a custom modifier descriptor on this
|
||||
* room's per-engine custom registry. Server validates structurally
|
||||
* and rejects with `CUSTOM_MODIFIER_INVALID` on shape failure or
|
||||
* `CUSTOM_MODIFIER_LIMIT` when the room is at capacity (10 custom
|
||||
* descriptors). On success broadcasts `custom-modifier.registered`
|
||||
* to every connected client in the room so opponents' engines
|
||||
* mirror the same custom registry.
|
||||
*/
|
||||
export const CustomModifierRegisterPayloadSchema = z.object({
|
||||
roomCode: RoomCodeSchema,
|
||||
descriptor: CustomModifierDescriptorSchema,
|
||||
});
|
||||
export type CustomModifierRegisterPayload = z.infer<
|
||||
typeof CustomModifierRegisterPayloadSchema
|
||||
>;
|
||||
|
||||
/**
|
||||
* Server → all clients in room: a new custom modifier descriptor is
|
||||
* available for reference in subsequent ModifierProfile entries.
|
||||
* Clients register the descriptor into their local engine's
|
||||
* `customModifiers` registry on receipt.
|
||||
*/
|
||||
export const CustomModifierRegisteredPayloadSchema = z.object({
|
||||
roomCode: RoomCodeSchema,
|
||||
descriptor: CustomModifierDescriptorSchema,
|
||||
});
|
||||
export type CustomModifierRegisteredPayload = z.infer<
|
||||
typeof CustomModifierRegisteredPayloadSchema
|
||||
>;
|
||||
|
||||
export const RoomJoinPayloadSchema = z.object({
|
||||
code: RoomCodeSchema,
|
||||
});
|
||||
|
|
@ -631,6 +717,10 @@ export const ModifierProfileConsentMessageSchema = msg(
|
|||
"modifier-profile.consent",
|
||||
ModifierProfileConsentPayloadSchema,
|
||||
);
|
||||
export const CustomModifierRegisterMessageSchema = msg(
|
||||
"custom-modifier.register",
|
||||
CustomModifierRegisterPayloadSchema,
|
||||
);
|
||||
|
||||
export const ClientMessageSchema = z.discriminatedUnion("type", [
|
||||
RoomCreateMessageSchema,
|
||||
|
|
@ -641,6 +731,7 @@ export const ClientMessageSchema = z.discriminatedUnion("type", [
|
|||
ModifierProfileUpdateMessageSchema,
|
||||
ModifierProfileProposeMessageSchema,
|
||||
ModifierProfileConsentMessageSchema,
|
||||
CustomModifierRegisterMessageSchema,
|
||||
]);
|
||||
export type ClientMessage = z.infer<typeof ClientMessageSchema>;
|
||||
|
||||
|
|
@ -679,6 +770,10 @@ export const ModifierProfileConsentReceivedMessageSchema = msg(
|
|||
"modifier-profile.consent-received",
|
||||
ModifierProfileConsentReceivedPayloadSchema,
|
||||
);
|
||||
export const CustomModifierRegisteredMessageSchema = msg(
|
||||
"custom-modifier.registered",
|
||||
CustomModifierRegisteredPayloadSchema,
|
||||
);
|
||||
export const ErrorMessageSchema = msg("error", ErrorPayloadSchema);
|
||||
|
||||
export const ServerMessageSchema = z.discriminatedUnion("type", [
|
||||
|
|
@ -693,6 +788,7 @@ export const ServerMessageSchema = z.discriminatedUnion("type", [
|
|||
ModifierProfileProposalPendingMessageSchema,
|
||||
ModifierProfileRejectedMessageSchema,
|
||||
ModifierProfileConsentReceivedMessageSchema,
|
||||
CustomModifierRegisteredMessageSchema,
|
||||
ErrorMessageSchema,
|
||||
]);
|
||||
export type ServerMessage = z.infer<typeof ServerMessageSchema>;
|
||||
|
|
@ -706,6 +802,7 @@ export const AnyMessageSchema = z.discriminatedUnion("type", [
|
|||
ModifierProfileUpdateMessageSchema,
|
||||
ModifierProfileProposeMessageSchema,
|
||||
ModifierProfileConsentMessageSchema,
|
||||
CustomModifierRegisterMessageSchema,
|
||||
RoomCreatedMessageSchema,
|
||||
RoomJoinedMessageSchema,
|
||||
GameStateMessageSchema,
|
||||
|
|
@ -717,6 +814,7 @@ export const AnyMessageSchema = z.discriminatedUnion("type", [
|
|||
ModifierProfileProposalPendingMessageSchema,
|
||||
ModifierProfileRejectedMessageSchema,
|
||||
ModifierProfileConsentReceivedMessageSchema,
|
||||
CustomModifierRegisteredMessageSchema,
|
||||
ErrorMessageSchema,
|
||||
]);
|
||||
export type AnyMessage = z.infer<typeof AnyMessageSchema>;
|
||||
|
|
@ -730,6 +828,7 @@ export const KNOWN_MESSAGE_TYPES = [
|
|||
"modifier-profile.update",
|
||||
"modifier-profile.propose",
|
||||
"modifier-profile.consent",
|
||||
"custom-modifier.register",
|
||||
"room.created",
|
||||
"room.joined",
|
||||
"game.state",
|
||||
|
|
@ -741,6 +840,7 @@ export const KNOWN_MESSAGE_TYPES = [
|
|||
"modifier-profile.proposal-pending",
|
||||
"modifier-profile.rejected",
|
||||
"modifier-profile.consent-received",
|
||||
"custom-modifier.registered",
|
||||
"error",
|
||||
] as const;
|
||||
export type MessageType = (typeof KNOWN_MESSAGE_TYPES)[number];
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@
|
|||
// six-character upper-case alphanumeric strings ([A-Z0-9]) drawn from a CSPRNG.
|
||||
// Nothing here persists across restarts — PROTOCOL.md §Auth & Rooms mandates
|
||||
// in-memory-only storage.
|
||||
import type { Color } from "./protocol.js";
|
||||
import type {
|
||||
Color,
|
||||
CustomModifierDescriptorWire,
|
||||
} from "./protocol.js";
|
||||
import {
|
||||
CLASSIC_LAYOUT,
|
||||
type ModifierProfile,
|
||||
|
|
@ -44,6 +47,14 @@ export interface Room {
|
|||
* and the server replays it onto reconstructed engines.
|
||||
*/
|
||||
profile?: ModifierProfile;
|
||||
/**
|
||||
* T3: user-authored custom modifier descriptors registered into
|
||||
* this room via `custom-modifier.register`. Keyed by descriptor id.
|
||||
* Scoped to the room so per-room isolation (ADR-4) holds across
|
||||
* server restarts — a room that dies and is re-created is a new
|
||||
* room, not a resumption. Capped at 10 entries (T3 DoS guard).
|
||||
*/
|
||||
customModifiers?: Map<string, CustomModifierDescriptorWire>;
|
||||
/**
|
||||
* Queued profile swap awaiting the next turn boundary (T2-ADR-1).
|
||||
*
|
||||
|
|
|
|||
278
packages/server/src/ws.custom-modifier-register.test.ts
Normal file
278
packages/server/src/ws.custom-modifier-register.test.ts
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
/**
|
||||
* T3 integration tests: `custom-modifier.register` WebSocket handler.
|
||||
*
|
||||
* Covers:
|
||||
* 1. Valid register → room customModifiers populated + broadcast fires.
|
||||
* 2. Re-register (same id) → replace in place, still broadcasts.
|
||||
* 3. 11th distinct descriptor → CUSTOM_MODIFIER_LIMIT rejection.
|
||||
* 4. Room mismatch → BAD_TOKEN.
|
||||
* 5. No auth → BAD_TOKEN.
|
||||
* 6. Unknown room → ROOM_NOT_FOUND.
|
||||
* 7. Malformed descriptor (validator catches it at schema layer).
|
||||
*/
|
||||
import type { ServerWebSocket } from "bun";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
handleMessage,
|
||||
registerConnection,
|
||||
roomRegistry,
|
||||
type ClientData,
|
||||
} from "./broadcast.js";
|
||||
import {
|
||||
PROTOCOL_VERSION,
|
||||
type ClientMessage,
|
||||
type CustomModifierDescriptorWire,
|
||||
} from "./protocol.js";
|
||||
|
||||
// ─── Mock ServerWebSocket (same harness as other WS tests) ───────────
|
||||
|
||||
interface MockWs extends ServerWebSocket<ClientData> {
|
||||
readonly sent: unknown[];
|
||||
readonly closed: boolean;
|
||||
}
|
||||
|
||||
function makeMockWs(clientId: string): MockWs {
|
||||
const sent: unknown[] = [];
|
||||
const closedFlag = { value: false };
|
||||
const ws = {
|
||||
data: { clientId } as ClientData,
|
||||
sent,
|
||||
get closed(): boolean {
|
||||
return closedFlag.value;
|
||||
},
|
||||
send(msg: string | Buffer): number {
|
||||
const str = typeof msg === "string" ? msg : msg.toString("utf8");
|
||||
sent.push(JSON.parse(str));
|
||||
return str.length;
|
||||
},
|
||||
close(): void {
|
||||
closedFlag.value = true;
|
||||
},
|
||||
} as unknown as MockWs;
|
||||
return ws;
|
||||
}
|
||||
|
||||
function nextMsgOfType(
|
||||
ws: MockWs,
|
||||
type: string,
|
||||
): { payload: Record<string, unknown> } {
|
||||
const idx = ws.sent.findIndex(
|
||||
(m) =>
|
||||
typeof m === "object" &&
|
||||
m !== null &&
|
||||
(m as { type: unknown }).type === type,
|
||||
);
|
||||
if (idx < 0) {
|
||||
const types = ws.sent.map((m) => (m as { type?: string }).type);
|
||||
throw new Error(
|
||||
`no message of type "${type}" in inbox (got ${JSON.stringify(types)})`,
|
||||
);
|
||||
}
|
||||
const msg = ws.sent[idx] as { payload: Record<string, unknown> };
|
||||
ws.sent.splice(idx, 1);
|
||||
return msg;
|
||||
}
|
||||
|
||||
function sendClient(
|
||||
ws: MockWs,
|
||||
type: ClientMessage["type"],
|
||||
payload: unknown,
|
||||
seq = 1,
|
||||
): void {
|
||||
handleMessage(
|
||||
ws,
|
||||
JSON.stringify({
|
||||
v: PROTOCOL_VERSION,
|
||||
seq,
|
||||
ts: Date.now(),
|
||||
type,
|
||||
payload,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Descriptor fixtures ─────────────────────────────────────────────
|
||||
|
||||
function makeDescriptor(
|
||||
id: string,
|
||||
overrides: Partial<CustomModifierDescriptorWire> = {},
|
||||
): CustomModifierDescriptorWire {
|
||||
return {
|
||||
type: "data",
|
||||
id,
|
||||
name: id,
|
||||
description: "",
|
||||
version: 1,
|
||||
primitives: [
|
||||
{ kind: "seed-attribute", params: { attr: "HpBonus", value: 1 } },
|
||||
],
|
||||
targetAttrs: ["HpBonus"],
|
||||
uiForm: "primitive-composer",
|
||||
source: "custom",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function setupRoom(): { white: MockWs; black: MockWs; code: string } {
|
||||
const white = makeMockWs(`T3CM-W-${crypto.randomUUID()}`);
|
||||
const black = makeMockWs(`T3CM-B-${crypto.randomUUID()}`);
|
||||
registerConnection(white);
|
||||
registerConnection(black);
|
||||
|
||||
sendClient(white, "room.create", {});
|
||||
const created = nextMsgOfType(white, "room.created");
|
||||
const code = created.payload["code"] as string;
|
||||
|
||||
sendClient(black, "room.join", { code });
|
||||
nextMsgOfType(black, "room.joined");
|
||||
// Drain any game.state broadcasts from both inboxes so subsequent
|
||||
// assertions only see custom-modifier messages.
|
||||
while (
|
||||
white.sent.some(
|
||||
(m) => (m as { type?: string }).type === "game.state",
|
||||
)
|
||||
) {
|
||||
nextMsgOfType(white, "game.state");
|
||||
}
|
||||
while (
|
||||
black.sent.some(
|
||||
(m) => (m as { type?: string }).type === "game.state",
|
||||
)
|
||||
) {
|
||||
nextMsgOfType(black, "game.state");
|
||||
}
|
||||
|
||||
return { white, black, code };
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("custom-modifier.register WS handler (T24)", () => {
|
||||
it("accepts a valid descriptor and broadcasts to both clients", () => {
|
||||
const { white, black, code } = setupRoom();
|
||||
const desc = makeDescriptor("custom:t3-accept");
|
||||
|
||||
sendClient(white, "custom-modifier.register", {
|
||||
roomCode: code,
|
||||
descriptor: desc,
|
||||
});
|
||||
|
||||
// Both receive the broadcast.
|
||||
const wMsg = nextMsgOfType(white, "custom-modifier.registered");
|
||||
expect(wMsg.payload["roomCode"]).toBe(code);
|
||||
expect((wMsg.payload["descriptor"] as { id: string }).id).toBe(
|
||||
"custom:t3-accept",
|
||||
);
|
||||
|
||||
const bMsg = nextMsgOfType(black, "custom-modifier.registered");
|
||||
expect((bMsg.payload["descriptor"] as { id: string }).id).toBe(
|
||||
"custom:t3-accept",
|
||||
);
|
||||
|
||||
// Room state reflects the registration.
|
||||
const room = roomRegistry.getRoom(code);
|
||||
expect(room?.customModifiers?.get("custom:t3-accept")?.id).toBe(
|
||||
"custom:t3-accept",
|
||||
);
|
||||
});
|
||||
|
||||
it("re-register with same id replaces the stored descriptor", () => {
|
||||
const { white, black, code } = setupRoom();
|
||||
const v1 = makeDescriptor("custom:t3-replace", { name: "v1" });
|
||||
const v2 = makeDescriptor("custom:t3-replace", { name: "v2" });
|
||||
|
||||
sendClient(white, "custom-modifier.register", {
|
||||
roomCode: code,
|
||||
descriptor: v1,
|
||||
});
|
||||
nextMsgOfType(white, "custom-modifier.registered");
|
||||
nextMsgOfType(black, "custom-modifier.registered");
|
||||
|
||||
sendClient(white, "custom-modifier.register", {
|
||||
roomCode: code,
|
||||
descriptor: v2,
|
||||
});
|
||||
const latest = nextMsgOfType(black, "custom-modifier.registered");
|
||||
expect((latest.payload["descriptor"] as { name: string }).name).toBe("v2");
|
||||
|
||||
const room = roomRegistry.getRoom(code);
|
||||
expect(room?.customModifiers?.size).toBe(1);
|
||||
expect(room?.customModifiers?.get("custom:t3-replace")?.name).toBe("v2");
|
||||
});
|
||||
|
||||
it("rejects the 11th distinct descriptor with CUSTOM_MODIFIER_LIMIT", () => {
|
||||
const { white, code } = setupRoom();
|
||||
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
sendClient(white, "custom-modifier.register", {
|
||||
roomCode: code,
|
||||
descriptor: makeDescriptor(`custom:t3-bulk-${i}`),
|
||||
});
|
||||
nextMsgOfType(white, "custom-modifier.registered");
|
||||
}
|
||||
|
||||
sendClient(white, "custom-modifier.register", {
|
||||
roomCode: code,
|
||||
descriptor: makeDescriptor("custom:t3-overflow"),
|
||||
});
|
||||
|
||||
const err = nextMsgOfType(white, "error");
|
||||
expect(err.payload["code"]).toBe("CUSTOM_MODIFIER_LIMIT");
|
||||
|
||||
const room = roomRegistry.getRoom(code);
|
||||
expect(room?.customModifiers?.size).toBe(10);
|
||||
expect(room?.customModifiers?.has("custom:t3-overflow")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects roomCode mismatch with BAD_TOKEN", () => {
|
||||
const { white, code } = setupRoom();
|
||||
sendClient(white, "custom-modifier.register", {
|
||||
roomCode: "ZZZZZZ", // doesn't match authenticated room
|
||||
descriptor: makeDescriptor("custom:t3-wrong-room"),
|
||||
});
|
||||
const err = nextMsgOfType(white, "error");
|
||||
expect(err.payload["code"]).toBe("BAD_TOKEN");
|
||||
|
||||
const room = roomRegistry.getRoom(code);
|
||||
expect(room?.customModifiers?.size ?? 0).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects unauthenticated sockets with BAD_TOKEN", () => {
|
||||
const stranger = makeMockWs(`T3CM-stranger-${crypto.randomUUID()}`);
|
||||
registerConnection(stranger);
|
||||
|
||||
sendClient(stranger, "custom-modifier.register", {
|
||||
roomCode: "ABCDEF",
|
||||
descriptor: makeDescriptor("custom:t3-stranger"),
|
||||
});
|
||||
|
||||
const err = nextMsgOfType(stranger, "error");
|
||||
expect(err.payload["code"]).toBe("BAD_TOKEN");
|
||||
});
|
||||
|
||||
it("rejects malformed descriptor at the schema layer (INVALID_MESSAGE)", () => {
|
||||
const { white, code } = setupRoom();
|
||||
|
||||
// Missing required `primitives` field.
|
||||
const malformed = {
|
||||
type: "data",
|
||||
id: "custom:t3-malformed",
|
||||
name: "Bad",
|
||||
description: "",
|
||||
version: 1,
|
||||
// primitives: missing
|
||||
targetAttrs: [],
|
||||
uiForm: "primitive-composer",
|
||||
source: "custom",
|
||||
};
|
||||
|
||||
sendClient(white, "custom-modifier.register", {
|
||||
roomCode: code,
|
||||
descriptor: malformed,
|
||||
});
|
||||
|
||||
const err = nextMsgOfType(white, "error");
|
||||
expect(err.payload["code"]).toBe("INVALID_MESSAGE");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue