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:
parent
0ce500906c
commit
dfdc8aba63
8 changed files with 534 additions and 15 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue