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:
Joey Yakimowich-Payne 2026-04-26 21:39:13 -06:00
commit d5abaf13bc
No known key found for this signature in database
5 changed files with 269 additions and 10 deletions

View file

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

View file

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

View file

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

View 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 e4d5 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);
});
});

View file

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