feat(presets): berolina-pawns en-passant (Parton 1952, both variants)

Feature 3 of post-epic-deferrals. Adds en-passant to berolina-pawns
and berolina-pawns-2 following the Parton 1952 variant — the most
common published ruleset. Reflects standard ep semantics through
Berolina's reversed geometry (diagonal push, orthogonal capture).

Engine:
  - MoveHookContext extended with pieceId + from + to. Existing
    presets (piece-hp, poisoned-squares, etc.) are purely additive
    on the new fields and don't need changes. Dispatch site in
    applyMove populates them from the resolved move.

berolina-pawns + berolina-pawns-2:
  - overridePieceMoves: after emitting normal Berolina moves, read
    the preset's ep latch (namespaced preset state). If the mover's
    orthogonal-forward square equals the stored skipped square AND
    the capturer color matches, emit a capture move onto that
    square. Note destination is empty by construction — the engine's
    getPieceAt returns null there, so the default capture path is a
    no-op; onAfterMove below does the actual retraction.
  - onAfterMove: two responsibilities. (1) If the mover just
    accepted a latched ep (move.to === skippedSquare) retract the
    stored capturedPieceId. (2) Clear the latch. (3) If THIS move
    was a Berolina double-diagonal push from the home rank, record
    skippedSquare + capturedPieceId (the mover's own pieceId, since
    that's what the opponent can remove next turn) + capturer color.
  - Scope-flip semantics preserved — scope='white' means only white
    pawns emit ep moves; the latch still records for downstream, but
    the opposing black pawns follow FIDE rules and don't emit it.
  - berolina-pawns-2: sideways captures do NOT trigger or accept
    ep (only the orthogonal-forward direction participates).

Tests:
  - 5 new berolina-pawns.test.ts cases: (r) latch + ep emission,
    (s) accept-ep retracts the double-pushed pawn, (t) one-half-move
    window expiry, (u) single-push doesn't latch, (v) scope='white'
    latch set on white's double-push.
  - 3 new berolina-pawns-2.test.ts cases: (l) ep through the
    extended preset, (m) retraction on accept, (n) sideways capture
    does NOT set the latch.
  - All 31 pre-existing Berolina tests unchanged and still pass.

Docs:
  - RULES.md gallery entries: remove 'en-passant deferred' language;
    document the Parton 1952 rule and the sideways-ep exclusion.
  - PRESET-API.md post-landing-backlog: drop the berolina ep
    deferral.
  - Preset docblocks: rewrite the en-passant section to describe
    the shipped mechanism + plan reference.

Verification: 1671 unit tests (+8 ep). Typecheck + lint clean.

Plan: .sisyphus/plans/post-epic-deferrals.md Feature 3 complete.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-21 11:06:53 -06:00
commit dfdc8aba63
No known key found for this signature in database
8 changed files with 534 additions and 15 deletions

View file

@ -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

View file

@ -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.

View file

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

View file

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

View file

@ -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<string, unknown> {
skippedSquare: Square;
capturedPieceId: EntityId;
capturerColor: PieceColor;
}
/** Promotion rank for each color. */
const PROMOTION_RANK: Record<PieceColor, number> = {
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<BerolinaEpState>(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<BerolinaEpState>(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");
},
});

View file

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

View file

@ -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<string, unknown> {
/** 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<PieceColor, number> = {
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<BerolinaEpState>(PRESET_ID)
.get("skippedSquare");
const epColor = engine
.presetState<BerolinaEpState>(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<BerolinaEpState>(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;

View file

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