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) ─────────────────
|
// ── Multiplayer scenarios (require live WS server) ─────────────────
|
||||||
|
|
||||||
test.fixme(
|
test('multiplayer custom modifier sharing — both clients see the registered descriptor', async ({
|
||||||
'multiplayer custom modifier sharing — both clients see the registered descriptor',
|
browser,
|
||||||
async () => {
|
}) => {
|
||||||
// Wire is fully implemented end-to-end:
|
// Skip when no WS server is running (e.g. in CI without the dev
|
||||||
// - server: custom-modifier.register handler broadcasts to room (T24)
|
// server) — same gate the modifier-profiles MP tests use.
|
||||||
// - client: GameClient.sendRegisterCustomModifier() ships the message
|
let wsUp = false;
|
||||||
// - client: PredictionManager subscriber mirrors the descriptor onto
|
try {
|
||||||
// every local engine's customModifiers registry
|
const res = await fetch('http://localhost:7357/healthz');
|
||||||
// What's missing for the full e2e: an editor-side button that
|
wsUp = res.ok;
|
||||||
// calls sendRegisterCustomModifier from inside CustomModifierEditor
|
} catch {
|
||||||
// when the editor is opened from a multiplayer game (vs solo).
|
wsUp = false;
|
||||||
// That requires propagating the GameClient handle into the editor,
|
}
|
||||||
// which is a UI plumbing task scoped for a T3.1 follow-up.
|
test.skip(!wsUp, 'No WS server on :7357 — multiplayer test skipped');
|
||||||
|
|
||||||
|
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,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
test.fixme(
|
expect(flow.sawError).toBe(true);
|
||||||
'server rejects custom modifier with > 50 primitives — visible UI error',
|
// The Zod cap fires first on an INVALID_MESSAGE; either is
|
||||||
async () => {
|
// acceptable as proof the server rejected the oversize.
|
||||||
// Same dependency: depends on the editor-side multiplayer send
|
expect(['INVALID_MESSAGE', 'CUSTOM_MODIFIER_INVALID']).toContain(
|
||||||
// path described above. Once shipped, this test seeds a
|
flow.errorCode,
|
||||||
// 51-primitive descriptor, calls sendRegisterCustomModifier, and
|
);
|
||||||
// asserts a toast appears with code CUSTOM_MODIFIER_INVALID
|
expect(flow.sawRegistered).toBe(false);
|
||||||
// (the server's Zod .max(50) catches the oversize payload).
|
} finally {
|
||||||
// The structural cap itself is unit-tested server-side in
|
await ctxHost.close();
|
||||||
// ws.custom-modifier-register.test.ts.
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── 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);
|
}, 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);
|
client.on('connected', onConnected);
|
||||||
|
|
@ -328,5 +338,22 @@ export function useMultiplayerGame(code: string, token: string) {
|
||||||
payload: { roomCode: code, decision }
|
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 {
|
interface Props {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
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[]> = {
|
const CATEGORIES: Record<string, PrimitiveKind[]> = {
|
||||||
|
|
@ -98,7 +110,7 @@ function generateDefaultParams(schema: ZodType<unknown>): unknown {
|
||||||
return {}; // Fallback
|
return {}; // Fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CustomModifierEditor({ isOpen, onClose }: Props) {
|
export function CustomModifierEditor({ isOpen, onClose, onShareWithRoom }: Props) {
|
||||||
const [descriptor, setDescriptor] = useState<CustomModifierDescriptor>(makeBlankDescriptor());
|
const [descriptor, setDescriptor] = useState<CustomModifierDescriptor>(makeBlankDescriptor());
|
||||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
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;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -180,6 +211,17 @@ export function CustomModifierEditor({ isOpen, onClose }: Props) {
|
||||||
>
|
>
|
||||||
Save to Library
|
Save to Library
|
||||||
</button>
|
</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" />
|
<div className="w-px h-6 bg-neutral-300 mx-2" />
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,12 @@ interface GameEngineState {
|
||||||
modifierRejectionMessage?: string | null;
|
modifierRejectionMessage?: string | null;
|
||||||
isProposer?: boolean;
|
isProposer?: boolean;
|
||||||
sendConsent?: (decision: 'approve' | 'reject') => void;
|
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 {
|
interface GameViewProps {
|
||||||
|
|
@ -149,11 +155,15 @@ function GameLayout({
|
||||||
modifierRejectionMessage,
|
modifierRejectionMessage,
|
||||||
isProposer,
|
isProposer,
|
||||||
sendConsent,
|
sendConsent,
|
||||||
|
sendRegisterCustomModifier,
|
||||||
} = state as GameEngineState & {
|
} = state as GameEngineState & {
|
||||||
modifierProposal?: { profile: ModifierProfileWire; expiresAt: number; proposer: Color } | null;
|
modifierProposal?: { profile: ModifierProfileWire; expiresAt: number; proposer: Color } | null;
|
||||||
modifierRejectionMessage?: string | null;
|
modifierRejectionMessage?: string | null;
|
||||||
isProposer?: boolean;
|
isProposer?: boolean;
|
||||||
sendConsent?: (decision: 'approve' | 'reject') => void;
|
sendConsent?: (decision: 'approve' | 'reject') => void;
|
||||||
|
sendRegisterCustomModifier?: (
|
||||||
|
descriptor: import('../modifiers/custom/types.js').CustomModifierDescriptor,
|
||||||
|
) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMove = (from: number, to: number, promoteTo?: PieceType) => {
|
const handleMove = (from: number, to: number, promoteTo?: PieceType) => {
|
||||||
|
|
@ -265,6 +275,9 @@ function GameLayout({
|
||||||
activations={activations}
|
activations={activations}
|
||||||
setPresets={setPresets}
|
setPresets={setPresets}
|
||||||
onRulesChanged={refresh}
|
onRulesChanged={refresh}
|
||||||
|
{...(sendRegisterCustomModifier !== undefined
|
||||||
|
? { onShareCustomModifierWithRoom: sendRegisterCustomModifier }
|
||||||
|
: {})}
|
||||||
/>
|
/>
|
||||||
<ModifierProposalDialog
|
<ModifierProposalDialog
|
||||||
proposal={modifierProposal || null}
|
proposal={modifierProposal || null}
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,14 @@ export type ModifierClipboard =
|
||||||
interface Props {
|
interface Props {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
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 {
|
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 [showCustomModifierEditor, setShowCustomModifierEditor] = useState(false);
|
||||||
|
|
||||||
const [history, setHistory] = useState<ModifierProfile[]>(() => [
|
const [history, setHistory] = useState<ModifierProfile[]>(() => [
|
||||||
|
|
@ -415,6 +427,9 @@ export function ModifierProfileEditor({ isOpen, onClose }: Props) {
|
||||||
<CustomModifierEditor
|
<CustomModifierEditor
|
||||||
isOpen={showCustomModifierEditor}
|
isOpen={showCustomModifierEditor}
|
||||||
onClose={() => setShowCustomModifierEditor(false)}
|
onClose={() => setShowCustomModifierEditor(false)}
|
||||||
|
{...(onShareCustomModifierWithRoom !== undefined
|
||||||
|
? { onShareWithRoom: onShareCustomModifierWithRoom }
|
||||||
|
: {})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,15 @@ interface RulesDrawerProps {
|
||||||
/** Called after any toggle so the parent can recompute derived UI
|
/** Called after any toggle so the parent can recompute derived UI
|
||||||
* state (legal-move highlights, etc) — local mode only. */
|
* state (legal-move highlights, etc) — local mode only. */
|
||||||
onRulesChanged?: () => void;
|
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. */
|
/** Build a fresh activation list that reflects a single edit. */
|
||||||
|
|
@ -64,6 +73,7 @@ export function RulesDrawer({
|
||||||
activations,
|
activations,
|
||||||
setPresets,
|
setPresets,
|
||||||
onRulesChanged,
|
onRulesChanged,
|
||||||
|
onShareCustomModifierWithRoom,
|
||||||
}: RulesDrawerProps) {
|
}: RulesDrawerProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [modifierEditorOpen, setModifierEditorOpen] = useState(false);
|
const [modifierEditorOpen, setModifierEditorOpen] = useState(false);
|
||||||
|
|
@ -521,6 +531,9 @@ export function RulesDrawer({
|
||||||
<ModifierProfileEditor
|
<ModifierProfileEditor
|
||||||
isOpen={modifierEditorOpen}
|
isOpen={modifierEditorOpen}
|
||||||
onClose={() => setModifierEditorOpen(false)}
|
onClose={() => setModifierEditorOpen(false)}
|
||||||
|
{...(onShareCustomModifierWithRoom !== undefined
|
||||||
|
? { onShareCustomModifierWithRoom }
|
||||||
|
: {})}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue