From c0c32359ec8de8aa43247b675704f3e67364aa3b Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Tue, 21 Apr 2026 09:03:26 -0600 Subject: [PATCH] test(presets): enforce incompatibleWith symmetry + reciprocate declarations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- packages/chess/src/presets/capture-to-win.ts | 10 ++- .../chess/src/presets/double-pawn-sprint.ts | 7 +- .../chess/src/presets/extinction-chess.ts | 4 ++ .../chess/src/presets/knightmate-rules.ts | 10 ++- .../chess/src/presets/last-piece-standing.ts | 10 ++- .../src/presets/pawn-diagonal-no-capture.ts | 7 +- .../chess/src/presets/pawns-move-backward.ts | 7 +- packages/chess/src/presets/presets.test.ts | 64 +++++++++++++++++++ packages/chess/src/presets/wrap-board.ts | 9 ++- 9 files changed, 121 insertions(+), 7 deletions(-) diff --git a/packages/chess/src/presets/capture-to-win.ts b/packages/chess/src/presets/capture-to-win.ts index 33be00e..b811ee8 100644 --- a/packages/chess/src/presets/capture-to-win.ts +++ b/packages/chess/src/presets/capture-to-win.ts @@ -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 }) { diff --git a/packages/chess/src/presets/double-pawn-sprint.ts b/packages/chess/src/presets/double-pawn-sprint.ts index 88aa078..8ac3829 100644 --- a/packages/chess/src/presets/double-pawn-sprint.ts +++ b/packages/chess/src/presets/double-pawn-sprint.ts @@ -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, }); diff --git a/packages/chess/src/presets/extinction-chess.ts b/packages/chess/src/presets/extinction-chess.ts index 5874223..ed70f4e 100644 --- a/packages/chess/src/presets/extinction-chess.ts +++ b/packages/chess/src/presets/extinction-chess.ts @@ -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: [], diff --git a/packages/chess/src/presets/knightmate-rules.ts b/packages/chess/src/presets/knightmate-rules.ts index a4e1574..4dbead6 100644 --- a/packages/chess/src/presets/knightmate-rules.ts +++ b/packages/chess/src/presets/knightmate-rules.ts @@ -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[] { diff --git a/packages/chess/src/presets/last-piece-standing.ts b/packages/chess/src/presets/last-piece-standing.ts index eda62de..2a58b43 100644 --- a/packages/chess/src/presets/last-piece-standing.ts +++ b/packages/chess/src/presets/last-piece-standing.ts @@ -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 { diff --git a/packages/chess/src/presets/pawn-diagonal-no-capture.ts b/packages/chess/src/presets/pawn-diagonal-no-capture.ts index 44d8993..9217553 100644 --- a/packages/chess/src/presets/pawn-diagonal-no-capture.ts +++ b/packages/chess/src/presets/pawn-diagonal-no-capture.ts @@ -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, }); diff --git a/packages/chess/src/presets/pawns-move-backward.ts b/packages/chess/src/presets/pawns-move-backward.ts index e2c1a2d..1f12999 100644 --- a/packages/chess/src/presets/pawns-move-backward.ts +++ b/packages/chess/src/presets/pawns-move-backward.ts @@ -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, }); diff --git a/packages/chess/src/presets/presets.test.ts b/packages/chess/src/presets/presets.test.ts index 1c1d8f9..ee8345e 100644 --- a/packages/chess/src/presets/presets.test.ts +++ b/packages/chess/src/presets/presets.test.ts @@ -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", () => { diff --git a/packages/chess/src/presets/wrap-board.ts b/packages/chess/src/presets/wrap-board.ts index 4b811c4..ca355d1 100644 --- a/packages/chess/src/presets/wrap-board.ts +++ b/packages/chess/src/presets/wrap-board.ts @@ -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();