feat(ui): AttrCombobox declare vs. consume semantics

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.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-21 13:07:27 -06:00
commit 8605a22530
No known key found for this signature in database
5 changed files with 349 additions and 25 deletions

View file

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

View file

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

View file

@ -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<string> = 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 (
<div
ref={rootRef}
className="relative"
data-testid={testId ?? "attr-combobox"}
data-recognized={isRecognized ? "true" : "false"}
data-mode={mode}
>
<div className="flex items-center gap-2">
<input
@ -170,18 +316,20 @@ export function AttrCombobox({
setHighlight(-1);
}}
onFocus={() => 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 && (
<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."
className={`text-[10px] font-semibold px-1.5 py-0.5 rounded ${badgeClass}`}
title={badge.title}
data-testid={testId ? `${testId}-badge` : "attr-combobox-badge"}
>
user-defined
{badge.label}
</span>
)}
</div>

View file

@ -406,6 +406,7 @@ export function CustomModifierEditor({ isOpen, onClose, onShareWithRoom }: Props
<PrimitiveInspector
node={descriptor.primitives[selectedIndex]}
primitive={PRIMITIVE_REGISTRY.get(descriptor.primitives[selectedIndex].kind)!}
allPrimitives={descriptor.primitives}
onChange={(newParams) => {
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<string>();
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 } : {})}
/>
);

View file

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