houserules/packages/chess/src/hooks/useMultiplayerGame.ts
Joey Yakimowich-Payne babee38702
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.
2026-04-19 21:56:34 -06:00

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,
);
},
};
}