feat(ui): enhanced modifier source chain in pinned panel

This commit is contained in:
Joey Yakimowich-Payne 2026-04-19 09:22:49 -06:00
commit 980d567354
No known key found for this signature in database
4 changed files with 154 additions and 1 deletions

View file

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

View file

@ -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" });
});

View file

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

View file

@ -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<unknown>. 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 (