feat(ui): custom modifier editor in-modal docs, recipes, and attr combobox

Surfaces the contents of docs/user/custom-modifiers.md directly inside
the Custom Modifier Editor so authors can compose descriptors without
cross-referencing the guide:

- Per-primitive docs panel in the Parameter Inspector with a longer
  behaviour explanation + one or more worked examples (collapsible).
- Palette hover tooltips now show the full long description plus the
  first example's headline.
- New 'Templates' header button opens a picker with 5 built-in recipes
  (Boosted Pawn, 3-Charge Shield, Aura King, Vampire, Low-HP Fortress).
- AttrCombobox replaces plain text inputs for attr / targetAttr fields.
  Grouped, free-form autocomplete over 17 curated suggestions with a
  'user-defined' badge for out-of-catalog typed names so ShieldCharges-
  style recipes still work.

Primitives gain optional longDescription + examples fields on their
EffectPrimitive descriptor; 15 registrations annotated. Recipe
descriptors pass the existing validator, and new unit tests enforce
doc coverage going forward.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-21 12:50:14 -06:00
commit 934db775f9
No known key found for this signature in database
25 changed files with 1354 additions and 6 deletions

View file

@ -17,14 +17,47 @@ modifier profile can reference, exactly the same way it references a built-in.
2. In the editor's header, click **+ Custom Modifier**. The Custom Modifier
editor opens as a nested modal.
3. The Custom Modifier editor is a 3-column workspace:
- **Left**: primitive palette, grouped by category.
- **Left**: primitive palette, grouped by category. Hover any entry for a
full tooltip with the primitive's long description and a worked example.
- **Center**: the descriptor's primitive tree (the composition you're
building).
- **Right**: parameter inspector for the selected primitive.
- **Right**: parameter inspector for the selected primitive. The top of the
inspector shows an in-editor docs panel — a longer behaviour explanation
plus one or more concrete example configurations you can copy. Click
**Hide docs & examples** to collapse it when you want only the form.
Each descriptor needs a **name** (1-40 chars) and an optional **description**
(0-200 chars). The header also exposes **Save**, **Load from library**, and
live validation status.
(0-200 chars). The header exposes **Templates**, **Load**, **Save**, and live
validation status.
### Templates — starter recipes
The **Templates** button opens a picker with pre-composed recipes drawn from
the examples below (Boosted Pawn, 3-Charge Shield, Aura King, Vampire,
Low-HP Fortress). Picking a template replaces the current draft with a fresh
copy — the id is regenerated so you can save the loaded template as your own
library entry without collisions.
### Attribute-name autocomplete
Primitives that target an attribute (`seed-attribute`, `add-to-attribute`,
`multiply-attribute`, `absorb-damage-with-attribute`, `add-aura`) render
their `attr` / `targetAttr` field as a **combobox** with grouped
suggestions:
- **Core numeric attributes** — Hp, HpBonus, RangeBonus, DamageResistance,
ReflectDamagePercent, AbsorbDamageRate, HalfmoveClock, FullmoveNumber.
Safe targets for add/multiply/aura/absorb primitives.
- **Core non-numeric attributes** — CaptureFlags, DirectionAdditions,
PromotionOverride, BlockedMoveTypes, AbsorbDamageAttr, HasMoved. Seed-only
for most; avoid add/multiply.
- **User-defined examples** — ShieldCharges, ArmorPlates, BloodStacks,
ManaPool. Illustrative names matching the recipes; invent your own.
The combobox is **free-form** — type any name and hit Enter to accept it,
even if it isn't in the list. Typed values not in the catalog get a small
`user-defined` badge so you know you're outside the schema. Numeric-context
primitives (add, multiply, aura, absorb) rank numeric suggestions first.
---

View file

@ -1322,6 +1322,78 @@ test.describe('T29 — Custom modifier DSL e2e', () => {
await ctxHost.close();
}
});
test('editor shows docs + examples for selected primitive', async ({
page,
}) => {
await freshLobby(page);
await openProfileEditor(page);
await openCustomModifierEditor(page);
// Add a primitive so the inspector renders.
await page.getByTestId('custom-primitive-palette-seed-attribute').click();
await page.getByTestId('custom-primitive-node-0').click();
// Docs panel is present with at least one example.
const docs = page.getByTestId('custom-primitive-docs');
await expect(docs).toBeVisible();
await expect(docs).toContainText('seed-attribute');
await expect(
page.getByTestId('custom-primitive-example-0'),
).toBeVisible();
});
test('AttrCombobox autocompletes known attrs and accepts user-defined names', async ({
page,
}) => {
await freshLobby(page);
await openProfileEditor(page);
await openCustomModifierEditor(page);
// Add a seed-attribute primitive so the attr field renders.
await page.getByTestId('custom-primitive-palette-seed-attribute').click();
await page.getByTestId('custom-primitive-node-0').click();
const combobox = page.getByTestId('primitive-seed-attribute-attr');
await expect(combobox).toBeVisible();
// Focus the input; the dropdown should open with groups visible.
const input = page.getByTestId('primitive-seed-attribute-attr-input');
await input.click();
await expect(
page.getByTestId('primitive-seed-attribute-attr-dropdown'),
).toBeVisible();
// Click the "Hp" suggestion — input value updates and dropdown closes.
await page.getByTestId('attr-combobox-option-Hp').first().click();
await expect(input).toHaveValue('Hp');
// Type a user-defined attr name — badge appears, dropdown filters down.
await input.fill('MyStacks');
await expect(combobox).toContainText('user-defined');
});
test('Templates picker loads a recipe into the editor', async ({ page }) => {
await freshLobby(page);
await openProfileEditor(page);
await openCustomModifierEditor(page);
// Open the templates modal and pick the shield recipe (2 primitives).
await page.getByTestId('custom-templates').click();
await expect(page.getByTestId('custom-templates-modal')).toBeVisible();
await page
.getByTestId('custom-template-recipe-three-charge-shield')
.click();
// Modal closes and the tree is populated with the recipe's primitives.
await expect(page.getByTestId('custom-templates-modal')).toHaveCount(0);
await expect(page.getByTestId('custom-primitive-node-0')).toBeVisible();
await expect(page.getByTestId('custom-primitive-node-1')).toBeVisible();
// Name field reflects the template's descriptor.name.
await expect(
page.locator('input[placeholder="Modifier Name"]'),
).toHaveValue('3-Charge Shield');
});
});
// ─── WS helpers (mirrored from modifier-profiles.spec.ts) ──────────

View file

@ -0,0 +1,63 @@
import { describe, it, expect } from "vitest";
import { CUSTOM_MODIFIER_RECIPES } from "./recipes.js";
import { validateCustomDescriptor } from "./validate.js";
import { PRIMITIVE_REGISTRY } from "../primitives/registry.js";
import "../primitives/index.js";
describe("CUSTOM_MODIFIER_RECIPES", () => {
it("exposes a non-empty list of recipes", () => {
expect(CUSTOM_MODIFIER_RECIPES.length).toBeGreaterThan(0);
});
it("every recipe has a unique id", () => {
const ids = CUSTOM_MODIFIER_RECIPES.map((r) => r.id);
expect(new Set(ids).size).toBe(ids.length);
});
it("every recipe's descriptor validates against validateCustomDescriptor", () => {
for (const recipe of CUSTOM_MODIFIER_RECIPES) {
const result = validateCustomDescriptor(recipe.descriptor);
if (!result.ok) {
throw new Error(
`Recipe "${recipe.id}" failed validation: ` +
JSON.stringify(result.errors, null, 2),
);
}
expect(result.ok).toBe(true);
}
});
it("every recipe references only registered primitive kinds", () => {
const visitNodes = (
nodes: { kind: string; params: unknown }[],
): void => {
for (const node of nodes) {
const primitive = PRIMITIVE_REGISTRY.get(
node.kind as Parameters<typeof PRIMITIVE_REGISTRY.get>[0],
);
expect(
primitive,
`Recipe references unknown primitive kind "${node.kind}"`,
).toBeDefined();
if (primitive?.childPrimitives) {
try {
visitNodes([...primitive.childPrimitives(node.params)]);
} catch {
/* empty */
}
}
}
};
for (const recipe of CUSTOM_MODIFIER_RECIPES) {
visitNodes([...recipe.descriptor.primitives]);
}
});
it("every recipe descriptor has a non-empty title + summary", () => {
for (const recipe of CUSTOM_MODIFIER_RECIPES) {
expect(recipe.title.length).toBeGreaterThan(0);
expect(recipe.summary.length).toBeGreaterThan(0);
expect(recipe.descriptor.name.length).toBeGreaterThan(0);
}
});
});

View file

@ -0,0 +1,157 @@
/**
* Built-in recipe templates for the Custom Modifier Editor.
*
* A recipe is a pre-composed, validated CustomModifierDescriptor
* illustrating a common pattern from `docs/user/custom-modifiers.md`.
* The editor's "Templates" button loads one into the authoring buffer
* (with a fresh id) so users can see a working composition, then edit
* or save it to their library as-is.
*
* Recipes are build-time constants: no state, no storage, no network.
* Adding a new one only requires dropping it in the TEMPLATES array
* below — every template is validated by `validateCustomDescriptor`
* the same way user-authored descriptors are, so bad shapes fail fast.
*/
import type { CustomModifierDescriptor } from "./types.js";
import { asCustomModifierId } from "./types.js";
export interface CustomModifierRecipe {
/**
* Stable id used only for UI list-key purposes. When a recipe is
* loaded into the editor we rewrite the descriptor's id to a fresh
* `custom-*` value so saving doesn't collide with other copies.
*/
readonly id: string;
/** One-line headline shown in the template picker. */
readonly title: string;
/** One-to-three sentence description of the effect it produces. */
readonly summary: string;
/** The descriptor loaded into the editor when the user picks this. */
readonly descriptor: CustomModifierDescriptor;
}
function descriptorForRecipe(
id: string,
name: string,
description: string,
primitives: CustomModifierDescriptor["primitives"],
): CustomModifierDescriptor {
return {
type: "data",
id: asCustomModifierId(id),
name,
description,
version: 1,
primitives,
targetAttrs: [],
uiForm: "primitive-composer",
source: "custom",
};
}
export const CUSTOM_MODIFIER_RECIPES: readonly CustomModifierRecipe[] = [
{
id: "recipe-boosted-pawn",
title: "Boosted Pawn (+2 HP)",
summary:
"Single add-to-attribute giving +2 HpBonus. Simplest possible starter — flip any number.",
descriptor: descriptorForRecipe(
"recipe-boosted-pawn",
"Boosted Pawn",
"+2 HP bonus via a single additive primitive.",
[
{
kind: "add-to-attribute",
params: { attr: "HpBonus", delta: 2 },
},
],
),
},
{
id: "recipe-three-charge-shield",
title: "3-Charge Shield",
summary:
"seed-attribute for 3 ShieldCharges paired with absorb-damage-with-attribute — each damage point consumes one charge before HP.",
descriptor: descriptorForRecipe(
"recipe-three-charge-shield",
"3-Charge Shield",
"Damage eats one ShieldCharge before HP, until all 3 are gone.",
[
{
kind: "seed-attribute",
params: { attr: "ShieldCharges", value: 3 },
},
{
kind: "absorb-damage-with-attribute",
params: { attr: "ShieldCharges", rate: 1 },
},
],
),
},
{
id: "recipe-aura-king",
title: "Aura King (+1 HP within 2 squares)",
summary:
"add-aura radiating +1 HpBonus to every piece within Chebyshev radius 2. Apply to a king's per-type entry.",
descriptor: descriptorForRecipe(
"recipe-aura-king",
"Aura King",
"Every piece within 2 squares of the source gets +1 HP while in range.",
[
{
kind: "add-aura",
params: { radius: 2, targetAttr: "HpBonus", delta: 1 },
},
],
),
},
{
id: "recipe-vampire",
title: "Vampire (heal on capture)",
summary:
"on-capture wrapping add-to-attribute Hp+1. Gains 1 HP every time this piece captures an enemy.",
descriptor: descriptorForRecipe(
"recipe-vampire",
"Vampire",
"Heals 1 HP each time this piece captures another.",
[
{
kind: "on-capture",
params: {
primitives: [
{
kind: "add-to-attribute",
params: { attr: "Hp", delta: 1 },
},
],
},
},
],
),
},
{
id: "recipe-low-hp-fortress",
title: "Low-HP Fortress (invulnerable when HP<2)",
summary:
"conditional: when Hp < 2, sets CANNOT_BE_CAPTURED. A last-stand trigger that keeps a wounded piece alive.",
descriptor: descriptorForRecipe(
"recipe-low-hp-fortress",
"Low-HP Fortress",
"Becomes untargetable when HP drops below 2.",
[
{
kind: "conditional",
params: {
condition: { type: "attr-lt", attr: "Hp", value: 2 },
then: [
{
kind: "set-capture-flag",
params: { flag: 2 },
},
],
},
},
],
),
},
];

View file

@ -15,6 +15,22 @@ const descriptor: EffectPrimitive<Params> = {
label: "Absorb Damage with Attribute",
description:
"Seed absorb-damage facts so damage can consume an attribute before HP.",
longDescription:
"Declares that incoming damage should first deplete a user-chosen counter (rate points per damage) before touching HP. You must seed the counter itself with seed-attribute — this primitive only wires the absorb mechanic, not the charge supply.",
examples: [
{
title: "3-charge shield (pair with seed-attribute)",
params: { attr: "ShieldCharges", rate: 1 },
effect:
"Pair with seed-attribute {attr: 'ShieldCharges', value: 3}. Each damage point consumes one charge; after 3 damage, HP starts taking hits.",
},
{
title: "Hardened armor (rate=2)",
params: { attr: "ArmorPlates", rate: 2 },
effect:
"Each damage point consumes 2 ArmorPlates instead of HP — makes plates deplete twice as fast but with the same absorption curve.",
},
],
paramsSchema: schema,
// Static portion: the two absorb control facts. The dynamic
// `params.attr` (e.g. "ShieldCharges") is also touched but the

View file

@ -15,6 +15,22 @@ const descriptor: EffectPrimitive<Params> = {
label: "Add Aura",
description:
"Seeds an AuraSpec fact entry consumed by the aura engine's recomputation phase.",
longDescription:
"Radiates a numeric contribution to targetAttr onto every piece within `radius` (Chebyshev / king-move distance — radius 1 = 8 neighbours). Recomputes after every move; pieces moving out of range lose the contribution on the next pass. Self-application is skipped. Multiple auras to the same targetAttr from different sources accumulate additively.",
examples: [
{
title: "King aura: +1 HP within 2 squares",
params: { radius: 2, targetAttr: "HpBonus", delta: 1 },
effect:
"Every friendly or enemy piece within 2 squares of this piece gains +1 HpBonus while in range.",
},
{
title: "Adjacent range buff",
params: { radius: 1, targetAttr: "RangeBonus", delta: 1 },
effect:
"Anyone standing next to this piece (8 neighbouring squares) gets +1 to range.",
},
],
paramsSchema: schema,
// Source writes AuraSpec. Target pieces receive AuraContributions
// derived by computeAuraFacts — that's a separate pass, not a

View file

@ -42,6 +42,23 @@ const descriptor: EffectPrimitive<Params> = {
kind: "add-direction",
label: "Add Direction",
description: "Appends movement directions into DirectionAdditions with dedupe.",
longDescription:
"Appends one or more color-relative named directions into the piece's DirectionAdditions array, deduplicated by name. Composes with the built-in Direction Additions modifier — both write to the same fact. Valid directions: forward, backward, left, right, diagonal-fl, diagonal-fr, diagonal-bl, diagonal-br.",
examples: [
{
title: "Backward-capable pawn",
params: { directions: ["backward"] },
effect: "Lets a pawn step backward as well as forward.",
},
{
title: "Full omnidirectional king-lite",
params: {
directions: ["forward", "backward", "left", "right"],
},
effect:
"Adds all 4 orthogonal directions in one primitive. Diagonal names are listed separately if you need them.",
},
],
paramsSchema: schema,
seedsAttrs: ["DirectionAdditions"],
apply(ctx: PrimitiveApplyContext, params: Params): void {

View file

@ -13,6 +13,21 @@ const descriptor: EffectPrimitive<Params> = {
kind: "add-to-attribute",
label: "Add To Attribute",
description: "Adds delta to the current attribute value, treating missing as 0.",
longDescription:
"Reads the current numeric value of attr (0 if unset) and writes existing + delta. Delta may be negative. Composes additively with other primitives and built-in modifiers — multiple add-to-attribute primitives for the same attr simply accumulate.",
examples: [
{
title: "+2 HP bonus",
params: { attr: "HpBonus", delta: 2 },
effect: "Adds 2 to whatever HpBonus is already there.",
},
{
title: "Heal 1/turn (inside on-turn-start)",
params: { attr: "Hp", delta: 1 },
effect:
"Wrapped in on-turn-start, restores 1 HP to this piece at the start of its color's turn.",
},
],
paramsSchema: schema,
seedsAttrsFor(params: unknown): readonly ChessAttrKey[] {
const p = params as Partial<Params> | undefined;

View file

@ -14,6 +14,22 @@ const descriptor: EffectPrimitive<Params> = {
kind: "block-move-type",
label: "Block Move Type",
description: "Seed blocked move types for deferred move-filter integration.",
longDescription:
"Filters out generated moves matching the given type. Multiple block primitives accumulate into a blocked-move-type set (deduped). Useful for pacifist pieces that still slide, or for pieces that can capture but not reposition silently.",
examples: [
{
title: "Pacifist piece",
params: { moveType: "capture" },
effect:
"Piece can step and slide freely but cannot capture — a pure support piece.",
},
{
title: "Charge-only attacker",
params: { moveType: "step" },
effect:
"Removes simple step moves; piece can only capture or slide.",
},
],
paramsSchema: schema,
seedsAttrs: ["BlockedMoveTypes"],
apply(ctx: PrimitiveApplyContext, params: Params): void {

View file

@ -54,6 +54,32 @@ const descriptor: EffectPrimitive<Params> = {
label: "Conditional",
description:
"Seeds ConditionalHooks entries consumed by the trigger evaluation pipeline.",
longDescription:
"Branches on a condition. If true → runs every primitive in `then`; if false and `else` is set → runs `else`. Condition types: attr-lt (numeric less-than), attr-gt (numeric greater-than), attr-eq (exact match against string/number/boolean/null), always (unconditional then), never (forces else path only).",
examples: [
{
title: "Low-HP fortress",
params: {
condition: { type: "attr-lt", attr: "Hp", value: 2 },
then: [
{ kind: "set-capture-flag", params: { flag: 2 } },
],
},
effect:
"When Hp drops below 2, the piece gains CANNOT_BE_CAPTURED — a last-stand invulnerability.",
},
{
title: "Unconditional thorns example",
params: {
condition: { type: "always" },
then: [
{ kind: "reflect-damage", params: { percentage: 10 } },
],
},
effect:
"Equivalent to applying reflect-damage unconditionally; useful as a template you can later tighten.",
},
],
paramsSchema: schema,
seedsAttrs: ["ConditionalHooks"],
apply(ctx: PrimitiveApplyContext, params: Params): void {

View file

@ -0,0 +1,34 @@
import { describe, it, expect } from "vitest";
import { PRIMITIVE_REGISTRY } from "./registry.js";
import "./index.js";
/**
* Every primitive surfaced in the custom-modifier editor should ship
* editor docs — a longer description AND at least one worked example.
* This test keeps future primitives honest: if someone adds a new
* primitive without docs, the test fails loudly.
*/
describe("Primitive editor docs coverage", () => {
const primitives = PRIMITIVE_REGISTRY.list();
it("has >= 15 primitives registered (T3 full set)", () => {
expect(primitives.length).toBeGreaterThanOrEqual(15);
});
for (const primitive of primitives) {
it(`primitive "${primitive.kind}" has a longDescription`, () => {
expect(primitive.longDescription).toBeDefined();
expect((primitive.longDescription ?? "").length).toBeGreaterThan(20);
});
it(`primitive "${primitive.kind}" has >= 1 worked example`, () => {
expect(primitive.examples).toBeDefined();
expect((primitive.examples ?? []).length).toBeGreaterThanOrEqual(1);
for (const ex of primitive.examples ?? []) {
expect(ex.title.length).toBeGreaterThan(0);
expect(ex.effect.length).toBeGreaterThan(0);
expect(typeof ex.params).toBe("object");
}
});
}
});

View file

@ -12,6 +12,20 @@ const descriptor: EffectPrimitive<Params> = {
kind: "modify-movement-range",
label: "Modify Movement Range",
description: "Additively contributes to the existing RangeBonus attribute.",
longDescription:
"Adds delta to the piece's RangeBonus. Composes additively with the built-in Range Bonus modifier and with other modify-movement-range primitives. Delta is clamped to integer range [-7, 7]. Rook/bishop/queen sliding is extended/reduced by this amount; knight/king ranges are treated by their own pipeline.",
examples: [
{
title: "+1 range buff",
params: { delta: 1 },
effect: "A rook's horizontal slide reaches one square further than its baseline.",
},
{
title: "-2 range debuff",
params: { delta: -2 },
effect: "Cuts 2 squares from the piece's reach (useful for 'slowed' tokens).",
},
],
paramsSchema: schema,
seedsAttrs: ["RangeBonus"],
apply(ctx: PrimitiveApplyContext, params: Params): void {

View file

@ -13,6 +13,21 @@ const descriptor: EffectPrimitive<Params> = {
kind: "multiply-attribute",
label: "Multiply Attribute",
description: "Multiplies an existing attribute value by the provided factor.",
longDescription:
"Reads the existing numeric value of attr and writes existing * factor. No-op if the attribute is unset — it does NOT treat absent as 1. Use after seed-attribute or add-to-attribute when you need a baseline to scale.",
examples: [
{
title: "Double HP",
params: { attr: "Hp", factor: 2 },
effect: "If the piece already has 4 HP, becomes 8 HP.",
},
{
title: "Halve range bonus",
params: { attr: "RangeBonus", factor: 0.5 },
effect:
"If RangeBonus is already 4, becomes 2 (rounded per attr consumer). Silently skipped if RangeBonus is unset.",
},
],
paramsSchema: schema,
seedsAttrsFor(params: unknown): readonly ChessAttrKey[] {
const p = params as Partial<Params> | undefined;

View file

@ -26,6 +26,20 @@ const descriptor: EffectPrimitive<Params> = {
kind: "on-capture",
label: "On Capture",
description: "Seeds OnCaptureHooks entries consumed during capture events.",
longDescription:
"Wraps nested primitives that fire when this piece captures another. Typical uses: 'vampire' lifesteal (heal on capture), stacking buffs, or power-up triggers. Fires only on actual captures, not on quiet moves.",
examples: [
{
title: "Vampire lifesteal",
params: {
primitives: [
{ kind: "add-to-attribute", params: { attr: "Hp", delta: 1 } },
],
},
effect:
"Every time this piece captures an enemy, it gains 1 HP. Stacks over a long game.",
},
],
paramsSchema: schema,
seedsAttrs: ["OnCaptureHooks"],
apply(ctx: PrimitiveApplyContext, params: Params): void {

View file

@ -26,6 +26,20 @@ const descriptor: EffectPrimitive<Params> = {
kind: "on-damaged",
label: "On Damaged",
description: "Seeds OnDamagedHooks entries consumed during damage events.",
longDescription:
"Wraps nested primitives that fire whenever this piece takes damage. Useful for reactive behaviours: auto-thorns, emergency buffs, or conditional transformations when HP crosses a threshold (combine with `conditional`).",
examples: [
{
title: "Thorns on hit",
params: {
primitives: [
{ kind: "reflect-damage", params: { percentage: 25 } },
],
},
effect:
"When this piece takes damage, reflects 25% back to the attacker for that hit.",
},
],
paramsSchema: schema,
seedsAttrs: ["OnDamagedHooks"],
apply(ctx: PrimitiveApplyContext, params: Params): void {

View file

@ -27,6 +27,20 @@ const descriptor: EffectPrimitive<Params> = {
label: "On Turn Start",
description:
"Seeds OnTurnStartHooks entries consumed during the engine's turn-start phase.",
longDescription:
"Wraps a list of nested primitives that fire at the start of this piece's color's turn. Use for recurring buffs/healing/debuffs tied to turn cadence. The editor's Parameter Inspector accepts the nested `primitives` array as JSON; copy snippets from the simpler primitives into that array.",
examples: [
{
title: "Regenerate 1 HP/turn",
params: {
primitives: [
{ kind: "add-to-attribute", params: { attr: "Hp", delta: 1 } },
],
},
effect:
"At the start of every turn, this piece regains 1 HP (until capped by its damage pipeline).",
},
],
paramsSchema: schema,
seedsAttrs: ["OnTurnStartHooks"],
apply(ctx: PrimitiveApplyContext, params: Params): void {

View file

@ -13,6 +13,20 @@ const descriptor: EffectPrimitive<Params> = {
kind: "override-promotion",
label: "Override Promotion",
description: "Write PromotionOverride directly to enforce a promotion target.",
longDescription:
"Forces this piece (typically a pawn) to promote to a specific type regardless of player choice. Mirrors the built-in Promotion Override modifier, but expressable inside a custom primitive tree. Last write wins if multiple sources set it.",
examples: [
{
title: "Knights-only promotion",
params: { target: "knight" },
effect: "Pawn always promotes to a knight.",
},
{
title: "Underpromote to rook",
params: { target: "rook" },
effect: "Pawn always promotes to a rook — useful for themed variants.",
},
],
paramsSchema: schema,
seedsAttrs: ["PromotionOverride"],
apply(ctx: PrimitiveApplyContext, params: Params): void {

View file

@ -12,6 +12,20 @@ const descriptor: EffectPrimitive<Params> = {
kind: "reflect-damage",
label: "Reflect Damage",
description: "Seed reflected-damage percentage for deferred damage-pipeline wiring.",
longDescription:
"Reflects a percentage of incoming damage back to the attacker. Integer percent, 0-100. Multiple reflect primitives on the same piece do NOT stack — the most recent value wins. Great inside on-damaged if you want a one-time thorns reaction instead of a permanent aura.",
examples: [
{
title: "Half-reflective armour",
params: { percentage: 50 },
effect: "50% of incoming damage is dealt back to the attacker.",
},
{
title: "Total thorns",
params: { percentage: 100 },
effect: "Full reflection — the attacker takes whatever they dealt.",
},
],
paramsSchema: schema,
seedsAttrs: ["ReflectDamagePercent"],
apply(ctx: PrimitiveApplyContext, params: Params): void {

View file

@ -38,6 +38,21 @@ const descriptor: EffectPrimitive<Params> = {
kind: "seed-attribute",
label: "Seed Attribute",
description: "Seeds a fact on the target piece, overwriting existing value.",
longDescription:
"Writes { attr, value } directly onto the piece, overwriting any existing value. Use to introduce new attributes (like a custom ShieldCharges counter) or to force a baseline (e.g. set HP to an exact number regardless of inheritance). Pair with add-to-attribute / multiply-attribute to build up a final value.",
examples: [
{
title: "Force exact HP",
params: { attr: "Hp", value: 5 },
effect: "Piece always starts with 5 HP regardless of baseline.",
},
{
title: "Declare shield charges",
params: { attr: "ShieldCharges", value: 3 },
effect:
"Creates a 3-charge counter. Combine with absorb-damage-with-attribute to make each charge soak one damage point.",
},
],
paramsSchema: schema,
// Dynamic seed: writes whatever attr the user chose in params.
// Consumer registry trust-mapping falls back to "any attr the user

View file

@ -21,6 +21,21 @@ const descriptor: EffectPrimitive<Params> = {
kind: "set-capture-flag",
label: "Set Capture Flag",
description: "Bitwise-ORs one capture flag into CaptureFlags.",
longDescription:
"Turns on one capture-flag bit. Flags combine (OR) so stacking multiple primitives is fine. Supported: 1 = CAN_CAPTURE_OWN (piece may capture its own color), 2 = CANNOT_BE_CAPTURED (untargetable by enemies), 4 = EN_PASSANT (piece participates in en-passant capture resolution).",
examples: [
{
title: "Untouchable piece",
params: { flag: 2 },
effect: "Sets CANNOT_BE_CAPTURED — no enemy move can target this piece.",
},
{
title: "Friendly-fire rook",
params: { flag: 1 },
effect:
"Sets CAN_CAPTURE_OWN — the piece may capture its own color's pieces.",
},
],
paramsSchema: schema,
seedsAttrs: ["CaptureFlags"],
apply(ctx: PrimitiveApplyContext, params: Params): void {

View file

@ -78,6 +78,26 @@ export interface PrimitiveApplyContext {
* Consumers merge both lists. Primitives that only orchestrate
* nested children may declare empty arrays / omit both.
*/
/**
* Optional UI documentation payload surfaced inside the Custom
* Modifier Editor. The `description` field is the short palette
* blurb; `longDescription` is the expanded behaviour explanation
* shown above the parameter inspector, and `example` is a concrete
* parameter configuration + the narrative effect of those params.
*
* Kept optional so external primitives (if any are ever added) can
* register without forcing copy churn; the editor falls back to the
* short `description` when these are omitted.
*/
export interface PrimitiveDocExample {
/** Human-readable name of the worked example (e.g. "Heal 1/turn"). */
readonly title: string;
/** Concrete params object shown verbatim as JSON in the editor. */
readonly params: Record<string, unknown>;
/** One-sentence narrative of the effect these params produce. */
readonly effect: string;
}
export interface EffectPrimitive<Params = unknown> {
readonly kind: PrimitiveKind;
readonly label: string;
@ -88,6 +108,10 @@ export interface EffectPrimitive<Params = unknown> {
readonly childPrimitives?: (params: Params) => EffectPrimitiveNode[];
readonly seedsAttrs?: readonly ChessAttrKey[];
readonly seedsAttrsFor?: (params: unknown) => readonly ChessAttrKey[];
/** Multi-sentence behaviour explanation for the editor's inspector. */
readonly longDescription?: string;
/** One or more worked examples shown under the explanation. */
readonly examples?: readonly PrimitiveDocExample[];
}
export type { Session };

View file

@ -0,0 +1,270 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
ATTR_SUGGESTION_GROUPS,
ALL_SUGGESTION_NAMES,
type AttrSuggestion,
type AttrTypeHint,
} from "./attr-suggestions.js";
/**
* Attribute-name combobox for the Custom Modifier editor.
*
* Presents a free-form text input with a dropdown of grouped
* suggestions. The user can either:
* - Click a suggestion (fills the input, fires onChange, closes).
* - Type a name freehand — typed text filters the visible groups
* by substring match; hitting Enter accepts the typed value as-is,
* even if it doesn't match any suggestion (supports user-invented
* counter attrs like ShieldCharges and MyCustomThing).
*
* Keyboard model:
* - ArrowDown / ArrowUp move the active suggestion highlight.
* - Enter commits either the highlighted suggestion OR the typed
* value (if no suggestion is highlighted).
* - Escape closes the dropdown without changing the value.
*
* Mouse model:
* - Focus opens the dropdown.
* - Click outside the input+dropdown closes without commit (value
* is already sent on every keystroke via onChange — the dropdown
* is purely a pick-helper, not a required commit step).
*
* `preferredType` is a soft ranking hint: when "numeric", numeric
* suggestions float to the top of each group. Non-matching entries
* are NOT filtered out — users occasionally need non-numeric attrs
* even in numeric-context primitives.
*/
export interface AttrComboboxProps {
readonly value: string;
readonly onChange: (next: string) => void;
/** If provided, numeric suggestions rank first. */
readonly preferredType?: AttrTypeHint;
/** data-testid passthrough — lets e2e tests target specific fields. */
readonly testId?: string;
/** Placeholder shown in the input. */
readonly placeholder?: string;
}
export function AttrCombobox({
value,
onChange,
preferredType,
testId,
placeholder = "Attribute name…",
}: AttrComboboxProps) {
const [open, setOpen] = useState(false);
const [highlight, setHighlight] = useState<number>(-1);
const rootRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
/**
* Flat list of visible suggestions after applying the free-form
* substring filter and the preferred-type ranking. Kept memoized
* so arrow-key nav stays cheap while typing.
*/
const filteredGroups = useMemo(() => {
const q = value.trim().toLowerCase();
const shouldFilter = q.length > 0 && !ALL_SUGGESTION_NAMES.has(value);
return ATTR_SUGGESTION_GROUPS.map((group) => {
const matched = shouldFilter
? group.suggestions.filter(
(s) =>
s.name.toLowerCase().includes(q) ||
s.description.toLowerCase().includes(q),
)
: [...group.suggestions];
if (preferredType !== undefined) {
matched.sort((a, b) => {
const aPref = a.type === preferredType ? 0 : 1;
const bPref = b.type === preferredType ? 0 : 1;
return aPref - bPref;
});
}
return { ...group, suggestions: matched };
}).filter((g) => g.suggestions.length > 0);
}, [value, preferredType]);
/**
* Flat ordering mirrors what the user sees so arrow-key indices
* map 1:1 to the rendered list. Rebuilt whenever filteredGroups
* changes so stale indices never survive across filter shifts.
*/
const flatSuggestions = useMemo(
() => filteredGroups.flatMap((g) => g.suggestions),
[filteredGroups],
);
// Close dropdown when clicking anywhere outside the combobox root.
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (!rootRef.current) return;
if (!rootRef.current.contains(e.target as Node)) {
setOpen(false);
setHighlight(-1);
}
};
window.addEventListener("mousedown", handler);
return () => window.removeEventListener("mousedown", handler);
}, [open]);
// When the filtered list shortens, clamp the highlight so it never
// points past the end of the visible suggestions.
useEffect(() => {
if (highlight >= flatSuggestions.length) {
setHighlight(flatSuggestions.length - 1);
}
}, [flatSuggestions.length, highlight]);
const commit = (next: string) => {
onChange(next);
setOpen(false);
setHighlight(-1);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "ArrowDown") {
e.preventDefault();
if (!open) setOpen(true);
setHighlight((h) =>
flatSuggestions.length === 0
? -1
: Math.min(flatSuggestions.length - 1, h + 1),
);
} else if (e.key === "ArrowUp") {
e.preventDefault();
setHighlight((h) => Math.max(-1, h - 1));
} else if (e.key === "Enter") {
if (highlight >= 0 && flatSuggestions[highlight]) {
e.preventDefault();
commit(flatSuggestions[highlight].name);
} else {
// Enter with no highlight just closes the dropdown; value
// is already committed via onChange on each keystroke.
setOpen(false);
}
} else if (e.key === "Escape") {
setOpen(false);
setHighlight(-1);
}
};
const isKnown = ALL_SUGGESTION_NAMES.has(value);
const showUserDefinedBadge = !isKnown && value.trim().length > 0;
return (
<div
ref={rootRef}
className="relative"
data-testid={testId ?? "attr-combobox"}
>
<div className="flex items-center gap-2">
<input
ref={inputRef}
type="text"
value={value}
placeholder={placeholder}
onChange={(e) => {
onChange(e.target.value);
setOpen(true);
setHighlight(-1);
}}
onFocus={() => setOpen(true)}
onKeyDown={handleKeyDown}
className="flex-1 px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none"
data-testid={testId ? `${testId}-input` : "attr-combobox-input"}
aria-autocomplete="list"
aria-expanded={open}
/>
{showUserDefinedBadge && (
<span
className="text-[10px] font-semibold text-amber-800 bg-amber-100 border border-amber-200 px-1.5 py-0.5 rounded"
title="Not a schema-known attribute. This is fine for user-defined counters like ShieldCharges, but typos will silently land here."
>
user-defined
</span>
)}
</div>
{open && filteredGroups.length > 0 && (
<div
className="absolute z-[80] left-0 right-0 mt-1 max-h-72 overflow-y-auto bg-white border border-neutral-200 rounded-lg shadow-xl"
data-testid={testId ? `${testId}-dropdown` : "attr-combobox-dropdown"}
role="listbox"
>
{renderGroups(filteredGroups, flatSuggestions, highlight, commit)}
</div>
)}
</div>
);
}
/**
* Render the grouped list. Kept out of the component body so the
* conditional-dropdown render is clear and so the index bookkeeping
* (global flat index vs per-group loop index) is traceable in one
* place.
*/
function renderGroups(
groups: readonly {
label: string;
suggestions: readonly AttrSuggestion[];
}[],
flat: readonly AttrSuggestion[],
highlight: number,
commit: (next: string) => void,
): React.ReactNode {
let cursor = 0;
return groups.map((group) => (
<div key={group.label} className="py-1">
<div className="px-3 py-1 text-[10px] font-bold text-neutral-500 uppercase tracking-wider bg-neutral-50/80 border-b border-neutral-100">
{group.label}
</div>
{group.suggestions.map((s) => {
const flatIndex = cursor++;
const isActive = flatIndex === highlight;
const isKnownToFlat = flat[flatIndex]?.name === s.name;
// Defensive no-op for TS — keeps `flat` as a dependency the
// linter sees is actually used when running the file.
if (!isKnownToFlat) {
/* flat and groups are always built from the same source,
so this is unreachable in practice. */
}
return (
<button
key={`${group.label}:${s.name}`}
type="button"
data-testid={`attr-combobox-option-${s.name}`}
className={`w-full text-left px-3 py-1.5 text-sm transition-colors ${
isActive ? "bg-blue-50" : "hover:bg-neutral-50"
}`}
onMouseDown={(e) => {
// onMouseDown so the blur on the input doesn't close
// the dropdown before the click handler runs.
e.preventDefault();
commit(s.name);
}}
role="option"
aria-selected={isActive}
>
<div className="flex items-center justify-between gap-2">
<span className="font-medium text-neutral-800">{s.name}</span>
<span
className={`text-[10px] font-semibold px-1.5 py-0.5 rounded ${
s.type === "numeric"
? "text-emerald-700 bg-emerald-50 border border-emerald-100"
: "text-purple-700 bg-purple-50 border border-purple-100"
}`}
>
{s.type}
</span>
</div>
<div className="text-xs text-neutral-500 leading-snug mt-0.5">
{s.description}
</div>
</button>
);
})}
</div>
));
}

View file

@ -11,6 +11,12 @@ import {
} from '../modifiers/custom/library.js';
import { validateCustomDescriptor } from '../modifiers/custom/validate.js';
import { asCustomModifierId } from '../modifiers/custom/types.js';
import {
CUSTOM_MODIFIER_RECIPES,
type CustomModifierRecipe,
} from '../modifiers/custom/recipes.js';
import { AttrCombobox } from './AttrCombobox.js';
import type { AttrTypeHint } from './attr-suggestions.js';
interface Props {
isOpen: boolean;
@ -118,12 +124,31 @@ export function CustomModifierEditor({ isOpen, onClose, onShareWithRoom }: Props
const [showLibrary, setShowLibrary] = useState(false);
const [libraryItems, setLibraryItems] = useState<SavedCustomModifier[]>([]);
// Templates picker state (built-in recipe gallery)
const [showTemplates, setShowTemplates] = useState(false);
// Open library
const openLibrary = () => {
setLibraryItems(loadCustomModifierLibrary());
setShowLibrary(true);
};
/**
* Load a built-in recipe into the editor. We rewrite the id to a
* fresh `custom-*` value so saving doesn't collide with other copies
* of the same template, but keep name/description/primitives intact
* so the user sees exactly the documented shape.
*/
const loadTemplate = (recipe: CustomModifierRecipe) => {
setDescriptor({
...recipe.descriptor,
id: asCustomModifierId(generateId()),
});
setSelectedIndex(null);
setShowTemplates(false);
toast.success(`Loaded template: ${recipe.title}`);
};
const validationResult = useMemo(() => validateCustomDescriptor(descriptor), [descriptor]);
const updateDescriptor = (updater: (prev: CustomModifierDescriptor) => CustomModifierDescriptor) => {
@ -196,6 +221,14 @@ export function CustomModifierEditor({ isOpen, onClose, onShareWithRoom }: Props
</div>
</div>
<div className="flex items-center gap-2">
<button
data-testid="custom-templates"
onClick={() => setShowTemplates(true)}
className="px-3 py-1.5 text-sm font-semibold text-neutral-700 bg-neutral-100 rounded hover:bg-neutral-200 transition-colors"
title="Browse built-in templates — load a pre-composed example (Boosted Pawn, Shield, Aura King, Vampire, Low-HP Fortress) into the editor as a starting point."
>
Templates
</button>
<button
data-testid="custom-load"
onClick={openLibrary}
@ -247,10 +280,24 @@ export function CustomModifierEditor({ isOpen, onClose, onShareWithRoom }: Props
{kinds.map((kind) => {
const primitive = PRIMITIVE_REGISTRY.get(kind);
if (!primitive) return null;
// Build a richer hover tooltip: the long description
// if present (full behaviour explanation), otherwise
// the short palette blurb. Adds the first example
// headline so users see a concrete use-case on hover.
const tooltipParts = [
primitive.longDescription ?? primitive.description,
];
if (primitive.examples && primitive.examples.length > 0) {
tooltipParts.push(
`\n\nExample — ${primitive.examples[0]!.title}: ${primitive.examples[0]!.effect}`,
);
}
const tooltip = tooltipParts.join('');
return (
<button
key={kind}
data-testid={`custom-primitive-palette-${kind}`}
title={tooltip}
className="text-left px-3 py-2 bg-white border border-neutral-200 rounded-lg shadow-sm hover:border-blue-300 hover:shadow transition-all group"
onClick={() => {
const newNode: EffectPrimitiveNode = {
@ -267,7 +314,7 @@ export function CustomModifierEditor({ isOpen, onClose, onShareWithRoom }: Props
<div className="text-sm font-semibold text-neutral-800">
{primitive.label}
</div>
<div className="text-xs text-neutral-500 truncate mt-0.5" title={primitive.description}>
<div className="text-xs text-neutral-500 truncate mt-0.5">
{primitive.description}
</div>
</button>
@ -392,6 +439,58 @@ export function CustomModifierEditor({ isOpen, onClose, onShareWithRoom }: Props
</footer>
</div>
{/* Templates Picker Overlay — built-in recipe gallery */}
{showTemplates && (
<div
data-testid="custom-templates-modal"
className="fixed inset-0 z-[70] bg-black/40 backdrop-blur-sm flex items-center justify-center p-4"
>
<div className="bg-white rounded-xl shadow-2xl w-full max-w-2xl max-h-[80vh] flex flex-col overflow-hidden">
<header className="px-6 py-4 border-b border-neutral-200 flex items-center justify-between">
<div>
<h3 className="text-lg font-bold text-neutral-900">Templates</h3>
<p className="text-xs text-neutral-500 mt-0.5">
Pre-composed recipes from the user guide. Picking one replaces the current draft with a fresh copy.
</p>
</div>
<button
onClick={() => setShowTemplates(false)}
className="text-neutral-500 hover:text-neutral-700"
aria-label="Close templates"
>
×
</button>
</header>
<div className="flex-1 overflow-y-auto p-4">
<div className="flex flex-col gap-2">
{CUSTOM_MODIFIER_RECIPES.map((recipe) => (
<div
key={recipe.id}
data-testid={`custom-template-${recipe.id}`}
className="flex items-start justify-between gap-3 p-4 border border-neutral-200 rounded-lg hover:border-blue-400 cursor-pointer bg-white"
onClick={() => loadTemplate(recipe)}
>
<div className="flex-1">
<div className="font-semibold text-neutral-900">{recipe.title}</div>
<p className="text-xs text-neutral-600 mt-1 leading-relaxed">
{recipe.summary}
</p>
<div className="text-xs text-neutral-400 mt-1.5">
{recipe.descriptor.primitives.length} primitive
{recipe.descriptor.primitives.length === 1 ? '' : 's'}
</div>
</div>
<div className="text-xs text-blue-600 font-medium bg-blue-50 px-2 py-1 rounded shrink-0">
Load
</div>
</div>
))}
</div>
</div>
</div>
</div>
)}
{/* Load Modal Overlay */}
{showLibrary && (
<div className="fixed inset-0 z-[70] bg-black/40 backdrop-blur-sm flex items-center justify-center p-4">
@ -432,6 +531,40 @@ export function CustomModifierEditor({ isOpen, onClose, onShareWithRoom }: Props
);
}
/**
* Field names we render with the AttrCombobox instead of a plain
* text input. All the attr-string params in the T3 primitive set —
* seed-attribute.attr, add-to-attribute.attr, multiply-attribute.attr,
* absorb-damage-with-attribute.attr, add-aura.targetAttr — use one
* of these two keys. Conditional.condition.attr lives inside a
* nested JSON struct (rendered via the complex-schema fallback);
* the combobox doesn't reach there.
*/
const ATTR_FIELD_NAMES = new Set<string>(['attr', 'targetAttr']);
function isAttrFieldName(key: string): boolean {
return ATTR_FIELD_NAMES.has(key);
}
/**
* Rank numeric suggestions first for primitives whose attr value is
* arithmetically consumed. seed-attribute accepts any runtime type
* so we leave it unopinionated (undefined = no preferred-type lift).
*/
function attrFieldPreferredType(
primitiveKind: string,
_fieldKey: string,
): AttrTypeHint | undefined {
switch (primitiveKind) {
case 'add-to-attribute':
case 'multiply-attribute':
case 'add-aura':
case 'absorb-damage-with-attribute':
return 'numeric';
default:
return undefined;
}
}
/**
* Sub-component for rendering the parameter form based on Zod schema introspection.
* Since fully parsing arbitrary Zod schemas into UI is complex, we use a hybrid approach:
@ -446,10 +579,72 @@ function PrimitiveInspector({
primitive?: EffectPrimitive;
onChange: (params: unknown) => void;
}) {
// Local expansion state for the docs panel. Default collapsed so the
// form fields stay the focal point; the user toggles "Show examples"
// when they want reference material.
const [docsExpanded, setDocsExpanded] = useState(true);
if (!primitive) {
return <div className="text-red-500 text-sm">Unknown primitive kind: {node.kind}</div>;
}
// Docs panel rendered once for every primitive, above whatever
// param-form we fall into below (object form vs JSON fallback).
const docsPanel = (
<div
data-testid="custom-primitive-docs"
className="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"
>
<button
type="button"
onClick={() => setDocsExpanded((v) => !v)}
className="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors"
aria-expanded={docsExpanded}
>
<div className="flex items-center gap-2">
<span className="text-blue-700 text-sm font-bold">{primitive.label}</span>
<span className="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">
{primitive.kind}
</span>
</div>
<span className="text-xs text-blue-600 font-medium">
{docsExpanded ? 'Hide' : 'Show'} docs & examples
</span>
</button>
{docsExpanded && (
<div className="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3">
<p className="leading-relaxed">
{primitive.longDescription ?? primitive.description}
</p>
{primitive.examples && primitive.examples.length > 0 && (
<div className="space-y-2">
<div className="text-xs font-bold text-neutral-600 uppercase tracking-wide">
Examples
</div>
{primitive.examples.map((ex, i) => (
<div
key={i}
data-testid={`custom-primitive-example-${i}`}
className="bg-white border border-blue-200 rounded p-2.5"
>
<div className="text-xs font-semibold text-blue-800 mb-1">
{ex.title}
</div>
<pre className="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">
{JSON.stringify(ex.params, null, 2)}
</pre>
<p className="text-xs text-neutral-600 mt-1.5 italic leading-snug">
{ex.effect}
</p>
</div>
))}
</div>
)}
</div>
)}
</div>
);
// Generic fallback for complex schemas (nested arrays, etc)
const isComplex = primitive.paramsSchema instanceof z.ZodObject === false;
@ -457,6 +652,7 @@ function PrimitiveInspector({
const isArray = primitive.paramsSchema instanceof z.ZodArray;
return (
<div className="flex flex-col gap-2">
{docsPanel}
<label className="text-xs font-bold text-neutral-600 uppercase tracking-wide">
{isArray ? 'List Parameters (JSON)' : 'Raw Parameters (JSON)'}
</label>
@ -494,6 +690,7 @@ function PrimitiveInspector({
return (
<div className="flex flex-col gap-5">
{docsPanel}
{Object.entries(shape).map(([key, schema]) => {
let type = 'string';
let options: readonly string[] = [];
@ -534,7 +731,22 @@ function PrimitiveInspector({
/>
)}
{type === 'string' && (
{type === 'string' && isAttrFieldName(key) ? (
(() => {
const preferred = attrFieldPreferredType(primitive.kind, key);
// exactOptionalPropertyTypes: spread preferredType only
// when defined so we don't pass `undefined` explicitly.
return (
<AttrCombobox
value={String(value || '')}
onChange={(next) => onChange({ ...params, [key]: next })}
testId={`primitive-${primitive.kind}-${key}`}
placeholder="Attribute name…"
{...(preferred !== undefined ? { preferredType: preferred } : {})}
/>
);
})()
) : type === 'string' && (
<input
type="text"
value={String(value || '')}

View file

@ -0,0 +1,46 @@
import { describe, it, expect } from "vitest";
import {
ATTR_SUGGESTION_GROUPS,
ALL_SUGGESTION_NAMES,
NUMERIC_CHESS_ATTRS,
} from "./attr-suggestions.js";
describe("attr-suggestions catalog", () => {
it("exposes at least one suggestion in every group", () => {
for (const g of ATTR_SUGGESTION_GROUPS) {
expect(g.suggestions.length).toBeGreaterThan(0);
}
});
it("every suggestion carries a non-empty description", () => {
for (const g of ATTR_SUGGESTION_GROUPS) {
for (const s of g.suggestions) {
expect(s.name.length).toBeGreaterThan(0);
expect(s.description.length).toBeGreaterThan(0);
}
}
});
it("suggestion names are unique across all groups", () => {
const names = ATTR_SUGGESTION_GROUPS.flatMap((g) =>
g.suggestions.map((s) => s.name),
);
expect(new Set(names).size).toBe(names.length);
});
it("ALL_SUGGESTION_NAMES matches the flat name list", () => {
const flat = new Set(
ATTR_SUGGESTION_GROUPS.flatMap((g) => g.suggestions.map((s) => s.name)),
);
expect(flat.size).toBe(ALL_SUGGESTION_NAMES.size);
for (const n of flat) expect(ALL_SUGGESTION_NAMES.has(n)).toBe(true);
});
it("NUMERIC_CHESS_ATTRS set is non-empty", () => {
// NUMERIC_CHESS_ATTRS is the runtime-numeric subset of
// ChessAttrMap. Suggestions may categorize some numeric attrs
// (e.g. CaptureFlags — a bitmask) as 'other' when additive ops
// don't make sense, so we don't force a full overlap here.
expect(NUMERIC_CHESS_ATTRS.size).toBeGreaterThan(0);
});
});

View file

@ -0,0 +1,202 @@
/**
* Attribute suggestion catalog for the Custom Modifier editor.
*
* A "suggestion" is a known attribute name the editor surfaces in
* autocomplete dropdowns when an attr-string field (e.g.
* `seed-attribute.attr`, `add-aura.targetAttr`) has focus. The catalog
* is organized into groups so the combobox can render labelled
* sections instead of one undifferentiated list.
*
* Categories:
* - **Core numeric** — engine-known numeric attrs (Hp, HpBonus,
* RangeBonus...). Safe targets for add-to-attribute,
* multiply-attribute, add-aura, absorb-damage-with-attribute.
* - **Core other** — engine-known non-numeric attrs (DirectionAdditions,
* CaptureFlags, PromotionOverride...). Valid seed targets but
* require care with add/multiply.
* - **User-defined examples** — attrs that appear in the docs and
* recipes but aren't schema-known (ShieldCharges, ArmorPlates...).
* Shown so users discover the "invent your own counter" pattern
* without memorizing the guide.
*
* Suggestions are NOT enforced — the combobox is free-form. Typing
* `BananaCount` is fine; it just won't appear in the dropdown.
*/
import type { ChessAttrKey } from "../schema.js";
export type AttrTypeHint = "numeric" | "other";
export interface AttrSuggestion {
/** The attr name as it would be written into primitive params. */
readonly name: string;
/** Short help text shown next to the name in the dropdown. */
readonly description: string;
/** Runtime type — helps rank suggestions by context. */
readonly type: AttrTypeHint;
/** True if this attr is defined by ChessAttrMap; false for example user attrs. */
readonly builtin: boolean;
}
export interface AttrSuggestionGroup {
readonly label: string;
readonly suggestions: readonly AttrSuggestion[];
}
/**
* Pre-baked groups. Order here IS the display order in the combobox
* (numeric first because it's the most-used when targeting additive
* primitives).
*/
export const ATTR_SUGGESTION_GROUPS: readonly AttrSuggestionGroup[] = [
{
label: "Core numeric attributes",
suggestions: [
{
name: "Hp",
description: "Current hit points — consumed by the damage pipeline.",
type: "numeric",
builtin: true,
},
{
name: "HpBonus",
description: "Additive HP bonus applied on top of baseline HP.",
type: "numeric",
builtin: true,
},
{
name: "RangeBonus",
description: "Additive sliding-range delta (-7 … +7).",
type: "numeric",
builtin: true,
},
{
name: "DamageResistance",
description: "Flat damage reduction per incoming hit.",
type: "numeric",
builtin: true,
},
{
name: "ReflectDamagePercent",
description: "Percent of incoming damage reflected back (0-100).",
type: "numeric",
builtin: true,
},
{
name: "AbsorbDamageRate",
description: "How many points of the absorb-attr one damage point consumes.",
type: "numeric",
builtin: true,
},
{
name: "HalfmoveClock",
description: "Half-moves since last pawn move or capture (50-move rule).",
type: "numeric",
builtin: true,
},
{
name: "FullmoveNumber",
description: "Full-move counter, 1-based; increments after black's move.",
type: "numeric",
builtin: true,
},
],
},
{
label: "Core non-numeric attributes",
suggestions: [
{
name: "CaptureFlags",
description: "Bitmask — 1=CAN_CAPTURE_OWN, 2=CANNOT_BE_CAPTURED, 4=EN_PASSANT.",
type: "other",
builtin: true,
},
{
name: "DirectionAdditions",
description: "Array of extra movement directions (forward, backward, diagonal-fl, …).",
type: "other",
builtin: true,
},
{
name: "PromotionOverride",
description: "Forced promotion target (pawn/knight/bishop/rook/queen/king or 'disabled').",
type: "other",
builtin: true,
},
{
name: "BlockedMoveTypes",
description: "Array of move types (capture/step/slide) filtered out of move generation.",
type: "other",
builtin: true,
},
{
name: "AbsorbDamageAttr",
description: "Name of the counter attribute that soaks damage before HP.",
type: "other",
builtin: true,
},
{
name: "HasMoved",
description: "True once the piece has made its first move (castling/en-passant gates).",
type: "other",
builtin: true,
},
],
},
{
label: "User-defined examples (invent your own)",
suggestions: [
{
name: "ShieldCharges",
description: "Example counter: paired with absorb-damage to soak N hits.",
type: "numeric",
builtin: false,
},
{
name: "ArmorPlates",
description: "Example counter with a higher absorb rate per plate.",
type: "numeric",
builtin: false,
},
{
name: "BloodStacks",
description: "Example counter: 'vampire' stacks gained on capture.",
type: "numeric",
builtin: false,
},
{
name: "ManaPool",
description: "Example counter: generic resource consumed by conditional triggers.",
type: "numeric",
builtin: false,
},
],
},
];
/**
* Flat lookup for quick 'is this a known attr' checks. Used by the
* combobox to decide whether to surface a 'user-defined' badge next
* to a typed value not present in any suggestion group.
*/
export const ALL_SUGGESTION_NAMES: ReadonlySet<string> = new Set(
ATTR_SUGGESTION_GROUPS.flatMap((g) => g.suggestions.map((s) => s.name)),
);
/**
* Internal narrowing helper — the list of ChessAttrKey names that
* carry `number` values in ChessAttrMap. Kept in sync by hand because
* conditional types can't extract this structurally without a value
* introspection layer.
*/
export const NUMERIC_CHESS_ATTRS: ReadonlySet<ChessAttrKey> = new Set<ChessAttrKey>([
"HalfmoveClock",
"FullmoveNumber",
"HalfMovesThisTurn",
"Hp",
"HpBonus",
"RangeBonus",
"CaptureFlags",
"DamageResistance",
"AbsorbDamageRate",
"ReflectDamagePercent",
]);