test(presets): enforce incompatibleWith symmetry + reciprocate declarations

Phase F.1 of the rule-variants epic.

Adds three global audit tests to presets.test.ts:
  - incompatibleWith is symmetric across every registered preset
    (if A declares B, B must declare A).
  - every incompatibleWith entry references a real preset id.
  - no preset declares itself incompatible.

The symmetry audit caught 16 existing asymmetries — presets in
Phase B/D/E declared incompat with capture-to-win /
last-piece-standing / knightmate-rules / wrap-board / etc., but
those older presets hadn't reciprocated. Adding the reciprocal
entries across 7 files:
  - capture-to-win, last-piece-standing now declare incompat with
    the five Phase B-D terminal-state presets.
  - knightmate-rules adds suicide-chess, capture-all.
  - pawn-diagonal-no-capture, pawns-move-backward,
    double-pawn-sprint each declare incompat with berolina-pawns
    and berolina-pawns-2.
  - wrap-board adds bouncing-pieces, bouncing-pieces-2.
  - extinction-chess reciprocates back to capture-to-win,
    last-piece-standing.

Tests: 1650 passing (was 1647, +3 new audit tests).
This commit is contained in:
Joey Yakimowich-Payne 2026-04-21 09:03:26 -06:00
commit c0c32359ec
No known key found for this signature in database
9 changed files with 121 additions and 7 deletions

View file

