From 934db775f98e961e125e92943641e2799479527b Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Tue, 21 Apr 2026 12:50:14 -0600 Subject: [PATCH] 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. --- docs/user/custom-modifiers.md | 41 ++- packages/chess/e2e/custom-modifiers.spec.ts | 72 +++++ .../src/modifiers/custom/recipes.test.ts | 63 ++++ .../chess/src/modifiers/custom/recipes.ts | 157 ++++++++++ .../absorb-damage-with-attribute.ts | 16 ++ .../src/modifiers/primitives/add-aura.ts | 16 ++ .../src/modifiers/primitives/add-direction.ts | 17 ++ .../modifiers/primitives/add-to-attribute.ts | 15 + .../modifiers/primitives/block-move-type.ts | 16 ++ .../src/modifiers/primitives/conditional.ts | 26 ++ .../src/modifiers/primitives/docs.test.ts | 34 +++ .../primitives/modify-movement-range.ts | 14 + .../primitives/multiply-attribute.ts | 15 + .../src/modifiers/primitives/on-capture.ts | 14 + .../src/modifiers/primitives/on-damaged.ts | 14 + .../src/modifiers/primitives/on-turn-start.ts | 14 + .../primitives/override-promotion.ts | 14 + .../modifiers/primitives/reflect-damage.ts | 14 + .../modifiers/primitives/seed-attribute.ts | 15 + .../modifiers/primitives/set-capture-flag.ts | 15 + .../chess/src/modifiers/primitives/types.ts | 24 ++ packages/chess/src/ui/AttrCombobox.tsx | 270 ++++++++++++++++++ .../chess/src/ui/CustomModifierEditor.tsx | 216 +++++++++++++- .../chess/src/ui/attr-suggestions.test.ts | 46 +++ packages/chess/src/ui/attr-suggestions.ts | 202 +++++++++++++ 25 files changed, 1354 insertions(+), 6 deletions(-) create mode 100644 packages/chess/src/modifiers/custom/recipes.test.ts create mode 100644 packages/chess/src/modifiers/custom/recipes.ts create mode 100644 packages/chess/src/modifiers/primitives/docs.test.ts create mode 100644 packages/chess/src/ui/AttrCombobox.tsx create mode 100644 packages/chess/src/ui/attr-suggestions.test.ts create mode 100644 packages/chess/src/ui/attr-suggestions.ts diff --git a/docs/user/custom-modifiers.md b/docs/user/custom-modifiers.md index 8be8b27..162f6a1 100644 --- a/docs/user/custom-modifiers.md +++ b/docs/user/custom-modifiers.md @@ -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. --- diff --git a/packages/chess/e2e/custom-modifiers.spec.ts b/packages/chess/e2e/custom-modifiers.spec.ts index 7853754..6507084 100644 --- a/packages/chess/e2e/custom-modifiers.spec.ts +++ b/packages/chess/e2e/custom-modifiers.spec.ts @@ -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) ────────── diff --git a/packages/chess/src/modifiers/custom/recipes.test.ts b/packages/chess/src/modifiers/custom/recipes.test.ts new file mode 100644 index 0000000..698c337 --- /dev/null +++ b/packages/chess/src/modifiers/custom/recipes.test.ts @@ -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[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); + } + }); +}); diff --git a/packages/chess/src/modifiers/custom/recipes.ts b/packages/chess/src/modifiers/custom/recipes.ts new file mode 100644 index 0000000..40c8b50 --- /dev/null +++ b/packages/chess/src/modifiers/custom/recipes.ts @@ -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 }, + }, + ], + }, + }, + ], + ), + }, +]; diff --git a/packages/chess/src/modifiers/primitives/absorb-damage-with-attribute.ts b/packages/chess/src/modifiers/primitives/absorb-damage-with-attribute.ts index 0c6e4dc..df53eed 100644 --- a/packages/chess/src/modifiers/primitives/absorb-damage-with-attribute.ts +++ b/packages/chess/src/modifiers/primitives/absorb-damage-with-attribute.ts @@ -15,6 +15,22 @@ const descriptor: EffectPrimitive = { 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 diff --git a/packages/chess/src/modifiers/primitives/add-aura.ts b/packages/chess/src/modifiers/primitives/add-aura.ts index c8d3385..a6c52e7 100644 --- a/packages/chess/src/modifiers/primitives/add-aura.ts +++ b/packages/chess/src/modifiers/primitives/add-aura.ts @@ -15,6 +15,22 @@ const descriptor: EffectPrimitive = { 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 diff --git a/packages/chess/src/modifiers/primitives/add-direction.ts b/packages/chess/src/modifiers/primitives/add-direction.ts index 06a3c5a..780e51b 100644 --- a/packages/chess/src/modifiers/primitives/add-direction.ts +++ b/packages/chess/src/modifiers/primitives/add-direction.ts @@ -42,6 +42,23 @@ const descriptor: EffectPrimitive = { 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 { diff --git a/packages/chess/src/modifiers/primitives/add-to-attribute.ts b/packages/chess/src/modifiers/primitives/add-to-attribute.ts index 7c7ec59..9d8de4f 100644 --- a/packages/chess/src/modifiers/primitives/add-to-attribute.ts +++ b/packages/chess/src/modifiers/primitives/add-to-attribute.ts @@ -13,6 +13,21 @@ const descriptor: EffectPrimitive = { 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 | undefined; diff --git a/packages/chess/src/modifiers/primitives/block-move-type.ts b/packages/chess/src/modifiers/primitives/block-move-type.ts index ff68ee0..ae0d3b8 100644 --- a/packages/chess/src/modifiers/primitives/block-move-type.ts +++ b/packages/chess/src/modifiers/primitives/block-move-type.ts @@ -14,6 +14,22 @@ const descriptor: EffectPrimitive = { 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 { diff --git a/packages/chess/src/modifiers/primitives/conditional.ts b/packages/chess/src/modifiers/primitives/conditional.ts index 8b50ebb..948a9c4 100644 --- a/packages/chess/src/modifiers/primitives/conditional.ts +++ b/packages/chess/src/modifiers/primitives/conditional.ts @@ -54,6 +54,32 @@ const descriptor: EffectPrimitive = { 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 { diff --git a/packages/chess/src/modifiers/primitives/docs.test.ts b/packages/chess/src/modifiers/primitives/docs.test.ts new file mode 100644 index 0000000..097c994 --- /dev/null +++ b/packages/chess/src/modifiers/primitives/docs.test.ts @@ -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"); + } + }); + } +}); diff --git a/packages/chess/src/modifiers/primitives/modify-movement-range.ts b/packages/chess/src/modifiers/primitives/modify-movement-range.ts index 8f7745c..e0b7d47 100644 --- a/packages/chess/src/modifiers/primitives/modify-movement-range.ts +++ b/packages/chess/src/modifiers/primitives/modify-movement-range.ts @@ -12,6 +12,20 @@ const descriptor: EffectPrimitive = { 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 { diff --git a/packages/chess/src/modifiers/primitives/multiply-attribute.ts b/packages/chess/src/modifiers/primitives/multiply-attribute.ts index 16511b5..3fabd45 100644 --- a/packages/chess/src/modifiers/primitives/multiply-attribute.ts +++ b/packages/chess/src/modifiers/primitives/multiply-attribute.ts @@ -13,6 +13,21 @@ const descriptor: EffectPrimitive = { 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 | undefined; diff --git a/packages/chess/src/modifiers/primitives/on-capture.ts b/packages/chess/src/modifiers/primitives/on-capture.ts index 212b9e8..c75d652 100644 --- a/packages/chess/src/modifiers/primitives/on-capture.ts +++ b/packages/chess/src/modifiers/primitives/on-capture.ts @@ -26,6 +26,20 @@ const descriptor: EffectPrimitive = { 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 { diff --git a/packages/chess/src/modifiers/primitives/on-damaged.ts b/packages/chess/src/modifiers/primitives/on-damaged.ts index 9f11774..97f5a04 100644 --- a/packages/chess/src/modifiers/primitives/on-damaged.ts +++ b/packages/chess/src/modifiers/primitives/on-damaged.ts @@ -26,6 +26,20 @@ const descriptor: EffectPrimitive = { 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 { diff --git a/packages/chess/src/modifiers/primitives/on-turn-start.ts b/packages/chess/src/modifiers/primitives/on-turn-start.ts index f2826cb..645fbd3 100644 --- a/packages/chess/src/modifiers/primitives/on-turn-start.ts +++ b/packages/chess/src/modifiers/primitives/on-turn-start.ts @@ -27,6 +27,20 @@ const descriptor: EffectPrimitive = { 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 { diff --git a/packages/chess/src/modifiers/primitives/override-promotion.ts b/packages/chess/src/modifiers/primitives/override-promotion.ts index 7f02f9c..7adbab2 100644 --- a/packages/chess/src/modifiers/primitives/override-promotion.ts +++ b/packages/chess/src/modifiers/primitives/override-promotion.ts @@ -13,6 +13,20 @@ const descriptor: EffectPrimitive = { 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 { diff --git a/packages/chess/src/modifiers/primitives/reflect-damage.ts b/packages/chess/src/modifiers/primitives/reflect-damage.ts index eb685ef..ab11963 100644 --- a/packages/chess/src/modifiers/primitives/reflect-damage.ts +++ b/packages/chess/src/modifiers/primitives/reflect-damage.ts @@ -12,6 +12,20 @@ const descriptor: EffectPrimitive = { 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 { diff --git a/packages/chess/src/modifiers/primitives/seed-attribute.ts b/packages/chess/src/modifiers/primitives/seed-attribute.ts index 9aa6ee4..328bf01 100644 --- a/packages/chess/src/modifiers/primitives/seed-attribute.ts +++ b/packages/chess/src/modifiers/primitives/seed-attribute.ts @@ -38,6 +38,21 @@ const descriptor: EffectPrimitive = { 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 diff --git a/packages/chess/src/modifiers/primitives/set-capture-flag.ts b/packages/chess/src/modifiers/primitives/set-capture-flag.ts index 10d5886..6304696 100644 --- a/packages/chess/src/modifiers/primitives/set-capture-flag.ts +++ b/packages/chess/src/modifiers/primitives/set-capture-flag.ts @@ -21,6 +21,21 @@ const descriptor: EffectPrimitive = { 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 { diff --git a/packages/chess/src/modifiers/primitives/types.ts b/packages/chess/src/modifiers/primitives/types.ts index 4c016de..df225e3 100644 --- a/packages/chess/src/modifiers/primitives/types.ts +++ b/packages/chess/src/modifiers/primitives/types.ts @@ -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; + /** One-sentence narrative of the effect these params produce. */ + readonly effect: string; +} + export interface EffectPrimitive { readonly kind: PrimitiveKind; readonly label: string; @@ -88,6 +108,10 @@ export interface EffectPrimitive { 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 }; diff --git a/packages/chess/src/ui/AttrCombobox.tsx b/packages/chess/src/ui/AttrCombobox.tsx new file mode 100644 index 0000000..f17571e --- /dev/null +++ b/packages/chess/src/ui/AttrCombobox.tsx @@ -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(-1); + const rootRef = useRef(null); + const inputRef = useRef(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) => { + 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 ( +
+
+ { + 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 && ( + + user-defined + + )} +
+ + {open && filteredGroups.length > 0 && ( +
+ {renderGroups(filteredGroups, flatSuggestions, highlight, commit)} +
+ )} +
+ ); +} + +/** + * 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) => ( +
+
+ {group.label} +
+ {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 ( + + ); + })} +
+ )); +} diff --git a/packages/chess/src/ui/CustomModifierEditor.tsx b/packages/chess/src/ui/CustomModifierEditor.tsx index 0a72b18..dc1380e 100644 --- a/packages/chess/src/ui/CustomModifierEditor.tsx +++ b/packages/chess/src/ui/CustomModifierEditor.tsx @@ -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([]); + // 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
+ @@ -392,6 +439,58 @@ export function CustomModifierEditor({ isOpen, onClose, onShareWithRoom }: Props
+ {/* Templates Picker Overlay — built-in recipe gallery */} + {showTemplates && ( +
+
+
+
+

Templates

+

+ Pre-composed recipes from the user guide. Picking one replaces the current draft with a fresh copy. +

+
+ +
+
+
+ {CUSTOM_MODIFIER_RECIPES.map((recipe) => ( +
loadTemplate(recipe)} + > +
+
{recipe.title}
+

+ {recipe.summary} +

+
+ {recipe.descriptor.primitives.length} primitive + {recipe.descriptor.primitives.length === 1 ? '' : 's'} +
+
+
+ Load +
+
+ ))} +
+
+
+
+ )} + {/* Load Modal Overlay */} {showLibrary && (
@@ -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(['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
Unknown primitive kind: {node.kind}
; } + // Docs panel rendered once for every primitive, above whatever + // param-form we fall into below (object form vs JSON fallback). + const docsPanel = ( +
+ + {docsExpanded && ( +
+

+ {primitive.longDescription ?? primitive.description} +

+ {primitive.examples && primitive.examples.length > 0 && ( +
+
+ Examples +
+ {primitive.examples.map((ex, i) => ( +
+
+ {ex.title} +
+
+                    {JSON.stringify(ex.params, null, 2)}
+                  
+

+ {ex.effect} +

+
+ ))} +
+ )} +
+ )} +
+ ); + // 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 (
+ {docsPanel} @@ -494,6 +690,7 @@ function PrimitiveInspector({ return (
+ {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 ( + onChange({ ...params, [key]: next })} + testId={`primitive-${primitive.kind}-${key}`} + placeholder="Attribute name…" + {...(preferred !== undefined ? { preferredType: preferred } : {})} + /> + ); + })() + ) : type === 'string' && ( { + 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); + }); +}); diff --git a/packages/chess/src/ui/attr-suggestions.ts b/packages/chess/src/ui/attr-suggestions.ts new file mode 100644 index 0000000..ac2808a --- /dev/null +++ b/packages/chess/src/ui/attr-suggestions.ts @@ -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 = 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 = new Set([ + "HalfmoveClock", + "FullmoveNumber", + "HalfMovesThisTurn", + "Hp", + "HpBonus", + "RangeBonus", + "CaptureFlags", + "DamageResistance", + "AbsorbDamageRate", + "ReflectDamagePercent", +]);