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:
parent
d6bc1ca2cc
commit
934db775f9
25 changed files with 1354 additions and 6 deletions
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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) ──────────
|
||||
|
|
|
|||
63
packages/chess/src/modifiers/custom/recipes.test.ts
Normal file
63
packages/chess/src/modifiers/custom/recipes.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
157
packages/chess/src/modifiers/custom/recipes.ts
Normal file
157
packages/chess/src/modifiers/custom/recipes.ts
Normal 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 },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
),
|
||||
},
|
||||
];
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
34
packages/chess/src/modifiers/primitives/docs.test.ts
Normal file
34
packages/chess/src/modifiers/primitives/docs.test.ts
Normal 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");
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
270
packages/chess/src/ui/AttrCombobox.tsx
Normal file
270
packages/chess/src/ui/AttrCombobox.tsx
Normal 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>
|
||||
));
|
||||
}
|
||||
|
|
@ -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 || '')}
|
||||
|
|
|
|||
46
packages/chess/src/ui/attr-suggestions.test.ts
Normal file
46
packages/chess/src/ui/attr-suggestions.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
202
packages/chess/src/ui/attr-suggestions.ts
Normal file
202
packages/chess/src/ui/attr-suggestions.ts
Normal 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",
|
||||
]);
|
||||
Loading…
Add table
Add a link
Reference in a new issue