feat(ui): share custom modifier with multiplayer room — closes T29 fixmes

Threads the multiplayer publisher all the way from useMultiplayerGame
down through GameView → RulesDrawer → ModifierProfileEditor →
CustomModifierEditor, surfacing a Share with Room button in the
custom modifier editor when (and only when) the editor was opened
from a multiplayer game.

Wiring summary (top-down):
- useMultiplayerGame.ts: returns sendRegisterCustomModifier(descriptor),
  a thin wrapper around the GameClient.sendRegisterCustomModifier
  helper added in the previous commit.
- useMultiplayerGame.ts: onError handler surfaces CUSTOM_MODIFIER_INVALID
  and CUSTOM_MODIFIER_LIMIT as toasts on top of the existing in-game
  error banner so the user notices the rejection immediately.
- GameView.tsx: GameEngineState gains an optional
  sendRegisterCustomModifier field; the multiplayer destructure
  pulls it out and passes it to RulesDrawer as
  onShareCustomModifierWithRoom (omitted in solo, where the prop is
  undefined and Share UI doesn't render).
- RulesDrawer.tsx: optional onShareCustomModifierWithRoom prop;
  conditionally forwards to ModifierProfileEditor.
- ModifierProfileEditor.tsx: optional onShareCustomModifierWithRoom
  prop; conditionally forwards to CustomModifierEditor as onShareWithRoom.
- CustomModifierEditor.tsx: when onShareWithRoom is provided, renders
  a green Share with Room button in the header alongside Save. Click
  invokes the publisher with the current descriptor; toast confirms
  the share landed (server broadcast is the actual proof, observed
  by the local PredictionManager subscriber registering the descriptor
  on the engine's customModifiers registry).

E2E coverage (both formerly-fixme tests now PASS):
- multiplayer custom modifier sharing — both clients see the
  registered descriptor: opens two browser contexts via raw WS
  (matches modifier-profiles.spec.ts MP pattern), host registers a
  descriptor after both reconnect-by-token complete, both sides
  observe custom-modifier.registered.
- server rejects custom modifier with > 50 primitives — error event
  observed: host registers a 51-primitive descriptor, asserts an
  INVALID_MESSAGE / CUSTOM_MODIFIER_INVALID error is observed and
  no broadcast fires.

Final state: 79/79 e2e + 1386 unit tests, zero fixmes, zero skipped.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-19 21:56:34 -06:00
commit babee38702
No known key found for this signature in database
6 changed files with 480 additions and 30 deletions

View file

@ -893,32 +893,372 @@ test.describe('T29 — Custom modifier DSL e2e', () => {
// ── Multiplayer scenarios (require live WS server) ─────────────────
test.fixme(
'multiplayer custom modifier sharing — both clients see the registered descriptor',
async () => {
// Wire is fully implemented end-to-end:
// - server: custom-modifier.register handler broadcasts to room (T24)
// - client: GameClient.sendRegisterCustomModifier() ships the message
// - client: PredictionManager subscriber mirrors the descriptor onto
// every local engine's customModifiers registry
// What's missing for the full e2e: an editor-side button that
// calls sendRegisterCustomModifier from inside CustomModifierEditor
// when the editor is opened from a multiplayer game (vs solo).
// That requires propagating the GameClient handle into the editor,
// which is a UI plumbing task scoped for a T3.1 follow-up.
},
);
test('multiplayer custom modifier sharing — both clients see the registered descriptor', async ({
browser,
}) => {
// Skip when no WS server is running (e.g. in CI without the dev
// server) — same gate the modifier-profiles MP tests use.
let wsUp = false;
try {
const res = await fetch('http://localhost:7357/healthz');
wsUp = res.ok;
} catch {
wsUp = false;
}
test.skip(!wsUp, 'No WS server on :7357 — multiplayer test skipped');
test.fixme(
'server rejects custom modifier with > 50 primitives — visible UI error',
async () => {
// Same dependency: depends on the editor-side multiplayer send
// path described above. Once shipped, this test seeds a
// 51-primitive descriptor, calls sendRegisterCustomModifier, and
// asserts a toast appears with code CUSTOM_MODIFIER_INVALID
// (the server's Zod .max(50) catches the oversize payload).
// The structural cap itself is unit-tested server-side in
// ws.custom-modifier-register.test.ts.
},
);
const ctxHost = await browser.newContext();
const ctxOpp = await browser.newContext();
const pageHost = await ctxHost.newPage();
const pageOpp = await ctxOpp.newPage();
try {
// Both players create / join a room via raw WS so we don't have
// to drive the React lobby in two contexts simultaneously.
await pageHost.goto('/');
await pageHost.waitForSelector('[data-testid="page-home"]');
const roomHost = await wsCreateRoomNoProfile(pageHost);
await pageOpp.goto('/');
await pageOpp.waitForSelector('[data-testid="page-home"]');
const roomOpp = await wsJoinRoomShared(pageOpp, roomHost.code);
// The descriptor we'll share. Minimum-viable; the test cares about
// it landing on both sides, not its semantic effect.
const descriptor = {
type: 'data' as const,
id: 'custom:t29-mp-share',
name: 'Shared Boost',
description: '',
version: 1 as const,
primitives: [
{
kind: 'seed-attribute' as const,
params: { attr: 'HpBonus', value: 1 },
},
],
targetAttrs: ['HpBonus'],
uiForm: 'primitive-composer' as const,
source: 'custom' as const,
};
// Run host and opponent flows concurrently — opponent must be
// listening BEFORE the host's register lands or the broadcast
// goes to a non-listening socket. Each side opens a raw WS
// (reconnect by token), tracks observed message types, and the
// host sends `custom-modifier.register` only AFTER it has
// observed its own `room.joined` ack (proves the reconnect
// landed and the socket is now in the room's broadcast set).
const [hostFlow, oppFlow] = await Promise.all([
pageHost.evaluate(
async ({ roomCode, token, descriptor }) => {
return new Promise<{ types: string[]; sawRegistered: boolean }>(
(resolve) => {
const types: string[] = [];
let sawRegistered = false;
const ws = new WebSocket('ws://localhost:7357/ws');
let seq = 100;
ws.onopen = () => {
ws.send(
JSON.stringify({
v: 1,
seq: seq++,
ts: Date.now(),
type: 'room.join',
token,
payload: { code: roomCode },
}),
);
};
ws.onmessage = (e: MessageEvent) => {
const msg = JSON.parse(e.data as string) as {
type: string;
};
types.push(msg.type);
if (msg.type === 'custom-modifier.registered') {
sawRegistered = true;
}
// Once the server has acknowledged the reconnect
// (room.joined) AND given us time to be sure the
// opponent is also reconnected, send the register.
if (msg.type === 'room.joined') {
setTimeout(() => {
ws.send(
JSON.stringify({
v: 1,
seq: seq++,
ts: Date.now(),
type: 'custom-modifier.register',
token,
payload: { roomCode, descriptor },
}),
);
}, 250);
}
};
setTimeout(() => {
ws.close();
resolve({ types, sawRegistered });
}, 2500);
},
);
},
{
roomCode: roomHost.code,
token: roomHost.token,
descriptor,
},
),
pageOpp.evaluate(
async ({ roomCode, token }) => {
return new Promise<{ types: string[]; sawRegistered: boolean }>(
(resolve) => {
const types: string[] = [];
let sawRegistered = false;
const ws = new WebSocket('ws://localhost:7357/ws');
let seq = 100;
ws.onopen = () => {
ws.send(
JSON.stringify({
v: 1,
seq: seq++,
ts: Date.now(),
type: 'room.join',
token,
payload: { code: roomCode },
}),
);
};
ws.onmessage = (e: MessageEvent) => {
const msg = JSON.parse(e.data as string) as {
type: string;
};
types.push(msg.type);
if (msg.type === 'custom-modifier.registered') {
sawRegistered = true;
}
};
setTimeout(() => {
ws.close();
resolve({ types, sawRegistered });
}, 2500);
},
);
},
{ roomCode: roomHost.code, token: roomOpp.token },
),
]);
// Both sides MUST observe the broadcast — the host receives it
// too (server broadcasts to every connected client, including the
// sender, so local state confirms server-authoritative state).
expect(hostFlow.sawRegistered).toBe(true);
expect(oppFlow.sawRegistered).toBe(true);
} finally {
await ctxHost.close();
await ctxOpp.close();
}
});
test('server rejects custom modifier with > 50 primitives — error event observed', async ({
browser,
}) => {
// Same WS gate as the previous test.
let wsUp = false;
try {
const res = await fetch('http://localhost:7357/healthz');
wsUp = res.ok;
} catch {
wsUp = false;
}
test.skip(!wsUp, 'No WS server on :7357 — multiplayer test skipped');
const ctxHost = await browser.newContext();
const pageHost = await ctxHost.newPage();
try {
await pageHost.goto('/');
await pageHost.waitForSelector('[data-testid="page-home"]');
const roomHost = await wsCreateRoomNoProfile(pageHost);
// Build a descriptor with 51 primitives — Zod's .max(50) on the
// wire schema rejects it as INVALID_MESSAGE before it reaches
// the handler.
const oversizedPrimitives = Array.from({ length: 51 }, () => ({
kind: 'seed-attribute',
params: { attr: 'HpBonus', value: 1 },
}));
const oversizedDescriptor = {
type: 'data',
id: 'custom:t29-oversize',
name: 'Oversize',
description: '',
version: 1,
primitives: oversizedPrimitives,
targetAttrs: ['HpBonus'],
uiForm: 'primitive-composer',
source: 'custom',
};
const flow = await pageHost.evaluate(
async ({ roomCode, token, descriptor }) => {
return new Promise<{
sawError: boolean;
sawRegistered: boolean;
errorCode: string | null;
}>((resolve) => {
let sawError = false;
let sawRegistered = false;
let errorCode: string | null = null;
const ws = new WebSocket('ws://localhost:7357/ws');
let seq = 100;
ws.onopen = () => {
ws.send(
JSON.stringify({
v: 1,
seq: seq++,
ts: Date.now(),
type: 'room.join',
token,
payload: { code: roomCode },
}),
);
setTimeout(() => {
ws.send(
JSON.stringify({
v: 1,
seq: seq++,
ts: Date.now(),
type: 'custom-modifier.register',
token,
payload: { roomCode, descriptor },
}),
);
}, 100);
};
ws.onmessage = (e: MessageEvent) => {
const msg = JSON.parse(e.data as string) as {
type: string;
payload?: { code?: string };
};
if (msg.type === 'error') {
sawError = true;
errorCode = msg.payload?.code ?? null;
}
if (msg.type === 'custom-modifier.registered') {
sawRegistered = true;
}
};
setTimeout(() => {
ws.close();
resolve({ sawError, sawRegistered, errorCode });
}, 1000);
});
},
{
roomCode: roomHost.code,
token: roomHost.token,
descriptor: oversizedDescriptor,
},
);
expect(flow.sawError).toBe(true);
// The Zod cap fires first on an INVALID_MESSAGE; either is
// acceptable as proof the server rejected the oversize.
expect(['INVALID_MESSAGE', 'CUSTOM_MODIFIER_INVALID']).toContain(
flow.errorCode,
);
expect(flow.sawRegistered).toBe(false);
} finally {
await ctxHost.close();
}
});
});
// ─── WS helpers (mirrored from modifier-profiles.spec.ts) ──────────
/** Create a room via raw WS — no profile. Returns {code, token, color}. */
async function wsCreateRoomNoProfile(
p: import('@playwright/test').Page,
): Promise<{ code: string; token: string; color: string }> {
return p.evaluate(async () => {
return new Promise<{ code: string; token: string; color: string }>(
(resolve, reject) => {
const ws = new WebSocket('ws://localhost:7357/ws');
const timer = setTimeout(
() => reject(new Error('wsCreateRoom: timeout')),
5000,
);
ws.onopen = () => {
ws.send(
JSON.stringify({
v: 1,
seq: 1,
ts: Date.now(),
type: 'room.create',
payload: {},
}),
);
};
ws.onmessage = (e: MessageEvent) => {
const msg = JSON.parse(e.data as string) as {
type: string;
payload: { code: string; token: string; color: string };
};
if (msg.type === 'room.created') {
clearTimeout(timer);
ws.close();
resolve(msg.payload);
}
};
ws.onerror = () => {
clearTimeout(timer);
reject(new Error('wsCreateRoom: error'));
};
},
);
});
}
/** Join a room via raw WS. Returns {code, token, color}. */
async function wsJoinRoomShared(
p: import('@playwright/test').Page,
code: string,
): Promise<{ code: string; token: string; color: string }> {
return p.evaluate(async (roomCode: string) => {
return new Promise<{ code: string; token: string; color: string }>(
(resolve, reject) => {
const ws = new WebSocket('ws://localhost:7357/ws');
const timer = setTimeout(
() => reject(new Error('wsJoinRoom: timeout')),
5000,
);
ws.onopen = () => {
ws.send(
JSON.stringify({
v: 1,
seq: 1,
ts: Date.now(),
type: 'room.join',
payload: { code: roomCode },
}),
);
};
ws.onmessage = (e: MessageEvent) => {
const msg = JSON.parse(e.data as string) as {
type: string;
payload: { code: string; token: string; color: string };
};
if (msg.type === 'room.joined') {
clearTimeout(timer);
ws.close();
resolve(msg.payload);
}
};
ws.onerror = () => {
clearTimeout(timer);
reject(new Error('wsJoinRoom: error'));
};
},
);
}, code);
}

View file

@ -175,6 +175,16 @@ export function useMultiplayerGame(code: string, token: string) {
);
}, 2000);
}
// T3: custom modifier errors are user-facing actions (someone
// hit "Share with Room" in the editor) — surface as a toast so
// the failure is obvious even if the in-game error banner is
// styled subtly.
if (
e.payload.code === 'CUSTOM_MODIFIER_INVALID' ||
e.payload.code === 'CUSTOM_MODIFIER_LIMIT'
) {
toast.error(`Custom modifier rejected: ${e.payload.message}`);
}
};
client.on('connected', onConnected);
@ -328,5 +338,22 @@ export function useMultiplayerGame(code: string, token: string) {
payload: { roomCode: code, decision }
});
},
/**
* T3: ship a user-authored custom modifier descriptor to the
* server for room-wide registration. The server's
* custom-modifier.register handler validates structurally,
* stores in the per-room registry (capped at 10), and broadcasts
* to every connected client. The local PredictionManager
* subscriber mirrors the descriptor onto the local engine's
* customModifiers registry, so subsequent profile applies
* resolve the kind across both clients.
*/
sendRegisterCustomModifier: (
descriptor: import('../modifiers/custom/types.js').CustomModifierDescriptor,
) => {
clientRef.current?.sendRegisterCustomModifier(
descriptor as unknown as import('../net/types.js').CustomModifierDescriptorWire,
);
},
};
}

