feat(chess): allow pawns-move-backward and double-pawn-sprint simultaneously

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.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-17 14:34:18 -06:00
commit 08b5f794a7
No known key found for this signature in database
5 changed files with 37 additions and 24 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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", () => {