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();