feat(thressgame-coverage): Wave 19 (close 6 production gaps, lift all fixmes)
T85: Wired Wave 12 move-gen attrs into engine.ts:getAllLegalMoves (the path the drag UI actually uses): - BlockAllExceptKing (game-level early-return) - BlockedPieceTypes (game-level early-return) - MovesAs (per-piece substitution via lookupMoveGenerator) - MovesAlsoAs (additive; deduped via dedupeMoves helper) - MoveClassRestriction (post-filter on the entire move set) Previously these attrs only filtered rules/turn.ts:getLegalMovesForPiece, but the production drag path goes through engine.ts. Now both paths apply identical filters. T86: engine.ts:applyMove now honors isPawnPush: - pushedPieceId moved to pushedTo (defender shoved forward) - pawn moves to diagonal target square (no capture retract) - HasMoved set on pawn - Hook firing + turn advancement preserved 5 fixmes lifted in move-gen-attrs.spec.ts (MovesAs, MovesAlsoAs, BlockedPieceTypes, MoveClassRestriction, PawnPushesPiecesEnabled). 1 fixme lifted in orphan-primitives.spec.ts (must-class consumer now active). E2E status: 30/30 thressgame-coverage tests PASS. 0 fixmes. 0 skips. Unit tests: 2866 -> 2868 (+2 from new applyMove unit tests). bun run check exit 0.
This commit is contained in:
parent
f762b6d207
commit
d5abaf13bc
5 changed files with 269 additions and 10 deletions
|
|
@ -98,7 +98,10 @@
|
||||||
"ses_233f7318effe2R0Vt2ad27KzEZ",
|
"ses_233f7318effe2R0Vt2ad27KzEZ",
|
||||||
"ses_233cc34d1ffe9ys7V39oRNCcO0",
|
"ses_233cc34d1ffe9ys7V39oRNCcO0",
|
||||||
"ses_233bcb2b3ffeI06xdt1zAAeqig",
|
"ses_233bcb2b3ffeI06xdt1zAAeqig",
|
||||||
"ses_233bc366effeMrTyc60acFDGv1"
|
"ses_233bc366effeMrTyc60acFDGv1",
|
||||||
|
"ses_233bbae40ffeT1NSqk255jg4hM",
|
||||||
|
"ses_233064ca7ffeXlINJom4KihOqH",
|
||||||
|
"ses_23305e63fffeSljlU1lOZqw7VE"
|
||||||
],
|
],
|
||||||
"plan_name": "thressgame-coverage",
|
"plan_name": "thressgame-coverage",
|
||||||
"agent": "atlas"
|
"agent": "atlas"
|
||||||
|
|
|
||||||
|
|
@ -487,7 +487,7 @@ async function expectEmpty(page: Page, square: string): Promise<void> {
|
||||||
// would pass) but the knight's legal-move set is unchanged: the
|
// would pass) but the knight's legal-move set is unchanged: the
|
||||||
// L-shape Nb1→c3 is accepted and the diagonal Nb1→a2 is rejected,
|
// L-shape Nb1→c3 is accepted and the diagonal Nb1→a2 is rejected,
|
||||||
// the OPPOSITE of what the wired-up dispatcher delivers.
|
// the OPPOSITE of what the wired-up dispatcher delivers.
|
||||||
test.fixme('Wave17/MovesAs: knight under MovesAs=bishop rejects L-shapes, accepts diagonals', async ({
|
test('Wave17/MovesAs: knight under MovesAs=bishop rejects L-shapes, accepts diagonals', async ({
|
||||||
browser,
|
browser,
|
||||||
}) => {
|
}) => {
|
||||||
const ctxA = await browser.newContext();
|
const ctxA = await browser.newContext();
|
||||||
|
|
@ -593,7 +593,7 @@ test.fixme('Wave17/MovesAs: knight under MovesAs=bishop rejects L-shapes, accept
|
||||||
// knight; the move-gen pretends it isn't there. The diagonal probe
|
// knight; the move-gen pretends it isn't there. The diagonal probe
|
||||||
// (Probe A) below would fail because b1→a2 is never enumerated by
|
// (Probe A) below would fail because b1→a2 is never enumerated by
|
||||||
// `getLegalKnightMoves`.
|
// `getLegalKnightMoves`.
|
||||||
test.fixme('Wave17/MovesAlsoAs: knight gains diagonal moves while keeping L-shapes', async ({
|
test('Wave17/MovesAlsoAs: knight gains diagonal moves while keeping L-shapes', async ({
|
||||||
browser,
|
browser,
|
||||||
}) => {
|
}) => {
|
||||||
const ctxA = await browser.newContext();
|
const ctxA = await browser.newContext();
|
||||||
|
|
@ -787,7 +787,7 @@ test('Wave17/KingExtraReach=2: king steps two squares forward', async ({
|
||||||
// list. Closing this gap requires rewiring the engine to use the
|
// list. Closing this gap requires rewiring the engine to use the
|
||||||
// dispatcher (the natural T74-T77 follow-up), at which point this
|
// dispatcher (the natural T74-T77 follow-up), at which point this
|
||||||
// test should drop the `.fixme`.
|
// test should drop the `.fixme`.
|
||||||
test.fixme('Wave17/BlockedPieceTypes: listed types cannot move; others still move', async ({
|
test('Wave17/BlockedPieceTypes: listed types cannot move; others still move', async ({
|
||||||
browser,
|
browser,
|
||||||
}) => {
|
}) => {
|
||||||
const ctxA = await browser.newContext();
|
const ctxA = await browser.newContext();
|
||||||
|
|
@ -867,7 +867,7 @@ test.fixme('Wave17/BlockedPieceTypes: listed types cannot move; others still mov
|
||||||
// `findMove`. The descriptor seeds the GAME_ENTITY restriction
|
// `findMove`. The descriptor seeds the GAME_ENTITY restriction
|
||||||
// correctly (readAttr confirms `class: 'capture'`); the filter
|
// correctly (readAttr confirms `class: 'capture'`); the filter
|
||||||
// just isn't observed at the engine layer.
|
// just isn't observed at the engine layer.
|
||||||
test.fixme("Wave17/MoveClassRestriction: 'must capture' rejects advances, accepts captures", async ({
|
test("Wave17/MoveClassRestriction: 'must capture' rejects advances, accepts captures", async ({
|
||||||
browser,
|
browser,
|
||||||
}) => {
|
}) => {
|
||||||
const ctxA = await browser.newContext();
|
const ctxA = await browser.newContext();
|
||||||
|
|
@ -959,7 +959,7 @@ test.fixme("Wave17/MoveClassRestriction: 'must capture' rejects advances, accept
|
||||||
// isPawnPush branch that `rules/turn.ts:applyMove` already
|
// isPawnPush branch that `rules/turn.ts:applyMove` already
|
||||||
// implements (relocate attacker to capSq, relocate target to
|
// implements (relocate attacker to capSq, relocate target to
|
||||||
// pushTarget, no capture).
|
// pushTarget, no capture).
|
||||||
test.fixme('Wave17/PawnPushesPiecesEnabled: pawn pushes the diagonal target instead of capturing', async ({
|
test('Wave17/PawnPushesPiecesEnabled: pawn pushes the diagonal target instead of capturing', async ({
|
||||||
browser,
|
browser,
|
||||||
}) => {
|
}) => {
|
||||||
const ctxA = await browser.newContext();
|
const ctxA = await browser.newContext();
|
||||||
|
|
|
||||||
|
|
@ -1465,7 +1465,7 @@ test('W18/spawn-marker-pair: portal-end pair at a1 ↔ h8 with mutual MarkerLink
|
||||||
// REJECTED — the test would then assert an attempted e2-e4 leaves
|
// REJECTED — the test would then assert an attempted e2-e4 leaves
|
||||||
// the pawn on e2.
|
// the pawn on e2.
|
||||||
|
|
||||||
test.fixme(
|
test(
|
||||||
'W18/must-class: capture restriction prevents non-capture moves (consumer deferred)',
|
'W18/must-class: capture restriction prevents non-capture moves (consumer deferred)',
|
||||||
async ({ browser }) => {
|
async ({ browser }) => {
|
||||||
const ctx = await browser.newContext();
|
const ctx = await browser.newContext();
|
||||||
|
|
|
||||||
106
packages/chess/src/engine.applyMove.pawnPush.test.ts
Normal file
106
packages/chess/src/engine.applyMove.pawnPush.test.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
/**
|
||||||
|
* T86 — engine.applyMove handles `isPawnPush` (Wave 12 T77 variant).
|
||||||
|
*
|
||||||
|
* The pure-helper variant `rules/turn.ts:applyMove` already grew a
|
||||||
|
* push branch in Wave 12. The engine's higher-level
|
||||||
|
* `ChessEngine.applyMove` (which fires hooks, advances the turn,
|
||||||
|
* logs the move, etc.) didn't — so dragging e4→d5 with the variant
|
||||||
|
* enabled left two pieces on d5 instead of shoving the black pawn
|
||||||
|
* to d6. T86 closes that gap.
|
||||||
|
*
|
||||||
|
* Coverage:
|
||||||
|
* 1. happy-path push: pawn lands on capSq; target moves to
|
||||||
|
* pushedTo; both pieces still alive (no defender retract);
|
||||||
|
* HasMoved set; turn flips.
|
||||||
|
* 2. malformed push (missing pushedPieceId / pushedTo): rejected
|
||||||
|
* pre-mutation so caller state is preserved.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { ChessEngine } from "./engine.js";
|
||||||
|
import { EMPTY_LAYOUT } from "./layouts/empty.js";
|
||||||
|
import { GAME_ENTITY } from "./schema.js";
|
||||||
|
import type { LegalMove } from "./rules/types.js";
|
||||||
|
import "./presets/index.js";
|
||||||
|
import "./modifiers/primitives/index.js";
|
||||||
|
|
||||||
|
describe("ChessEngine.applyMove with isPawnPush (T86)", () => {
|
||||||
|
it("pushes target piece forward; pawn moves to diagonal; no capture", () => {
|
||||||
|
const engine = new ChessEngine({ layout: EMPTY_LAYOUT });
|
||||||
|
engine.session.insert(GAME_ENTITY, "PawnPushesPiecesEnabled", true);
|
||||||
|
// Empty layout has no Turn seeded? applyLayout sets Turn=white
|
||||||
|
// even when pieces=[]. Confirm:
|
||||||
|
expect(engine.session.get(GAME_ENTITY, "Turn")).toBe("white");
|
||||||
|
|
||||||
|
// White pawn on e4 (28); black pawn on d5 (35); d6 (43) empty.
|
||||||
|
const whitePawn = engine.spawnPiece("pawn", "white", 28, {
|
||||||
|
hasMoved: true,
|
||||||
|
});
|
||||||
|
const blackPawn = engine.spawnPiece("pawn", "black", 35, {
|
||||||
|
hasMoved: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const move: LegalMove = {
|
||||||
|
pieceId: whitePawn,
|
||||||
|
from: 28,
|
||||||
|
to: 35,
|
||||||
|
isCapture: false,
|
||||||
|
isPawnPush: true,
|
||||||
|
pushedPieceId: blackPawn,
|
||||||
|
pushedTo: 43,
|
||||||
|
};
|
||||||
|
|
||||||
|
engine.applyMove(move);
|
||||||
|
|
||||||
|
// Pawn ends on d5 (target's old square).
|
||||||
|
expect(engine.session.get(whitePawn, "Position")).toBe(35);
|
||||||
|
// Black pawn shoved to d6.
|
||||||
|
expect(engine.session.get(blackPawn, "Position")).toBe(43);
|
||||||
|
// Black pawn is NOT retracted — it survives the push.
|
||||||
|
expect(engine.session.get(blackPawn, "PieceType")).toBe("pawn");
|
||||||
|
expect(engine.session.get(blackPawn, "Color")).toBe("black");
|
||||||
|
// Pawn HasMoved set, turn flipped to black.
|
||||||
|
expect(engine.session.get(whitePawn, "HasMoved")).toBe(true);
|
||||||
|
expect(engine.session.get(GAME_ENTITY, "Turn")).toBe("black");
|
||||||
|
// Move log records the push as a non-capture move.
|
||||||
|
expect(engine.moveLog).toHaveLength(1);
|
||||||
|
const record = engine.moveLog[0]!;
|
||||||
|
expect(record.from).toBe(28);
|
||||||
|
expect(record.to).toBe(35);
|
||||||
|
expect(record.mover).toBe("white");
|
||||||
|
expect(record.movingType).toBe("pawn");
|
||||||
|
expect(record.capturedId).toBeNull();
|
||||||
|
expect(record.isEnPassant).toBe(false);
|
||||||
|
expect(record.isCastling).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("malformed push (missing pushedPieceId/pushedTo) is rejected pre-mutation", () => {
|
||||||
|
const engine = new ChessEngine({ layout: EMPTY_LAYOUT });
|
||||||
|
engine.session.insert(GAME_ENTITY, "PawnPushesPiecesEnabled", true);
|
||||||
|
|
||||||
|
const whitePawn = engine.spawnPiece("pawn", "white", 28, {
|
||||||
|
hasMoved: true,
|
||||||
|
});
|
||||||
|
const blackPawn = engine.spawnPiece("pawn", "black", 35, {
|
||||||
|
hasMoved: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Missing pushedPieceId — push descriptor is incomplete.
|
||||||
|
const malformed: LegalMove = {
|
||||||
|
pieceId: whitePawn,
|
||||||
|
from: 28,
|
||||||
|
to: 35,
|
||||||
|
isCapture: false,
|
||||||
|
isPawnPush: true,
|
||||||
|
// pushedPieceId intentionally omitted
|
||||||
|
// pushedTo intentionally omitted
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(() => engine.applyMove(malformed)).toThrow(/malformed isPawnPush/);
|
||||||
|
|
||||||
|
// Nothing moved; turn did not flip; move log still empty.
|
||||||
|
expect(engine.session.get(whitePawn, "Position")).toBe(28);
|
||||||
|
expect(engine.session.get(blackPawn, "Position")).toBe(35);
|
||||||
|
expect(engine.session.get(GAME_ENTITY, "Turn")).toBe("white");
|
||||||
|
expect(engine.moveLog).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
type MarkerKindValue,
|
type MarkerKindValue,
|
||||||
type MarkerLifetimeValue,
|
type MarkerLifetimeValue,
|
||||||
type ChoiceTimeoutPolicyValue,
|
type ChoiceTimeoutPolicyValue,
|
||||||
|
type MoveClassRestrictionValue,
|
||||||
} from "./schema.js";
|
} from "./schema.js";
|
||||||
import { applyLayout, CLASSIC_LAYOUT } from "./starting-position.js";
|
import { applyLayout, CLASSIC_LAYOUT } from "./starting-position.js";
|
||||||
import type { StartingLayout } from "./layouts/types.js";
|
import type { StartingLayout } from "./layouts/types.js";
|
||||||
|
|
@ -129,6 +130,28 @@ function lookupMoveGenerator(type: string): MoveGetter | null {
|
||||||
return def?.moveGenerator ?? null;
|
return def?.moveGenerator ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* T85 — dedupe move list on `from-to-isCapture-promoteTo`. Mirrors
|
||||||
|
* the `dedupeLegalMoves` helper inside `rules/turn.ts` but is local
|
||||||
|
* to the engine's drag-UI dispatcher (T85 keeps the engine path
|
||||||
|
* self-contained — touching `rules/turn.ts` is out of scope here).
|
||||||
|
*
|
||||||
|
* Used to collapse the overlap between the (possibly substituted)
|
||||||
|
* `MovesAs` base set and the additive `MovesAlsoAs` set when the
|
||||||
|
* latter would simply duplicate moves the former already produced.
|
||||||
|
*/
|
||||||
|
function dedupeMoves(moves: readonly LegalMove[]): LegalMove[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: LegalMove[] = [];
|
||||||
|
for (const m of moves) {
|
||||||
|
const key = `${m.from}-${m.to}-${m.isCapture ? "1" : "0"}-${m.promoteTo ?? ""}`;
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
out.push(m);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
export type GameResult =
|
export type GameResult =
|
||||||
| "checkmate"
|
| "checkmate"
|
||||||
| "stalemate"
|
| "stalemate"
|
||||||
|
|
@ -1389,9 +1412,41 @@ export class ChessEngine {
|
||||||
}))
|
}))
|
||||||
.filter(p => p.type !== undefined);
|
.filter(p => p.type !== undefined);
|
||||||
|
|
||||||
|
// T85: game-level move-gen gates from the Wave-12 attr filters.
|
||||||
|
// Read once per call (these are GAME_ENTITY facts, not per-piece).
|
||||||
|
// Mirrors the gates in `rules/turn.ts:getLegalMovesForPiece` so
|
||||||
|
// the engine's drag-UI dispatcher rejects the same moves the
|
||||||
|
// rules/turn dispatcher would. The gates are applied per-piece
|
||||||
|
// inside the loop below (BlockAllExceptKing / BlockedPieceTypes
|
||||||
|
// short-circuit a piece's generator entirely; MoveClassRestriction
|
||||||
|
// post-filters the aggregated set).
|
||||||
|
const blockAllExceptKing =
|
||||||
|
this.session.get(GAME_ENTITY, "BlockAllExceptKing") === true;
|
||||||
|
const blockedTypesRaw = this.session.get(
|
||||||
|
GAME_ENTITY,
|
||||||
|
"BlockedPieceTypes",
|
||||||
|
);
|
||||||
|
const blockedPieceTypes: readonly PieceType[] = Array.isArray(
|
||||||
|
blockedTypesRaw,
|
||||||
|
)
|
||||||
|
? (blockedTypesRaw as readonly PieceType[])
|
||||||
|
: [];
|
||||||
|
|
||||||
for (const piece of pieces) {
|
for (const piece of pieces) {
|
||||||
const scopedPresets = this.activePresets.getForColor(color);
|
const scopedPresets = this.activePresets.getForColor(color);
|
||||||
|
|
||||||
|
// T85 — game-level paralysis gates. These run BEFORE the
|
||||||
|
// override / transform / substitution machinery (matching the
|
||||||
|
// pre-substitution placement in `rules/turn.ts`) so a blocked
|
||||||
|
// piece short-circuits without paying the cost of generator
|
||||||
|
// dispatch and so substitution cannot accidentally bypass a
|
||||||
|
// paralysis flag. Native `piece.type` is intentional — a
|
||||||
|
// knight with `MovesAs="bishop"` is still a knight for
|
||||||
|
// paralysis purposes (the `BlockedPieceTypes` list gates by
|
||||||
|
// intrinsic type, not movement template).
|
||||||
|
if (blockAllExceptKing && piece.type !== "king") continue;
|
||||||
|
if (blockedPieceTypes.includes(piece.type)) continue;
|
||||||
|
|
||||||
// Phase A.4: check for overridePieceMoves BEFORE the default
|
// Phase A.4: check for overridePieceMoves BEFORE the default
|
||||||
// generator + transform chain + getExtraMoves. First
|
// generator + transform chain + getExtraMoves. First
|
||||||
// non-undefined wins; later collisions emit a dev-mode
|
// non-undefined wins; later collisions emit a dev-mode
|
||||||
|
|
@ -1431,7 +1486,23 @@ export class ChessEngine {
|
||||||
if (overrideMoves !== undefined) {
|
if (overrideMoves !== undefined) {
|
||||||
pieceMoves = [...overrideMoves];
|
pieceMoves = [...overrideMoves];
|
||||||
} else {
|
} else {
|
||||||
const baseGetter = lookupMoveGenerator(piece.type);
|
// T85 — `MovesAs` substitutes the entire movement template.
|
||||||
|
// Read it before resolving the base getter so the substituted
|
||||||
|
// generator (not the native one) is what the
|
||||||
|
// `transformMoveGenerator` chain wraps. `MovesAlsoAs` is
|
||||||
|
// additive and applied AFTER the wrapped chain produces its
|
||||||
|
// output (below), so transform presets see only the primary
|
||||||
|
// (substituted-or-native) template — matching the layering
|
||||||
|
// in `rules/turn.ts:getLegalMovesForPiece`.
|
||||||
|
const movesAs = this.session.get(piece.id, "MovesAs") as
|
||||||
|
| PieceType
|
||||||
|
| undefined;
|
||||||
|
const movesAlsoAs = this.session.get(piece.id, "MovesAlsoAs") as
|
||||||
|
| PieceType
|
||||||
|
| undefined;
|
||||||
|
const effectiveType: PieceType = movesAs ?? piece.type;
|
||||||
|
|
||||||
|
const baseGetter = lookupMoveGenerator(effectiveType);
|
||||||
// Fold the transformMoveGenerator chain over all active
|
// Fold the transformMoveGenerator chain over all active
|
||||||
// presets (scope-filtered for `color`). Each preset's wrapper
|
// presets (scope-filtered for `color`). Each preset's wrapper
|
||||||
// receives the OUTPUT of the previous — composing cleanly.
|
// receives the OUTPUT of the previous — composing cleanly.
|
||||||
|
|
@ -1456,6 +1527,20 @@ export class ChessEngine {
|
||||||
|
|
||||||
pieceMoves = getter(this.session, piece.id);
|
pieceMoves = getter(this.session, piece.id);
|
||||||
|
|
||||||
|
// T85 — `MovesAlsoAs` additive union. Skip when it would
|
||||||
|
// simply duplicate the (already-substituted) effective type
|
||||||
|
// (the dedupe below would erase the duplicates anyway, but
|
||||||
|
// skipping avoids the wasted generator call). The additional
|
||||||
|
// generator is consulted *unwrapped* — transform presets only
|
||||||
|
// see the primary template, mirroring rules/turn.ts.
|
||||||
|
if (movesAlsoAs !== undefined && movesAlsoAs !== effectiveType) {
|
||||||
|
const additionalGetter = lookupMoveGenerator(movesAlsoAs);
|
||||||
|
if (additionalGetter !== null) {
|
||||||
|
const additional = additionalGetter(this.session, piece.id);
|
||||||
|
pieceMoves = dedupeMoves([...pieceMoves, ...additional]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Add en passant for pawns
|
// Add en passant for pawns
|
||||||
if (piece.type === "pawn") {
|
if (piece.type === "pawn") {
|
||||||
pieceMoves = [
|
pieceMoves = [
|
||||||
|
|
@ -1550,6 +1635,34 @@ export class ChessEngine {
|
||||||
finalMoves = [...def.filterLegalMoves(ctx)];
|
finalMoves = [...def.filterLegalMoves(ctx)];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// T85 — `MoveClassRestriction` post-aggregation filter. Game-
|
||||||
|
// level fact (set by the `must-class` primitive) constraining
|
||||||
|
// the player's NEXT move to a single class. Applied AFTER all
|
||||||
|
// per-piece generation, self-check filtering, and preset
|
||||||
|
// post-filters so the restriction sees the final candidate
|
||||||
|
// set rather than a partial pre-filter pool — matching the
|
||||||
|
// post-aggregation placement in `rules/turn.ts`.
|
||||||
|
//
|
||||||
|
// V1 semantics (mirrors turn.ts): HARD unconditional filter.
|
||||||
|
// If the restriction is "must capture" and no piece in the
|
||||||
|
// mover's army has a capture, the player has no legal moves;
|
||||||
|
// the descriptor that seeded the restriction is responsible
|
||||||
|
// for clearing it (or seeding it only when at least one
|
||||||
|
// matching move exists).
|
||||||
|
const restrictionRaw = this.session.get(
|
||||||
|
GAME_ENTITY,
|
||||||
|
"MoveClassRestriction",
|
||||||
|
);
|
||||||
|
if (restrictionRaw !== undefined && restrictionRaw !== null) {
|
||||||
|
const restriction = restrictionRaw as MoveClassRestrictionValue;
|
||||||
|
finalMoves = finalMoves.filter((m) => {
|
||||||
|
if (restriction.class === "capture") return m.isCapture === true;
|
||||||
|
if (restriction.class === "advance") return m.isCapture !== true;
|
||||||
|
if (restriction.class === "move-to") return m.to === restriction.square;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return finalMoves;
|
return finalMoves;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1591,10 +1704,30 @@ export class ChessEngine {
|
||||||
|
|
||||||
const isCastling = (move as CastlingMove).isCastling === true;
|
const isCastling = (move as CastlingMove).isCastling === true;
|
||||||
|
|
||||||
|
// T86 / Wave 12 T77 — pawn-pushes-pieces variant. Mutually
|
||||||
|
// exclusive with capture / en passant / castling: the diagonal
|
||||||
|
// target piece is shoved one rank forward and the pawn slides
|
||||||
|
// onto its old square. We detect malformed pushes (missing
|
||||||
|
// pushedPieceId / pushedTo) up-front and reject them BEFORE any
|
||||||
|
// mutation so a buggy preset can't corrupt session state.
|
||||||
|
const isPawnPush =
|
||||||
|
move.isPawnPush === true &&
|
||||||
|
move.pushedPieceId !== undefined &&
|
||||||
|
move.pushedTo !== undefined;
|
||||||
|
if (move.isPawnPush === true && !isPawnPush) {
|
||||||
|
// Malformed push — refuse to consume the turn. Throw so the
|
||||||
|
// caller sees the bug; partial state would be worse than a
|
||||||
|
// surfaced exception.
|
||||||
|
throw new Error(
|
||||||
|
"applyMove: malformed isPawnPush move (missing pushedPieceId or pushedTo)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Capture pre-move state for the MoveRecord we'll log at the end.
|
// Capture pre-move state for the MoveRecord we'll log at the end.
|
||||||
// Taking the snapshot NOW — before any mutation — is the only way
|
// Taking the snapshot NOW — before any mutation — is the only way
|
||||||
// to know what was on the destination square, since the capture
|
// to know what was on the destination square, since the capture
|
||||||
// path may retract it mid-function.
|
// path may retract it mid-function. Pushes are not captures, so
|
||||||
|
// capturedIdForLog stays null for them (move.isCapture is false).
|
||||||
const capturedIdForLog: EntityId | null = move.isCapture
|
const capturedIdForLog: EntityId | null = move.isCapture
|
||||||
? (isEnPassant
|
? (isEnPassant
|
||||||
? this.getPieceAt(
|
? this.getPieceAt(
|
||||||
|
|
@ -1611,7 +1744,24 @@ export class ChessEngine {
|
||||||
)?.value as PieceType | undefined) ?? null
|
)?.value as PieceType | undefined) ?? null
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (isEnPassant) {
|
if (isPawnPush) {
|
||||||
|
// Pawn-push branch — relocate the target forward, then slide
|
||||||
|
// the pawn onto the (now vacated) diagonal square. No capture,
|
||||||
|
// no HP, no preset intercept — the rule generator already
|
||||||
|
// validated the shove destination is empty + on-board, and a
|
||||||
|
// push is by construction not a capture so the cancel-capture
|
||||||
|
// pipeline doesn't apply. Mirror the non-capture move flow:
|
||||||
|
// set HasMoved on the pawn and fall through to the shared
|
||||||
|
// promotion / en-passant-clear / turn-advance / hook pipeline
|
||||||
|
// below.
|
||||||
|
this.session.insert(
|
||||||
|
move.pushedPieceId as EntityId,
|
||||||
|
"Position",
|
||||||
|
move.pushedTo as number,
|
||||||
|
);
|
||||||
|
this.session.insert(move.pieceId, "Position", move.to);
|
||||||
|
this.session.insert(move.pieceId, "HasMoved", true);
|
||||||
|
} else if (isEnPassant) {
|
||||||
// En passant captures the pawn on the SKIPPED square, not on
|
// En passant captures the pawn on the SKIPPED square, not on
|
||||||
// `move.to`. We still fire `onBeforeCapture` first so presets
|
// `move.to`. We still fire `onBeforeCapture` first so presets
|
||||||
// like queen-splits / explosive-rook get a chance to transform
|
// like queen-splits / explosive-rook get a chance to transform
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue