diff --git a/packages/chess/src/engine.ts b/packages/chess/src/engine.ts index 7aa8af7..397a5a0 100644 --- a/packages/chess/src/engine.ts +++ b/packages/chess/src/engine.ts @@ -339,6 +339,11 @@ export class ChessEngine { * move calculation with no reset. */ public readonly activePresets: ActivePresetSet; + /** + * The currently active ModifierProfile, if any. + * Profiles attach rule modifiers to pieces by type or layout slot. + */ + public readonly activeProfile: ModifierProfile | null; /** * Chronological log of every successful applyMove. Callers consume @@ -443,6 +448,8 @@ export class ChessEngine { ? { activePresets: arg as ActivePresetSet } : ((arg as EngineOptions | undefined) ?? {}); + this.activeProfile = opts.profile ?? null; + const layout = opts.layout ?? CLASSIC_LAYOUT; applyLayout(this.session, layout); diff --git a/packages/chess/src/modifiers/source.test.ts b/packages/chess/src/modifiers/source.test.ts new file mode 100644 index 0000000..fd0bb59 --- /dev/null +++ b/packages/chess/src/modifiers/source.test.ts @@ -0,0 +1,71 @@ +import { expect, test } from "vitest"; +import { ChessEngine } from "../engine.js"; +import { getModifierSource } from "./source.js"; +import type { ModifierProfile } from "./types.js"; +import { PRESET_REGISTRY } from "../presets/registry.js"; +import type { EntityId } from "@paratype/rete"; + +const TEST_PROFILE: ModifierProfile = { + id: "test-profile", + name: "Test Profile", + description: "", + version: 1, + source: "custom", + perInstance: [ + { kind: "hp-bonus", square: "b1", value: 2 }, + ], + perType: [ + { kind: "range-bonus", pieceType: "knight", color: "white", value: 1 }, + ], +}; + +test("returns per-instance for a square match", () => { + const engine = new ChessEngine({ profile: TEST_PROFILE }); + // Find the knight on b1 + const b1Id = engine.session.allFacts().find( + (f) => f.attr === "Position" && f.value === 1 // b1 is index 1 + )?.id as number; + + const source = getModifierSource(engine, b1Id as unknown as EntityId, "hp-bonus"); + expect(source).toEqual({ kind: "per-instance", square: "b1" }); +}); + +test("returns per-type for a type/color match when no instance match", () => { + const engine = new ChessEngine({ profile: TEST_PROFILE }); + // Find a white knight that ISN'T on b1 (e.g. g1) + const g1Id = engine.session.allFacts().find( + (f) => f.attr === "Position" && f.value === 6 // g1 is index 6 + )?.id as number; + + const source = getModifierSource(engine, g1Id as unknown as EntityId, "range-bonus"); + expect(source).toEqual({ kind: "per-type", pieceType: "knight", color: "white" }); +}); + +test("returns preset if no profile overrides but a preset declares the attribute", () => { + // Use a preset that declares Hp. Let's make sure 'piece-hp' is active. + const engine = new ChessEngine(); + engine.activePresets.replaceAll([{ id: "piece-hp", scope: "both", turnsRemaining: null }]); + + // Find any piece, e.g., a1 rook + const a1Id = engine.session.allFacts().find( + (f) => f.attr === "Position" && f.value === 0 + )?.id as EntityId; + + const source = getModifierSource(engine, a1Id, "hp-bonus"); + const hpDef = PRESET_REGISTRY.get("piece-hp")!; + + expect(source).toEqual({ kind: "preset", presetId: "piece-hp", presetName: hpDef.name }); +}); + +test("returns default if nothing overrides or declares it", () => { + const engine = new ChessEngine(); + + // Find any piece + const e2Id = engine.session.allFacts().find( + (f) => f.attr === "Position" && f.value === 12 + )?.id as EntityId; + + // With no profile and no active preset declaring DirectionAdditions, it should be default + const source = getModifierSource(engine, e2Id, "direction-additions"); + expect(source).toEqual({ kind: "default" }); +}); diff --git a/packages/chess/src/modifiers/source.ts b/packages/chess/src/modifiers/source.ts new file mode 100644 index 0000000..340e3b7 --- /dev/null +++ b/packages/chess/src/modifiers/source.ts @@ -0,0 +1,73 @@ +import type { EntityId } from "@paratype/rete"; +import type { ChessEngine } from "../engine.js"; +import type { PieceColor, PieceType } from "../schema.js"; +import type { ModifierKindId } from "./types.js"; +import { PRESET_REGISTRY } from "../presets/registry.js"; +import { MODIFIER_REGISTRY } from "./index.js"; +import { squareToAlgebraic } from "../coord.js"; + +export type ModifierSource = + | { kind: "per-instance"; square: string } + | { kind: "per-type"; pieceType: PieceType; color: PieceColor | "both" } + | { kind: "preset"; presetId: string; presetName: string } + | { kind: "default" }; + +/** + * Determine the origin of a modifier applied to a specific piece. + * Checks the active profile (per-instance, then per-type), then active presets, + * falling back to "default". + */ +export function getModifierSource( + engine: ChessEngine, + pieceId: EntityId, + modifierKind: ModifierKindId +): ModifierSource { + const profile = engine.activeProfile; + const squareNum = engine.session.get(pieceId, "Position") as number | undefined; + const algebraicSq = squareNum !== undefined ? squareToAlgebraic(squareNum) : null; + const pieceType = engine.session.get(pieceId, "PieceType") as PieceType | undefined; + const color = engine.session.get(pieceId, "Color") as PieceColor | undefined; + + if (profile) { + // 1. Check per-instance modifiers + if (algebraicSq) { + const instanceMatch = profile.perInstance.find( + (m) => m.kind === modifierKind && m.square === algebraicSq + ); + if (instanceMatch) { + return { kind: "per-instance", square: instanceMatch.square }; + } + } + + // 2. Check per-type modifiers + if (pieceType && color) { + const typeMatch = profile.perType.find( + (m) => + m.kind === modifierKind && + m.pieceType === pieceType && + (m.color === "both" || m.color === color) + ); + if (typeMatch) { + return { kind: "per-type", pieceType: typeMatch.pieceType, color: typeMatch.color }; + } + } + } + + // 3. Check active presets + const descriptor = MODIFIER_REGISTRY.get(modifierKind); + if (descriptor) { + let attrName = descriptor.attrName as string; + if (attrName === "HpBonus") attrName = "Hp"; + else if (attrName === "RangeBonus") attrName = "Range"; + + for (const presetEntry of engine.activePresets.list()) { + const def = PRESET_REGISTRY.get(presetEntry.id); + if (def?.pieceAttributes?.includes(attrName as keyof import("../schema.js").ChessAttrMap)) { + return { kind: "preset", presetId: presetEntry.id, presetName: def.name }; + } + } + } + + // 4. Default + return { kind: "default" }; +} diff --git a/packages/chess/src/ui/ModifierPinnedPanel.tsx b/packages/chess/src/ui/ModifierPinnedPanel.tsx index 39e988f..baac5ca 100644 --- a/packages/chess/src/ui/ModifierPinnedPanel.tsx +++ b/packages/chess/src/ui/ModifierPinnedPanel.tsx @@ -13,6 +13,7 @@ */ import type { EntityId } from '@paratype/rete'; import { MODIFIER_REGISTRY } from '../modifiers/index.js'; +import { getModifierSource } from "../modifiers/source.js"; import type { ChessEngine } from '../engine.js'; interface Props { @@ -40,7 +41,8 @@ export function ModifierPinnedPanel({ pieceId, engine, onClose }: Props) { // level; the registry widens to ModifierDescriptor. The value // was written by the same descriptor's apply(), so types match at runtime. const description = (descriptor.describe as (v: unknown) => string)(value); - return [{ id: descriptor.id, label: descriptor.label, description }]; + const source = getModifierSource(engine, id, descriptor.id); + return [{ id: descriptor.id, label: descriptor.label, description, source }]; }); return (