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:
parent
8fb5669c9a
commit
babee38702
6 changed files with 480 additions and 30 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue