diff --git a/packages/chess/src/app/App.tsx b/packages/chess/src/app/App.tsx index c9ac41c..7400266 100644 --- a/packages/chess/src/app/App.tsx +++ b/packages/chess/src/app/App.tsx @@ -29,7 +29,7 @@ export function App() { } /> } /> - } /> + } /> } /> diff --git a/packages/chess/src/engine-presets.test.ts b/packages/chess/src/engine-presets.test.ts new file mode 100644 index 0000000..986ce32 --- /dev/null +++ b/packages/chess/src/engine-presets.test.ts @@ -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(); + }); +}); diff --git a/packages/chess/src/engine.ts b/packages/chess/src/engine.ts index d8a1d74..0698c89 100644 --- a/packages/chess/src/engine.ts +++ b/packages/chess/src/engine.ts @@ -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); } diff --git a/packages/chess/src/ui/RulesView.tsx b/packages/chess/src/ui/RulesView.tsx index 7775c1a..29b5d29 100644 --- a/packages/chess/src/ui/RulesView.tsx +++ b/packages/chess/src/ui/RulesView.tsx @@ -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; + isGameActive?: boolean; +} + +export function RulesView({ chessState, isGameActive }: RulesViewProps) { const navigate = useNavigate(); - const [selected, setSelected] = useState>(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>( + () => 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 (
-

Preset Rules

+
+
+

Preset Rules

+

+ Toggle custom chess rules. Changes apply to the next game. +

+
+ +
{isGameActive && (
@@ -49,8 +124,27 @@ export function RulesView({ isGameActive }: { isGameActive?: boolean }) { )} {hasIncompatibilities && ( -
- Warning: Some selected rules are mutually incompatible. +
+ Warning: Some selected rules are mutually incompatible — they will not both apply. +
+ )} + + {missingRequires.length > 0 && ( +
+ Some selected rules are missing prerequisites: +
    + {missingRequires.map((m) => ( +
  • + {m.id} requires {m.needs} +
  • + ))} +
)} @@ -66,6 +160,26 @@ export function RulesView({ isGameActive }: { isGameActive?: boolean }) {

{preset.name}

{preset.description}

+ {preset.incompatibleWith.length > 0 && ( +

+ Incompatible with:{' '} + {preset.incompatibleWith.map((x) => ( + + {x} + + ))} +

+ )} + {preset.requires.length > 0 && ( +

+ Requires:{' '} + {preset.requires.map((x) => ( + + {x} + + ))} +

+ )}