@ -38,7 +38,15 @@ PRESET_REGISTRY.register({
name: "First Blood",
description:
"The first player to capture any enemy piece wins the game immediately. Makes every piece precious.",
incompatibleWith: ["last-piece-standing"],
incompatibleWith: [
"last-piece-standing",
// Reciprocal: these presets redefine the terminal-state logic
// and are declared incompatible with us from their own files.
"first-promotion-wins",
"capture-all",
"suicide-chess",
"extinction-chess",
],
requires: [],
onBeforeCapture({ engine, attacker }) {

View file

@ -58,7 +58,12 @@ PRESET_REGISTRY.register({
name: "Perpetual Sprint",
description:
"Pawns may advance 2 squares straight forward from ANY rank (not only the home rank).",
incompatibleWith: [],
incompatibleWith: [
// Reciprocal: berolina-pawns redefines pawn movement and
// conflicts with additive pawn rules.
"berolina-pawns",
"berolina-pawns-2",
],
requires: [],
getExtraMoves: getDoubleSprintMove,
});

View file

@ -152,6 +152,10 @@ PRESET_REGISTRY.register({
"suicide-chess",
"capture-all",
"first-promotion-wins",
// Reciprocal: every terminal-state preset blocks composition
// with us.
"capture-to-win",
"last-piece-standing",
],
requires: [],

View file

@ -50,7 +50,15 @@ PRESET_REGISTRY.register({
name: "Knightmate",
description:
"Knights are royal instead of kings. Mate the last knight to win; kings are regular pieces.",
incompatibleWith: ["coregal", "dual-king", "weak-dual-king"],
incompatibleWith: [
"coregal",
"dual-king",
"weak-dual-king",
// Reciprocal: these presets empty the royal set entirely and
// so conflict with any preset that declares non-king royalty.
"suicide-chess",
"capture-all",
],
requires: [],
getRoyalPieces({ engine, color }): readonly EntityId[] {

View file

@ -33,7 +33,15 @@ PRESET_REGISTRY.register({
name: "Annihilation",
description:
"Checkmate is disabled. The player who captures ALL enemy pieces (king included) wins. Every capture counts.",
incompatibleWith: ["capture-to-win"],
incompatibleWith: [
"capture-to-win",
// Reciprocal: these terminal-state presets declare incompat
// with us from their own files.
"first-promotion-wins",
"capture-all",
"suicide-chess",
"extinction-chess",
],
requires: [],
onCheckGameResult({ engine }): GameResult | undefined {

View file

@ -44,7 +44,12 @@ PRESET_REGISTRY.register({
name: "Slanting Pawns",
description:
"Pawns may move one square diagonally forward to an empty square without capturing.",
incompatibleWith: [],
incompatibleWith: [
// Reciprocal: the berolina-pawns presets wholly redefine pawn
// movement and conflict with any additive pawn-movement rule.
"berolina-pawns",
"berolina-pawns-2",
],
requires: [],
getExtraMoves: getDiagonalQuietMoves,
});

View file

@ -44,7 +44,12 @@ PRESET_REGISTRY.register({
name: "Backward-Marching Pawns",
description:
"Pawns may also move 1 square straight backward to an empty square (no capture).",
incompatibleWith: [],
incompatibleWith: [
// Reciprocal: berolina-pawns redefines pawn movement and
// conflicts with additive pawn rules.
"berolina-pawns",
"berolina-pawns-2",
],
requires: [],
getExtraMoves: getPawnBackwardMove,
});

View file

@ -107,6 +107,70 @@ describe("Incompatibility declarations", () => {
expect(a.incompatibleWith).not.toContain("explosive-rook");
expect(b.incompatibleWith).not.toContain("piece-hp");
});
// ─────────────────────────────────────────────────────────────────
// Global audit (Phase F.1 of the rule-variants epic)
//
// Every preset's `incompatibleWith` entry must be SYMMETRIC: if A
// declares B incompatible, B must declare A incompatible too.
// Asymmetric declarations are bugs — the activation validator only
// fires when EITHER side declares the other, so a one-way
// declaration works by accident, but a later refactor that
// re-registers A without the line silently breaks the guarantee.
//
// Every entry must also reference a REAL preset id. Dangling
// references (typo'd ids, deleted presets) are caught here before
// they cause runtime `UNKNOWN_PRESET` errors.
// ─────────────────────────────────────────────────────────────────
it("incompatibleWith is symmetric across every registered preset", () => {
const all = PRESET_REGISTRY.getAll();
const byId = new Map(all.map(p => [p.id, p] as const));
const asymmetric: string[] = [];
for (const a of all) {
for (const bId of a.incompatibleWith) {
const b = byId.get(bId);
if (b === undefined) continue; // dangling refs caught separately
if (!b.incompatibleWith.includes(a.id)) {
asymmetric.push(
`"${a.id}" declares "${bId}" incompatible but "${bId}" does NOT reciprocate`,
);
}
}
}
expect(asymmetric).toEqual([]);
});
it("every incompatibleWith entry references a registered preset id", () => {
const all = PRESET_REGISTRY.getAll();
const ids = new Set(all.map(p => p.id));
const dangling: string[] = [];
for (const preset of all) {
for (const ref of preset.incompatibleWith) {
if (!ids.has(ref)) {
dangling.push(`"${preset.id}" → "${ref}" (unknown)`);
}
}
}
expect(dangling).toEqual([]);
});
it("no preset declares itself incompatible", () => {
const all = PRESET_REGISTRY.getAll();
const selfRefs: string[] = [];
for (const preset of all) {
if (preset.incompatibleWith.includes(preset.id)) {
selfRefs.push(preset.id);
}
}
expect(selfRefs).toEqual([]);
});
});
describe("Requires declarations", () => {

View file

@ -206,7 +206,14 @@ PRESET_REGISTRY.register({
name: "Cylindrical Board",
description:
"The board wraps horizontally: every piece (knights, kings, bishops, queens, rooks, and pawn captures) can move off one side and appear on the other.",
incompatibleWith: ["rook-warp"],
incompatibleWith: [
"rook-warp",
// Reciprocal: bouncing-pieces presets declare incompat with
// wrap-board because their diagonal reflection geometry is
// undefined on a wrapping (cylindrical) board.
"bouncing-pieces",
"bouncing-pieces-2",
],
requires: [],
getExtraMoves: (engine, pieceId) => {
const facts = engine.session.allFacts();