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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
: {})}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue