From 08b5f794a7d9a769e86333a0dde482cf74398a63 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Fri, 17 Apr 2026 14:34:18 -0600 Subject: [PATCH] feat(chess): allow pawns-move-backward and double-pawn-sprint simultaneously MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audited all 15 preset incompatibilities. Five of the six declared pairs are genuine mechanical conflicts (rook-warp vs wrap-board have contradictory board-topology semantics; piece-hp vs explosive-rook define capture as damage vs removal; capture-to-win vs last-piece-standing are competing win conditions). The pawns-move-backward vs double-pawn-sprint pair is not — both are pure additive getExtraMoves hooks over the base pawn rule; the move sets are disjoint (backward-1 vs forward-2); and no generated move contradicts any other. Dropped the declaration from both preset files. Moved the mutually-incompatible-pair tests to use rook-warp vs wrap-board, which is the canonical genuine-conflict pair and exercises the same compat machinery. --- packages/chess/src/presets/active-set.test.ts | 24 ++++++++++--------- .../chess/src/presets/double-pawn-sprint.ts | 6 +++-- .../src/presets/pawns-move-backward.test.ts | 14 ++++++----- .../chess/src/presets/pawns-move-backward.ts | 6 +++-- packages/chess/src/presets/presets.test.ts | 11 ++++++--- 5 files changed, 37 insertions(+), 24 deletions(-) diff --git a/packages/chess/src/presets/active-set.test.ts b/packages/chess/src/presets/active-set.test.ts index dfeba6e..78e177a 100644 --- a/packages/chess/src/presets/active-set.test.ts +++ b/packages/chess/src/presets/active-set.test.ts @@ -61,32 +61,34 @@ describe("ActivePresetSet", () => { }); describe("replaceAll — loose compatibility", () => { + // Uses rook-warp ↔ wrap-board as the canonical mutually-incompatible + // pair — they define contradictory board-topology semantics and + // genuinely cannot coexist. See RULES.md for the full matrix. it("blocks incompatible presets when scopes overlap (both+any)", () => { - // pawns-move-backward is incompatibleWith double-pawn-sprint expect(() => set.replaceAll([ - { id: "pawns-move-backward", scope: "both", turnsRemaining: null }, - { id: "double-pawn-sprint", scope: "white", turnsRemaining: null }, + { id: "rook-warp", scope: "both", turnsRemaining: null }, + { id: "wrap-board", scope: "white", turnsRemaining: null }, ]), ).toThrow(/incompatible/); }); it("allows incompatible presets when scopes are disjoint", () => { - // White-only pawns-move-backward + black-only double-pawn-sprint — - // per the loose rule these never overlap, so it's allowed. + // White-only rook-warp + black-only wrap-board — per the loose + // rule these never overlap, so the block is lifted. set.replaceAll([ - { id: "pawns-move-backward", scope: "white", turnsRemaining: null }, - { id: "double-pawn-sprint", scope: "black", turnsRemaining: null }, + { id: "rook-warp", scope: "white", turnsRemaining: null }, + { id: "wrap-board", scope: "black", turnsRemaining: null }, ]); - expect(set.has("pawns-move-backward")).toBe(true); - expect(set.has("double-pawn-sprint")).toBe(true); + expect(set.has("rook-warp")).toBe(true); + expect(set.has("wrap-board")).toBe(true); }); it("blocks when both are the same explicit color", () => { expect(() => set.replaceAll([ - { id: "pawns-move-backward", scope: "white", turnsRemaining: null }, - { id: "double-pawn-sprint", scope: "white", turnsRemaining: null }, + { id: "rook-warp", scope: "white", turnsRemaining: null }, + { id: "wrap-board", scope: "white", turnsRemaining: null }, ]), ).toThrow(/incompatible/); }); diff --git a/packages/chess/src/presets/double-pawn-sprint.ts b/packages/chess/src/presets/double-pawn-sprint.ts index 298db5b..88aa078 100644 --- a/packages/chess/src/presets/double-pawn-sprint.ts +++ b/packages/chess/src/presets/double-pawn-sprint.ts @@ -8,7 +8,9 @@ * preset only adds the double advance when the pawn is OFF its home rank. * * Mode: override (conceptually removes the `HasMoved = false` guard). - * Incompatible with `pawns-move-backward`. + * Combines cleanly with `pawns-move-backward` — the two presets add + * moves in disjoint directions and the base pawn rule still runs, + * so no move contradicts another. */ import type { EntityId } from "@paratype/rete"; import type { ChessEngine } from "../engine.js"; @@ -56,7 +58,7 @@ PRESET_REGISTRY.register({ name: "Perpetual Sprint", description: "Pawns may advance 2 squares straight forward from ANY rank (not only the home rank).", - incompatibleWith: ["pawns-move-backward"], + incompatibleWith: [], requires: [], getExtraMoves: getDoubleSprintMove, }); diff --git a/packages/chess/src/presets/pawns-move-backward.test.ts b/packages/chess/src/presets/pawns-move-backward.test.ts index c6503d8..26ee6c2 100644 --- a/packages/chess/src/presets/pawns-move-backward.test.ts +++ b/packages/chess/src/presets/pawns-move-backward.test.ts @@ -32,12 +32,14 @@ describe("Preset registry (catalog)", () => { ).toThrow(/not registered/); }); - it("cannot activate two mutually-incompatible presets under overlapping scope", () => { + it("mutually-incompatible presets throw under overlapping scope", () => { + // rook-warp + wrap-board is the canonical hard-incompatible pair + // (see RULES.md): they define conflicting board topology semantics. const set = new ActivePresetSet(); expect(() => set.replaceAll([ - { id: "pawns-move-backward", scope: "both", turnsRemaining: null }, - { id: "double-pawn-sprint", scope: "both", turnsRemaining: null }, + { id: "rook-warp", scope: "both", turnsRemaining: null }, + { id: "wrap-board", scope: "both", turnsRemaining: null }, ]), ).toThrow(/incompatible/); }); @@ -56,11 +58,11 @@ describe("Preset: pawns-move-backward", () => { return pos ? (pos.id as EntityId) : null; } - it("declares incompatibility with double-pawn-sprint", () => { - expect(preset().incompatibleWith).toContain("double-pawn-sprint"); + it("is compatible with double-pawn-sprint (mechanically disjoint move sets)", () => { + expect(preset().incompatibleWith).not.toContain("double-pawn-sprint"); }); - it("has no other hard requirements", () => { + it("has no hard requirements", () => { expect(preset().requires).toEqual([]); }); diff --git a/packages/chess/src/presets/pawns-move-backward.ts b/packages/chess/src/presets/pawns-move-backward.ts index 4af4338..e2c1a2d 100644 --- a/packages/chess/src/presets/pawns-move-backward.ts +++ b/packages/chess/src/presets/pawns-move-backward.ts @@ -4,7 +4,9 @@ * Pawns may additionally move exactly one square straight backward to an * empty square. Backward moves may NOT capture and do NOT enable en passant. * - * Mode: additive. Incompatible with `double-pawn-sprint`. + * Mode: additive. Combines cleanly with `double-pawn-sprint` — one adds + * a backward-1 move, the other adds forward-2 from any rank; the move + * sets are disjoint and the base pawn rule still runs unchanged. */ import type { EntityId } from "@paratype/rete"; import type { ChessEngine } from "../engine.js"; @@ -42,7 +44,7 @@ PRESET_REGISTRY.register({ name: "Backward-Marching Pawns", description: "Pawns may also move 1 square straight backward to an empty square (no capture).", - incompatibleWith: ["double-pawn-sprint"], + incompatibleWith: [], requires: [], getExtraMoves: getPawnBackwardMove, }); diff --git a/packages/chess/src/presets/presets.test.ts b/packages/chess/src/presets/presets.test.ts index 69bf44a..984a448 100644 --- a/packages/chess/src/presets/presets.test.ts +++ b/packages/chess/src/presets/presets.test.ts @@ -54,11 +54,16 @@ describe("Preset registry — all 15 registered", () => { }); describe("Incompatibility declarations", () => { - it("pawns-move-backward ↔ double-pawn-sprint are mutually incompatible", () => { + it("pawns-move-backward ↔ double-pawn-sprint are now compatible", () => { + // Historical note: these were originally incompatible on design + // grounds (author felt oscillating pawns were ugly). Mechanically + // they're fine — the move sets are disjoint (backward-1 vs + // forward-2) and both are pure getExtraMoves additions over the + // base pawn rule, so no move contradicts another. const a = PRESET_REGISTRY.getAll().find(p => p.id === "pawns-move-backward")!; const b = PRESET_REGISTRY.getAll().find(p => p.id === "double-pawn-sprint")!; - expect(a.incompatibleWith).toContain("double-pawn-sprint"); - expect(b.incompatibleWith).toContain("pawns-move-backward"); + expect(a.incompatibleWith).not.toContain("double-pawn-sprint"); + expect(b.incompatibleWith).not.toContain("pawns-move-backward"); }); it("rook-warp ↔ wrap-board are mutually incompatible", () => {