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