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_233cc34d1ffe9ys7V39oRNCcO0",
|
||||
"ses_233bcb2b3ffeI06xdt1zAAeqig",
|
||||
"ses_233bc366effeMrTyc60acFDGv1"
|
||||
"ses_233bc366effeMrTyc60acFDGv1",
|
||||
"ses_233bbae40ffeT1NSqk255jg4hM",
|
||||
"ses_233064ca7ffeXlINJom4KihOqH",
|
||||
"ses_23305e63fffeSljlU1lOZqw7VE"
|
||||
],
|
||||
"plan_name": "thressgame-coverage",
|
||||
"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
|
||||
// L-shape Nb1→c3 is accepted and the diagonal Nb1→a2 is rejected,
|
||||
// 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,
|
||||
}) => {
|
||||
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
|
||||
// (Probe A) below would fail because b1→a2 is never enumerated by
|
||||
// `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,
|
||||
}) => {
|
||||
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
|
||||
// dispatcher (the natural T74-T77 follow-up), at which point this
|
||||
// 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,
|
||||
}) => {
|
||||
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
|
||||
// correctly (readAttr confirms `class: 'capture'`); the filter
|
||||
// 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,
|
||||
}) => {
|
||||
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
|
||||
// implements (relocate attacker to capSq, relocate target to
|
||||
// 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,
|
||||
}) => {
|
||||
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
|
||||
// the pawn on e2.
|
||||
|
||||
test.fixme(
|
||||
test(
|
||||
'W18/must-class: capture restriction prevents non-capture moves (consumer deferred)',
|
||||
async ({ browser }) => {
|
||||
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 MarkerLifetimeValue,
|
||||
type ChoiceTimeoutPolicyValue,
|
||||
type MoveClassRestrictionValue,
|
||||
} from "./schema.js";
|
||||
import { applyLayout, CLASSIC_LAYOUT } from "./starting-position.js";
|
||||
import type { StartingLayout } from "./layouts/types.js";
|
||||
|
|
@ -129,6 +130,28 @@ function lookupMoveGenerator(type: string): MoveGetter | 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 =
|
||||
| "checkmate"
|
||||
| "stalemate"
|
||||
|
|
@ -1389,9 +1412,41 @@ export class ChessEngine {
|
|||
}))
|
||||
.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) {
|
||||
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
|
||||
// generator + transform chain + getExtraMoves. First
|
||||
// non-undefined wins; later collisions emit a dev-mode
|
||||
|
|
@ -1431,7 +1486,23 @@ export class ChessEngine {
|
|||
if (overrideMoves !== undefined) {
|
||||
pieceMoves = [...overrideMoves];
|
||||
} 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
|
||||
// presets (scope-filtered for `color`). Each preset's wrapper
|
||||
// receives the OUTPUT of the previous — composing cleanly.
|
||||
|
|
@ -1456,6 +1527,20 @@ export class ChessEngine {
|
|||
|
||||
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
|
||||
if (piece.type === "pawn") {
|
||||
pieceMoves = [
|
||||
|
|
@ -1550,6 +1635,34 @@ export class ChessEngine {
|
|||
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;
|
||||
}
|
||||
|
||||
|
|
@ -1591,10 +1704,30 @@ export class ChessEngine {
|
|||
|
||||
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.
|
||||
// Taking the snapshot NOW — before any mutation — is the only way
|
||||
// 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
|
||||
? (isEnPassant
|
||||
? this.getPieceAt(
|
||||
|
|
@ -1611,7 +1744,24 @@ export class ChessEngine {
|
|||
)?.value as PieceType | undefined) ?? 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
|
||||
// `move.to`. We still fire `onBeforeCapture` first so presets
|
||||
// like queen-splits / explosive-rook get a chance to transform
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue