feat(chess): add in-game RulesDrawer for mid-game preset toggling

This commit is contained in:
Joey Yakimowich-Payne 2026-04-17 11:41:39 -06:00
commit 7e040c0f27
No known key found for this signature in database
4 changed files with 246 additions and 10 deletions

View file

@ -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");

View file

@ -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 (
<div className="flex flex-col items-center gap-8 py-8 w-full max-w-4xl mx-auto">
<RulesDrawer />
<div className="flex flex-col md:flex-row w-full items-start justify-between gap-4 px-4">
{/* Header/Info section */}

View file

@ -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 */}
<button
type="button"
data-action="open-rules-drawer"
onClick={() => setOpen(true)}
className="fixed top-4 right-4 z-40 flex items-center gap-2 bg-white px-4 py-2 rounded-full shadow-md border border-neutral-200 hover:border-blue-400 hover:shadow-lg transition-all text-sm font-medium text-neutral-700"
aria-label="Toggle rules"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path d="M5 4a1 1 0 00-2 0v7.268a2 2 0 000 3.464V16a1 1 0 102 0v-1.268a2 2 0 000-3.464V4zM11 4a1 1 0 10-2 0v1.268a2 2 0 000 3.464V16a1 1 0 102 0V8.732a2 2 0 000-3.464V4zM16 3a1 1 0 011 1v7.268a2 2 0 010 3.464V16a1 1 0 11-2 0v-1.268a2 2 0 010-3.464V4a1 1 0 011-1z" />
</svg>
Rules
{activeIds.size > 0 && (
<span className="bg-blue-600 text-white rounded-full px-2 py-0.5 text-xs font-bold">
{activeIds.size}
</span>
)}
</button>
{/* Backdrop + drawer */}
{open && (
<>
<div
className="fixed inset-0 bg-black/30 z-40 transition-opacity"
onClick={() => setOpen(false)}
/>
<aside
data-testid="rules-drawer"
className="fixed top-0 right-0 h-full w-full max-w-md bg-white shadow-2xl z-50 flex flex-col overflow-hidden animate-slide-in"
style={{ animation: 'slide-in 0.2s ease-out' }}
>
<header className="p-5 border-b border-neutral-200 flex items-center justify-between bg-neutral-50">
<div>
<h2 className="text-xl font-bold text-neutral-900">Live Rules</h2>
<p className="text-xs text-neutral-500 mt-0.5">
Toggle rules mid-game applies on the next move
</p>
</div>
<button
type="button"
data-action="close-rules-drawer"
onClick={() => setOpen(false)}
className="p-2 hover:bg-neutral-200 rounded-md transition-colors text-neutral-600"
aria-label="Close"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</button>
</header>
<div className="flex-1 overflow-y-auto p-5 space-y-3" key={tick}>
{presets.map((preset) => {
const isOn = activeIds.has(preset.id);
const blockedBy = preset.incompatibleWith.filter((id) =>
activeIds.has(id),
);
const missingReqs = preset.requires.filter(
(id) => !activeIds.has(id),
);
const blocked =
!isOn && (blockedBy.length > 0 || missingReqs.length > 0);
return (
<div
key={preset.id}
data-preset={preset.id}
className={`border rounded-lg p-3 transition-colors ${
isOn
? 'border-blue-300 bg-blue-50'
: blocked
? 'border-neutral-200 bg-neutral-50 opacity-60'
: 'border-neutral-200 hover:border-blue-300'
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-neutral-900 text-sm">
{preset.name}
</h3>
<p className="text-xs text-neutral-600 mt-0.5 leading-relaxed">
{preset.description}
</p>
{blockedBy.length > 0 && (
<p className="text-xs text-amber-700 mt-1.5">
Conflicts with active: {blockedBy.join(', ')}
</p>
)}
{missingReqs.length > 0 && (
<p className="text-xs text-amber-700 mt-1.5">
Requires: {missingReqs.join(', ')}
</p>
)}
</div>
<button
type="button"
data-role="toggle"
disabled={blocked}
onClick={() => toggle(preset.id)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2 ${
isOn ? 'bg-blue-600' : 'bg-neutral-300'
} ${blocked ? 'cursor-not-allowed' : ''}`}
role="switch"
aria-checked={isOn}
>
<span
aria-hidden="true"
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition ${
isOn ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
</div>
);
})}
</div>
<footer className="p-4 border-t border-neutral-200 bg-neutral-50 text-xs text-neutral-500 text-center">
{activeIds.size === 0
? 'Standard FIDE chess — no presets active'
: `${activeIds.size} preset${activeIds.size === 1 ? '' : 's'} active`}
</footer>
</aside>
</>
)}
</>
);
}

View file

@ -203,20 +203,32 @@ export function RulesView({ chessState, isGameActive }: RulesViewProps) {
))}
</div>
<div className="pt-6 space-y-2">
<div className="pt-6 space-y-3">
<div className="bg-blue-50 border border-blue-200 rounded-md p-3 text-sm text-blue-900">
<strong>Tip:</strong> toggles above take effect <em>immediately</em> including in the middle of a game. No reset required.
</div>
<div className="text-sm text-gray-500">
{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`}
</div>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => navigate('/game')}
className="bg-white text-slate-900 border border-slate-300 rounded-lg py-3 px-4 font-semibold hover:bg-slate-50 transition-colors"
>
Back to current game
</button>
<button
type="button"
data-action="start-new-game"
onClick={handleApply}
className="bg-blue-600 text-white rounded-lg py-3 px-4 font-semibold hover:bg-blue-700 transition-colors"
>
Reset board &amp; start fresh
</button>
</div>
<button
type="button"
data-action="start-new-game"
onClick={handleApply}
className="w-full bg-blue-600 text-white rounded-lg py-3 px-4 font-semibold hover:bg-blue-700 transition-colors"
>
Apply and start new game
</button>
</div>
</div>
);