feat(chess): wire PRESET_REGISTRY into ChessEngine.getAllLegalMoves

This commit is contained in:
Joey Yakimowich-Payne 2026-04-17 11:31:07 -06:00
commit 51afd9a6f5
No known key found for this signature in database
4 changed files with 278 additions and 14 deletions

View file

@ -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>

View 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();
});
});

View file

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

View file

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