feat(chess): wire PRESET_REGISTRY into ChessEngine.getAllLegalMoves
This commit is contained in:
parent
dbf3b4551d
commit
51afd9a6f5
4 changed files with 278 additions and 14 deletions
|
|
@ -29,7 +29,7 @@ export function App() {
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Lobby />} />
|
<Route path="/" element={<Lobby />} />
|
||||||
<Route path="/game" element={<GameView engineState={chessState} />} />
|
<Route path="/game" element={<GameView engineState={chessState} />} />
|
||||||
<Route path="/rules" element={<RulesView />} />
|
<Route path="/rules" element={<RulesView chessState={chessState} />} />
|
||||||
<Route path="/save" element={<SaveWrapper chessState={chessState} />} />
|
<Route path="/save" element={<SaveWrapper chessState={chessState} />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
126
packages/chess/src/engine-presets.test.ts
Normal file
126
packages/chess/src/engine-presets.test.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
/**
|
||||||
|
* Integration tests proving that toggling presets on PRESET_REGISTRY
|
||||||
|
* actually changes what ChessEngine.getAllLegalMoves() returns.
|
||||||
|
*
|
||||||
|
* These tests cross the preset → engine boundary, so they live at the
|
||||||
|
* package root rather than under presets/.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import { ChessEngine } from "./engine.js";
|
||||||
|
import { PRESET_REGISTRY } from "./presets/index.js";
|
||||||
|
import { algebraicToSquare } from "./coord.js";
|
||||||
|
|
||||||
|
describe("PRESET_REGISTRY ↔ ChessEngine.getAllLegalMoves integration", () => {
|
||||||
|
beforeEach(() => PRESET_REGISTRY.clear());
|
||||||
|
afterEach(() => PRESET_REGISTRY.clear());
|
||||||
|
|
||||||
|
it("standard chess: no preset lets a pawn move backward (sanity baseline)", () => {
|
||||||
|
const engine = new ChessEngine();
|
||||||
|
// Move e2 → e4 (standard two-square advance)
|
||||||
|
const advance = engine.findMove(
|
||||||
|
algebraicToSquare("e2"),
|
||||||
|
algebraicToSquare("e4"),
|
||||||
|
);
|
||||||
|
expect(advance).not.toBeNull();
|
||||||
|
engine.applyMove(advance!);
|
||||||
|
|
||||||
|
// Black plays something random so it's white's turn again
|
||||||
|
engine.applyMove(
|
||||||
|
engine.findMove(algebraicToSquare("e7"), algebraicToSquare("e5"))!,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Now try to move e4 → e3 (backward). Standard rules forbid it.
|
||||||
|
const backward = engine.findMove(
|
||||||
|
algebraicToSquare("e4"),
|
||||||
|
algebraicToSquare("e3"),
|
||||||
|
);
|
||||||
|
expect(backward).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pawns-move-backward preset: e4 pawn CAN move back to e3", () => {
|
||||||
|
PRESET_REGISTRY.activate("pawns-move-backward");
|
||||||
|
const engine = new ChessEngine();
|
||||||
|
|
||||||
|
// Same setup: push e2 → e4, black plays e7 → e5
|
||||||
|
engine.applyMove(
|
||||||
|
engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!,
|
||||||
|
);
|
||||||
|
engine.applyMove(
|
||||||
|
engine.findMove(algebraicToSquare("e7"), algebraicToSquare("e5"))!,
|
||||||
|
);
|
||||||
|
|
||||||
|
// NOW backward move should be legal with preset active.
|
||||||
|
const backward = engine.findMove(
|
||||||
|
algebraicToSquare("e4"),
|
||||||
|
algebraicToSquare("e3"),
|
||||||
|
);
|
||||||
|
expect(backward).not.toBeNull();
|
||||||
|
expect(backward!.from).toBe(algebraicToSquare("e4"));
|
||||||
|
expect(backward!.to).toBe(algebraicToSquare("e3"));
|
||||||
|
expect(backward!.isCapture).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deactivating a preset mid-session removes its extra moves immediately", () => {
|
||||||
|
PRESET_REGISTRY.activate("pawns-move-backward");
|
||||||
|
const engine = new ChessEngine();
|
||||||
|
engine.applyMove(
|
||||||
|
engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!,
|
||||||
|
);
|
||||||
|
engine.applyMove(
|
||||||
|
engine.findMove(algebraicToSquare("e7"), algebraicToSquare("e5"))!,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Preset active: backward legal
|
||||||
|
expect(
|
||||||
|
engine.findMove(algebraicToSquare("e4"), algebraicToSquare("e3")),
|
||||||
|
).not.toBeNull();
|
||||||
|
|
||||||
|
// Deactivate — engine should immediately reflect the change
|
||||||
|
PRESET_REGISTRY.deactivate("pawns-move-backward");
|
||||||
|
expect(
|
||||||
|
engine.findMove(algebraicToSquare("e4"), algebraicToSquare("e3")),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pawn-diagonal-no-capture preset ('Slanting Pawns'): adds diagonal quiet moves to empty squares", () => {
|
||||||
|
const engine = new ChessEngine();
|
||||||
|
|
||||||
|
// Before activation: a pawn on e2 cannot move diagonally to an empty
|
||||||
|
// d3 or f3 square (those squares are empty from the starting position,
|
||||||
|
// and FIDE pawns only go diagonal when capturing).
|
||||||
|
const diagonalEmptyBefore = engine.findMove(
|
||||||
|
algebraicToSquare("e2"),
|
||||||
|
algebraicToSquare("f3"),
|
||||||
|
);
|
||||||
|
expect(diagonalEmptyBefore).toBeNull();
|
||||||
|
|
||||||
|
// With preset active: e2 → f3 (empty, diagonal) becomes legal.
|
||||||
|
PRESET_REGISTRY.activate("pawn-diagonal-no-capture");
|
||||||
|
const diagonalEmptyAfter = engine.findMove(
|
||||||
|
algebraicToSquare("e2"),
|
||||||
|
algebraicToSquare("f3"),
|
||||||
|
);
|
||||||
|
expect(diagonalEmptyAfter).not.toBeNull();
|
||||||
|
expect(diagonalEmptyAfter!.isCapture).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("multiple presets compose: backward + diagonal-no-capture both apply", () => {
|
||||||
|
PRESET_REGISTRY.activate("pawns-move-backward");
|
||||||
|
PRESET_REGISTRY.activate("pawn-diagonal-no-capture");
|
||||||
|
const engine = new ChessEngine();
|
||||||
|
|
||||||
|
// Move a pawn up and back — both legal under this combo.
|
||||||
|
engine.applyMove(
|
||||||
|
engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!,
|
||||||
|
);
|
||||||
|
engine.applyMove(
|
||||||
|
engine.findMove(algebraicToSquare("a7"), algebraicToSquare("a6"))!,
|
||||||
|
);
|
||||||
|
|
||||||
|
const backward = engine.findMove(
|
||||||
|
algebraicToSquare("e4"),
|
||||||
|
algebraicToSquare("e3"),
|
||||||
|
);
|
||||||
|
expect(backward).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -47,6 +47,7 @@ import {
|
||||||
} from "./rules/draws.js";
|
} from "./rules/draws.js";
|
||||||
import { applyCapture } from "./rules/capture.js";
|
import { applyCapture } from "./rules/capture.js";
|
||||||
import type { LegalMove } from "./rules/types.js";
|
import type { LegalMove } from "./rules/types.js";
|
||||||
|
import { PRESET_REGISTRY } from "./presets/index.js";
|
||||||
|
|
||||||
type MoveGetter = (session: Session, pieceId: EntityId) => LegalMove[];
|
type MoveGetter = (session: Session, pieceId: EntityId) => LegalMove[];
|
||||||
|
|
||||||
|
|
@ -129,6 +130,24 @@ export class ChessEngine {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply active preset rules: add extra moves, then run filter hooks.
|
||||||
|
// Order matters — `getExtraMoves` contributes to the set that
|
||||||
|
// `filterMoves` operates on, so every active preset sees the full
|
||||||
|
// aggregated set (including prior presets' additions).
|
||||||
|
for (const preset of PRESET_REGISTRY.getActive()) {
|
||||||
|
if (preset.getExtraMoves) {
|
||||||
|
pieceMoves = [
|
||||||
|
...pieceMoves,
|
||||||
|
...preset.getExtraMoves(this, piece.id),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const preset of PRESET_REGISTRY.getActive()) {
|
||||||
|
if (preset.filterMoves) {
|
||||||
|
pieceMoves = preset.filterMoves(pieceMoves, this, piece.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
moves.push(...pieceMoves);
|
moves.push(...pieceMoves);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,22 @@
|
||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { PRESET_REGISTRY } from '../presets/index.js';
|
import { PRESET_REGISTRY } from '../presets/index.js';
|
||||||
|
import { ChessEngine } from '../engine.js';
|
||||||
|
import type { useChessEngine } from '../hooks/useChessEngine.js';
|
||||||
|
|
||||||
export function RulesView({ isGameActive }: { isGameActive?: boolean }) {
|
interface RulesViewProps {
|
||||||
|
/** Only required when rules should drive a new game start. */
|
||||||
|
chessState?: ReturnType<typeof useChessEngine>;
|
||||||
|
isGameActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RulesView({ chessState, isGameActive }: RulesViewProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
// Seed local selection from whatever is already active on the registry so
|
||||||
|
// navigating to /rules mid-game reflects the real state (not a fresh set).
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(
|
||||||
|
() => new Set(PRESET_REGISTRY.getActive().map((p) => p.id)),
|
||||||
|
);
|
||||||
|
|
||||||
const presets = PRESET_REGISTRY.getAll();
|
const presets = PRESET_REGISTRY.getAll();
|
||||||
|
|
||||||
|
|
@ -20,27 +32,90 @@ export function RulesView({ isGameActive }: { isGameActive?: boolean }) {
|
||||||
return false;
|
return false;
|
||||||
}, [selected, presets]);
|
}, [selected, presets]);
|
||||||
|
|
||||||
|
const missingRequires = useMemo(() => {
|
||||||
|
const missing: Array<{ id: string; needs: string }> = [];
|
||||||
|
for (const a of selected) {
|
||||||
|
const def = presets.find((p) => p.id === a);
|
||||||
|
if (!def) continue;
|
||||||
|
for (const need of def.requires) {
|
||||||
|
if (!selected.has(need)) missing.push({ id: a, needs: need });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return missing;
|
||||||
|
}, [selected, presets]);
|
||||||
|
|
||||||
const togglePreset = (id: string) => {
|
const togglePreset = (id: string) => {
|
||||||
if (isGameActive) return;
|
if (isGameActive) return;
|
||||||
setSelected((prev) => {
|
setSelected((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
if (next.has(id)) {
|
if (next.has(id)) next.delete(id);
|
||||||
next.delete(id);
|
else next.add(id);
|
||||||
} else {
|
|
||||||
next.add(id);
|
|
||||||
}
|
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Keep the singleton registry in sync with the user's current selection on
|
||||||
|
// every change. This means toggles take effect live — players navigating
|
||||||
|
// back and forth between /rules and /game see the new rules immediately,
|
||||||
|
// and any newly-created engine (P4 server path or /save load) will pick
|
||||||
|
// up the active presets automatically.
|
||||||
|
useEffect(() => {
|
||||||
|
// Deactivate first so the activation loop can't trip the registry's
|
||||||
|
// incompatibility guard against stale entries.
|
||||||
|
for (const p of PRESET_REGISTRY.getActive()) {
|
||||||
|
if (!selected.has(p.id)) PRESET_REGISTRY.deactivate(p.id);
|
||||||
|
}
|
||||||
|
// Activate in dependency-friendly order: items whose `requires` are
|
||||||
|
// already active first. Simple fixpoint loop — stops when no more
|
||||||
|
// progress can be made, at which point remaining items are either
|
||||||
|
// already active or blocked by missing prerequisites / conflicts.
|
||||||
|
const toActivate = [...selected].filter((id) => !PRESET_REGISTRY.isActive(id));
|
||||||
|
let progress = true;
|
||||||
|
while (progress && toActivate.length > 0) {
|
||||||
|
progress = false;
|
||||||
|
for (let i = toActivate.length - 1; i >= 0; i--) {
|
||||||
|
const id = toActivate[i]!;
|
||||||
|
try {
|
||||||
|
PRESET_REGISTRY.activate(id);
|
||||||
|
toActivate.splice(i, 1);
|
||||||
|
progress = true;
|
||||||
|
} catch {
|
||||||
|
/* requires not met yet, or incompatible — try again next pass */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [selected]);
|
||||||
|
|
||||||
const handleApply = () => {
|
const handleApply = () => {
|
||||||
setSelected(new Set());
|
// 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).
|
||||||
|
if (chessState) {
|
||||||
|
chessState.loadEngine(new ChessEngine());
|
||||||
|
}
|
||||||
navigate('/game');
|
navigate('/game');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const activeCount = selected.size;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-3xl mx-auto space-y-6" data-testid="page-rules">
|
<div className="p-6 max-w-3xl mx-auto space-y-6" data-testid="page-rules">
|
||||||
<h1 className="text-3xl font-bold">Preset Rules</h1>
|
<header className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">Preset Rules</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
Toggle custom chess rules. Changes apply to the next game.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate('/game')}
|
||||||
|
className="text-sm text-slate-500 hover:text-slate-900 underline underline-offset-2"
|
||||||
|
>
|
||||||
|
back to game
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
{isGameActive && (
|
{isGameActive && (
|
||||||
<div className="bg-gray-100 text-gray-700 p-4 rounded-md">
|
<div className="bg-gray-100 text-gray-700 p-4 rounded-md">
|
||||||
|
|
@ -49,8 +124,27 @@ export function RulesView({ isGameActive }: { isGameActive?: boolean }) {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasIncompatibilities && (
|
{hasIncompatibilities && (
|
||||||
<div data-testid="compat-warning" className="bg-amber-100 text-amber-800 p-4 rounded-md font-semibold">
|
<div
|
||||||
Warning: Some selected rules are mutually incompatible.
|
data-testid="compat-warning"
|
||||||
|
className="bg-amber-100 text-amber-800 p-4 rounded-md font-semibold"
|
||||||
|
>
|
||||||
|
Warning: Some selected rules are mutually incompatible — they will not both apply.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{missingRequires.length > 0 && (
|
||||||
|
<div
|
||||||
|
data-testid="requires-warning"
|
||||||
|
className="bg-amber-100 text-amber-800 p-4 rounded-md"
|
||||||
|
>
|
||||||
|
Some selected rules are missing prerequisites:
|
||||||
|
<ul className="list-disc list-inside mt-1">
|
||||||
|
{missingRequires.map((m) => (
|
||||||
|
<li key={`${m.id}-${m.needs}`}>
|
||||||
|
<code>{m.id}</code> requires <code>{m.needs}</code>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -66,6 +160,26 @@ export function RulesView({ isGameActive }: { isGameActive?: boolean }) {
|
||||||
<div className="pr-4">
|
<div className="pr-4">
|
||||||
<h3 className="text-lg font-semibold">{preset.name}</h3>
|
<h3 className="text-lg font-semibold">{preset.name}</h3>
|
||||||
<p className="text-sm text-gray-600 mt-1">{preset.description}</p>
|
<p className="text-sm text-gray-600 mt-1">{preset.description}</p>
|
||||||
|
{preset.incompatibleWith.length > 0 && (
|
||||||
|
<p className="text-xs text-gray-400 mt-2">
|
||||||
|
Incompatible with:{' '}
|
||||||
|
{preset.incompatibleWith.map((x) => (
|
||||||
|
<code key={x} className="mx-1">
|
||||||
|
{x}
|
||||||
|
</code>
|
||||||
|
))}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{preset.requires.length > 0 && (
|
||||||
|
<p className="text-xs text-gray-400 mt-1">
|
||||||
|
Requires:{' '}
|
||||||
|
{preset.requires.map((x) => (
|
||||||
|
<code key={x} className="mx-1">
|
||||||
|
{x}
|
||||||
|
</code>
|
||||||
|
))}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -89,7 +203,12 @@ export function RulesView({ isGameActive }: { isGameActive?: boolean }) {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-6">
|
<div className="pt-6 space-y-2">
|
||||||
|
<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`}
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-action="start-new-game"
|
data-action="start-new-game"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue