From babee38702cd22bafcb3e9542ff5ffc8bd5bef43 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Sun, 19 Apr 2026 21:56:34 -0600 Subject: [PATCH] =?UTF-8?q?feat(ui):=20share=20custom=20modifier=20with=20?= =?UTF-8?q?multiplayer=20room=20=E2=80=94=20closes=20T29=20fixmes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/chess/e2e/custom-modifiers.spec.ts | 394 ++++++++++++++++-- .../chess/src/hooks/useMultiplayerGame.ts | 27 ++ .../chess/src/ui/CustomModifierEditor.tsx | 44 +- packages/chess/src/ui/GameView.tsx | 13 + .../chess/src/ui/ModifierProfileEditor.tsx | 19 +- packages/chess/src/ui/RulesDrawer.tsx | 13 + 6 files changed, 480 insertions(+), 30 deletions(-) diff --git a/packages/chess/e2e/custom-modifiers.spec.ts b/packages/chess/e2e/custom-modifiers.spec.ts index 492f0cc..5b0f89e 100644 --- a/packages/chess/e2e/custom-modifiers.spec.ts +++ b/packages/chess/e2e/custom-modifiers.spec.ts @@ -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); +} diff --git a/packages/chess/src/hooks/useMultiplayerGame.ts b/packages/chess/src/hooks/useMultiplayerGame.ts index 23ef3cd..db401a8 100644 --- a/packages/chess/src/hooks/useMultiplayerGame.ts +++ b/packages/chess/src/hooks/useMultiplayerGame.ts @@ -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, + ); + }, }; } diff --git a/packages/chess/src/ui/CustomModifierEditor.tsx b/packages/chess/src/ui/CustomModifierEditor.tsx index 6d5c25b..0a72b18 100644 --- a/packages/chess/src/ui/CustomModifierEditor.tsx +++ b/packages/chess/src/ui/CustomModifierEditor.tsx @@ -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 = { @@ -98,7 +110,7 @@ function generateDefaultParams(schema: ZodType): unknown { return {}; // Fallback } -export function CustomModifierEditor({ isOpen, onClose }: Props) { +export function CustomModifierEditor({ isOpen, onClose, onShareWithRoom }: Props) { const [descriptor, setDescriptor] = useState(makeBlankDescriptor()); const [selectedIndex, setSelectedIndex] = useState(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 + {onShareWithRoom !== undefined && ( + + )}
- setShowCustomModifierEditor(false)} + {...(onShareCustomModifierWithRoom !== undefined + ? { onShareWithRoom: onShareCustomModifierWithRoom } + : {})} /> ); diff --git a/packages/chess/src/ui/RulesDrawer.tsx b/packages/chess/src/ui/RulesDrawer.tsx index 3473fd1..6cf29ea 100644 --- a/packages/chess/src/ui/RulesDrawer.tsx +++ b/packages/chess/src/ui/RulesDrawer.tsx @@ -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({ setModifierEditorOpen(false)} + {...(onShareCustomModifierWithRoom !== undefined + ? { onShareCustomModifierWithRoom } + : {})} /> );