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:
parent
934db775f9
commit
8605a22530
5 changed files with 349 additions and 25 deletions
|
|
@ -55,9 +55,26 @@ suggestions:
|
||||||
ManaPool. Illustrative names matching the recipes; invent your own.
|
ManaPool. Illustrative names matching the recipes; invent your own.
|
||||||
|
|
||||||
The combobox is **free-form** — type any name and hit Enter to accept it,
|
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
|
even if it isn't in the list. Numeric-context primitives (add, multiply,
|
||||||
`user-defined` badge so you know you're outside the schema. Numeric-context
|
aura, absorb) rank numeric suggestions first.
|
||||||
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1368,11 +1368,94 @@ test.describe('T29 — Custom modifier DSL e2e', () => {
|
||||||
await page.getByTestId('attr-combobox-option-Hp').first().click();
|
await page.getByTestId('attr-combobox-option-Hp').first().click();
|
||||||
await expect(input).toHaveValue('Hp');
|
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.
|
// Type a user-defined attr name — badge appears, dropdown filters down.
|
||||||
await input.fill('MyStacks');
|
await input.fill('MyStacks');
|
||||||
await expect(combobox).toContainText('user-defined');
|
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 }) => {
|
test('Templates picker loads a recipe into the editor', async ({ page }) => {
|
||||||
await freshLobby(page);
|
await freshLobby(page);
|
||||||
await openProfileEditor(page);
|
await openProfileEditor(page);
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,29 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
ATTR_SUGGESTION_GROUPS,
|
ATTR_SUGGESTION_GROUPS,
|
||||||
ALL_SUGGESTION_NAMES,
|
ALL_SUGGESTION_NAMES,
|
||||||
|
USER_DEFINED_EXAMPLES_GROUP_LABEL,
|
||||||
|
CORE_NUMERIC_GROUP_LABEL,
|
||||||
|
CORE_OTHER_GROUP_LABEL,
|
||||||
type AttrSuggestion,
|
type AttrSuggestion,
|
||||||
type AttrTypeHint,
|
type AttrTypeHint,
|
||||||
} from "./attr-suggestions.js";
|
} 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.
|
* Attribute-name combobox for the Custom Modifier editor.
|
||||||
*
|
*
|
||||||
|
|
@ -34,11 +53,42 @@ import {
|
||||||
* are NOT filtered out — users occasionally need non-numeric attrs
|
* are NOT filtered out — users occasionally need non-numeric attrs
|
||||||
* even in numeric-context primitives.
|
* 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 {
|
export interface AttrComboboxProps {
|
||||||
readonly value: string;
|
readonly value: string;
|
||||||
readonly onChange: (next: string) => void;
|
readonly onChange: (next: string) => void;
|
||||||
/** If provided, numeric suggestions rank first. */
|
/** If provided, numeric suggestions rank first. */
|
||||||
readonly preferredType?: AttrTypeHint;
|
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. */
|
/** data-testid passthrough — lets e2e tests target specific fields. */
|
||||||
readonly testId?: string;
|
readonly testId?: string;
|
||||||
/** Placeholder shown in the input. */
|
/** Placeholder shown in the input. */
|
||||||
|
|
@ -49,6 +99,8 @@ export function AttrCombobox({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
preferredType,
|
preferredType,
|
||||||
|
mode = "declare",
|
||||||
|
seededAttrs = [],
|
||||||
testId,
|
testId,
|
||||||
placeholder = "Attribute name…",
|
placeholder = "Attribute name…",
|
||||||
}: AttrComboboxProps) {
|
}: AttrComboboxProps) {
|
||||||
|
|
@ -61,28 +113,68 @@ export function AttrCombobox({
|
||||||
* Flat list of visible suggestions after applying the free-form
|
* Flat list of visible suggestions after applying the free-form
|
||||||
* substring filter and the preferred-type ranking. Kept memoized
|
* substring filter and the preferred-type ranking. Kept memoized
|
||||||
* so arrow-key nav stays cheap while typing.
|
* 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 filteredGroups = useMemo(() => {
|
||||||
const q = value.trim().toLowerCase();
|
const q = value.trim().toLowerCase();
|
||||||
const shouldFilter = q.length > 0 && !ALL_SUGGESTION_NAMES.has(value);
|
const shouldFilter = q.length > 0 && !ALL_SUGGESTION_NAMES.has(value);
|
||||||
return ATTR_SUGGESTION_GROUPS.map((group) => {
|
|
||||||
const matched = shouldFilter
|
const baseGroups = ATTR_SUGGESTION_GROUPS.filter((g) =>
|
||||||
? group.suggestions.filter(
|
mode === "consume"
|
||||||
(s) =>
|
? g.label !== USER_DEFINED_EXAMPLES_GROUP_LABEL
|
||||||
s.name.toLowerCase().includes(q) ||
|
: true,
|
||||||
s.description.toLowerCase().includes(q),
|
);
|
||||||
)
|
|
||||||
: [...group.suggestions];
|
const extra = [];
|
||||||
if (preferredType !== undefined) {
|
if (mode === "consume" && seededAttrs.length > 0) {
|
||||||
matched.sort((a, b) => {
|
// Exclude only names that already appear in a core group —
|
||||||
const aPref = a.type === preferredType ? 0 : 1;
|
// illustrative examples (ShieldCharges, ArmorPlates, …) are
|
||||||
const bPref = b.type === preferredType ? 0 : 1;
|
// fair game here because the whole point of this group is to
|
||||||
return aPref - bPref;
|
// 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
|
* 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 trimmed = value.trim();
|
||||||
const showUserDefinedBadge = !isKnown && value.trim().length > 0;
|
// "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 (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={rootRef}
|
ref={rootRef}
|
||||||
className="relative"
|
className="relative"
|
||||||
data-testid={testId ?? "attr-combobox"}
|
data-testid={testId ?? "attr-combobox"}
|
||||||
|
data-recognized={isRecognized ? "true" : "false"}
|
||||||
|
data-mode={mode}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
|
|
@ -170,18 +316,20 @@ export function AttrCombobox({
|
||||||
setHighlight(-1);
|
setHighlight(-1);
|
||||||
}}
|
}}
|
||||||
onFocus={() => setOpen(true)}
|
onFocus={() => setOpen(true)}
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
onKeyDown={handleKeyDown}
|
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"
|
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"}
|
data-testid={testId ? `${testId}-input` : "attr-combobox-input"}
|
||||||
aria-autocomplete="list"
|
aria-autocomplete="list"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
/>
|
/>
|
||||||
{showUserDefinedBadge && (
|
{badge !== null && (
|
||||||
<span
|
<span
|
||||||
className="text-[10px] font-semibold text-amber-800 bg-amber-100 border border-amber-200 px-1.5 py-0.5 rounded"
|
className={`text-[10px] font-semibold px-1.5 py-0.5 rounded ${badgeClass}`}
|
||||||
title="Not a schema-known attribute. This is fine for user-defined counters like ShieldCharges, but typos will silently land here."
|
title={badge.title}
|
||||||
|
data-testid={testId ? `${testId}-badge` : "attr-combobox-badge"}
|
||||||
>
|
>
|
||||||
user-defined
|
{badge.label}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -406,6 +406,7 @@ export function CustomModifierEditor({ isOpen, onClose, onShareWithRoom }: Props
|
||||||
<PrimitiveInspector
|
<PrimitiveInspector
|
||||||
node={descriptor.primitives[selectedIndex]}
|
node={descriptor.primitives[selectedIndex]}
|
||||||
primitive={PRIMITIVE_REGISTRY.get(descriptor.primitives[selectedIndex].kind)!}
|
primitive={PRIMITIVE_REGISTRY.get(descriptor.primitives[selectedIndex].kind)!}
|
||||||
|
allPrimitives={descriptor.primitives}
|
||||||
onChange={(newParams) => {
|
onChange={(newParams) => {
|
||||||
updateDescriptor((p) => {
|
updateDescriptor((p) => {
|
||||||
const next = [...p.primitives];
|
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.
|
* 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:
|
* Since fully parsing arbitrary Zod schemas into UI is complex, we use a hybrid approach:
|
||||||
|
|
@ -573,12 +625,23 @@ function attrFieldPreferredType(
|
||||||
function PrimitiveInspector({
|
function PrimitiveInspector({
|
||||||
node,
|
node,
|
||||||
primitive,
|
primitive,
|
||||||
|
allPrimitives,
|
||||||
onChange
|
onChange
|
||||||
}: {
|
}: {
|
||||||
node: EffectPrimitiveNode;
|
node: EffectPrimitiveNode;
|
||||||
primitive?: EffectPrimitive;
|
primitive?: EffectPrimitive;
|
||||||
|
/** Full descriptor tree — used to compute seededAttrs for consume-mode fields. */
|
||||||
|
allPrimitives: readonly EffectPrimitiveNode[];
|
||||||
onChange: (params: unknown) => void;
|
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
|
// Local expansion state for the docs panel. Default collapsed so the
|
||||||
// form fields stay the focal point; the user toggles "Show examples"
|
// form fields stay the focal point; the user toggles "Show examples"
|
||||||
// when they want reference material.
|
// when they want reference material.
|
||||||
|
|
@ -734,6 +797,7 @@ function PrimitiveInspector({
|
||||||
{type === 'string' && isAttrFieldName(key) ? (
|
{type === 'string' && isAttrFieldName(key) ? (
|
||||||
(() => {
|
(() => {
|
||||||
const preferred = attrFieldPreferredType(primitive.kind, key);
|
const preferred = attrFieldPreferredType(primitive.kind, key);
|
||||||
|
const mode = attrFieldMode(primitive.kind, key);
|
||||||
// exactOptionalPropertyTypes: spread preferredType only
|
// exactOptionalPropertyTypes: spread preferredType only
|
||||||
// when defined so we don't pass `undefined` explicitly.
|
// when defined so we don't pass `undefined` explicitly.
|
||||||
return (
|
return (
|
||||||
|
|
@ -742,6 +806,8 @@ function PrimitiveInspector({
|
||||||
onChange={(next) => onChange({ ...params, [key]: next })}
|
onChange={(next) => onChange({ ...params, [key]: next })}
|
||||||
testId={`primitive-${primitive.kind}-${key}`}
|
testId={`primitive-${primitive.kind}-${key}`}
|
||||||
placeholder="Attribute name…"
|
placeholder="Attribute name…"
|
||||||
|
mode={mode}
|
||||||
|
seededAttrs={seededAttrs}
|
||||||
{...(preferred !== undefined ? { preferredType: preferred } : {})}
|
{...(preferred !== undefined ? { preferredType: preferred } : {})}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,16 @@ export interface AttrSuggestionGroup {
|
||||||
readonly suggestions: readonly AttrSuggestion[];
|
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
|
* Pre-baked groups. Order here IS the display order in the combobox
|
||||||
* (numeric first because it's the most-used when targeting additive
|
* (numeric first because it's the most-used when targeting additive
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue