From d5abaf13bcb356a7f893487c1d96c2bc616e56d7 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Sun, 26 Apr 2026 21:39:13 -0600 Subject: [PATCH] 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. --- .sisyphus/boulder.json | 5 +- packages/chess/e2e/move-gen-attrs.spec.ts | 10 +- packages/chess/e2e/orphan-primitives.spec.ts | 2 +- .../src/engine.applyMove.pawnPush.test.ts | 106 ++++++++++++ packages/chess/src/engine.ts | 156 +++++++++++++++++- 5 files changed, 269 insertions(+), 10 deletions(-) create mode 100644 packages/chess/src/engine.applyMove.pawnPush.test.ts diff --git a/.sisyphus/boulder.json b/.sisyphus/boulder.json index 45fa0e5..2605420 100644 --- a/.sisyphus/boulder.json +++ b/.sisyphus/boulder.json @@ -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" diff --git a/packages/chess/e2e/move-gen-attrs.spec.ts b/packages/chess/e2e/move-gen-attrs.spec.ts index ef1c6b7..b17f959 100644 --- a/packages/chess/e2e/move-gen-attrs.spec.ts +++ b/packages/chess/e2e/move-gen-attrs.spec.ts @@ -487,7 +487,7 @@ async function expectEmpty(page: Page, square: string): Promise { // 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(); diff --git a/packages/chess/e2e/orphan-primitives.spec.ts b/packages/chess/e2e/orphan-primitives.spec.ts index b527e9b..4c97624 100644 --- a/packages/chess/e2e/orphan-primitives.spec.ts +++ b/packages/chess/e2e/orphan-primitives.spec.ts @@ -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(); diff --git a/packages/chess/src/engine.applyMove.pawnPush.test.ts b/packages/chess/src/engine.applyMove.pawnPush.test.ts new file mode 100644 index 0000000..52abb8d --- /dev/null +++ b/packages/chess/src/engine.applyMove.pawnPush.test.ts @@ -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); + }); +}); diff --git a/packages/chess/src/engine.ts b/packages/chess/src/engine.ts index d7642ae..1900722 100644 --- a/packages/chess/src/engine.ts +++ b/packages/chess/src/engine.ts @@ -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(); + 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