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
|
||||
because the 1 call site per legal-move request is hot-path-
|
||||
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,
|
||||
RoyalContext,
|
||||
SelfCheckFilterContext,
|
||||
TurnAdvanceContext,
|
||||
TurnStartContext,
|
||||
} from "./presets/registry.js";
|
||||
import type { ChessAttrKey } from "./schema.js";
|
||||
|
|
@ -1176,18 +1177,63 @@ export class ChessEngine {
|
|||
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");
|
||||
|
||||
// Switch turn
|
||||
const nextColor: PieceColor = color === "white" ? "black" : "white";
|
||||
this.session.insert(GAME_ENTITY, "Turn", nextColor);
|
||||
// Phase A.3: increment HalfMovesThisTurn BEFORE polling
|
||||
// `shouldAdvanceTurn`. The hook sees the POST-increment count so
|
||||
// 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
|
||||
if (color === "black") {
|
||||
const fn =
|
||||
((this.session.get(GAME_ENTITY, "FullmoveNumber") as number) ?? 1) + 1;
|
||||
this.session.insert(GAME_ENTITY, "FullmoveNumber", fn);
|
||||
// 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") {
|
||||
const fn =
|
||||
((this.session.get(GAME_ENTITY, "FullmoveNumber") as number) ?? 1) + 1;
|
||||
this.session.insert(GAME_ENTITY, "FullmoveNumber", fn);
|
||||
}
|
||||
}
|
||||
|
||||
// Record position for threefold repetition
|
||||
|
|
@ -1223,9 +1269,15 @@ export class ChessEngine {
|
|||
// cooldown ticks — runs BEFORE the player asks for their legal
|
||||
// moves, so regenerated stamina / refreshed cooldowns are
|
||||
// reflected in the first legal-move query.
|
||||
const turnStartCtx: TurnStartContext = { engine: this, turn: nextColor };
|
||||
for (const preset of this.activePresets.getForColor(nextColor)) {
|
||||
preset.onTurnStart?.(turnStartCtx);
|
||||
//
|
||||
// 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 };
|
||||
for (const preset of this.activePresets.getForColor(nextColor)) {
|
||||
preset.onTurnStart?.(turnStartCtx);
|
||||
}
|
||||
}
|
||||
|
||||
// Determine terminal state BEFORE we log, so the MoveRecord
|
||||
|
|
|
|||
|
|
@ -251,6 +251,27 @@ export interface FilterLegalMovesContext extends HookContext {
|
|||
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
|
||||
* decide whether to participate (e.g., a preset that resurrects
|
||||
|
|
@ -546,6 +567,36 @@ export interface PresetDef {
|
|||
ctx: FilterLegalMovesContext,
|
||||
) => 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
|
||||
* 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;
|
||||
HalfmoveClock: 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;
|
||||
GameStatus: GameStatus;
|
||||
Winner: GameResult | null;
|
||||
|
|
|
|||
|
|
@ -119,6 +119,7 @@ describe("generateStartingPosition()", () => {
|
|||
expect(session.get(GAME_ENTITY, "Turn")).toBe("white");
|
||||
expect(session.get(GAME_ENTITY, "HalfmoveClock")).toBe(0);
|
||||
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, "GameStatus")).toBe("active");
|
||||
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, "HalfmoveClock", 0);
|
||||
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, "GameStatus", "active");
|
||||
session.insert(GAME_ENTITY, "Winner", null);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue