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.
359 lines
14 KiB
TypeScript
359 lines
14 KiB
TypeScript
/**
|
|
* React hook for a server-backed chess game.
|
|
*
|
|
* Wraps GameClient + PredictionManager and exposes the same surface as
|
|
* `useChessEngine` so `<GameView>` doesn't need to care whether it's
|
|
* running locally or over the wire. Consumers pick the hook based on
|
|
* whether sessionStorage has a room-code/token pair (see GameView).
|
|
*
|
|
* Lifecycle
|
|
* ─────────
|
|
* - On mount: opens a WebSocket, sends `room.join` with the stored token.
|
|
* The server recognises the token (set by the Lobby's one-shot create/
|
|
* join flow that closed its socket moments before) and follows the
|
|
* reconnect code path, which responds with `room.joined` + a full
|
|
* `game.state` snapshot. PredictionManager populates `baseEngine` from
|
|
* that snapshot.
|
|
* - While connected: remote `game.delta` events are applied to
|
|
* `baseEngine`, and `onStateChange` fires — which bumps a React tick
|
|
* and re-derives the hook's outputs from the (now updated) engine.
|
|
* Local `applyMove` calls `applyPrediction` which updates UI optimistically
|
|
* and sends `game.move` to the server. The server echoes a `game.delta`
|
|
* that clears the prediction.
|
|
* - On unmount: closes the socket. A fresh mount will reconnect again.
|
|
*
|
|
* Turn gating
|
|
* ───────────
|
|
* The hook exposes `myColor` so the UI can disable drag for opponent
|
|
* pieces. The server rejects out-of-turn `game.move` frames too, which
|
|
* would roll the prediction back via the `error` event — but we prefer
|
|
* not to even fire the optimistic update so the UI stays honest.
|
|
*/
|
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import type { PieceType } from '../schema';
|
|
import type { GameResult } from '../engine';
|
|
import { GameClient } from '../net/client';
|
|
import { PredictionManager } from '../net/prediction';
|
|
import type { Color, PresetActivation, PromotionPiece, ModifierProfileWire, ModifierProfileProposalPendingPayload } from '../net/types';
|
|
import * as audio from '../audio';
|
|
import { toast } from 'sonner';
|
|
|
|
const WS_URL =
|
|
(import.meta as { env?: Record<string, string> }).env?.['VITE_WS_URL'] ??
|
|
'ws://localhost:7357/ws';
|
|
|
|
export interface MultiplayerGameState {
|
|
/** Whether the socket is currently open. */
|
|
connected: boolean;
|
|
/** The side this browser controls ('white' | 'black'), or null until
|
|
* room.joined arrives. */
|
|
myColor: Color | null;
|
|
/** True while we're waiting for the initial game.state snapshot to
|
|
* arrive (engine may be empty). */
|
|
loading: boolean;
|
|
/** Room error surfaced to the UI (e.g. ROOM_NOT_FOUND on reconnect
|
|
* after grace expiry). Null when healthy. */
|
|
error: string | null;
|
|
// Modifier proposal state
|
|
modifierProposal: { profile: ModifierProfileWire; expiresAt: number; proposer: Color } | null;
|
|
modifierRejectionMessage: string | null;
|
|
isProposer: boolean;
|
|
}
|
|
|
|
/**
|
|
* @param code Room code from sessionStorage.
|
|
* @param token Player token from sessionStorage.
|
|
*/
|
|
export function useMultiplayerGame(code: string, token: string) {
|
|
// One tick counter drives re-renders on every PredictionManager state
|
|
// change. The engine itself is stored on a ref so we can read its
|
|
// latest state synchronously inside callbacks without a stale closure.
|
|
const clientRef = useRef<GameClient | null>(null);
|
|
const managerRef = useRef<PredictionManager | null>(null);
|
|
const [tick, setTick] = useState(0);
|
|
const [meta, setMeta] = useState<MultiplayerGameState>({
|
|
connected: false,
|
|
myColor: null,
|
|
loading: true,
|
|
error: null,
|
|
modifierProposal: null,
|
|
modifierRejectionMessage: null,
|
|
isProposer: false,
|
|
});
|
|
|
|
// Mount the connection exactly once per code/token pair.
|
|
useEffect(() => {
|
|
const client = new GameClient(WS_URL);
|
|
const manager = new PredictionManager(client, () => {
|
|
// Any change in authoritative or predicted state: bump tick so the
|
|
// component re-renders and picks up the new facts.
|
|
setTick((t) => t + 1);
|
|
});
|
|
clientRef.current = client;
|
|
managerRef.current = manager;
|
|
|
|
const onConnected = () => {
|
|
setMeta((m) => ({ ...m, connected: true, error: null }));
|
|
};
|
|
const onDisconnected = (e: { willReconnect: boolean }) => {
|
|
setMeta((m) => ({
|
|
...m,
|
|
connected: false,
|
|
error: e.willReconnect ? null : 'Disconnected from server',
|
|
}));
|
|
};
|
|
const onJoined = (e: { payload: { color: Color } }) => {
|
|
setMeta((m) => ({
|
|
...m,
|
|
myColor: e.payload.color,
|
|
loading: false,
|
|
}));
|
|
};
|
|
const onGameState = () => {
|
|
// The first game.state snapshot after (re)connect clears `loading`.
|
|
setMeta((m) => ({ ...m, loading: false }));
|
|
};
|
|
const onProposalPending = (e: { payload: ModifierProfileProposalPendingPayload }) => {
|
|
setMeta((m) => ({
|
|
...m,
|
|
modifierProposal: { profile: e.payload.profile, expiresAt: e.payload.expiresAt, proposer: e.payload.proposer },
|
|
modifierRejectionMessage: null,
|
|
isProposer: false,
|
|
}));
|
|
};
|
|
const onProposalRejected = (e: { payload: { reason: string } }) => {
|
|
setMeta((m) => ({
|
|
...m,
|
|
modifierProposal: null,
|
|
modifierRejectionMessage: e.payload.reason,
|
|
isProposer: false,
|
|
}));
|
|
setTimeout(() => {
|
|
setMeta((m) => m.modifierRejectionMessage === e.payload.reason ? { ...m, modifierRejectionMessage: null } : m);
|
|
}, 3000);
|
|
};
|
|
const onProposalConsentReceived = () => {
|
|
setMeta((m) => ({ ...m, modifierProposal: null, isProposer: false }));
|
|
toast.success("Opponent approved your profile change. It will take effect next turn.");
|
|
};
|
|
const onProposalQueued = () => {
|
|
setMeta((m) => ({ ...m, isProposer: true }));
|
|
};
|
|
const onGameDelta = (e: {
|
|
payload: {
|
|
gameOver: { winner: string; reason: string } | null;
|
|
turn: Color;
|
|
};
|
|
}) => {
|
|
// Play sound based on the delta outcome. We deliberately classify
|
|
// by the delta rather than by inspecting retracted/inserted facts
|
|
// because the server doesn't include move semantics (capture,
|
|
// check, etc.) in the wire payload today.
|
|
if (e.payload.gameOver !== null) {
|
|
audio.play(
|
|
e.payload.gameOver.reason === 'checkmate' ? 'checkmate' : 'move',
|
|
);
|
|
} else {
|
|
audio.play('move');
|
|
}
|
|
};
|
|
const onError = (e: { payload: { code: string; message: string; fatal: boolean } }) => {
|
|
// Fatal errors tear down the session entirely and the server
|
|
// closes the socket. Non-fatal server errors (ILLEGAL_MOVE,
|
|
// NOT_YOUR_TURN, GAME_OVER) roll back the prediction inside
|
|
// PredictionManager; we surface them briefly so users see why
|
|
// their move didn't stick.
|
|
if (e.payload.fatal) {
|
|
setMeta((m) => ({ ...m, error: e.payload.message }));
|
|
} else {
|
|
setMeta((m) => ({ ...m, error: e.payload.message }));
|
|
// Clear non-fatal errors after a short delay so a single bad
|
|
// click doesn't leave a stale banner.
|
|
setTimeout(() => {
|
|
setMeta((m) =>
|
|
m.error === e.payload.message ? { ...m, error: null } : m,
|
|
);
|
|
}, 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('disconnected', onDisconnected);
|
|
client.on('room.joined', onJoined);
|
|
client.on('game.state', onGameState);
|
|
client.on('game.delta', onGameDelta);
|
|
client.on('modifier-profile.proposal-pending', onProposalPending);
|
|
client.on('modifier-profile.rejected', onProposalRejected);
|
|
client.on('modifier-profile.consent-received', onProposalConsentReceived);
|
|
client.on('modifier-profile.queued', onProposalQueued);
|
|
client.on('error', onError);
|
|
|
|
client.connect(code, token).catch((err: unknown) => {
|
|
setMeta((m) => ({
|
|
...m,
|
|
error: err instanceof Error ? err.message : 'Connection failed',
|
|
loading: false,
|
|
}));
|
|
});
|
|
|
|
return () => {
|
|
// Remove listeners before close so in-flight events don't trigger
|
|
// state updates after unmount.
|
|
client.off('connected', onConnected);
|
|
client.off('disconnected', onDisconnected);
|
|
client.off('room.joined', onJoined);
|
|
client.off('game.state', onGameState);
|
|
client.off('game.delta', onGameDelta);
|
|
client.off('modifier-profile.proposal-pending', onProposalPending);
|
|
client.off('modifier-profile.rejected', onProposalRejected);
|
|
client.off('modifier-profile.consent-received', onProposalConsentReceived);
|
|
client.off('modifier-profile.queued', onProposalQueued);
|
|
client.off('error', onError);
|
|
client.close();
|
|
clientRef.current = null;
|
|
managerRef.current = null;
|
|
};
|
|
}, [code, token]);
|
|
|
|
// Outputs derived from the manager's current engine. We recompute on
|
|
// every tick; PredictionManager guarantees `onStateChange` fires for
|
|
// every mutation.
|
|
const manager = managerRef.current;
|
|
const engine = manager?.getCurrentEngine() ?? null;
|
|
|
|
const facts = engine?.session.allFacts() ?? [];
|
|
const turn = engine?.getCurrentTurn() ?? 'white';
|
|
const legalMoves = engine?.getAllLegalMoves() ?? [];
|
|
const result: GameResult = engine?.checkGameResult() ?? 'ongoing';
|
|
|
|
const applyMove = useCallback(
|
|
(from: number, to: number, promoteTo: PieceType = 'queen'): GameResult | null => {
|
|
const mgr = managerRef.current;
|
|
if (!mgr) return null;
|
|
const promo = promoteTo as PromotionPiece;
|
|
const ok = mgr.applyPrediction(from, to, promo);
|
|
if (!ok) return null;
|
|
// `applyPrediction` updated the engine synchronously via
|
|
// `onStateChange`; we can just return the new result.
|
|
const eng = mgr.getCurrentEngine();
|
|
const moveResult = eng.checkGameResult();
|
|
// Local sound playback for the moving player. The opponent's client
|
|
// plays its own sound off the `game.delta` event.
|
|
// Decisive results (checkmate AND variant wins) play the
|
|
// checkmate sound; ongoing / draws play the normal move sound.
|
|
const decisive =
|
|
moveResult === 'checkmate' ||
|
|
moveResult === 'white-wins' ||
|
|
moveResult === 'black-wins';
|
|
if (decisive) audio.play('checkmate');
|
|
else audio.play('move');
|
|
return moveResult;
|
|
},
|
|
[],
|
|
);
|
|
|
|
// Undo is not meaningful in multiplayer: moves are authoritative on
|
|
// the server. Return a no-op + canUndo=false so GameView's shared
|
|
// contract still works.
|
|
const undo = useCallback(() => {
|
|
// intentional no-op
|
|
}, []);
|
|
|
|
const refresh = useCallback(() => {
|
|
setTick((t) => t + 1);
|
|
}, []);
|
|
|
|
/**
|
|
* Replace the room's preset set. The server validates and, on
|
|
* success, broadcasts `game.presets` which PredictionManager applies
|
|
* to the base engine and re-renders us via the tick bump. We do NOT
|
|
* flip local UI state here — the UI should mirror the server's echo,
|
|
* not the request, so that rejected requests leave the UI unchanged.
|
|
*/
|
|
const setPresets = useCallback((activations: PresetActivation[]) => {
|
|
clientRef.current?.sendSetPresets(activations);
|
|
}, []);
|
|
|
|
const loadEngine = useCallback(() => {
|
|
// Also a no-op: authoritative state is server-driven. The UI should
|
|
// not have any need to swap the engine under multiplayer — all state
|
|
// changes come through `game.state` / `game.delta`.
|
|
}, []);
|
|
|
|
// `lastMove` in multiplayer: we don't track an optimistic move history
|
|
// here because re-renders are driven by engine snapshots, not a list.
|
|
// Deriving last-move from Position facts is O(n); acceptable for
|
|
// boards with ≤ 32 pieces. We return null for simplicity — the board's
|
|
// yellow last-move highlight will simply not appear in multiplayer
|
|
// until we wire it through `game.delta.moveNotation` in a later pass.
|
|
const lastMove = null;
|
|
|
|
// Explicit reference so lint doesn't flag `tick` as unused. Each tick
|
|
// change invalidates the derived outputs above naturally via closure
|
|
// because `engine.session.allFacts()` is called fresh each render.
|
|
void tick;
|
|
|
|
// Current authoritative preset activations. Read fresh each render
|
|
// so the UI reflects server echoes immediately after `setTick` fires.
|
|
const activations: PresetActivation[] = engine
|
|
? engine.activePresets.list()
|
|
: [];
|
|
|
|
return {
|
|
engine,
|
|
turn,
|
|
facts,
|
|
legalMoves,
|
|
result,
|
|
applyMove,
|
|
undo,
|
|
canUndo: false,
|
|
loadEngine,
|
|
refresh,
|
|
lastMove,
|
|
activations,
|
|
setPresets,
|
|
// Multiplayer-only metadata. GameView uses these to gate drag and
|
|
// render a "waiting for opponent" / error overlay.
|
|
myColor: meta.myColor,
|
|
connected: meta.connected,
|
|
loading: meta.loading,
|
|
error: meta.error,
|
|
modifierProposal: meta.modifierProposal,
|
|
modifierRejectionMessage: meta.modifierRejectionMessage,
|
|
isProposer: meta.isProposer,
|
|
sendConsent: (decision: 'approve' | 'reject') => {
|
|
clientRef.current?.send({
|
|
type: 'modifier-profile.consent',
|
|
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,
|
|
);
|
|
},
|
|
};
|
|
}
|