From 7e040c0f274a261ed56f80c3edaf7fe6b093c10b Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Fri, 17 Apr 2026 11:41:39 -0600 Subject: [PATCH] feat(chess): add in-game RulesDrawer for mid-game preset toggling --- packages/chess/src/engine-presets.test.ts | 47 ++++++ packages/chess/src/ui/GameView.tsx | 2 + packages/chess/src/ui/RulesDrawer.tsx | 175 ++++++++++++++++++++++ packages/chess/src/ui/RulesView.tsx | 32 ++-- 4 files changed, 246 insertions(+), 10 deletions(-) create mode 100644 packages/chess/src/ui/RulesDrawer.tsx diff --git a/packages/chess/src/engine-presets.test.ts b/packages/chess/src/engine-presets.test.ts index 986ce32..6bcf327 100644 --- a/packages/chess/src/engine-presets.test.ts +++ b/packages/chess/src/engine-presets.test.ts @@ -104,6 +104,53 @@ describe("PRESET_REGISTRY ↔ ChessEngine.getAllLegalMoves integration", () => { expect(diagonalEmptyAfter!.isCapture).toBe(false); }); + it("mid-game toggle: activating a preset after moves already played affects the very next move calculation", () => { + const engine = new ChessEngine(); + + // Play 3 moves of a normal game — no presets active. + engine.applyMove( + engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!, + ); + engine.applyMove( + engine.findMove(algebraicToSquare("e7"), algebraicToSquare("e5"))!, + ); + engine.applyMove( + engine.findMove(algebraicToSquare("d2"), algebraicToSquare("d4"))!, + ); + + // Black to move. Under standard rules the black e5 pawn cannot + // retreat to e6. + expect( + engine.findMove(algebraicToSquare("e5"), algebraicToSquare("e6")), + ).toBeNull(); + + // Activate the preset NOW — no board reset, same engine instance. + PRESET_REGISTRY.activate("pawns-move-backward"); + + // Same engine, same position — but the backward pawn move is now legal + // because `getAllLegalMoves` reads the registry fresh on every call. + const retreat = engine.findMove( + algebraicToSquare("e5"), + algebraicToSquare("e6"), + ); + expect(retreat).not.toBeNull(); + + // Black actually plays the retreat and the engine accepts it. + engine.applyMove(retreat!); + expect(engine.getCurrentTurn()).toBe("white"); + + // Toggle off — next move calculation drops the extra move set. + PRESET_REGISTRY.deactivate("pawns-move-backward"); + // After the retreat, black's pawn is now on e6. It's white's turn. + // Play a white move, then check black can no longer retreat e6→e7. + engine.applyMove( + engine.findMove(algebraicToSquare("a2"), algebraicToSquare("a3"))!, + ); + expect( + engine.findMove(algebraicToSquare("e6"), algebraicToSquare("e7")), + ).toBeNull(); + }); + it("multiple presets compose: backward + diagonal-no-capture both apply", () => { PRESET_REGISTRY.activate("pawns-move-backward"); PRESET_REGISTRY.activate("pawn-diagonal-no-capture"); diff --git a/packages/chess/src/ui/GameView.tsx b/packages/chess/src/ui/GameView.tsx index 21657fa..145a5d1 100644 --- a/packages/chess/src/ui/GameView.tsx +++ b/packages/chess/src/ui/GameView.tsx @@ -1,4 +1,5 @@ import { Board } from './Board'; +import { RulesDrawer } from './RulesDrawer'; import { useChessEngine } from '../hooks/useChessEngine'; import type { ChessFact, ChessAttrMap } from '../schema'; @@ -21,6 +22,7 @@ export function GameView({ engineState }: GameViewProps) { return (
+
{/* Header/Info section */} diff --git a/packages/chess/src/ui/RulesDrawer.tsx b/packages/chess/src/ui/RulesDrawer.tsx new file mode 100644 index 0000000..f80f287 --- /dev/null +++ b/packages/chess/src/ui/RulesDrawer.tsx @@ -0,0 +1,175 @@ +import { useState, useEffect } from 'react'; +import { PRESET_REGISTRY } from '../presets/index.js'; + +/** + * A collapsible side-drawer for toggling preset rules mid-game. + * + * Writes straight to PRESET_REGISTRY on every toggle. The ChessEngine reads + * PRESET_REGISTRY.getActive() fresh on every call to getAllLegalMoves(), so + * a toggle takes effect on the very next move calculation — no reset, no + * reload, no navigation. + * + * Mounts a small tick counter to force a re-render when the registry + * changes externally (e.g. via the /rules page), so this drawer stays in + * sync if both UIs are open. + */ +export function RulesDrawer() { + const [open, setOpen] = useState(false); + // Force-refresh trigger. The registry itself has no event emitter so we + // re-read on every open and every toggle click. + const [tick, setTick] = useState(0); + + // Re-read active set on every open/close so external changes reflect. + useEffect(() => { + if (open) setTick((t) => t + 1); + }, [open]); + + const presets = PRESET_REGISTRY.getAll(); + const activeIds = new Set(PRESET_REGISTRY.getActive().map((p) => p.id)); + + const toggle = (id: string) => { + if (activeIds.has(id)) { + PRESET_REGISTRY.deactivate(id); + } else { + try { + PRESET_REGISTRY.activate(id); + } catch (err) { + // activate() throws for missing requires or incompatibilities. We + // surface the reason via the UI only for the user's next refresh. + console.warn(`Could not activate ${id}:`, err); + } + } + setTick((t) => t + 1); + }; + + return ( + <> + {/* Trigger pill — always visible, top-right of game view */} + + + {/* Backdrop + drawer */} + {open && ( + <> +
setOpen(false)} + /> + + + )} + + ); +} diff --git a/packages/chess/src/ui/RulesView.tsx b/packages/chess/src/ui/RulesView.tsx index 29b5d29..2a52f0c 100644 --- a/packages/chess/src/ui/RulesView.tsx +++ b/packages/chess/src/ui/RulesView.tsx @@ -203,20 +203,32 @@ export function RulesView({ chessState, isGameActive }: RulesViewProps) { ))}
-
+
+
+ Tip: toggles above take effect immediately — including in the middle of a game. No reset required. +
{activeCount === 0 ? 'No presets active — standard FIDE chess' - : `${activeCount} preset${activeCount === 1 ? '' : 's'} will be active in the next game`} + : `${activeCount} preset${activeCount === 1 ? '' : 's'} currently active`} +
+
+ +
-
);