feat(engine): shouldAdvanceTurn hook + HalfMovesThisTurn fact
Phase A.3 of the rule-variants epic — preset-controlled turn flip gating. Enables double-move (white plays 2, then black plays 2) and monster (scope-aware: white plays 2, black plays 1) without engine changes at the preset layer. - Adds HalfMovesThisTurn: number to ChessAttrMap. Seeded to 0 in applyLayout. Distinct from HalfmoveClock (FIDE 50-move rule) — this counter is within-turn and resets on flip. - Adds TurnAdvanceContext + shouldAdvanceTurn hook to PresetDef. Hook receives post-increment count via ctx.halfMovesThisTurn so 'play N half-moves before flipping' reads as the predicate 'halfMovesThisTurn < N -> false'. - engine.applyMove: increments HalfMovesThisTurn BEFORE polling shouldAdvanceTurn; first false wins; on flip resets to 0 and increments FullmoveNumber after black; onTurnStart is gated on shouldAdvance (vetoed flips don't fire turn-start hooks, so mid-turn stamina regen bugs are prevented by construction). - starting-position.test.ts updated with HalfMovesThisTurn=0 seed assertion. - New turn-advance.test.ts (7 tests): seeding, baseline unchanged, never-flip veto, flip-after-two (double-move surface), onTurnStart gating across vetoed + non-vetoed flips, FullmoveNumber increments only on actual flips after black. Tests: 1441 passing (was 1434, +7 new). Blocks: unblocks B.2 (double-move), B.3 (monster-rules), B.4 (first-promotion-wins).
This commit is contained in:
parent
db8145fb97
commit
f9475e9739
7 changed files with 395 additions and 12 deletions
|
|
@ -153,3 +153,44 @@ section).
|
||||||
would be a visible diff. Keeping enforcement out for now
|
would be a visible diff. Keeping enforcement out for now
|
||||||
because the 1 call site per legal-move request is hot-path-
|
because the 1 call site per legal-move request is hot-path-
|
||||||
adjacent and a `Set`-based prune would show up.
|
adjacent and a `Set`-based prune would show up.
|
||||||
|
|
||||||
|
## [2026-04-20 20:13] Task: A.3 — shouldAdvanceTurn hook + HalfMovesThisTurn fact
|
||||||
|
|
||||||
|
**Shipped**:
|
||||||
|
- `HalfMovesThisTurn: number` added to `ChessAttrMap`
|
||||||
|
(`packages/chess/src/schema.ts`). Seeded to 0 alongside `Turn` in
|
||||||
|
`applyLayout` (`packages/chess/src/starting-position.ts`).
|
||||||
|
- `TurnAdvanceContext` type + `shouldAdvanceTurn` hook on
|
||||||
|
`PresetDef`. Hook receives post-increment count. First `false`
|
||||||
|
wins; iteration stops.
|
||||||
|
- `engine.applyMove`:
|
||||||
|
1. Increments `HalfMovesThisTurn` BEFORE polling (so hooks see
|
||||||
|
count including the move just committed).
|
||||||
|
2. Polls every active preset's `shouldAdvanceTurn`; first `false`
|
||||||
|
cancels the flip.
|
||||||
|
3. On flip: updates `Turn`, resets `HalfMovesThisTurn` to 0,
|
||||||
|
increments `FullmoveNumber` after black.
|
||||||
|
4. On skip: leaves `Turn` and `HalfMovesThisTurn` alone (count
|
||||||
|
accumulates).
|
||||||
|
5. `onTurnStart` is GATED on `shouldAdvance === true`. Vetoed
|
||||||
|
flips don't fire turn-start (so double-move stamina doesn't
|
||||||
|
regen mid-turn).
|
||||||
|
- `starting-position.test.ts` updated to assert the new fact seed.
|
||||||
|
- New `turn-advance.test.ts` — 7 tests: seeding, baseline unchanged,
|
||||||
|
never-flip veto accumulates count, flip-after-two double-move
|
||||||
|
surface, `onTurnStart` gating against vetoes, FullmoveNumber only
|
||||||
|
on real flips.
|
||||||
|
|
||||||
|
**Verification**: `bun run check` green. 1441 tests (was 1434, +7).
|
||||||
|
|
||||||
|
**Gotchas**:
|
||||||
|
- Two DISTINCT half-move counters now exist. `HalfmoveClock`
|
||||||
|
(FIDE 50-move rule) vs `HalfMovesThisTurn` (this epic's
|
||||||
|
within-turn counter). Documented in schema comment; hook docs
|
||||||
|
emphasize the distinction.
|
||||||
|
- `tickAfterMove(color)` runs on the mover's color regardless of
|
||||||
|
flip decision — correct: a 3-turn buff ticks per actual move
|
||||||
|
made, not per Turn flip. Matches existing semantics.
|
||||||
|
- `session.allFacts()` automatically carries the new fact across
|
||||||
|
serialization boundaries (snapshot/restore) without protocol
|
||||||
|
changes — no server-side work needed for the A.3 addition.
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ import type {
|
||||||
PieceSpawnReason,
|
PieceSpawnReason,
|
||||||
RoyalContext,
|
RoyalContext,
|
||||||
SelfCheckFilterContext,
|
SelfCheckFilterContext,
|
||||||
|
TurnAdvanceContext,
|
||||||
TurnStartContext,
|
TurnStartContext,
|
||||||
} from "./presets/registry.js";
|
} from "./presets/registry.js";
|
||||||
import type { ChessAttrKey } from "./schema.js";
|
import type { ChessAttrKey } from "./schema.js";
|
||||||
|
|
@ -1176,19 +1177,64 @@ export class ChessEngine {
|
||||||
clearEnPassantTarget(this.session);
|
clearEnPassantTarget(this.session);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update halfmove clock
|
// Update halfmove clock (50-move rule). This is the FIDE clock —
|
||||||
|
// distinct from the rule-variants `HalfMovesThisTurn` counter
|
||||||
|
// below, which tracks within-turn move count for presets like
|
||||||
|
// double-move.
|
||||||
updateHalfmoveClock(this.session, move, movingType === "pawn");
|
updateHalfmoveClock(this.session, move, movingType === "pawn");
|
||||||
|
|
||||||
// Switch turn
|
// Phase A.3: increment HalfMovesThisTurn BEFORE polling
|
||||||
const nextColor: PieceColor = color === "white" ? "black" : "white";
|
// `shouldAdvanceTurn`. The hook sees the POST-increment count so
|
||||||
this.session.insert(GAME_ENTITY, "Turn", nextColor);
|
// a "play N half-moves before flipping" predicate reads as
|
||||||
|
// `ctx.halfMovesThisTurn < N → false`.
|
||||||
|
const prevHalfMovesThisTurn =
|
||||||
|
(this.session.get(GAME_ENTITY, "HalfMovesThisTurn") as number) ?? 0;
|
||||||
|
const nextHalfMovesThisTurn = prevHalfMovesThisTurn + 1;
|
||||||
|
this.session.insert(
|
||||||
|
GAME_ENTITY,
|
||||||
|
"HalfMovesThisTurn",
|
||||||
|
nextHalfMovesThisTurn,
|
||||||
|
);
|
||||||
|
|
||||||
// Increment fullmove number after black's move
|
// Poll every active preset's shouldAdvanceTurn hook. FIRST FALSE
|
||||||
|
// WINS — as soon as one veto lands, the engine stops polling and
|
||||||
|
// skips the turn flip. Scope-unaware: a `scope=white` preset that
|
||||||
|
// vetoes on black's moves is a bug the preset must guard against
|
||||||
|
// (check `ctx.mover`).
|
||||||
|
const turnAdvanceCtx: TurnAdvanceContext = {
|
||||||
|
engine: this,
|
||||||
|
mover: color,
|
||||||
|
halfMovesThisTurn: nextHalfMovesThisTurn,
|
||||||
|
};
|
||||||
|
let shouldAdvance = true;
|
||||||
|
for (const entry of this.activePresets.list()) {
|
||||||
|
const def = PRESET_REGISTRY.get(entry.id);
|
||||||
|
const verdict = def?.shouldAdvanceTurn?.(turnAdvanceCtx);
|
||||||
|
if (verdict === false) {
|
||||||
|
shouldAdvance = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switch turn iff no preset vetoed. When skipped, `Turn` and
|
||||||
|
// `HalfMovesThisTurn` are left as-is (HalfMovesThisTurn was
|
||||||
|
// already incremented above; the next move's poll sees it grow).
|
||||||
|
const nextColor: PieceColor = shouldAdvance
|
||||||
|
? (color === "white" ? "black" : "white")
|
||||||
|
: color;
|
||||||
|
|
||||||
|
if (shouldAdvance) {
|
||||||
|
this.session.insert(GAME_ENTITY, "Turn", nextColor);
|
||||||
|
// Reset within-turn counter on every actual flip.
|
||||||
|
this.session.insert(GAME_ENTITY, "HalfMovesThisTurn", 0);
|
||||||
|
|
||||||
|
// Increment fullmove number after black's completed turn.
|
||||||
if (color === "black") {
|
if (color === "black") {
|
||||||
const fn =
|
const fn =
|
||||||
((this.session.get(GAME_ENTITY, "FullmoveNumber") as number) ?? 1) + 1;
|
((this.session.get(GAME_ENTITY, "FullmoveNumber") as number) ?? 1) + 1;
|
||||||
this.session.insert(GAME_ENTITY, "FullmoveNumber", fn);
|
this.session.insert(GAME_ENTITY, "FullmoveNumber", fn);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Record position for threefold repetition
|
// Record position for threefold repetition
|
||||||
recordPosition(this.session);
|
recordPosition(this.session);
|
||||||
|
|
@ -1223,10 +1269,16 @@ export class ChessEngine {
|
||||||
// cooldown ticks — runs BEFORE the player asks for their legal
|
// cooldown ticks — runs BEFORE the player asks for their legal
|
||||||
// moves, so regenerated stamina / refreshed cooldowns are
|
// moves, so regenerated stamina / refreshed cooldowns are
|
||||||
// reflected in the first legal-move query.
|
// reflected in the first legal-move query.
|
||||||
|
//
|
||||||
|
// Skipped when `shouldAdvanceTurn` vetoed the flip — no new turn
|
||||||
|
// began, so "start of turn" hooks must NOT fire (otherwise
|
||||||
|
// stamina regens mid-turn for double-move, etc.).
|
||||||
|
if (shouldAdvance) {
|
||||||
const turnStartCtx: TurnStartContext = { engine: this, turn: nextColor };
|
const turnStartCtx: TurnStartContext = { engine: this, turn: nextColor };
|
||||||
for (const preset of this.activePresets.getForColor(nextColor)) {
|
for (const preset of this.activePresets.getForColor(nextColor)) {
|
||||||
preset.onTurnStart?.(turnStartCtx);
|
preset.onTurnStart?.(turnStartCtx);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Determine terminal state BEFORE we log, so the MoveRecord
|
// Determine terminal state BEFORE we log, so the MoveRecord
|
||||||
// reflects game-over status on the move that caused it.
|
// reflects game-over status on the move that caused it.
|
||||||
|
|
|
||||||
|
|
@ -251,6 +251,27 @@ export interface FilterLegalMovesContext extends HookContext {
|
||||||
readonly moves: readonly LegalMove[];
|
readonly moves: readonly LegalMove[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context passed to `shouldAdvanceTurn`. Fires from `engine.applyMove`
|
||||||
|
* AFTER the move is committed AND AFTER `HalfMovesThisTurn` has been
|
||||||
|
* incremented, but BEFORE the engine decides whether to flip `Turn`.
|
||||||
|
*
|
||||||
|
* - `mover` is the color that just moved.
|
||||||
|
* - `halfMovesThisTurn` is the post-increment count (i.e. how many
|
||||||
|
* consecutive half-moves this side has played in the CURRENT turn,
|
||||||
|
* INCLUDING the one that just committed). First-move queries see
|
||||||
|
* `1`, second-move queries see `2`.
|
||||||
|
*
|
||||||
|
* Phase A.3 (rule-variants epic): lets presets like Double-Move
|
||||||
|
* (white plays 2, black plays 1) and Monster (white plays 2, black
|
||||||
|
* plays 1 — scope-aware white-only variant) gate the turn flip
|
||||||
|
* without touching the engine.
|
||||||
|
*/
|
||||||
|
export interface TurnAdvanceContext extends HookContext {
|
||||||
|
readonly mover: "white" | "black";
|
||||||
|
readonly halfMovesThisTurn: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Why a piece is being spawned. Used by `onPieceSpawn` hooks to
|
* Why a piece is being spawned. Used by `onPieceSpawn` hooks to
|
||||||
* decide whether to participate (e.g., a preset that resurrects
|
* decide whether to participate (e.g., a preset that resurrects
|
||||||
|
|
@ -546,6 +567,36 @@ export interface PresetDef {
|
||||||
ctx: FilterLegalMovesContext,
|
ctx: FilterLegalMovesContext,
|
||||||
) => readonly LegalMove[];
|
) => readonly LegalMove[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide whether the current turn should flip after this move.
|
||||||
|
*
|
||||||
|
* Called from `engine.applyMove` AFTER all mutations (move applied,
|
||||||
|
* captures resolved, damage dispatched, en-passant/castling side
|
||||||
|
* effects landed) and AFTER `HalfMovesThisTurn` has been
|
||||||
|
* incremented. The engine iterates every active preset's hook; if
|
||||||
|
* ANY returns `false`, the turn flip is SKIPPED (the mover plays
|
||||||
|
* again). `undefined` / `true` = no opinion, let subsequent
|
||||||
|
* presets / the default flip proceed.
|
||||||
|
*
|
||||||
|
* "First false wins" semantics:
|
||||||
|
* - As soon as one preset vetoes the flip, the engine stops
|
||||||
|
* polling and skips the flip.
|
||||||
|
* - `HalfMovesThisTurn` is NOT reset when the flip is skipped,
|
||||||
|
* so the next move's poll sees an incremented count.
|
||||||
|
* - `HalfMovesThisTurn` IS reset to 0 when the flip proceeds.
|
||||||
|
*
|
||||||
|
* The engine reads `ctx.halfMovesThisTurn` AFTER the increment, so
|
||||||
|
* a "play N half-moves before flipping" preset uses the predicate
|
||||||
|
* `ctx.halfMovesThisTurn < N → return false`.
|
||||||
|
*
|
||||||
|
* Canonical users: `double-move` (flip after 2 for both colors),
|
||||||
|
* `monster-rules` (flip after 2 for white, 1 for black — scope
|
||||||
|
* aware via `mover` inspection).
|
||||||
|
*/
|
||||||
|
readonly shouldAdvanceTurn?: (
|
||||||
|
ctx: TurnAdvanceContext,
|
||||||
|
) => boolean | undefined;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Phase hook: fires AFTER the move has been confirmed legal but
|
* Phase hook: fires AFTER the move has been confirmed legal but
|
||||||
* BEFORE any state mutation. Return `{ cancel: true, reason }` to
|
* BEFORE any state mutation. Return `{ cancel: true, reason }` to
|
||||||
|
|
|
||||||
221
packages/chess/src/presets/turn-advance.test.ts
Normal file
221
packages/chess/src/presets/turn-advance.test.ts
Normal file
|
|
@ -0,0 +1,221 @@
|
||||||
|
/**
|
||||||
|
* Tests for the `shouldAdvanceTurn` hook + `HalfMovesThisTurn` game
|
||||||
|
* fact (Phase A.3 of the rule-variants epic).
|
||||||
|
*
|
||||||
|
* Semantics under test:
|
||||||
|
* - `HalfMovesThisTurn` is seeded to 0 by `applyLayout`.
|
||||||
|
* - `engine.applyMove` increments `HalfMovesThisTurn` BEFORE polling
|
||||||
|
* `shouldAdvanceTurn`. Hooks see the post-increment count.
|
||||||
|
* - FIRST FALSE from any active preset vetoes the flip. When vetoed
|
||||||
|
* the mover plays again; `Turn` stays; `HalfMovesThisTurn` is
|
||||||
|
* NOT reset.
|
||||||
|
* - When the flip proceeds, `HalfMovesThisTurn` resets to 0 and
|
||||||
|
* `FullmoveNumber` increments after black.
|
||||||
|
* - `onTurnStart` only fires when the flip actually happened — no
|
||||||
|
* mid-turn regen for double-move stamina etc.
|
||||||
|
*
|
||||||
|
* Coverage:
|
||||||
|
* - Baseline (no preset with the hook): every move flips, count
|
||||||
|
* always 0 after applyMove. Protects the 1434-test regression.
|
||||||
|
* - "Never flip" prototype: white keeps moving; `Turn` stays white;
|
||||||
|
* count accumulates.
|
||||||
|
* - "Flip after 2" prototype (the double-move surface): white plays
|
||||||
|
* 2, then black plays 2, then white plays 2, …
|
||||||
|
* - Skip-flip does NOT fire `onTurnStart`.
|
||||||
|
* - `FullmoveNumber` only increments on actual flips after black's
|
||||||
|
* turn.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import "./index.js";
|
||||||
|
import { ChessEngine } from "../engine.js";
|
||||||
|
import { PRESET_REGISTRY } from "./registry.js";
|
||||||
|
import { GAME_ENTITY } from "../schema.js";
|
||||||
|
|
||||||
|
const NEVER_FLIP_ID = "test-never-flip";
|
||||||
|
const FLIP_AFTER_TWO_ID = "test-flip-after-two";
|
||||||
|
const TURN_START_COUNTER_ID = "test-turn-start-counter";
|
||||||
|
|
||||||
|
// Use module-level counters captured by closure so the hook can
|
||||||
|
// observe how many times onTurnStart was invoked.
|
||||||
|
let turnStartInvocations = 0;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
turnStartInvocations = 0;
|
||||||
|
|
||||||
|
PRESET_REGISTRY.register({
|
||||||
|
id: NEVER_FLIP_ID,
|
||||||
|
name: "Never flip (test)",
|
||||||
|
description: "Always veto the turn flip — mover plays forever.",
|
||||||
|
incompatibleWith: [],
|
||||||
|
requires: [],
|
||||||
|
shouldAdvanceTurn() {
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
PRESET_REGISTRY.register({
|
||||||
|
id: FLIP_AFTER_TWO_ID,
|
||||||
|
name: "Flip after two (test)",
|
||||||
|
description: "Flip only after both colors have played 2 half-moves.",
|
||||||
|
incompatibleWith: [],
|
||||||
|
requires: [],
|
||||||
|
shouldAdvanceTurn({ halfMovesThisTurn }) {
|
||||||
|
// halfMovesThisTurn is POST-increment: seeing 1 means "mover
|
||||||
|
// just played their first move in this turn"; seeing 2 means
|
||||||
|
// "mover just played their second — OK to flip now".
|
||||||
|
if (halfMovesThisTurn < 2) return false;
|
||||||
|
return undefined; // no opinion — let defaults flip
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
PRESET_REGISTRY.register({
|
||||||
|
id: TURN_START_COUNTER_ID,
|
||||||
|
name: "Turn start counter (test)",
|
||||||
|
description: "Counts how many times onTurnStart fires.",
|
||||||
|
incompatibleWith: [],
|
||||||
|
requires: [],
|
||||||
|
onTurnStart() {
|
||||||
|
turnStartInvocations++;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function firstLegalMove(engine: ChessEngine) {
|
||||||
|
const moves = engine.getAllLegalMoves();
|
||||||
|
if (moves.length === 0) throw new Error("no legal moves available");
|
||||||
|
return moves[0]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("HalfMovesThisTurn — seeding", () => {
|
||||||
|
it("seeded to 0 by applyLayout", () => {
|
||||||
|
const engine = new ChessEngine();
|
||||||
|
expect(engine.session.get(GAME_ENTITY, "HalfMovesThisTurn")).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("shouldAdvanceTurn — baseline (no hook)", () => {
|
||||||
|
it("every move flips and resets HalfMovesThisTurn to 0", () => {
|
||||||
|
const engine = new ChessEngine();
|
||||||
|
|
||||||
|
expect(engine.getCurrentTurn()).toBe("white");
|
||||||
|
engine.applyMove(firstLegalMove(engine));
|
||||||
|
expect(engine.getCurrentTurn()).toBe("black");
|
||||||
|
expect(engine.session.get(GAME_ENTITY, "HalfMovesThisTurn")).toBe(0);
|
||||||
|
|
||||||
|
engine.applyMove(firstLegalMove(engine));
|
||||||
|
expect(engine.getCurrentTurn()).toBe("white");
|
||||||
|
expect(engine.session.get(GAME_ENTITY, "HalfMovesThisTurn")).toBe(0);
|
||||||
|
|
||||||
|
// FullmoveNumber went from 1 → 2 after black's move.
|
||||||
|
expect(engine.session.get(GAME_ENTITY, "FullmoveNumber")).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("shouldAdvanceTurn — never-flip veto", () => {
|
||||||
|
it("white plays forever; Turn never flips; count accumulates", () => {
|
||||||
|
const engine = new ChessEngine();
|
||||||
|
engine.setActivePresets([
|
||||||
|
{ id: NEVER_FLIP_ID, scope: "both", turnsRemaining: null },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(engine.getCurrentTurn()).toBe("white");
|
||||||
|
engine.applyMove(firstLegalMove(engine));
|
||||||
|
expect(engine.getCurrentTurn()).toBe("white");
|
||||||
|
expect(engine.session.get(GAME_ENTITY, "HalfMovesThisTurn")).toBe(1);
|
||||||
|
|
||||||
|
engine.applyMove(firstLegalMove(engine));
|
||||||
|
expect(engine.getCurrentTurn()).toBe("white");
|
||||||
|
expect(engine.session.get(GAME_ENTITY, "HalfMovesThisTurn")).toBe(2);
|
||||||
|
|
||||||
|
engine.applyMove(firstLegalMove(engine));
|
||||||
|
expect(engine.getCurrentTurn()).toBe("white");
|
||||||
|
expect(engine.session.get(GAME_ENTITY, "HalfMovesThisTurn")).toBe(3);
|
||||||
|
|
||||||
|
// FullmoveNumber never moves — it only increments on actual
|
||||||
|
// flips after black.
|
||||||
|
expect(engine.session.get(GAME_ENTITY, "FullmoveNumber")).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("shouldAdvanceTurn — flip-after-two prototype (double-move surface)", () => {
|
||||||
|
it("each side plays 2 half-moves before the flip", () => {
|
||||||
|
const engine = new ChessEngine();
|
||||||
|
engine.setActivePresets([
|
||||||
|
{ id: FLIP_AFTER_TWO_ID, scope: "both", turnsRemaining: null },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Move 1: white plays → count=1, veto. Still white's turn.
|
||||||
|
engine.applyMove(firstLegalMove(engine));
|
||||||
|
expect(engine.getCurrentTurn()).toBe("white");
|
||||||
|
expect(engine.session.get(GAME_ENTITY, "HalfMovesThisTurn")).toBe(1);
|
||||||
|
|
||||||
|
// Move 2: white plays → count=2, no veto. Flip to black.
|
||||||
|
engine.applyMove(firstLegalMove(engine));
|
||||||
|
expect(engine.getCurrentTurn()).toBe("black");
|
||||||
|
expect(engine.session.get(GAME_ENTITY, "HalfMovesThisTurn")).toBe(0);
|
||||||
|
|
||||||
|
// Move 3: black plays → count=1, veto. Still black's turn.
|
||||||
|
engine.applyMove(firstLegalMove(engine));
|
||||||
|
expect(engine.getCurrentTurn()).toBe("black");
|
||||||
|
expect(engine.session.get(GAME_ENTITY, "HalfMovesThisTurn")).toBe(1);
|
||||||
|
|
||||||
|
// Move 4: black plays → count=2, no veto. Flip to white.
|
||||||
|
engine.applyMove(firstLegalMove(engine));
|
||||||
|
expect(engine.getCurrentTurn()).toBe("white");
|
||||||
|
expect(engine.session.get(GAME_ENTITY, "HalfMovesThisTurn")).toBe(0);
|
||||||
|
|
||||||
|
// FullmoveNumber incremented exactly once (after black's full
|
||||||
|
// 2-half-move turn completed at move 4).
|
||||||
|
expect(engine.session.get(GAME_ENTITY, "FullmoveNumber")).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("shouldAdvanceTurn — onTurnStart gating", () => {
|
||||||
|
it("onTurnStart does NOT fire when flip is vetoed", () => {
|
||||||
|
const engine = new ChessEngine();
|
||||||
|
engine.setActivePresets([
|
||||||
|
{ id: NEVER_FLIP_ID, scope: "both", turnsRemaining: null },
|
||||||
|
{ id: TURN_START_COUNTER_ID, scope: "both", turnsRemaining: null },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Baseline: no move played → no turnStart fires.
|
||||||
|
expect(turnStartInvocations).toBe(0);
|
||||||
|
|
||||||
|
// Every move is vetoed → onTurnStart never fires.
|
||||||
|
engine.applyMove(firstLegalMove(engine));
|
||||||
|
engine.applyMove(firstLegalMove(engine));
|
||||||
|
engine.applyMove(firstLegalMove(engine));
|
||||||
|
expect(turnStartInvocations).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("onTurnStart fires once per actual flip (no veto)", () => {
|
||||||
|
const engine = new ChessEngine();
|
||||||
|
engine.setActivePresets([
|
||||||
|
{ id: TURN_START_COUNTER_ID, scope: "both", turnsRemaining: null },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(turnStartInvocations).toBe(0);
|
||||||
|
engine.applyMove(firstLegalMove(engine)); // flip → +1
|
||||||
|
expect(turnStartInvocations).toBe(1);
|
||||||
|
engine.applyMove(firstLegalMove(engine)); // flip → +1
|
||||||
|
expect(turnStartInvocations).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("onTurnStart fires once per flip even with flip-after-two", () => {
|
||||||
|
const engine = new ChessEngine();
|
||||||
|
engine.setActivePresets([
|
||||||
|
{ id: FLIP_AFTER_TWO_ID, scope: "both", turnsRemaining: null },
|
||||||
|
{ id: TURN_START_COUNTER_ID, scope: "both", turnsRemaining: null },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(turnStartInvocations).toBe(0);
|
||||||
|
engine.applyMove(firstLegalMove(engine)); // white move 1 — veto, no fire
|
||||||
|
expect(turnStartInvocations).toBe(0);
|
||||||
|
engine.applyMove(firstLegalMove(engine)); // white move 2 — flip, fire
|
||||||
|
expect(turnStartInvocations).toBe(1);
|
||||||
|
engine.applyMove(firstLegalMove(engine)); // black move 1 — veto, no fire
|
||||||
|
expect(turnStartInvocations).toBe(1);
|
||||||
|
engine.applyMove(firstLegalMove(engine)); // black move 2 — flip, fire
|
||||||
|
expect(turnStartInvocations).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -55,6 +55,18 @@ export interface ChessAttrMap {
|
||||||
Turn: PieceColor;
|
Turn: PieceColor;
|
||||||
HalfmoveClock: number;
|
HalfmoveClock: number;
|
||||||
FullmoveNumber: number;
|
FullmoveNumber: number;
|
||||||
|
/**
|
||||||
|
* Number of half-moves (single player moves) completed within the
|
||||||
|
* CURRENT turn. Reset to 0 whenever `Turn` flips. Incremented in
|
||||||
|
* `engine.applyMove` BEFORE the turn-flip decision is polled via
|
||||||
|
* `shouldAdvanceTurn`, so preset hooks see the count AFTER their
|
||||||
|
* current move has been accounted for.
|
||||||
|
*
|
||||||
|
* FIDE chess never exceeds 1 (every applyMove flips). Variants like
|
||||||
|
* Double-Move (white plays 2, black plays 1) and Monster (white
|
||||||
|
* plays 2, black plays 1) use this counter to gate the flip.
|
||||||
|
*/
|
||||||
|
HalfMovesThisTurn: number;
|
||||||
EnPassantTarget: Square | null;
|
EnPassantTarget: Square | null;
|
||||||
GameStatus: GameStatus;
|
GameStatus: GameStatus;
|
||||||
Winner: GameResult | null;
|
Winner: GameResult | null;
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,7 @@ describe("generateStartingPosition()", () => {
|
||||||
expect(session.get(GAME_ENTITY, "Turn")).toBe("white");
|
expect(session.get(GAME_ENTITY, "Turn")).toBe("white");
|
||||||
expect(session.get(GAME_ENTITY, "HalfmoveClock")).toBe(0);
|
expect(session.get(GAME_ENTITY, "HalfmoveClock")).toBe(0);
|
||||||
expect(session.get(GAME_ENTITY, "FullmoveNumber")).toBe(1);
|
expect(session.get(GAME_ENTITY, "FullmoveNumber")).toBe(1);
|
||||||
|
expect(session.get(GAME_ENTITY, "HalfMovesThisTurn")).toBe(0);
|
||||||
expect(session.get(GAME_ENTITY, "EnPassantTarget")).toBe(null);
|
expect(session.get(GAME_ENTITY, "EnPassantTarget")).toBe(null);
|
||||||
expect(session.get(GAME_ENTITY, "GameStatus")).toBe("active");
|
expect(session.get(GAME_ENTITY, "GameStatus")).toBe("active");
|
||||||
expect(session.get(GAME_ENTITY, "Winner")).toBe(null);
|
expect(session.get(GAME_ENTITY, "Winner")).toBe(null);
|
||||||
|
|
|
||||||
|
|
@ -182,6 +182,11 @@ export function applyLayout(
|
||||||
session.insert(GAME_ENTITY, "Turn", "white");
|
session.insert(GAME_ENTITY, "Turn", "white");
|
||||||
session.insert(GAME_ENTITY, "HalfmoveClock", 0);
|
session.insert(GAME_ENTITY, "HalfmoveClock", 0);
|
||||||
session.insert(GAME_ENTITY, "FullmoveNumber", 1);
|
session.insert(GAME_ENTITY, "FullmoveNumber", 1);
|
||||||
|
// `HalfMovesThisTurn` counts the number of half-moves played since
|
||||||
|
// the last `Turn` flip. Seeded alongside `Turn` so both start in
|
||||||
|
// the same committed-state. Incremented in `engine.applyMove`
|
||||||
|
// BEFORE the turn-flip decision; reset to 0 on flip.
|
||||||
|
session.insert(GAME_ENTITY, "HalfMovesThisTurn", 0);
|
||||||
session.insert(GAME_ENTITY, "EnPassantTarget", null);
|
session.insert(GAME_ENTITY, "EnPassantTarget", null);
|
||||||
session.insert(GAME_ENTITY, "GameStatus", "active");
|
session.insert(GAME_ENTITY, "GameStatus", "active");
|
||||||
session.insert(GAME_ENTITY, "Winner", null);
|
session.insert(GAME_ENTITY, "Winner", null);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue