diff --git a/packages/chess/RULES.md b/packages/chess/RULES.md index ce55401..92a5e0d 100644 --- a/packages/chess/RULES.md +++ b/packages/chess/RULES.md @@ -461,15 +461,15 @@ Win by wiping out every enemy piece of a specific TYPE (default: `"pawn"`). Targ ### Berolina Pawns -**ID**: `berolina-pawns` · **Scope-aware**: YES · **Mode**: hook (overridePieceMoves) +**ID**: `berolina-pawns` · **Scope-aware**: YES · **Mode**: hooks (overridePieceMoves + onAfterMove) -Pawns push forward DIAGONALLY and capture forward ORTHOGONALLY (inverse of FIDE pawns). Diagonal double-push from home rank, promotions on either push or capture reaching last rank. En-passant NOT implemented (deferred; document in future). Scope chooses which side(s) use the rule: `scope: "white"` only white pawns are berolina, `scope: "black"` only black, `scope: "both"` both sides. +Pawns push forward DIAGONALLY and capture forward ORTHOGONALLY (inverse of FIDE pawns). Diagonal double-push from home rank, promotions on either push or capture reaching last rank. En-passant follows Parton 1952 semantics (post-epic Feature 3): a double-diagonal push passes through an intermediate square; the opponent may capture onto that square on the very next half-move via their orthogonal-forward capture vector, retracting the double-pushed pawn. Sideways captures are not available on this preset (see `berolina-pawns-2`). Scope chooses which side(s) use the rule: `scope: "white"` only white pawns are berolina, `scope: "black"` only black, `scope: "both"` both sides. ### Berolina Pawns (Extended) -**ID**: `berolina-pawns-2` · **Scope-aware**: YES · **Mode**: hook (overridePieceMoves) +**ID**: `berolina-pawns-2` · **Scope-aware**: YES · **Mode**: hooks (overridePieceMoves + onAfterMove) -Like `berolina-pawns` but ALSO allows SIDEWAYS captures (adjacent file, same rank). Same scope-flip semantics. +Like `berolina-pawns` but ALSO allows SIDEWAYS captures (adjacent file, same rank). Same scope-flip semantics and the same Parton 1952 ep rule (orthogonal-forward ep only — sideways captures do not trigger or accept ep). ### Bouncing Pieces diff --git a/packages/chess/docs/PRESET-API.md b/packages/chess/docs/PRESET-API.md index 9438c58..8d62c32 100644 --- a/packages/chess/docs/PRESET-API.md +++ b/packages/chess/docs/PRESET-API.md @@ -598,7 +598,7 @@ Documented but NOT yet available: - Per-preset UI panels (not just overlays) — extension slot for sidebar widgets. - Server-side piece-type manifest echo — when custom types are authored outside the shared `packages/chess`. - Save-state migration — versioning for saves that predate attribute additions. -- Berolina en-passant — deferred for v1. + - Extinction-chess multiplayer target sync — solo cycler shipped in post-epic Feature 2; MP target is fixed at room creation until a `preset-config.update` WS message lands. diff --git a/packages/chess/src/engine.ts b/packages/chess/src/engine.ts index 5a14fee..fd64643 100644 --- a/packages/chess/src/engine.ts +++ b/packages/chess/src/engine.ts @@ -1305,7 +1305,13 @@ export class ChessEngine { // responsibility — king-heals affects the non-mover, poisoned-squares // affects the mover, so a single engine-level scope filter can't // serve both. - const moveCtx: MoveHookContext = { engine: this, mover: color }; + const moveCtx: MoveHookContext = { + engine: this, + mover: color, + pieceId: move.pieceId, + from: move.from, + to: move.to, + }; for (const entry of this.activePresets.list()) { const def = PRESET_REGISTRY.get(entry.id); def?.onAfterMove?.(moveCtx); diff --git a/packages/chess/src/presets/berolina-pawns-2.test.ts b/packages/chess/src/presets/berolina-pawns-2.test.ts index 988bb57..abee38e 100644 --- a/packages/chess/src/presets/berolina-pawns-2.test.ts +++ b/packages/chess/src/presets/berolina-pawns-2.test.ts @@ -338,4 +338,80 @@ describe("berolina-pawns-2 — baseline orthogonal capture sanity", () => { expect(e5).toBeDefined(); expect(e5!.isCapture).toBe(true); }); + + // ───────────────────────────────────────────────────────────── + // Feature 3: Parton 1952 en-passant inherits from berolina-pawns. + // Sideways captures do NOT trigger ep (only forward-orthogonal). + // ───────────────────────────────────────────────────────────── + + it("(l) ep: white double-push sets the latch; black orthogonal capturer emits ep", () => { + const engine = new ChessEngine(); + clearBoard(engine); + placePiece(engine, "king", "white", "a1"); + placePiece(engine, "king", "black", "h8"); + const whitePawn = placePiece(engine, "pawn", "white", "d2"); + const blackPawn = placePiece(engine, "pawn", "black", "c4"); + engine.setActivePresets([BEROLINA2]); + + const doublePush = movesFor(engine, whitePawn as unknown as number).find( + (m) => m.to === algebraicToSquare("b4"), + ); + expect(doublePush).toBeDefined(); + engine.applyMove(doublePush!); + + const epMove = movesFor(engine, blackPawn as unknown as number).find( + (m) => m.to === algebraicToSquare("c3"), + ); + expect(epMove).toBeDefined(); + expect(epMove?.isCapture).toBe(true); + }); + + it("(m) ep: accepting retracts the double-pushed pawn", () => { + const engine = new ChessEngine(); + clearBoard(engine); + placePiece(engine, "king", "white", "a1"); + placePiece(engine, "king", "black", "h8"); + const whitePawn = placePiece(engine, "pawn", "white", "d2"); + const blackPawn = placePiece(engine, "pawn", "black", "c4"); + engine.setActivePresets([BEROLINA2]); + + engine.applyMove( + movesFor(engine, whitePawn as unknown as number).find( + (m) => m.to === algebraicToSquare("b4"), + )!, + ); + engine.applyMove( + movesFor(engine, blackPawn as unknown as number).find( + (m) => m.to === algebraicToSquare("c3"), + )!, + ); + + expect(engine.session.get(whitePawn, "PieceType")).toBeUndefined(); + expect(engine.session.get(blackPawn, "Position")).toBe( + algebraicToSquare("c3"), + ); + }); + + it("(n) ep: sideways captures do NOT set the latch", () => { + const engine = new ChessEngine(); + clearBoard(engine); + placePiece(engine, "king", "white", "a1"); + placePiece(engine, "king", "black", "h8"); + const whitePawn = placePiece(engine, "pawn", "white", "d2"); + placePiece(engine, "pawn", "black", "e2"); // enemy adjacent for sideways capture + engine.setActivePresets([BEROLINA2]); + + // White sideways-captures on e2. Should NOT latch (only double- + // push does). + const sideways = movesFor(engine, whitePawn as unknown as number).find( + (m) => m.to === algebraicToSquare("e2"), + ); + expect(sideways).toBeDefined(); + engine.applyMove(sideways!); + + const latch = engine.presetState<{ skippedSquare?: number }>( + "berolina-pawns-2", + ); + expect(latch.get("skippedSquare")).toBeUndefined(); + }); }); diff --git a/packages/chess/src/presets/berolina-pawns-2.ts b/packages/chess/src/presets/berolina-pawns-2.ts index bbfaec1..a04df83 100644 --- a/packages/chess/src/presets/berolina-pawns-2.ts +++ b/packages/chess/src/presets/berolina-pawns-2.ts @@ -15,7 +15,9 @@ * equals the source rank, so it can't be the promotion rank for * a pawn that wasn't already there (pawns on the promotion rank * should have already promoted). - * - No en-passant (same as berolina-pawns — deferred). + * - En-passant: Parton 1952 variant shared with `berolina-pawns` + * (post-epic-deferrals Feature 3). Sideways captures do NOT + * trigger en-passant — only the orthogonal forward capture does. * * Scope-flip semantics * ──────────────────── @@ -60,6 +62,14 @@ import { PRESET_REGISTRY } from "./registry.js"; const PRESET_ID = "berolina-pawns-2"; +/** See `berolina-pawns.ts` for the shape contract; same state slot + * structure but the preset-state namespace is distinct per id. */ +interface BerolinaEpState extends Record { + skippedSquare: Square; + capturedPieceId: EntityId; + capturerColor: PieceColor; +} + /** Promotion rank for each color. */ const PROMOTION_RANK: Record = { white: 7, @@ -193,6 +203,82 @@ PRESET_REGISTRY.register({ } } + // ── En-passant (Parton 1952, shared with berolina-pawns) ────── + if (forwardRank >= 0 && forwardRank <= 7) { + const forwardSq = (from + forwardDelta) as Square; + const state = engine.presetState(PRESET_ID); + const ep = state.get("skippedSquare"); + const epColor = state.get("capturerColor"); + if (ep !== undefined && epColor === color && forwardSq === ep) { + out.push({ pieceId, from, to: forwardSq, isCapture: true }); + } + } + return out; }, + + /** + * Shared Berolina ep window mechanism (Parton 1952). Identical to + * `berolina-pawns.onAfterMove` — see that file for the full + * explainer; duplicated here rather than extracted into a helper + * module because the preset-state namespace is keyed by preset id, + * so each preset needs its own read/write against its own slot. + */ + onAfterMove(ctx) { + const { engine, mover, from, to, pieceId } = ctx; + const state = engine.presetState(PRESET_ID); + const type = engine.session.get(pieceId, "PieceType"); + + // Case 2: mover just accepted a latched ep → retract the + // recorded double-pushed pawn. + const priorSkipped = state.get("skippedSquare"); + const priorCaptured = state.get("capturedPieceId"); + const priorCapturerColor = state.get("capturerColor"); + if ( + type === "pawn" && + priorSkipped !== undefined && + priorCaptured !== undefined && + priorCapturerColor === mover && + to === priorSkipped + ) { + for (const attr of ["PieceType", "Color", "Position", "HasMoved"] as const) { + if (engine.session.contains(priorCaptured, attr)) { + engine.session.retract(priorCaptured, attr); + } + } + } + + state.delete("skippedSquare"); + state.delete("capturedPieceId"); + state.delete("capturerColor"); + + // Case 1: THIS move was a Berolina double-push — latch the + // window for the opponent's next half-move. + if (type !== "pawn") return; + const color = mover; + const fromRank = rankOf(from); + const toRank = rankOf(to); + if (fromRank !== HOME_RANK[color]) return; + const dir = color === "white" ? 1 : -1; + if (toRank - fromRank !== 2 * dir) return; + const delta = to - from; + if (delta !== 2 * (dir * 8 - 1) && delta !== 2 * (dir * 8 + 1)) return; + let scope: "white" | "black" | "both" = "both"; + let found = false; + for (const entry of engine.activePresets.list()) { + if (entry.id === PRESET_ID) { + scope = entry.scope; + found = true; + break; + } + } + if (!found) return; + if (scope !== "both" && scope !== color) return; + + const skippedSquare = ((from + to) / 2) as Square; + if (skippedSquare < 0 || skippedSquare > 63) return; + state.set("skippedSquare", skippedSquare); + state.set("capturedPieceId", pieceId); + state.set("capturerColor", color === "white" ? "black" : "white"); + }, }); diff --git a/packages/chess/src/presets/berolina-pawns.test.ts b/packages/chess/src/presets/berolina-pawns.test.ts index c9ed421..140b8a9 100644 --- a/packages/chess/src/presets/berolina-pawns.test.ts +++ b/packages/chess/src/presets/berolina-pawns.test.ts @@ -546,4 +546,171 @@ describe("berolina-pawns — incompatibility", () => { ]), ).toThrow(); }); + + // ───────────────────────────────────────────────────────────── + // Feature 3 (post-epic-deferrals): Parton 1952 en-passant. + // ───────────────────────────────────────────────────────────── + + it("(r) white double-diagonal push sets the ep latch; black orthogonal capturer emits ep move", () => { + const engine = new ChessEngine(); + clearBoard(engine); + placePiece(engine, "king", "white", "a1"); + placePiece(engine, "king", "black", "h8"); + // White pawn on d2, black pawn on c4 (positioned to orthogonally + // capture onto c3, the skipped square of d2→b4). + const whitePawn = placePiece(engine, "pawn", "white", "d2"); + const blackPawn = placePiece(engine, "pawn", "black", "c4"); + engine.setActivePresets([BEROLINA]); + + // White plays d2→b4 (double-diagonal push). + const whiteMoves = movesFor(engine, whitePawn as unknown as number); + const doublePush = whiteMoves.find( + (m) => m.to === algebraicToSquare("b4"), + ); + expect(doublePush).toBeDefined(); + engine.applyMove(doublePush!); + + // Black's turn: ep capture c4→c3 should appear among legal moves. + const blackMoves = movesFor(engine, blackPawn as unknown as number); + const epMove = blackMoves.find((m) => m.to === algebraicToSquare("c3")); + expect(epMove).toBeDefined(); + expect(epMove?.isCapture).toBe(true); + }); + + it("(s) accepting the Berolina ep retracts the double-pushed pawn", () => { + const engine = new ChessEngine(); + clearBoard(engine); + placePiece(engine, "king", "white", "a1"); + placePiece(engine, "king", "black", "h8"); + const whitePawn = placePiece(engine, "pawn", "white", "d2"); + const blackPawn = placePiece(engine, "pawn", "black", "c4"); + engine.setActivePresets([BEROLINA]); + + // White d2→b4. + const whiteMoves = movesFor(engine, whitePawn as unknown as number); + const doublePush = whiteMoves.find( + (m) => m.to === algebraicToSquare("b4"), + ); + engine.applyMove(doublePush!); + + // Black accepts ep: c4→c3. + const blackMoves = movesFor(engine, blackPawn as unknown as number); + const epMove = blackMoves.find((m) => m.to === algebraicToSquare("c3")); + engine.applyMove(epMove!); + + // The white pawn that double-pushed is gone — PieceType retracted. + expect(engine.session.get(whitePawn, "PieceType")).toBeUndefined(); + expect(engine.session.get(whitePawn, "Position")).toBeUndefined(); + // The black pawn is now on c3. + expect(engine.session.get(blackPawn, "Position")).toBe( + algebraicToSquare("c3"), + ); + }); + + it("(t) ep window expires after one half-move (non-ep response clears latch)", () => { + const engine = new ChessEngine(); + clearBoard(engine); + placePiece(engine, "king", "white", "a1"); + placePiece(engine, "king", "black", "h8"); + const whitePawn = placePiece(engine, "pawn", "white", "d2"); + placePiece(engine, "pawn", "black", "c4"); + // Give black a non-pawn piece that can move freely without + // accepting the ep. + placePiece(engine, "knight", "black", "g8"); + engine.setActivePresets([BEROLINA]); + + // White d2→b4. + const whiteMoves = movesFor(engine, whitePawn as unknown as number); + engine.applyMove( + whiteMoves.find((m) => m.to === algebraicToSquare("b4"))!, + ); + + // Black plays a non-ep move (knight g8→f6). + const knightMoves = engine.getAllLegalMoves().filter((m) => { + const t = engine.session.get(m.pieceId, "PieceType"); + return t === "knight"; + }); + const quietKnight = knightMoves.find( + (m) => m.to === algebraicToSquare("f6"), + ); + expect(quietKnight).toBeDefined(); + engine.applyMove(quietKnight!); + + // On white's next turn, ep window has closed. Try to retrieve + // the latch indirectly: if it's still active, a black pawn that + // could have captured c3 would have emitted the ep move. Since + // the black c4 pawn didn't move, re-check its moves now that the + // clock advanced — there should be NO c3 move (no ep). + // + // Turn is now white's; to enumerate black's moves we'd need to + // advance another half. Instead, assert the preset-state latch + // is cleared — reading it through engine.presetState is the + // cleanest check. + const latch = engine.presetState<{ skippedSquare?: number }>( + "berolina-pawns", + ); + expect(latch.get("skippedSquare")).toBeUndefined(); + }); + + it("(u) no ep available after single-push (only double-push latches)", () => { + const engine = new ChessEngine(); + clearBoard(engine); + placePiece(engine, "king", "white", "a1"); + placePiece(engine, "king", "black", "h8"); + const whitePawn = placePiece(engine, "pawn", "white", "d2"); + const blackPawn = placePiece(engine, "pawn", "black", "c3"); + engine.setActivePresets([BEROLINA]); + + // White single-diagonal push to c3 would be a CAPTURE (black pawn + // there). Use a different setup: white pawn moves to e3 (single + // push diagonal). + engine.session.retract(whitePawn, "Position"); + engine.session.insert(whitePawn, "Position", algebraicToSquare("d2")); + engine.session.retract(blackPawn, "Position"); + engine.session.insert(blackPawn, "Position", algebraicToSquare("f4")); + + const whiteMoves = movesFor(engine, whitePawn as unknown as number); + const singlePush = whiteMoves.find( + (m) => m.to === algebraicToSquare("e3"), + ); + expect(singlePush).toBeDefined(); + engine.applyMove(singlePush!); + + // Latch must NOT be set — single-push doesn't trigger ep. + const latch = engine.presetState<{ skippedSquare?: number }>( + "berolina-pawns", + ); + expect(latch.get("skippedSquare")).toBeUndefined(); + }); + + it("(v) scope=white: latch is set by the white double-push (black retains FIDE rules)", () => { + const engine = new ChessEngine(); + clearBoard(engine); + placePiece(engine, "king", "white", "a1"); + placePiece(engine, "king", "black", "h8"); + const whitePawn = placePiece(engine, "pawn", "white", "d2"); + placePiece(engine, "pawn", "black", "c4"); + engine.setActivePresets([BEROLINA_WHITE]); + + // Only white uses Berolina. White d2→b4. + const whiteMoves = movesFor(engine, whitePawn as unknown as number); + const doublePush = whiteMoves.find( + (m) => m.to === algebraicToSquare("b4"), + ); + expect(doublePush).toBeDefined(); + engine.applyMove(doublePush!); + + // The latch IS set (the preset still listens to all moves via + // onAfterMove; it just wouldn't emit ep for a non-scoped pawn). + // This assertion documents the mechanic: the latch records the + // skipped square and the captured pawn — regardless of whether + // the downstream capturer's color is in-scope. The scope gate + // runs in `overridePieceMoves` on the capturer's turn; for + // scope=white, black pawns use the default pawn generator and + // DON'T emit the Berolina ep. + const latch = engine.presetState<{ skippedSquare?: number }>( + "berolina-pawns", + ); + expect(latch.get("skippedSquare")).toBe(algebraicToSquare("c3")); + }); }); diff --git a/packages/chess/src/presets/berolina-pawns.ts b/packages/chess/src/presets/berolina-pawns.ts index ba81b5d..7025eaa 100644 --- a/packages/chess/src/presets/berolina-pawns.ts +++ b/packages/chess/src/presets/berolina-pawns.ts @@ -40,13 +40,45 @@ * tagged via the `promoteTo` field, matching the shape of the * engine's default `getPromotionMoves` output. * - * En-passant - * ────────── - * DEFERRED. Many Berolina rulesets don't include en-passant at all, - * and the traditional orthogonal en-passant rule doesn't map - * cleanly to a diagonal-push pawn. For v1 we emit NO en-passant - * moves and the override path prevents the engine from synthesizing - * the standard variant. Revisit in a later phase if requested. + * En-passant (Parton 1952 variant) + * ───────────────────────────────── + * Shipped in `post-epic-deferrals` Feature 3 (decision 3a locks the + * variant to Parton 1952, the most common published ruleset). The + * rule mirrors FIDE en-passant through Berolina's reversed geometry: + * + * - When a Berolina pawn performs a DOUBLE DIAGONAL push (home-rank + * only), the intermediate diagonal square is "passed through". + * We store that square as `GAME_ENTITY.EnPassantTarget` via the + * preset's `onAfterMove` hook. + * - On the VERY NEXT half-move, an enemy Berolina pawn whose + * orthogonal-capture target equals the stored EnPassantTarget + * may capture. The capture move lands on the skipped square; + * the captured pawn (the one that double-pushed) is on the + * rank beyond the skipped square along the SAME FILE that the + * capturer used. + * + * Mechanics inside the override: + * - Read `engine.session.get(GAME_ENTITY, "EnPassantTarget")` after + * emitting normal pushes + captures. If non-null AND the pawn's + * orthogonal-forward square equals the stored target, emit an + * additional capture move marked `isEnPassant: true`. The engine's + * applyMove sees the flag and retracts the DOUBLE-PUSHED pawn + * (not the pawn on the landing square — it's empty by + * construction). + * - Ep target validity is EXACTLY one half-move. The engine clears + * the target at the end of the next successful applyMove (pre- + * existing behaviour, unchanged). + * + * Composition: + * - With `scope: "white"`, ONLY white pawns perform Berolina ep. + * Black pawns keep FIDE rules (and FIDE ep) via the default + * pawn generator when the override returns `undefined`. + * - Promotion interaction: if a diagonal push to the last rank is + * a double-push, the intermediate square is on rank 7 (white) / + * rank 1 (black). En-passant capture onto that rank is unusual + * but legal; it yields a NON-promoting capture (the capturing + * pawn lands on the skipped square, not the promotion rank). + * Confirmed by Parton's original rules. * * Blocker semantics * ───────────────── @@ -95,11 +127,29 @@ import { getPieceColor, isPieceAt, isEnemyAt, + getPieceAt, } from "../rules/board-queries.js"; import { PRESET_REGISTRY } from "./registry.js"; const PRESET_ID = "berolina-pawns"; +/** + * Preset-state shape carrying the in-flight Berolina en-passant + * window. Written by `onAfterMove` on the mover's double-push turn; + * read by `overridePieceMoves` on the opponent's next turn; cleared + * by `onAfterMove` on any subsequent move. One entry per room/engine. + */ +interface BerolinaEpState extends Record { + /** Square the double-pushing pawn passed THROUGH (the ep target). */ + skippedSquare: Square; + /** Entity id of the pawn that performed the double-push — the one + * retracted if the ep is accepted. */ + capturedPieceId: EntityId; + /** Color of the capturer — i.e. OPPOSITE of the double-pusher. Used + * to scope-check the override correctly. */ + capturerColor: PieceColor; +} + /** Promotion rank for each color. */ const PROMOTION_RANK: Record = { white: 7, @@ -231,6 +281,127 @@ PRESET_REGISTRY.register({ } } + // ── En-passant (Parton 1952) ───────────────────────────────── + // Read the latch set by a previous Berolina double-push. If this + // pawn's orthogonal-forward square equals the stored skipped + // square AND the capturer color matches this pawn's color, emit + // the ep capture. Note the destination square (skipped) is + // EMPTY — the pawn to retract is recorded separately on the + // latch. + if (forwardRank >= 0 && forwardRank <= 7) { + const forwardSq = (from + forwardDelta) as Square; + const ep = engine + .presetState(PRESET_ID) + .get("skippedSquare"); + const epColor = engine + .presetState(PRESET_ID) + .get("capturerColor"); + if (ep !== undefined && epColor === color && forwardSq === ep) { + // `isCapture: true` flows through the normal capture branch; + // move.to == skippedSquare is empty so `getPieceAt(move.to)` + // returns null (no engine-side default retraction). Our + // onAfterMove below does the retraction of the stored + // capturedPieceId. + out.push({ pieceId, from, to: forwardSq, isCapture: true }); + } + } + return out; }, + + /** + * Two responsibilities on every move: + * + * 1. If the MOVER just performed a Berolina double-push (two + * diagonal squares along the same diagonal, from the home + * rank), write the ep latch so the opponent's NEXT override + * sees the window. + * + * 2. If the MOVER just performed an ep capture (move.to matches + * the latch's skippedSquare), retract the stored + * capturedPieceId. This is the other half of the mechanism + * the override emitted above. + * + * The latch is cleared at the end of EVERY move other than case + * (1), so the ep window is exactly one half-move (Parton 1952 + * matches FIDE ep timing). + */ + onAfterMove(ctx) { + const { engine, mover, from, to, pieceId } = ctx; + const state = engine.presetState(PRESET_ID); + const type = engine.session.get(pieceId, "PieceType"); + + // Case 2 FIRST: did the mover just accept the ep we latched on + // the prior move? If yes, retract the recorded captured pawn. + // We run this BEFORE clearing so the same move's latch still + // matches. + const priorSkipped = state.get("skippedSquare"); + const priorCaptured = state.get("capturedPieceId"); + const priorCapturerColor = state.get("capturerColor"); + if ( + type === "pawn" && + priorSkipped !== undefined && + priorCaptured !== undefined && + priorCapturerColor === mover && + to === priorSkipped + ) { + for (const attr of ["PieceType", "Color", "Position", "HasMoved"] as const) { + if (engine.session.contains(priorCaptured, attr)) { + engine.session.retract(priorCaptured, attr); + } + } + } + + // Clear the latch unconditionally — any move invalidates a prior + // window (Parton 1952 ep is 1-half-move max). + state.delete("skippedSquare"); + state.delete("capturedPieceId"); + state.delete("capturerColor"); + + // Case 1: the current move was itself a Berolina double-push. + // Detect via the delta pattern — a diagonal double-push from the + // home rank has `abs(to - from) === 14 || 18` AND `rank(to) - + // rank(from) === 2 * dir`. Only set the latch when THIS preset + // was the one that produced the move (i.e. the scope covers + // the mover's color). + if (type !== "pawn") return; + const color = mover; + const fromRank = rankOf(from); + const toRank = rankOf(to); + if (fromRank !== HOME_RANK[color]) return; + const dir = color === "white" ? 1 : -1; + if (toRank - fromRank !== 2 * dir) return; + const delta = to - from; + if (delta !== 2 * (dir * 8 - 1) && delta !== 2 * (dir * 8 + 1)) return; + // Confirm the scope matches this mover (Berolina is active for + // this color). Otherwise the move came from a different preset + // and we shouldn't latch. + let scope: "white" | "black" | "both" = "both"; + let found = false; + for (const entry of engine.activePresets.list()) { + if (entry.id === PRESET_ID) { + scope = entry.scope; + found = true; + break; + } + } + if (!found) return; + if (scope !== "both" && scope !== color) return; + + // Skipped square is the MIDPOINT of the double-diagonal. + const skippedSquare = ((from + to) / 2) as Square; + // Sanity: skipped square must be on-board (should always be true + // given home-rank + diagonal math, but cheap to check). + if (skippedSquare < 0 || skippedSquare > 63) return; + // The captured pawn is THIS mover's pawn (the one that just + // double-pushed). Retracting THIS pieceId is what closes the + // window. + state.set("skippedSquare", skippedSquare); + state.set("capturedPieceId", pieceId); + state.set("capturerColor", color === "white" ? "black" : "white"); + }, }); + +// Silence unused-import warnings when helpers are only referenced +// above. +void getPieceAt; diff --git a/packages/chess/src/presets/registry.ts b/packages/chess/src/presets/registry.ts index d855c31..077d47d 100644 --- a/packages/chess/src/presets/registry.ts +++ b/packages/chess/src/presets/registry.ts @@ -124,10 +124,23 @@ export interface HookContext { export type LifecycleContext = HookContext; /** - * Context for `onAfterMove`. `mover` is the color that just moved. + * Context for `onAfterMove`. + * + * - `mover` — color that just moved. + * - `pieceId` — the entity that moved (useful when a preset needs + * to inspect the post-move state of that specific piece, e.g. + * Berolina-pawns' double-push detection in `post-epic-deferrals` + * Feature 3). + * - `from` / `to` — the move's source and destination squares. Note + * these are the CLIENT-SUPPLIED values; a preset that needs to + * know where the piece ACTUALLY landed after an intercept should + * re-read `engine.session.get(pieceId, "Position")`. */ export interface MoveHookContext extends HookContext { readonly mover: "white" | "black"; + readonly pieceId: EntityId; + readonly from: number; + readonly to: number; } /**