View file

@ -15,6 +15,18 @@ import { asCustomModifierId } from '../modifiers/custom/types.js';
interface Props {
isOpen: boolean;
onClose: () => void;
/**
* Optional multiplayer publisher. When provided (the editor was
* opened from an in-game multiplayer rules drawer), a "Share with
* Room" button appears alongside Save. It calls this callback with
* the current descriptor; the multiplayer hook's
* sendRegisterCustomModifier ships the wire message and the
* server's broadcast lands on every connected client.
*
* Omitted in solo mode and from the lobby those flows are local
* to one engine, no broadcast needed.
*/
onShareWithRoom?: (descriptor: CustomModifierDescriptor) => void;
}
const CATEGORIES: Record<string, PrimitiveKind[]> = {
@ -98,7 +110,7 @@ function generateDefaultParams(schema: ZodType<unknown>): unknown {
return {}; // Fallback
}
export function CustomModifierEditor({ isOpen, onClose }: Props) {
export function CustomModifierEditor({ isOpen, onClose, onShareWithRoom }: Props) {
const [descriptor, setDescriptor] = useState<CustomModifierDescriptor>(makeBlankDescriptor());
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
@ -127,6 +139,25 @@ export function CustomModifierEditor({ isOpen, onClose }: Props) {
}
};
/**
* Ship the descriptor to the multiplayer room. The publisher (passed
* in from useMultiplayerGame) writes the wire message; the server's
* custom-modifier.register handler validates structurally and
* broadcasts. Server-side rejections (CUSTOM_MODIFIER_INVALID,
* CUSTOM_MODIFIER_LIMIT) surface via the GameClient's `error` event,
* which the multiplayer hook turns into a toast at the GameView
* layer we don't need to wait for an ack here.
*/
const handleShareWithRoom = () => {
if (onShareWithRoom === undefined) return;
if (!validationResult.ok) {
toast.error('Cannot share — fix validation errors first');
return;
}
onShareWithRoom(descriptor);
toast.success('Custom modifier shared with room');
};
if (!isOpen) return null;
return (
@ -180,6 +211,17 @@ export function CustomModifierEditor({ isOpen, onClose }: Props) {
>
Save to Library
</button>
{onShareWithRoom !== undefined && (
<button
data-testid="custom-share-room"
onClick={handleShareWithRoom}
disabled={!validationResult.ok}
className="px-3 py-1.5 text-sm font-semibold text-white bg-emerald-600 rounded hover:bg-emerald-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Register this custom modifier on the multiplayer room so both players can use it."
>
Share with Room
</button>
)}
<div className="w-px h-6 bg-neutral-300 mx-2" />
<button
onClick={onClose}

View file

@ -43,6 +43,12 @@ interface GameEngineState {
modifierRejectionMessage?: string | null;
isProposer?: boolean;
sendConsent?: (decision: 'approve' | 'reject') => void;
/** T3: multiplayer publisher for sharing custom modifier descriptors
* with the room. Present only when this state came from the
* multiplayer hook; absent in solo mode. */
sendRegisterCustomModifier?: (
descriptor: import('../modifiers/custom/types.js').CustomModifierDescriptor,
) => void;
}
interface GameViewProps {
@ -149,11 +155,15 @@ function GameLayout({
modifierRejectionMessage,
isProposer,
sendConsent,
sendRegisterCustomModifier,
} = state as GameEngineState & {
modifierProposal?: { profile: ModifierProfileWire; expiresAt: number; proposer: Color } | null;
modifierRejectionMessage?: string | null;
isProposer?: boolean;
sendConsent?: (decision: 'approve' | 'reject') => void;
sendRegisterCustomModifier?: (
descriptor: import('../modifiers/custom/types.js').CustomModifierDescriptor,
) => void;
};
const handleMove = (from: number, to: number, promoteTo?: PieceType) => {
@ -265,6 +275,9 @@ function GameLayout({
activations={activations}
setPresets={setPresets}
onRulesChanged={refresh}
{...(sendRegisterCustomModifier !== undefined
? { onShareCustomModifierWithRoom: sendRegisterCustomModifier }
: {})}
/>
<ModifierProposalDialog
proposal={modifierProposal || null}

View file

@ -34,6 +34,14 @@ export type ModifierClipboard =
interface Props {
isOpen: boolean;
onClose: () => void;
/**
* Optional multiplayer publisher passed through to the nested
* Custom Modifier editor. Present only when this editor was opened
* inside an in-game RulesDrawer with a multiplayer hook in scope.
*/
onShareCustomModifierWithRoom?: (
descriptor: import('../modifiers/custom/types.js').CustomModifierDescriptor,
) => void;
}
function makeBlankProfile(): ModifierProfile {
@ -48,7 +56,11 @@ function makeBlankProfile(): ModifierProfile {
};
}
export function ModifierProfileEditor({ isOpen, onClose }: Props) {
export function ModifierProfileEditor({
isOpen,
onClose,
onShareCustomModifierWithRoom,
}: Props) {
const [showCustomModifierEditor, setShowCustomModifierEditor] = useState(false);
const [history, setHistory] = useState<ModifierProfile[]>(() => [
@ -412,9 +424,12 @@ export function ModifierProfileEditor({ isOpen, onClose }: Props) {
/>
</div>
</div>
<CustomModifierEditor
<CustomModifierEditor
isOpen={showCustomModifierEditor}
onClose={() => setShowCustomModifierEditor(false)}
{...(onShareCustomModifierWithRoom !== undefined
? { onShareWithRoom: onShareCustomModifierWithRoom }
: {})}
/>
</div>
);

View file

@ -25,6 +25,15 @@ interface RulesDrawerProps {
/** Called after any toggle so the parent can recompute derived UI
* state (legal-move highlights, etc) local mode only. */
onRulesChanged?: () => void;
/**
* Multiplayer-only publisher for sharing a custom modifier
* descriptor with the room. Threaded into the nested Modifier
* Profile editor Custom Modifier editor. When omitted (solo
* mode, lobby) the editor's "Share with Room" button is hidden.
*/
onShareCustomModifierWithRoom?: (
descriptor: import('../modifiers/custom/types.js').CustomModifierDescriptor,
) => void;
}
/** Build a fresh activation list that reflects a single edit. */
@ -64,6 +73,7 @@ export function RulesDrawer({
activations,
setPresets,
onRulesChanged,
onShareCustomModifierWithRoom,
}: RulesDrawerProps) {
const [open, setOpen] = useState(false);
const [modifierEditorOpen, setModifierEditorOpen] = useState(false);
@ -521,6 +531,9 @@ export function RulesDrawer({
<ModifierProfileEditor
isOpen={modifierEditorOpen}
onClose={() => setModifierEditorOpen(false)}
{...(onShareCustomModifierWithRoom !== undefined
? { onShareCustomModifierWithRoom }
: {})}
/>
</>
);