feat(chess): add in-game RulesDrawer for mid-game preset toggling
This commit is contained in:
parent
51afd9a6f5
commit
7e040c0f27
4 changed files with 246 additions and 10 deletions
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
175
packages/chess/src/ui/RulesDrawer.tsx
Normal file
175
packages/chess/src/ui/RulesDrawer.tsx
Normal 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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 & 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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue