fix(chess): preset toggles refresh engine state immediately; new-game flows reset the board

This commit is contained in:
Joey Yakimowich-Payne 2026-04-17 12:15:23 -06:00
commit 858d326895
No known key found for this signature in database
6 changed files with 70 additions and 12 deletions

View file

@ -32,7 +32,7 @@ export function App() {
<Toaster position="top-center" richColors closeButton />
<AnimatePresence mode="wait">
<Routes location={location} key={location.pathname}>
<Route path="/" element={<PageTransition><Lobby /></PageTransition>} />
<Route path="/" element={<PageTransition><Lobby chessState={chessState} /></PageTransition>} />
<Route path="/game" element={<PageTransition><GameView engineState={chessState} /></PageTransition>} />
<Route path="/rules" element={<PageTransition><RulesView chessState={chessState} /></PageTransition>} />
<Route path="/save" element={<PageTransition><SaveWrapper chessState={chessState} /></PageTransition>} />

View file

@ -87,6 +87,16 @@ export function useChessEngine() {
? moveHistoryRef.current[moveHistoryRef.current.length - 1]
: null;
/**
* Force the hook's derived state (facts, legalMoves, turn, result) to
* recompute. Call this after mutating external state that the engine
* reads but React can't see for example, toggling PRESET_REGISTRY
* entries that change the set of legal moves without triggering a move.
*/
const refresh = useCallback(() => {
setTick(t => t + 1);
}, []);
return {
engine,
turn: getTurn(),
@ -97,6 +107,7 @@ export function useChessEngine() {
undo,
canUndo: moveHistoryRef.current.length > 0,
loadEngine,
refresh,
lastMove,
};
}

View file

@ -17,7 +17,7 @@ export function GameView({ engineState }: GameViewProps) {
const localChessState = useChessEngine();
const state = engineState || localChessState;
const { facts, legalMoves, turn, result, applyMove, undo, canUndo, lastMove } = state;
const { facts, legalMoves, turn, result, applyMove, undo, canUndo, lastMove, refresh } = state;
const handleMove = (from: number, to: number, promoteTo?: PieceType) => {
applyMove(from, to, promoteTo || 'queen');
@ -82,7 +82,7 @@ export function GameView({ engineState }: GameViewProps) {
transition={{ duration: 0.3 }}
className="flex flex-col items-center gap-8 py-8 w-full max-w-4xl mx-auto"
>
<RulesDrawer />
<RulesDrawer onRulesChanged={refresh} />
<div className="flex flex-col md:flex-row w-full items-start justify-between gap-4 px-4">
{/* Header/Info section */}

View file

@ -2,6 +2,8 @@ import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { motion, AnimatePresence } from 'motion/react';
import { pieceAssets } from '../assets/pieces';
import { ChessEngine } from '../engine';
import { clearAutoSave } from '../persist/autosave';
const WS_URL =
(import.meta as { env?: Record<string, string> }).env?.['VITE_WS_URL'] ??
@ -86,13 +88,31 @@ function oneShotRoomRequest(
});
}
export function Lobby() {
interface LobbyProps {
/** Optional when provided, create/join/solo flows reset the local
* ChessEngine so the user starts from the standard opening rather
* than whatever was restored from autosave. */
chessState?: {
loadEngine: (engine: ChessEngine) => void;
};
}
export function Lobby({ chessState }: LobbyProps = {}) {
const [codeInput, setCodeInput] = useState('');
const [roomCode, setRoomCode] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const resetToFreshGame = () => {
// Wipe the autosave so a later full-page reload doesn't re-hydrate
// the previous (finished) game, and replace the in-memory engine
// with a brand-new one now so the board shows the starting position
// the instant the user lands on /game.
clearAutoSave();
chessState?.loadEngine(new ChessEngine());
};
const handleCreate = async () => {
setLoading(true);
setError(null);
@ -101,6 +121,7 @@ export function Lobby() {
sessionStorage.setItem('room-code', code);
sessionStorage.setItem('room-token', token);
sessionStorage.setItem('player-color', color);
resetToFreshGame();
setRoomCode(code);
} catch (err) {
setError(err instanceof Error ? err.message : 'Could not connect to server');
@ -122,6 +143,7 @@ export function Lobby() {
sessionStorage.setItem('room-code', result.code);
sessionStorage.setItem('room-token', result.token);
sessionStorage.setItem('player-color', result.color);
resetToFreshGame();
navigate('/game');
} catch (err) {
setError(err instanceof Error ? err.message : 'Invalid room code');
@ -130,6 +152,11 @@ export function Lobby() {
}
};
const handlePlaySolo = () => {
resetToFreshGame();
navigate('/game');
};
return (
<main
data-testid="page-home"
@ -151,11 +178,11 @@ export function Lobby() {
<h1 className="text-3xl font-extrabold text-neutral-900 tracking-tight">Paratype Chess</h1>
<p className="text-neutral-500 font-medium mt-2">Realtime multiplayer with custom rules</p>
<button
data-action="start-new-game"
onClick={() => navigate('/game')}
data-action="play-solo"
onClick={handlePlaySolo}
className="mt-4 text-sm font-semibold text-neutral-500 hover:text-neutral-900 transition-colors underline underline-offset-4 decoration-neutral-300 hover:decoration-neutral-900"
>
Play Solo (Local)
Play Solo (Local) fresh board
</button>
</div>
@ -187,7 +214,13 @@ export function Lobby() {
</span>
</div>
<button
onClick={() => navigate('/game')}
onClick={() => {
// Re-reset just before navigation in case the user
// spent a long time on this page and autosave was
// somehow re-written in the meantime.
resetToFreshGame();
navigate('/game');
}}
className="w-full bg-blue-600 text-white font-bold py-3 px-4 rounded-lg hover:bg-blue-700 transition-all shadow-md active:scale-[0.98]"
>
Enter Room

View file

@ -16,7 +16,13 @@ import { toast } from 'sonner';
* changes externally (e.g. via the /rules page), so this drawer stays in
* sync if both UIs are open.
*/
export function RulesDrawer() {
interface RulesDrawerProps {
/** Called after any toggle so the parent can recompute derived UI state
* (legal-move highlights, etc.) that depends on the active preset set. */
onRulesChanged?: () => void;
}
export function RulesDrawer({ onRulesChanged }: RulesDrawerProps = {}) {
const [open, setOpen] = useState(false);
// Force-refresh trigger. The registry itself has no event emitter, so we
// bump this counter on every open and every toggle click to re-read the
@ -49,6 +55,10 @@ export function RulesDrawer() {
}
}
setTick((t) => t + 1);
// Ask the parent engine state to recompute legalMoves — otherwise the
// Board's highlighted drop targets won't reflect the new rule set
// until the next move bumps the hook's internal tick.
onRulesChanged?.();
};
return (

View file

@ -2,6 +2,7 @@ import { useState, useMemo, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { PRESET_REGISTRY } from '../presets/index.js';
import { ChessEngine } from '../engine.js';
import { clearAutoSave } from '../persist/autosave.js';
import type { useChessEngine } from '../hooks/useChessEngine.js';
interface RulesViewProps {
@ -88,9 +89,12 @@ export function RulesView({ chessState, isGameActive }: RulesViewProps) {
const handleApply = () => {
// Starting a new game with the selected ruleset: reset the engine so
// opening moves are generated under the active presets. We keep the
// user's selection intact in local state (the registry is the source
// of truth; the useEffect above has already synced it).
// opening moves are generated under the active presets. Clear the
// autosave too — otherwise a full-page reload would re-hydrate the
// previous in-progress game. The registry is already synced via the
// useEffect above, so the fresh engine will see the active presets
// on its first getAllLegalMoves() call.
clearAutoSave();
if (chessState) {
chessState.loadEngine(new ChessEngine());
}