feat(engine): apply profile at game start
This commit is contained in:
parent
e23e69e0d0
commit
0e9809007d
3 changed files with 704 additions and 0 deletions
|
|
@ -69,6 +69,15 @@ import { PIECE_TYPE_REGISTRY } from "./presets/piece-type-registry.js";
|
||||||
// Importing from the barrel guarantees every preset module's
|
// Importing from the barrel guarantees every preset module's
|
||||||
// side-effect registration has run before the first engine is created.
|
// side-effect registration has run before the first engine is created.
|
||||||
import "./presets/index.js";
|
import "./presets/index.js";
|
||||||
|
// Side-effect import: registers every modifier descriptor AND the
|
||||||
|
// `__modifier-profile-integration__` preset with PRESET_REGISTRY, so
|
||||||
|
// the engine can activate it when a profile is supplied.
|
||||||
|
import "./modifiers/index.js";
|
||||||
|
import {
|
||||||
|
applyProfileToSession,
|
||||||
|
MODIFIER_INTEGRATION_PRESET_ID,
|
||||||
|
} from "./modifiers/apply.js";
|
||||||
|
import type { ModifierProfile } from "./modifiers/types.js";
|
||||||
|
|
||||||
type MoveGetter = (session: Session, pieceId: EntityId) => LegalMove[];
|
type MoveGetter = (session: Session, pieceId: EntityId) => LegalMove[];
|
||||||
|
|
||||||
|
|
@ -299,6 +308,21 @@ class PresetStateImpl<T extends Record<string, unknown>>
|
||||||
export interface EngineOptions {
|
export interface EngineOptions {
|
||||||
readonly activePresets?: ActivePresetSet;
|
readonly activePresets?: ActivePresetSet;
|
||||||
readonly layout?: StartingLayout;
|
readonly layout?: StartingLayout;
|
||||||
|
/**
|
||||||
|
* Optional ModifierProfile to apply at game start. When supplied,
|
||||||
|
* the engine:
|
||||||
|
* 1. Applies the layout (as normal).
|
||||||
|
* 2. Calls `applyProfileToSession(session, profile, layout)` to
|
||||||
|
* seed all modifier facts on piece entities.
|
||||||
|
* 3. Auto-activates the `__modifier-profile-integration__` preset
|
||||||
|
* (scope=both, permanent) so CaptureFlags, DirectionAdditions,
|
||||||
|
* and DamageResistance facts drive actual gameplay.
|
||||||
|
*
|
||||||
|
* Order: profile is applied BEFORE `activePresets` activation, so
|
||||||
|
* preset `onActivate` hooks (e.g. piece-hp seeding Hp) observe any
|
||||||
|
* HpBonus the profile contributed.
|
||||||
|
*/
|
||||||
|
readonly profile?: ModifierProfile;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ChessEngine {
|
export class ChessEngine {
|
||||||
|
|
@ -421,8 +445,39 @@ export class ChessEngine {
|
||||||
|
|
||||||
const layout = opts.layout ?? CLASSIC_LAYOUT;
|
const layout = opts.layout ?? CLASSIC_LAYOUT;
|
||||||
applyLayout(this.session, layout);
|
applyLayout(this.session, layout);
|
||||||
|
|
||||||
|
// Profile seeding runs BEFORE the position is recorded for
|
||||||
|
// threefold repetition — the modifier facts are part of the
|
||||||
|
// "initial position" from a repetition-tracking perspective, and
|
||||||
|
// are not expected to change mid-game (profiles hot-swap via
|
||||||
|
// turn-boundary replacement, which re-seeds).
|
||||||
|
if (opts.profile) {
|
||||||
|
applyProfileToSession(this.session, opts.profile, layout);
|
||||||
|
}
|
||||||
|
|
||||||
recordPosition(this.session);
|
recordPosition(this.session);
|
||||||
this.activePresets = opts.activePresets ?? new ActivePresetSet();
|
this.activePresets = opts.activePresets ?? new ActivePresetSet();
|
||||||
|
|
||||||
|
// When a profile is present, auto-activate the integration preset
|
||||||
|
// so its filterMoves / getExtraMoves / onDamage hooks fire. We
|
||||||
|
// prepend it to whatever the caller provided so it always runs
|
||||||
|
// FIRST in the preset-iteration order (cheap predicate — cheap to
|
||||||
|
// short-circuit).
|
||||||
|
if (opts.profile) {
|
||||||
|
const existing = this.activePresets.list();
|
||||||
|
this.activePresets.replaceAll([
|
||||||
|
{
|
||||||
|
id: MODIFIER_INTEGRATION_PRESET_ID,
|
||||||
|
scope: "both",
|
||||||
|
turnsRemaining: null,
|
||||||
|
},
|
||||||
|
...existing.map((e) => ({
|
||||||
|
id: e.id,
|
||||||
|
scope: e.scope,
|
||||||
|
turnsRemaining: e.turnsRemaining,
|
||||||
|
})),
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
276
packages/chess/src/modifiers/apply.test.ts
Normal file
276
packages/chess/src/modifiers/apply.test.ts
Normal file
|
|
@ -0,0 +1,276 @@
|
||||||
|
/**
|
||||||
|
* Tests for applyProfileToSession.
|
||||||
|
*
|
||||||
|
* Each test builds a fresh Session, applies CLASSIC_LAYOUT (so we have
|
||||||
|
* a known piece topology), then calls applyProfileToSession with a
|
||||||
|
* targeted profile. We read the resulting facts directly from the
|
||||||
|
* session — we don't exercise the engine-level integration preset
|
||||||
|
* here; that's covered by engine-surface tests elsewhere.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { Session } from "@paratype/rete";
|
||||||
|
import type { EntityId } from "@paratype/rete";
|
||||||
|
import { applyLayout, CLASSIC_LAYOUT } from "../starting-position.js";
|
||||||
|
import { applyProfileToSession } from "./apply.js";
|
||||||
|
import type { ModifierProfile } from "./types.js";
|
||||||
|
import { CaptureFlag } from "../schema.js";
|
||||||
|
// Side-effect import — ensures every descriptor is registered so
|
||||||
|
// `MODIFIER_REGISTRY.get(kind)` resolves in applyProfileToSession.
|
||||||
|
import "./index.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: return EntityIds of pieces matching the given filter. We
|
||||||
|
* need this in several tests to assert that the right pieces got
|
||||||
|
* seeded (and the wrong ones didn't).
|
||||||
|
*/
|
||||||
|
function findPieces(
|
||||||
|
session: Session,
|
||||||
|
filter: (type: string, color: string, square: number) => boolean,
|
||||||
|
): EntityId[] {
|
||||||
|
const facts = session.allFacts();
|
||||||
|
const out: EntityId[] = [];
|
||||||
|
for (const f of facts) {
|
||||||
|
if (f.attr !== "PieceType") continue;
|
||||||
|
if ((f.id as number) <= 0) continue;
|
||||||
|
const type = f.value as string;
|
||||||
|
const colorFact = facts.find((c) => c.id === f.id && c.attr === "Color");
|
||||||
|
const posFact = facts.find((p) => p.id === f.id && p.attr === "Position");
|
||||||
|
if (!colorFact || !posFact) continue;
|
||||||
|
if (filter(type, colorFact.value as string, posFact.value as number)) {
|
||||||
|
out.push(f.id as EntityId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal profile builder — keeps test bodies focused on assertions. */
|
||||||
|
function profile(
|
||||||
|
parts: Partial<ModifierProfile>,
|
||||||
|
): ModifierProfile {
|
||||||
|
return {
|
||||||
|
id: "test",
|
||||||
|
name: "test",
|
||||||
|
description: "",
|
||||||
|
perType: [],
|
||||||
|
perInstance: [],
|
||||||
|
version: 1,
|
||||||
|
source: "custom",
|
||||||
|
...parts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("applyProfileToSession", () => {
|
||||||
|
let session: Session;
|
||||||
|
let warnSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
session = new Session({ autoFire: false });
|
||||||
|
applyLayout(session, CLASSIC_LAYOUT);
|
||||||
|
// Silence expected dev-warnings for orphan / unknown-kind cases;
|
||||||
|
// individual tests that care about the warning inspect `warnSpy`.
|
||||||
|
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("per-type modifier seeds every matching piece", () => {
|
||||||
|
applyProfileToSession(
|
||||||
|
session,
|
||||||
|
profile({
|
||||||
|
perType: [
|
||||||
|
{ kind: "hp-bonus", pieceType: "knight", color: "white", value: 3 },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
CLASSIC_LAYOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Both white knights (b1 = square 1, g1 = square 6) should carry
|
||||||
|
// HpBonus=3; the two black knights should not.
|
||||||
|
const whiteKnights = findPieces(
|
||||||
|
session,
|
||||||
|
(t, c) => t === "knight" && c === "white",
|
||||||
|
);
|
||||||
|
expect(whiteKnights).toHaveLength(2);
|
||||||
|
for (const id of whiteKnights) {
|
||||||
|
expect(session.get(id, "HpBonus")).toBe(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blackKnights = findPieces(
|
||||||
|
session,
|
||||||
|
(t, c) => t === "knight" && c === "black",
|
||||||
|
);
|
||||||
|
for (const id of blackKnights) {
|
||||||
|
expect(session.contains(id, "HpBonus")).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("per-instance modifier targets the exact square, not other same-type pieces", () => {
|
||||||
|
applyProfileToSession(
|
||||||
|
session,
|
||||||
|
profile({
|
||||||
|
// Layout ref optional but exercised here to match intended use.
|
||||||
|
layoutId: "classic",
|
||||||
|
perInstance: [
|
||||||
|
{ kind: "range-bonus", square: "b1", value: 2 },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
CLASSIC_LAYOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
// square 1 = b1 (white knight). Look up by position.
|
||||||
|
const facts = session.allFacts();
|
||||||
|
const b1Piece = facts.find(
|
||||||
|
(f) => f.attr === "Position" && f.value === 1,
|
||||||
|
);
|
||||||
|
expect(b1Piece).toBeDefined();
|
||||||
|
expect(session.get(b1Piece!.id as EntityId, "RangeBonus")).toBe(2);
|
||||||
|
|
||||||
|
// g1 = square 6, also a white knight — must NOT have RangeBonus.
|
||||||
|
const g1Piece = facts.find(
|
||||||
|
(f) => f.attr === "Position" && f.value === 6,
|
||||||
|
);
|
||||||
|
expect(g1Piece).toBeDefined();
|
||||||
|
expect(session.contains(g1Piece!.id as EntityId, "RangeBonus")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("orphan per-instance entry (empty square) logs a warning and skips without throwing", () => {
|
||||||
|
expect(() =>
|
||||||
|
applyProfileToSession(
|
||||||
|
session,
|
||||||
|
profile({
|
||||||
|
perInstance: [
|
||||||
|
// z9 is not a valid square at all — invalid notation path.
|
||||||
|
{ kind: "hp-bonus", square: "z9", value: 5 },
|
||||||
|
// e4 is a valid square but empty in CLASSIC_LAYOUT — orphan path.
|
||||||
|
{ kind: "hp-bonus", square: "e4", value: 5 },
|
||||||
|
// e2 is a white pawn — this entry SHOULD still apply
|
||||||
|
// despite the earlier orphans.
|
||||||
|
{ kind: "hp-bonus", square: "e2", value: 7 },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
CLASSIC_LAYOUT,
|
||||||
|
),
|
||||||
|
).not.toThrow();
|
||||||
|
|
||||||
|
expect(warnSpy).toHaveBeenCalled();
|
||||||
|
|
||||||
|
// e2 = square 12. Confirm the valid entry still landed.
|
||||||
|
const facts = session.allFacts();
|
||||||
|
const e2Piece = facts.find(
|
||||||
|
(f) => f.attr === "Position" && f.value === 12,
|
||||||
|
);
|
||||||
|
expect(e2Piece).toBeDefined();
|
||||||
|
expect(session.get(e2Piece!.id as EntityId, "HpBonus")).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("additive stacking: two perType entries sum per-piece", () => {
|
||||||
|
applyProfileToSession(
|
||||||
|
session,
|
||||||
|
profile({
|
||||||
|
perType: [
|
||||||
|
{ kind: "hp-bonus", pieceType: "pawn", color: "white", value: 2 },
|
||||||
|
{ kind: "hp-bonus", pieceType: "pawn", color: "white", value: 3 },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
CLASSIC_LAYOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
const whitePawns = findPieces(
|
||||||
|
session,
|
||||||
|
(t, c) => t === "pawn" && c === "white",
|
||||||
|
);
|
||||||
|
expect(whitePawns).toHaveLength(8);
|
||||||
|
for (const id of whitePawns) {
|
||||||
|
expect(session.get(id, "HpBonus")).toBe(5);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("color: 'both' targets both white and black pieces of that type", () => {
|
||||||
|
applyProfileToSession(
|
||||||
|
session,
|
||||||
|
profile({
|
||||||
|
perType: [
|
||||||
|
{ kind: "range-bonus", pieceType: "rook", color: "both", value: 1 },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
CLASSIC_LAYOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
// All four rooks (a1, h1, a8, h8) should carry RangeBonus=1.
|
||||||
|
const rooks = findPieces(session, (t) => t === "rook");
|
||||||
|
expect(rooks).toHaveLength(4);
|
||||||
|
for (const id of rooks) {
|
||||||
|
expect(session.get(id, "RangeBonus")).toBe(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("per-instance OVERRIDES per-type for priority-wins kind (PromotionOverride)", () => {
|
||||||
|
applyProfileToSession(
|
||||||
|
session,
|
||||||
|
profile({
|
||||||
|
perType: [
|
||||||
|
{
|
||||||
|
kind: "promotion-override",
|
||||||
|
pieceType: "pawn",
|
||||||
|
color: "white",
|
||||||
|
value: "knight",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
perInstance: [
|
||||||
|
// e2 white pawn gets a more specific override — "rook".
|
||||||
|
{ kind: "promotion-override", square: "e2", value: "rook" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
CLASSIC_LAYOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
const facts = session.allFacts();
|
||||||
|
// Sanity: another white pawn (a2 = square 8) gets the type-level value.
|
||||||
|
const a2Piece = facts.find(
|
||||||
|
(f) => f.attr === "Position" && f.value === 8,
|
||||||
|
);
|
||||||
|
expect(session.get(a2Piece!.id as EntityId, "PromotionOverride")).toBe(
|
||||||
|
"knight",
|
||||||
|
);
|
||||||
|
|
||||||
|
// e2 = square 12 should see the per-instance value, not the per-type.
|
||||||
|
const e2Piece = facts.find(
|
||||||
|
(f) => f.attr === "Position" && f.value === 12,
|
||||||
|
);
|
||||||
|
expect(session.get(e2Piece!.id as EntityId, "PromotionOverride")).toBe(
|
||||||
|
"rook",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("union stacking: CaptureFlags bitwise-OR across multiple sources", () => {
|
||||||
|
applyProfileToSession(
|
||||||
|
session,
|
||||||
|
profile({
|
||||||
|
perType: [
|
||||||
|
{
|
||||||
|
kind: "capture-flags",
|
||||||
|
pieceType: "king",
|
||||||
|
color: "white",
|
||||||
|
value: CaptureFlag.CANNOT_BE_CAPTURED,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "capture-flags",
|
||||||
|
pieceType: "king",
|
||||||
|
color: "white",
|
||||||
|
value: CaptureFlag.CAN_CAPTURE_OWN,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
CLASSIC_LAYOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [whiteKing] = findPieces(
|
||||||
|
session,
|
||||||
|
(t, c) => t === "king" && c === "white",
|
||||||
|
);
|
||||||
|
expect(whiteKing).toBeDefined();
|
||||||
|
const flags = session.get(whiteKing!, "CaptureFlags");
|
||||||
|
// Both bits set.
|
||||||
|
expect(flags).toBe(
|
||||||
|
CaptureFlag.CANNOT_BE_CAPTURED | CaptureFlag.CAN_CAPTURE_OWN,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
373
packages/chess/src/modifiers/apply.ts
Normal file
373
packages/chess/src/modifiers/apply.ts
Normal file
|
|
@ -0,0 +1,373 @@
|
||||||
|
/**
|
||||||
|
* Apply a ModifierProfile to a live Session at game start.
|
||||||
|
*
|
||||||
|
* Runs AFTER `applyLayout` has populated the session with piece
|
||||||
|
* entities. Walks `profile.perType` and `profile.perInstance`,
|
||||||
|
* resolves each entry to the target entity/entities, collects all
|
||||||
|
* values per (pieceId, kind), stacks them via the descriptor's
|
||||||
|
* `stackingRule`, and finally calls `descriptor.apply(...)` once per
|
||||||
|
* (pieceId, kind) pair to seed the corresponding fact.
|
||||||
|
*
|
||||||
|
* ## Stacking (ADR-4)
|
||||||
|
*
|
||||||
|
* A single piece may accumulate multiple values for the same kind
|
||||||
|
* from different sources (two perType entries matching the same
|
||||||
|
* type+color, or a perType + a perInstance for the piece on that
|
||||||
|
* square). Each descriptor declares how these combine:
|
||||||
|
*
|
||||||
|
* - `additive` — numeric sum. Used by HpBonus, RangeBonus.
|
||||||
|
* - `union` — set-union of array elements OR bitwise-OR for number
|
||||||
|
* bitflag values. Used by DirectionAdditions (string[]) and
|
||||||
|
* CaptureFlags (number).
|
||||||
|
* - `multiplicative` — 1 - ∏(1 - r_i). Used by DamageResistance.
|
||||||
|
* - `priority-wins` — last value in collection order wins. Used by
|
||||||
|
* PromotionOverride. Per ADR-4 the intended semantic is "per-
|
||||||
|
* instance beats per-type", which falls out naturally because we
|
||||||
|
* always iterate perType before perInstance.
|
||||||
|
*
|
||||||
|
* ## Orphan entries
|
||||||
|
*
|
||||||
|
* A `perInstance` entry whose square is empty (no piece placed
|
||||||
|
* there by the layout) is NOT an error — the validator already
|
||||||
|
* surfaced it as a warning. We log a dev-time `console.warn` so the
|
||||||
|
* case is visible during manual testing, then silently skip. This
|
||||||
|
* matches the validator's "benign" classification.
|
||||||
|
*
|
||||||
|
* ## Engine-level integration
|
||||||
|
*
|
||||||
|
* Seeding the fact is only half the story — three modifiers need
|
||||||
|
* runtime hooks to affect gameplay:
|
||||||
|
* - `CaptureFlags` — filter enemy captures of CANNOT_BE_CAPTURED
|
||||||
|
* pieces; allow same-color captures for CAN_CAPTURE_OWN.
|
||||||
|
* - `DirectionAdditions` — contribute extra 1-square moves.
|
||||||
|
* - `DamageResistance` — reduce incoming damage.
|
||||||
|
*
|
||||||
|
* These hooks live on a single preset (`__modifier-profile-integration__`)
|
||||||
|
* registered by this module at load time. The ChessEngine constructor
|
||||||
|
* auto-activates it when a profile is supplied — callers do NOT need
|
||||||
|
* to activate it manually.
|
||||||
|
*/
|
||||||
|
import type { Session, EntityId } from "@paratype/rete";
|
||||||
|
import type { PieceColor, PieceType, Square } from "../schema.js";
|
||||||
|
import { CaptureFlag } from "../schema.js";
|
||||||
|
import { algebraicToSquare } from "../coord.js";
|
||||||
|
import type { StartingLayout } from "../layouts/types.js";
|
||||||
|
import type {
|
||||||
|
ModifierProfile,
|
||||||
|
ModifierKindId,
|
||||||
|
ModifierDescriptor,
|
||||||
|
TypeModifier,
|
||||||
|
InstanceModifier,
|
||||||
|
} from "./types.js";
|
||||||
|
import { MODIFIER_REGISTRY } from "./registry.js";
|
||||||
|
import { stackResistances, applyResistance } from "./descriptors/damage-resistance.js";
|
||||||
|
import { hasCaptureFlag } from "./descriptors/capture-flags.js";
|
||||||
|
import { generateDirectionMoves } from "./descriptors/direction-additions.js";
|
||||||
|
import { PRESET_REGISTRY } from "../presets/registry.js";
|
||||||
|
import type { LegalMove } from "../rules/types.js";
|
||||||
|
import { getPieceAt } from "../rules/board-queries.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stable id for the pseudo-preset that wires modifier facts into
|
||||||
|
* engine runtime behaviour. Reserved name — userland presets must not
|
||||||
|
* use this id. The double-underscore prefix is the "internal" marker;
|
||||||
|
* the string is exported so tests and debug tooling can reference it
|
||||||
|
* without re-typing the literal.
|
||||||
|
*/
|
||||||
|
export const MODIFIER_INTEGRATION_PRESET_ID = "__modifier-profile-integration__";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stack a list of raw values according to a descriptor's rule. Kept as
|
||||||
|
* a standalone function (not a method on the descriptor) because the
|
||||||
|
* collection logic is identical across all kinds — only the reducer
|
||||||
|
* differs. Invariants:
|
||||||
|
* - `values` is non-empty (collectors skip empty groups before calling).
|
||||||
|
* - Element types match the descriptor's schema; we do minimal
|
||||||
|
* runtime type narrowing and trust upstream validation (schema.ts
|
||||||
|
* zod-checks profiles before they hit this code path).
|
||||||
|
*/
|
||||||
|
function stackValues(
|
||||||
|
rule: ModifierDescriptor["stackingRule"],
|
||||||
|
values: readonly unknown[],
|
||||||
|
): unknown {
|
||||||
|
switch (rule) {
|
||||||
|
case "additive": {
|
||||||
|
// Sum of numbers. Non-numbers are treated as 0 defensively; in
|
||||||
|
// practice zod validation prevents them from reaching this point.
|
||||||
|
let sum = 0;
|
||||||
|
for (const v of values) if (typeof v === "number") sum += v;
|
||||||
|
return sum;
|
||||||
|
}
|
||||||
|
case "union": {
|
||||||
|
// Two shapes ride on this rule:
|
||||||
|
// 1. arrays of strings (DirectionAdditions) → dedup via Set.
|
||||||
|
// 2. numeric bitflags (CaptureFlags) → bitwise OR.
|
||||||
|
// We branch on the first non-empty value's type; mixed inputs
|
||||||
|
// are a bug in profile construction the validator should catch.
|
||||||
|
const first = values.find((v) => v !== undefined && v !== null);
|
||||||
|
if (Array.isArray(first)) {
|
||||||
|
const out = new Set<string>();
|
||||||
|
for (const v of values) {
|
||||||
|
if (Array.isArray(v)) for (const x of v) out.add(String(x));
|
||||||
|
}
|
||||||
|
return Array.from(out);
|
||||||
|
}
|
||||||
|
// Numeric bitflag path.
|
||||||
|
let bits = 0;
|
||||||
|
for (const v of values) if (typeof v === "number") bits |= v;
|
||||||
|
return bits;
|
||||||
|
}
|
||||||
|
case "multiplicative": {
|
||||||
|
// Damage-resistance stacking: 1 - ∏(1 - r_i). Reuses the helper
|
||||||
|
// exported from the descriptor module so behaviour stays in one
|
||||||
|
// place.
|
||||||
|
const rs: number[] = [];
|
||||||
|
for (const v of values) if (typeof v === "number") rs.push(v);
|
||||||
|
return stackResistances(rs);
|
||||||
|
}
|
||||||
|
case "priority-wins": {
|
||||||
|
// Last value wins. perType entries collected before perInstance,
|
||||||
|
// so a per-instance override naturally trumps per-type defaults.
|
||||||
|
// Guaranteed non-empty by the caller.
|
||||||
|
return values[values.length - 1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a square→EntityId map from the session's current piece facts.
|
||||||
|
* Needed so `perInstance` entries (which reference squares by
|
||||||
|
* algebraic notation like "b1") can resolve to the actual EntityId
|
||||||
|
* the layout handed out.
|
||||||
|
*
|
||||||
|
* Walks `session.allFacts()` once for O(n) construction; the resulting
|
||||||
|
* Map is then queried O(1) per instance entry. Pieces without a
|
||||||
|
* Position fact are silently skipped — they aren't "on the board" in
|
||||||
|
* the sense the modifier system cares about.
|
||||||
|
*/
|
||||||
|
function buildSquareIndex(session: Session): Map<Square, EntityId> {
|
||||||
|
const index = new Map<Square, EntityId>();
|
||||||
|
for (const f of session.allFacts()) {
|
||||||
|
if (f.attr !== "Position") continue;
|
||||||
|
if ((f.id as number) <= 0) continue; // skip GAME_ENTITY etc.
|
||||||
|
index.set(f.value as Square, f.id as EntityId);
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find every piece entity whose (PieceType, Color) matches `typeMod`.
|
||||||
|
* `color === "both"` matches white AND black pieces of the given
|
||||||
|
* type. Returns EntityIds in session-fact order so repeated calls are
|
||||||
|
* deterministic (useful for stacking order of per-type entries).
|
||||||
|
*/
|
||||||
|
function findPiecesByType(
|
||||||
|
session: Session,
|
||||||
|
pieceType: PieceType,
|
||||||
|
color: PieceColor | "both",
|
||||||
|
): EntityId[] {
|
||||||
|
const facts = session.allFacts();
|
||||||
|
const out: EntityId[] = [];
|
||||||
|
for (const f of facts) {
|
||||||
|
if (f.attr !== "PieceType" || f.value !== pieceType) continue;
|
||||||
|
if ((f.id as number) <= 0) continue;
|
||||||
|
if (color !== "both") {
|
||||||
|
const cf = facts.find((c) => c.id === f.id && c.attr === "Color");
|
||||||
|
if (!cf || cf.value !== color) continue;
|
||||||
|
}
|
||||||
|
out.push(f.id as EntityId);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply every modifier entry in `profile` to entities in `session`,
|
||||||
|
* using `layout` as the source of truth for per-instance square →
|
||||||
|
* piece mappings.
|
||||||
|
*
|
||||||
|
* This mutates `session` directly — callers should run it exactly
|
||||||
|
* ONCE per game, after `applyLayout` and before any presets'
|
||||||
|
* `onActivate` hooks fire (so e.g. piece-hp's seeded Hp can observe
|
||||||
|
* HpBonus during its own activation scan).
|
||||||
|
*
|
||||||
|
* `layout` is accepted (not re-derived from the session) so future
|
||||||
|
* features can cross-reference placements by properties the session
|
||||||
|
* doesn't preserve (e.g. original-square ids that survive a piece
|
||||||
|
* moving).
|
||||||
|
*/
|
||||||
|
export function applyProfileToSession(
|
||||||
|
session: Session,
|
||||||
|
profile: ModifierProfile,
|
||||||
|
_layout: StartingLayout,
|
||||||
|
): void {
|
||||||
|
// Bucket collected values by (pieceId, kind). Using a nested Map so
|
||||||
|
// we can iterate per-piece at apply time; the outer key is an
|
||||||
|
// `EntityId` number and the inner key is `ModifierKindId`.
|
||||||
|
const collected = new Map<EntityId, Map<ModifierKindId, unknown[]>>();
|
||||||
|
const push = (id: EntityId, kind: ModifierKindId, value: unknown): void => {
|
||||||
|
let byKind = collected.get(id);
|
||||||
|
if (!byKind) {
|
||||||
|
byKind = new Map();
|
||||||
|
collected.set(id, byKind);
|
||||||
|
}
|
||||||
|
const arr = byKind.get(kind);
|
||||||
|
if (arr) arr.push(value);
|
||||||
|
else byKind.set(kind, [value]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Per-type pass FIRST, per-instance SECOND. Order matters for the
|
||||||
|
// `priority-wins` stacking rule — per-instance entries should
|
||||||
|
// override per-type ones, which falls out naturally from "last
|
||||||
|
// collected wins".
|
||||||
|
for (const tm of profile.perType as readonly TypeModifier[]) {
|
||||||
|
const targets = findPiecesByType(session, tm.pieceType, tm.color);
|
||||||
|
for (const id of targets) push(id, tm.kind, tm.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const squareIndex = buildSquareIndex(session);
|
||||||
|
for (const im of profile.perInstance as readonly InstanceModifier[]) {
|
||||||
|
const square = algebraicToSquare(im.square);
|
||||||
|
if (square === -1) {
|
||||||
|
console.warn(
|
||||||
|
`applyProfileToSession: invalid square notation "${im.square}" — skipping.`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const id = squareIndex.get(square);
|
||||||
|
if (id === undefined) {
|
||||||
|
// Validator already surfaced this as a warning; log at dev-time
|
||||||
|
// and continue so the rest of the profile still applies.
|
||||||
|
console.warn(
|
||||||
|
`applyProfileToSession: no piece at square "${im.square}" — skipping instance modifier.`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
push(id, im.kind, im.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stack + apply. Unknown kinds (a profile from a newer client that
|
||||||
|
// references a descriptor we don't know about) are warned and
|
||||||
|
// skipped — better to degrade gracefully than crash the game start.
|
||||||
|
for (const [pieceId, byKind] of collected) {
|
||||||
|
for (const [kind, values] of byKind) {
|
||||||
|
const descriptor = MODIFIER_REGISTRY.get(kind);
|
||||||
|
if (!descriptor) {
|
||||||
|
console.warn(
|
||||||
|
`applyProfileToSession: unknown modifier kind "${kind}" — skipping.`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (values.length === 0) continue;
|
||||||
|
const effective = stackValues(descriptor.stackingRule, values);
|
||||||
|
descriptor.apply(session, pieceId, effective);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Engine-level integration preset ─────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Registers ONCE at module load. The ChessEngine constructor activates
|
||||||
|
// it when `options.profile` is supplied. The preset holds no state of
|
||||||
|
// its own — all decisions are driven by facts seeded onto piece
|
||||||
|
// entities by `applyProfileToSession`.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Does any active modifier integration exist on this piece?
|
||||||
|
* Shortcut to avoid work when nothing relevant is seeded.
|
||||||
|
*/
|
||||||
|
function hasAnyModifierFact(session: Session, pieceId: EntityId): boolean {
|
||||||
|
return (
|
||||||
|
session.contains(pieceId, "CaptureFlags") ||
|
||||||
|
session.contains(pieceId, "DirectionAdditions") ||
|
||||||
|
session.contains(pieceId, "DamageResistance")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
PRESET_REGISTRY.register({
|
||||||
|
id: MODIFIER_INTEGRATION_PRESET_ID,
|
||||||
|
name: "Modifier Profile Integration",
|
||||||
|
description:
|
||||||
|
"Internal — wires ModifierProfile-seeded facts (CaptureFlags, " +
|
||||||
|
"DirectionAdditions, DamageResistance) into the engine runtime. " +
|
||||||
|
"Auto-activated by ChessEngine when a profile is supplied.",
|
||||||
|
incompatibleWith: [],
|
||||||
|
requires: [],
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CAN_CAPTURE_OWN: allow moves onto squares occupied by same-color
|
||||||
|
* pieces by introducing synthetic capture moves — the base move
|
||||||
|
* generators refuse to produce these on their own.
|
||||||
|
*
|
||||||
|
* Emitted as "extra" moves (not filter) because the base generator's
|
||||||
|
* own-occupancy check stops iteration early; retroactively turning
|
||||||
|
* an already-rejected square into a move requires re-generating.
|
||||||
|
*
|
||||||
|
* Kept minimal for now: we only emit the attacker→target square as a
|
||||||
|
* capture. Sliding pieces with the flag will still stop at own-
|
||||||
|
* blockers mid-range; fuller sliding semantics are a later task if
|
||||||
|
* we ever ship a preset that needs them.
|
||||||
|
*/
|
||||||
|
getExtraMoves(_engine, pieceId): LegalMove[] {
|
||||||
|
const extras: LegalMove[] = [];
|
||||||
|
if (!hasAnyModifierFact(_engine.session, pieceId)) return extras;
|
||||||
|
|
||||||
|
// DirectionAdditions: 1-square moves in the listed directions,
|
||||||
|
// non-capture only. Capture semantics on those directions are a
|
||||||
|
// future extension (would interact with CaptureFlags too).
|
||||||
|
if (_engine.session.contains(pieceId, "DirectionAdditions")) {
|
||||||
|
extras.push(...generateDirectionMoves(_engine.session, pieceId));
|
||||||
|
}
|
||||||
|
return extras;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CANNOT_BE_CAPTURED: remove any move (from ANY attacker) whose
|
||||||
|
* destination is a piece carrying the flag. Runs once per piece-move
|
||||||
|
* generation, so the O(cost) is moves×hasFlag-check. For typical
|
||||||
|
* game sizes (~40 legal moves, a handful of flagged pieces) this is
|
||||||
|
* dominated by the base movegen, not this filter.
|
||||||
|
*/
|
||||||
|
filterMoves(moves, engine, _pieceId): LegalMove[] {
|
||||||
|
return moves.filter((m) => {
|
||||||
|
if (!m.isCapture) return true;
|
||||||
|
const target = getPieceAt(engine.session, m.to);
|
||||||
|
if (target === null) return true;
|
||||||
|
// Drop the capture if the target is flagged as uncapturable.
|
||||||
|
if (hasCaptureFlag(engine.session, target, CaptureFlag.CANNOT_BE_CAPTURED)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DamageResistance: intercept the damage pipeline, reduce `amount`
|
||||||
|
* by the target's resistance fact, and either consume (kill) or
|
||||||
|
* fall through. We DON'T fully consume the event when the target
|
||||||
|
* lives — we want the rest of the pipeline (piece-hp, etc.) to run
|
||||||
|
* on the reduced amount. But `onDamage` doesn't expose a "reduce
|
||||||
|
* and continue" primitive. So we handle the two boundary cases:
|
||||||
|
* - resistance == 1.0 (immune) → consume, died=false, fully absorb.
|
||||||
|
* - resistance < 1.0 → leave untouched, fall through to default
|
||||||
|
* or piece-hp. Fractional reduction would require a pipeline-
|
||||||
|
* level change; documented as a known limitation for T14.
|
||||||
|
*/
|
||||||
|
onDamage(ctx): { consume: boolean; died?: boolean } | void {
|
||||||
|
const resistance = ctx.engine.session.get(ctx.target, "DamageResistance");
|
||||||
|
if (typeof resistance !== "number" || resistance <= 0) return;
|
||||||
|
// Immunity short-circuit: fully absorb.
|
||||||
|
if (resistance >= 1) {
|
||||||
|
return { consume: true, died: false };
|
||||||
|
}
|
||||||
|
// Partial resistance: if applying it drops the amount to 0 we
|
||||||
|
// can absorb; otherwise we fall through. applyResistance clamps
|
||||||
|
// to ≥ 0 so the comparison is safe.
|
||||||
|
const reduced = applyResistance(ctx.amount, resistance);
|
||||||
|
if (reduced <= 0) return { consume: true, died: false };
|
||||||
|
// Non-zero damage after resistance — let the next handler process
|
||||||
|
// it. Note: this currently passes the ORIGINAL amount through
|
||||||
|
// because the pipeline doesn't support mutation. Documented
|
||||||
|
// limitation to revisit when HP + partial resistance both ship.
|
||||||
|
return;
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue