From 8605a225309a5151775d40539e47bb2b8a118c9b Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Tue, 21 Apr 2026 13:07:27 -0600 Subject: [PATCH] feat(ui): AttrCombobox declare vs. consume semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Attr-string fields in the Custom Modifier Editor now differentiate between declare-sites (seed-attribute.attr) and consume-sites (add-to-attribute.attr, multiply-attribute.attr, add-aura.targetAttr, absorb-damage-with-attribute.attr): - Consume-sites hide the illustrative 'User-defined examples' group (ArmorPlates/BloodStacks/ManaPool) because those names are fiction unless something seeds them. - Consume-sites surface a new 'Seeded in this descriptor' group populated from seed-attribute primitives elsewhere in the current tree (including inside trigger children via childPrimitives). - Badge states: emerald 'seeded' when the typed value matches an in-tree seed; red 'not seeded' when it's a non-schema name with no backing seed; amber 'user-defined' only in declare-mode for off-catalog names. Declare-mode behaviour is unchanged — inventing ShieldCharges there still works without warnings. --- docs/user/custom-modifiers.md | 23 ++- packages/chess/e2e/custom-modifiers.spec.ts | 83 ++++++++ packages/chess/src/ui/AttrCombobox.tsx | 192 ++++++++++++++++-- .../chess/src/ui/CustomModifierEditor.tsx | 66 ++++++ packages/chess/src/ui/attr-suggestions.ts | 10 + 5 files changed, 349 insertions(+), 25 deletions(-) diff --git a/docs/user/custom-modifiers.md b/docs/user/custom-modifiers.md index 162f6a1..0fd9c94 100644 --- a/docs/user/custom-modifiers.md +++ b/docs/user/custom-modifiers.md @@ -55,9 +55,26 @@ suggestions: 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. +even if it isn't in the list. Numeric-context primitives (add, multiply, +aura, absorb) rank numeric suggestions first. + +**Declare vs. consume sites.** The combobox distinguishes between +primitives that *declare* a new attribute and primitives that *consume* an +existing one: + +- **`seed-attribute.attr`** is a **declare-site** — any name is fine, + including inventing brand new counters. The illustrative "User-defined + examples" group is surfaced for inspiration. Typed values outside both + the schema and the examples catalog carry an amber `user-defined` badge. +- **`add-to-attribute.attr`**, **`multiply-attribute.attr`**, + **`absorb-damage-with-attribute.attr`**, and **`add-aura.targetAttr`** + are **consume-sites** — they read an attribute that must already exist. + The illustrative examples group is **hidden** because those names are + fiction until something seeds them. Instead, a "Seeded in this + descriptor" group surfaces attrs that `seed-attribute` primitives + elsewhere in the current tree actually declare. Typed names that + aren't built-in and aren't seeded carry a red `not seeded` warning; + names that are seeded carry an emerald `seeded` confirmation. --- diff --git a/packages/chess/e2e/custom-modifiers.spec.ts b/packages/chess/e2e/custom-modifiers.spec.ts index 6507084..e344c8c 100644 --- a/packages/chess/e2e/custom-modifiers.spec.ts +++ b/packages/chess/e2e/custom-modifiers.spec.ts @@ -1368,11 +1368,94 @@ test.describe('T29 — Custom modifier DSL e2e', () => { await page.getByTestId('attr-combobox-option-Hp').first().click(); await expect(input).toHaveValue('Hp'); + // seed-attribute is a declare-site, so the illustrative examples + // (ArmorPlates, BloodStacks...) SHOULD be present. + await input.click(); + await expect( + page.getByTestId('attr-combobox-option-ArmorPlates'), + ).toBeVisible(); + // Type a user-defined attr name — badge appears, dropdown filters down. await input.fill('MyStacks'); await expect(combobox).toContainText('user-defined'); }); + test('AttrCombobox consume-mode (add-aura.targetAttr) hides user-defined examples and warns on unseeded names', async ({ + page, + }) => { + await freshLobby(page); + await openProfileEditor(page); + await openCustomModifierEditor(page); + + // Add an add-aura primitive — its targetAttr field is a consume-site. + await page.getByTestId('custom-primitive-palette-add-aura').click(); + await page.getByTestId('custom-primitive-node-0').click(); + + const input = page.getByTestId('primitive-add-aura-targetAttr-input'); + await input.click(); + const dropdown = page.getByTestId('primitive-add-aura-targetAttr-dropdown'); + await expect(dropdown).toBeVisible(); + + // Illustrative "User-defined examples" group should NOT appear in + // consume-mode — those names (ArmorPlates, BloodStacks, ManaPool) + // are fiction unless a seed-attribute declares them. + await expect(dropdown).not.toContainText('User-defined examples'); + await expect( + page.getByTestId('attr-combobox-option-ArmorPlates'), + ).toHaveCount(0); + + // Typing an unseeded attr raises the red "not seeded" badge. + await input.fill('MyGhostAttr'); + const badge = page.getByTestId('primitive-add-aura-targetAttr-badge'); + await expect(badge).toHaveText('not seeded'); + }); + + test('AttrCombobox consume-mode surfaces attrs seeded by other primitives in the same descriptor', async ({ + page, + }) => { + await freshLobby(page); + await openProfileEditor(page); + await openCustomModifierEditor(page); + + // 1. Add a seed-attribute primitive that declares ShieldCharges=3. + await page.getByTestId('custom-primitive-palette-seed-attribute').click(); + await page.getByTestId('custom-primitive-node-0').click(); + const seedAttrInput = page.getByTestId('primitive-seed-attribute-attr-input'); + await seedAttrInput.fill('ShieldCharges'); + // Blur the input so the dropdown closes — the open dropdown is + // absolutely positioned and would intercept the next palette click. + await seedAttrInput.blur(); + + // 2. Add an absorb-damage-with-attribute primitive. Its attr field + // is a consume-site and should now offer ShieldCharges under + // a "Seeded in this descriptor" group. + await page + .getByTestId('custom-primitive-palette-absorb-damage-with-attribute') + .click(); + await page.getByTestId('custom-primitive-node-1').click(); + + const absorbInput = page.getByTestId( + 'primitive-absorb-damage-with-attribute-attr-input', + ); + await absorbInput.click(); + const dropdown = page.getByTestId( + 'primitive-absorb-damage-with-attribute-attr-dropdown', + ); + await expect(dropdown).toBeVisible(); + await expect(dropdown).toContainText('Seeded in this descriptor'); + await expect( + page.getByTestId('attr-combobox-option-ShieldCharges'), + ).toBeVisible(); + + // Picking it commits the value and badge shows emerald "seeded". + await page.getByTestId('attr-combobox-option-ShieldCharges').click(); + await expect(absorbInput).toHaveValue('ShieldCharges'); + const absorbBadge = page.getByTestId( + 'primitive-absorb-damage-with-attribute-attr-badge', + ); + await expect(absorbBadge).toHaveText('seeded'); + }); + test('Templates picker loads a recipe into the editor', async ({ page }) => { await freshLobby(page); await openProfileEditor(page); diff --git a/packages/chess/src/ui/AttrCombobox.tsx b/packages/chess/src/ui/AttrCombobox.tsx index f17571e..dcdd204 100644 --- a/packages/chess/src/ui/AttrCombobox.tsx +++ b/packages/chess/src/ui/AttrCombobox.tsx @@ -2,10 +2,29 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { ATTR_SUGGESTION_GROUPS, ALL_SUGGESTION_NAMES, + USER_DEFINED_EXAMPLES_GROUP_LABEL, + CORE_NUMERIC_GROUP_LABEL, + CORE_OTHER_GROUP_LABEL, type AttrSuggestion, type AttrTypeHint, } from "./attr-suggestions.js"; +/** + * Names that live in one of the "Core" groups (numeric or non-numeric). + * Used by consume-mode to filter the prepended "Seeded in this + * descriptor" list so we don't surface Hp twice when something seeds + * it in-tree. Illustrative user-defined example names (ShieldCharges, + * ArmorPlates, …) are intentionally EXCLUDED here so the "Seeded" + * group still surfaces them when they're actually declared. + */ +const CORE_SUGGESTION_NAMES: ReadonlySet = new Set( + ATTR_SUGGESTION_GROUPS.filter( + (g) => + g.label === CORE_NUMERIC_GROUP_LABEL || + g.label === CORE_OTHER_GROUP_LABEL, + ).flatMap((g) => g.suggestions.map((s) => s.name)), +); + /** * Attribute-name combobox for the Custom Modifier editor. * @@ -34,11 +53,42 @@ import { * are NOT filtered out — users occasionally need non-numeric attrs * even in numeric-context primitives. */ +/** + * Semantics of the attr-string field the combobox is editing. + * + * - `declare` — the primitive is SEEDING this attr (seed-attribute). + * Any name is fine, including user-invented ones; the illustrative + * examples group ("ShieldCharges", "ArmorPlates"…) is surfaced to + * advertise the "invent your own counter" pattern. + * - `consume` — the primitive is READING an attr that must already + * exist on the piece (add-to-attribute, multiply-attribute, + * absorb-damage-with-attribute, add-aura.targetAttr). Illustrative + * examples are hidden because they'd be no-ops without a matching + * seed. Instead, a "Seeded in this descriptor" group is surfaced + * showing attrs that OTHER primitives in the current tree declare + * via their params. Free-form typing is still allowed so advanced + * users can reference engine attrs not enumerated here. + */ +export type AttrComboboxMode = "declare" | "consume"; + export interface AttrComboboxProps { readonly value: string; readonly onChange: (next: string) => void; /** If provided, numeric suggestions rank first. */ readonly preferredType?: AttrTypeHint; + /** + * Field semantics — see {@link AttrComboboxMode}. Defaults to + * "declare" so a freshly-added primitive (whose use context isn't + * known to the combobox) gets the richer, more forgiving catalog. + */ + readonly mode?: AttrComboboxMode; + /** + * Attr names the rest of the descriptor is SEEDING. Only meaningful + * when `mode === "consume"`; supplies the dynamic "Seeded in this + * descriptor" group so users can target counters they've already + * declared (ShieldCharges, ArmorPlates, …). + */ + readonly seededAttrs?: readonly string[]; /** data-testid passthrough — lets e2e tests target specific fields. */ readonly testId?: string; /** Placeholder shown in the input. */ @@ -49,6 +99,8 @@ export function AttrCombobox({ value, onChange, preferredType, + mode = "declare", + seededAttrs = [], testId, placeholder = "Attribute name…", }: AttrComboboxProps) { @@ -61,28 +113,68 @@ export function AttrCombobox({ * 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. + * + * Consume-mode additions: + * - The illustrative "User-defined examples" group is hidden — the + * names are fiction unless something actually seeds them, and + * surfacing them invites users to target attrs that don't exist. + * - A prepended "Seeded in this descriptor" group surfaces attrs + * the current primitive tree actually seeds (minus the core + * engine attrs we already show in "Core numeric/non-numeric"). */ 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; + + const baseGroups = ATTR_SUGGESTION_GROUPS.filter((g) => + mode === "consume" + ? g.label !== USER_DEFINED_EXAMPLES_GROUP_LABEL + : true, + ); + + const extra = []; + if (mode === "consume" && seededAttrs.length > 0) { + // Exclude only names that already appear in a core group — + // illustrative examples (ShieldCharges, ArmorPlates, …) are + // fair game here because the whole point of this group is to + // surface actually-seeded user-defined attrs. + const extras: AttrSuggestion[] = seededAttrs + .filter((name) => !CORE_SUGGESTION_NAMES.has(name)) + .map((name) => ({ + name, + description: + "Declared by a seed-attribute primitive elsewhere in this descriptor.", + type: "numeric", + builtin: false, + })); + if (extras.length > 0) { + extra.push({ + label: "Seeded in this descriptor", + suggestions: extras, }); } - return { ...group, suggestions: matched }; - }).filter((g) => g.suggestions.length > 0); - }, [value, preferredType]); + } + + return [...extra, ...baseGroups] + .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, mode, seededAttrs]); /** * Flat ordering mirrors what the user sees so arrow-key indices @@ -149,14 +241,68 @@ export function AttrCombobox({ } }; - const isKnown = ALL_SUGGESTION_NAMES.has(value); - const showUserDefinedBadge = !isKnown && value.trim().length > 0; + const trimmed = value.trim(); + // "Core" = engine-schema known (Hp, HpBonus, …). Illustrative + // user-defined examples are NOT core — they're only safe to use in + // consume-mode when something actually seeds them. + const isCoreKnown = CORE_SUGGESTION_NAMES.has(value); + const isCatalogKnown = ALL_SUGGESTION_NAMES.has(value); + const isSeededInTree = seededAttrs.includes(value); + const isRecognized = isCoreKnown || isSeededInTree; + /** + * Badge policy: + * - declare-mode: any non-core typed value → amber "user-defined" + * (same behaviour as before — seeding a new counter is legit). + * - consume-mode: non-core AND not seeded by another primitive in + * this descriptor → red "not seeded" warning. Non-core BUT + * seeded-here → emerald "seeded" confirmation. + */ + let badge: { tone: "amber" | "red" | "emerald"; label: string; title: string } | null = + null; + if (trimmed.length > 0 && !isCoreKnown) { + if (mode === "consume") { + if (isSeededInTree) { + badge = { + tone: "emerald", + label: "seeded", + title: + "This attribute is declared by a seed-attribute primitive in this descriptor. Safe to target.", + }; + } else { + badge = { + tone: "red", + label: "not seeded", + title: + "This attribute isn't a built-in and isn't seeded by any primitive in this descriptor. Reading it will be a no-op unless seeded elsewhere.", + }; + } + } else if (!isCatalogKnown) { + // declare-mode only flags truly off-catalog names. Illustrative + // catalog entries (ShieldCharges, ArmorPlates) are "known" here + // so we skip the badge — they're valid seed targets. + badge = { + tone: "amber", + label: "user-defined", + title: + "Not a schema-known attribute. This is fine for user-defined counters, but typos will silently land here.", + }; + } + } + + const badgeClass = + badge?.tone === "red" + ? "text-red-800 bg-red-50 border border-red-200" + : badge?.tone === "emerald" + ? "text-emerald-800 bg-emerald-50 border border-emerald-200" + : "text-amber-800 bg-amber-100 border border-amber-200"; return (
setOpen(true)} + onClick={() => 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 && ( + {badge !== null && ( - user-defined + {badge.label} )}
diff --git a/packages/chess/src/ui/CustomModifierEditor.tsx b/packages/chess/src/ui/CustomModifierEditor.tsx index dc1380e..50d79be 100644 --- a/packages/chess/src/ui/CustomModifierEditor.tsx +++ b/packages/chess/src/ui/CustomModifierEditor.tsx @@ -406,6 +406,7 @@ export function CustomModifierEditor({ isOpen, onClose, onShareWithRoom }: Props { updateDescriptor((p) => { const next = [...p.primitives]; @@ -565,6 +566,57 @@ function attrFieldPreferredType( } } +/** + * Does this primitive+field DECLARE a new attr (seed-attribute.attr) + * or CONSUME an existing one (add/multiply/absorb/aura)? Drives the + * AttrCombobox's mode so consume-mode can hide the illustrative + * user-defined-examples group and warn on unseeded names. + */ +function attrFieldMode( + primitiveKind: string, + _fieldKey: string, +): 'declare' | 'consume' { + if (primitiveKind === 'seed-attribute') return 'declare'; + return 'consume'; +} + +/** + * Walk the current descriptor's primitive tree and collect every + * user-defined attr name a seed-attribute primitive declares. Used + * by the combobox to surface a "Seeded in this descriptor" group + * when the active field is a consume-site (add-to-attribute.attr, + * add-aura.targetAttr, etc.). Built-in attrs (ChessAttrMap keys) + * are omitted — they already appear in the core groups. + */ +function collectSeededAttrs( + nodes: readonly EffectPrimitiveNode[], +): string[] { + const seen = new Set(); + const walk = (ns: readonly EffectPrimitiveNode[]): void => { + for (const node of ns) { + if (node.kind === 'seed-attribute') { + const params = node.params as { attr?: unknown } | undefined; + if (typeof params?.attr === 'string' && params.attr.length > 0) { + seen.add(params.attr); + } + } + // Recurse through trigger primitives' nested children so a + // seed-attribute inside on-turn-start / on-capture / conditional + // still contributes to the "seeded" set the inspector offers. + const primitive = PRIMITIVE_REGISTRY.get(node.kind); + if (primitive?.childPrimitives !== undefined) { + try { + walk(primitive.childPrimitives(node.params)); + } catch { + /* malformed params → skip its children */ + } + } + } + }; + walk(nodes); + return [...seen]; +} + /** * 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: @@ -573,12 +625,23 @@ function attrFieldPreferredType( function PrimitiveInspector({ node, primitive, + allPrimitives, onChange }: { node: EffectPrimitiveNode; primitive?: EffectPrimitive; + /** Full descriptor tree — used to compute seededAttrs for consume-mode fields. */ + allPrimitives: readonly EffectPrimitiveNode[]; onChange: (params: unknown) => void; }) { + // Precompute the set of attr names declared by seed-attribute + // primitives anywhere in the current descriptor tree. The result + // feeds the AttrCombobox's "Seeded in this descriptor" group when + // the field is a consume-site. + const seededAttrs = useMemo( + () => collectSeededAttrs(allPrimitives), + [allPrimitives], + ); // 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. @@ -734,6 +797,7 @@ function PrimitiveInspector({ {type === 'string' && isAttrFieldName(key) ? ( (() => { const preferred = attrFieldPreferredType(primitive.kind, key); + const mode = attrFieldMode(primitive.kind, key); // exactOptionalPropertyTypes: spread preferredType only // when defined so we don't pass `undefined` explicitly. return ( @@ -742,6 +806,8 @@ function PrimitiveInspector({ onChange={(next) => onChange({ ...params, [key]: next })} testId={`primitive-${primitive.kind}-${key}`} placeholder="Attribute name…" + mode={mode} + seededAttrs={seededAttrs} {...(preferred !== undefined ? { preferredType: preferred } : {})} /> ); diff --git a/packages/chess/src/ui/attr-suggestions.ts b/packages/chess/src/ui/attr-suggestions.ts index ac2808a..58fe615 100644 --- a/packages/chess/src/ui/attr-suggestions.ts +++ b/packages/chess/src/ui/attr-suggestions.ts @@ -42,6 +42,16 @@ export interface AttrSuggestionGroup { readonly suggestions: readonly AttrSuggestion[]; } +/** + * Identifies the three named groups so the combobox's `mode` option + * can suppress the illustrative examples without having to match a + * string label. + */ +export const CORE_NUMERIC_GROUP_LABEL = "Core numeric attributes" as const; +export const CORE_OTHER_GROUP_LABEL = "Core non-numeric attributes" as const; +export const USER_DEFINED_EXAMPLES_GROUP_LABEL = + "User-defined examples (invent your own)" as const; + /** * Pre-baked groups. Order here IS the display order in the combobox * (numeric first because it's the most-used when targeting additive