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>
|
||||
<Route path="/" element={<Lobby />} />
|
||||
<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} />} />
|
||||
</Routes>
|
||||
</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";
|
||||
import { applyCapture } from "./rules/capture.js";
|
||||
import type { LegalMove } from "./rules/types.js";
|
||||
import { PRESET_REGISTRY } from "./presets/index.js";
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,22 @@
|
|||
import { useState, useMemo } from 'react';
|
||||
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 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 [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();
|
||||
|
||||
|
|
@ -20,27 +32,90 @@ export function RulesView({ isGameActive }: { isGameActive?: boolean }) {
|
|||
return false;
|
||||
}, [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) => {
|
||||
if (isGameActive) return;
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
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 = () => {
|
||||
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');
|
||||
};
|
||||
|
||||
const activeCount = selected.size;
|
||||
|
||||
return (
|
||||
<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 && (
|
||||
<div className="bg-gray-100 text-gray-700 p-4 rounded-md">
|
||||
|
|
@ -49,8 +124,27 @@ export function RulesView({ isGameActive }: { isGameActive?: boolean }) {
|
|||
)}
|
||||
|
||||
{hasIncompatibilities && (
|
||||
<div data-testid="compat-warning" className="bg-amber-100 text-amber-800 p-4 rounded-md font-semibold">
|
||||
Warning: Some selected rules are mutually incompatible.
|
||||
<div
|
||||
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>
|
||||
)}
|
||||
|
||||
|
|
@ -66,6 +160,26 @@ export function RulesView({ isGameActive }: { isGameActive?: boolean }) {
|
|||
<div className="pr-4">
|
||||
<h3 className="text-lg font-semibold">{preset.name}</h3>
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -89,7 +203,12 @@ export function RulesView({ isGameActive }: { isGameActive?: boolean }) {
|
|||
))}
|
||||
</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
|
||||
type="button"
|
||||
data-action="start-new-game"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue