refactor(chess/ui): extract ParamField from CustomModifierEditor (no behavior change)
Moves the 247-line PrimitiveInspector function out of the 872-line CustomModifierEditor (now 533 lines) into its own ParamField module so the upcoming visual builder can reuse the same Zod-introspecting form renderer without coupling to the form-mode editor's three-pane layout. Discipline: - Snapshot test seeded under the original PrimitiveInspector first; component then extracted; snapshots re-verified byte-identical. - Renamed export from PrimitiveInspector to ParamField; props, data-testids, classNames, and Zod-internal access patterns preserved verbatim — including the existing block-move-type and override-promotion Zod-v4 enum bug (out of scope to fix here). - Internal helpers (isAttrFieldName, attrFieldPreferredType, attrFieldMode, collectSeededAttrs, ATTR_FIELD_NAMES) move with the component; generateDefaultParams stays in CustomModifierEditor since the palette button still uses it. Snapshot harness uses react-dom/server#renderToStaticMarkup for deterministic SSR — no @testing-library dependency added. vitest.config include pattern widened to *.test.tsx alongside *.test.ts.
This commit is contained in:
parent
d44b433889
commit
776c192874
5 changed files with 687 additions and 343 deletions
|
|
@ -2,7 +2,7 @@ import { useState, useMemo } from 'react';
|
|||
import { toast } from 'sonner';
|
||||
import { type ZodType, z } from 'zod';
|
||||
import { PRIMITIVE_REGISTRY } from '../modifiers/primitives/index.js';
|
||||
import type { EffectPrimitiveNode, PrimitiveKind, EffectPrimitive } from '../modifiers/primitives/types.js';
|
||||
import type { EffectPrimitiveNode, PrimitiveKind } from '../modifiers/primitives/types.js';
|
||||
import type { CustomModifierDescriptor } from '../modifiers/custom/types.js';
|
||||
import {
|
||||
saveToCustomModifierLibrary,
|
||||
|
|
@ -15,8 +15,7 @@ import {
|
|||
CUSTOM_MODIFIER_RECIPES,
|
||||
type CustomModifierRecipe,
|
||||
} from '../modifiers/custom/recipes.js';
|
||||
import { AttrCombobox } from './AttrCombobox.js';
|
||||
import type { AttrTypeHint } from './attr-suggestions.js';
|
||||
import { ParamField } from './ParamField.js';
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
|
|
@ -403,7 +402,7 @@ export function CustomModifierEditor({ isOpen, onClose, onShareWithRoom }: Props
|
|||
Select a primitive to edit its parameters.
|
||||
</div>
|
||||
) : (
|
||||
<PrimitiveInspector
|
||||
<ParamField
|
||||
node={descriptor.primitives[selectedIndex]}
|
||||
primitive={PRIMITIVE_REGISTRY.get(descriptor.primitives[selectedIndex].kind)!}
|
||||
allPrimitives={descriptor.primitives}
|
||||
|
|
@ -532,341 +531,3 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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:
|
||||
* basic types get inputs, complex types get a JSON textarea fallback.
|
||||
*/
|
||||
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.
|
||||
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;
|
||||
|
||||
if (isComplex) {
|
||||
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>
|
||||
<textarea
|
||||
className="w-full h-64 p-3 text-sm font-mono border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
value={JSON.stringify(node.params, null, 2)}
|
||||
onChange={(e) => {
|
||||
try {
|
||||
const val = JSON.parse(e.target.value);
|
||||
onChange(val);
|
||||
} catch {
|
||||
// Let them type invalid JSON temporarily — typing
|
||||
// states naturally pass through partial-parse states.
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-neutral-500">Edit the JSON representation directly for complex parameters.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Reach into Zod internals to introspect the schema's shape and
|
||||
// narrow the param-type rendering. The shape of a ZodObject and the
|
||||
// _def of optional/default/enum nodes is internal API; we cast to a
|
||||
// narrow structural shape rather than `any` so the access points
|
||||
// are auditable. If Zod ever renames _def, fix the cast in one place.
|
||||
type ZodObjectInternal = { shape: Record<string, ZodType<unknown>> };
|
||||
type ZodWrappedDefInternal = {
|
||||
_def: { innerType?: ZodType<unknown>; schema?: ZodType<unknown> };
|
||||
};
|
||||
type ZodEnumDefInternal = { _def: { values: readonly string[] } };
|
||||
|
||||
const shape = (primitive.paramsSchema as unknown as ZodObjectInternal).shape;
|
||||
const params = (node.params as Record<string, unknown>) || {};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
{docsPanel}
|
||||
{Object.entries(shape).map(([key, schema]) => {
|
||||
let type = 'string';
|
||||
let options: readonly string[] = [];
|
||||
|
||||
// Basic introspection
|
||||
let currentSchema: ZodType<unknown> = schema;
|
||||
if (
|
||||
currentSchema instanceof z.ZodOptional ||
|
||||
currentSchema instanceof z.ZodNullable ||
|
||||
currentSchema instanceof z.ZodDefault
|
||||
) {
|
||||
const def = (currentSchema as unknown as ZodWrappedDefInternal)._def;
|
||||
currentSchema = def.innerType ?? def.schema ?? currentSchema;
|
||||
}
|
||||
|
||||
if (currentSchema instanceof z.ZodNumber) type = 'number';
|
||||
else if (currentSchema instanceof z.ZodBoolean) type = 'boolean';
|
||||
else if (currentSchema instanceof z.ZodEnum) {
|
||||
type = 'enum';
|
||||
options = (currentSchema as unknown as ZodEnumDefInternal)._def.values;
|
||||
}
|
||||
else if (currentSchema instanceof z.ZodArray) type = 'array';
|
||||
|
||||
const value = params[key];
|
||||
|
||||
return (
|
||||
<div key={key} className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-bold text-neutral-700">
|
||||
{key}
|
||||
</label>
|
||||
|
||||
{type === 'number' && (
|
||||
<input
|
||||
type="number"
|
||||
value={Number(value) || 0}
|
||||
onChange={(e) => onChange({ ...params, [key]: Number(e.target.value) })}
|
||||
className="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
)}
|
||||
|
||||
{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 (
|
||||
<AttrCombobox
|
||||
value={String(value || '')}
|
||||
onChange={(next) => onChange({ ...params, [key]: next })}
|
||||
testId={`primitive-${primitive.kind}-${key}`}
|
||||
placeholder="Attribute name…"
|
||||
mode={mode}
|
||||
seededAttrs={seededAttrs}
|
||||
{...(preferred !== undefined ? { preferredType: preferred } : {})}
|
||||
/>
|
||||
);
|
||||
})()
|
||||
) : type === 'string' && (
|
||||
<input
|
||||
type="text"
|
||||
value={String(value || '')}
|
||||
onChange={(e) => onChange({ ...params, [key]: e.target.value })}
|
||||
className="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
)}
|
||||
|
||||
{type === 'boolean' && (
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(value)}
|
||||
onChange={(e) => onChange({ ...params, [key]: e.target.checked })}
|
||||
className="w-4 h-4 text-blue-600 border-neutral-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-neutral-600">Enabled</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{type === 'enum' && (
|
||||
<select
|
||||
value={String(value || options[0])}
|
||||
onChange={(e) => onChange({ ...params, [key]: e.target.value })}
|
||||
className="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none bg-white"
|
||||
>
|
||||
{options.map(opt => (
|
||||
<option key={opt} value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{type === 'array' && (
|
||||
<div className="border border-neutral-200 rounded bg-white overflow-hidden flex flex-col">
|
||||
<textarea
|
||||
className="w-full h-32 p-2 text-xs font-mono border-0 focus:ring-0 resize-none"
|
||||
value={JSON.stringify(value || [], null, 2)}
|
||||
onChange={(e) => {
|
||||
try {
|
||||
const val = JSON.parse(e.target.value);
|
||||
if (Array.isArray(val)) {
|
||||
onChange({ ...params, [key]: val });
|
||||
}
|
||||
} catch {
|
||||
// Partial-typing tolerance; commit on valid JSON only.
|
||||
}
|
||||
}}
|
||||
placeholder="[ ... ]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
142
packages/chess/src/ui/ParamField.snapshot.test.tsx
Normal file
142
packages/chess/src/ui/ParamField.snapshot.test.tsx
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
/**
|
||||
* T14 regression baseline — snapshots the current parameter-inspector
|
||||
* rendering for every T3 primitive kind. Drives the pure refactor that
|
||||
* extracts `PrimitiveInspector` into `./ParamField.tsx`; the extracted
|
||||
* component must produce byte-identical HTML for each of these fixtures
|
||||
* or the test fails and the extraction is rejected.
|
||||
*
|
||||
* Rendering approach: React SSR (`renderToStaticMarkup`) — no DOM
|
||||
* mount, no event handlers fire, purely the declarative tree. That
|
||||
* keeps the snapshot deterministic (no ids, no focus, no portals) and
|
||||
* avoids pulling in @testing-library just for HTML capture.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import "../modifiers/primitives/index.js";
|
||||
import { PRIMITIVE_REGISTRY } from "../modifiers/primitives/index.js";
|
||||
import type {
|
||||
EffectPrimitiveNode,
|
||||
PrimitiveKind,
|
||||
} from "../modifiers/primitives/types.js";
|
||||
import { ParamField } from "./ParamField.js";
|
||||
|
||||
/**
|
||||
* Sample params per primitive kind. Chosen to exercise each field
|
||||
* rendering branch:
|
||||
* - attr-string fields (rendered via AttrCombobox)
|
||||
* - number/boolean/enum/array/string fields (rendered via plain inputs)
|
||||
* - complex-schema fallback (conditional, triggers with nested array)
|
||||
* Values are literal — no randomness — so the captured HTML is stable.
|
||||
*/
|
||||
const SAMPLE_PARAMS: Record<PrimitiveKind, unknown> = {
|
||||
"seed-attribute": { attr: "ShieldCharges", value: 3 },
|
||||
"add-to-attribute": { attr: "Hp", delta: 2 },
|
||||
"multiply-attribute": { attr: "Hp", factor: 2 },
|
||||
"add-direction": { directions: ["forward", "backward"] },
|
||||
"set-capture-flag": { flag: 2 },
|
||||
"absorb-damage-with-attribute": { attr: "ShieldCharges", rate: 1 },
|
||||
"reflect-damage": { percentage: 25 },
|
||||
"block-move-type": { moveType: "capture" },
|
||||
"modify-movement-range": { delta: 1 },
|
||||
"override-promotion": { target: "knight" },
|
||||
"add-aura": { radius: 2, targetAttr: "HpBonus", delta: 1 },
|
||||
"on-turn-start": {
|
||||
primitives: [
|
||||
{ kind: "add-to-attribute", params: { attr: "Hp", delta: 1 } },
|
||||
],
|
||||
},
|
||||
"on-capture": {
|
||||
primitives: [
|
||||
{ kind: "add-to-attribute", params: { attr: "Hp", delta: 1 } },
|
||||
],
|
||||
},
|
||||
"on-damaged": {
|
||||
primitives: [{ kind: "reflect-damage", params: { percentage: 25 } }],
|
||||
},
|
||||
conditional: {
|
||||
condition: { type: "attr-lt", attr: "Hp", value: 2 },
|
||||
then: [{ kind: "set-capture-flag", params: { flag: 2 } }],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Deterministic wrapper — renders ParamField with a no-op onChange
|
||||
* (snapshots cover initial render only; event wiring is e2e territory)
|
||||
* and a one-node descriptor tree so the seededAttrs computation stays
|
||||
* predictable.
|
||||
*/
|
||||
function render(kind: PrimitiveKind): string {
|
||||
const node: EffectPrimitiveNode = {
|
||||
kind,
|
||||
params: SAMPLE_PARAMS[kind],
|
||||
};
|
||||
const primitive = PRIMITIVE_REGISTRY.get(kind);
|
||||
if (!primitive) throw new Error(`missing primitive: ${kind}`);
|
||||
return renderToStaticMarkup(
|
||||
<ParamField
|
||||
node={node}
|
||||
primitive={primitive}
|
||||
allPrimitives={[node]}
|
||||
onChange={() => {
|
||||
/* snapshot test — event wiring covered elsewhere */
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("ParamField rendering (T14 regression baseline)", () => {
|
||||
it("seed-attribute renders attr + value fields", () => {
|
||||
expect(render("seed-attribute")).toMatchSnapshot();
|
||||
});
|
||||
it("add-to-attribute renders attr + delta fields", () => {
|
||||
expect(render("add-to-attribute")).toMatchSnapshot();
|
||||
});
|
||||
it("multiply-attribute renders attr + factor fields", () => {
|
||||
expect(render("multiply-attribute")).toMatchSnapshot();
|
||||
});
|
||||
it("add-direction renders directions array fallback", () => {
|
||||
expect(render("add-direction")).toMatchSnapshot();
|
||||
});
|
||||
it("set-capture-flag renders flag enum", () => {
|
||||
expect(render("set-capture-flag")).toMatchSnapshot();
|
||||
});
|
||||
it("absorb-damage-with-attribute renders attr + rate", () => {
|
||||
expect(render("absorb-damage-with-attribute")).toMatchSnapshot();
|
||||
});
|
||||
it("reflect-damage renders percentage", () => {
|
||||
expect(render("reflect-damage")).toMatchSnapshot();
|
||||
});
|
||||
/**
|
||||
* `block-move-type` and `override-promotion` use `z.enum([...])`.
|
||||
* The inspector's current introspection reads `_def.values`, which
|
||||
* is `undefined` on Zod v4's ZodEnum (shape renamed to `entries`).
|
||||
* That means the inline component throws before rendering for these
|
||||
* kinds — a pre-existing behaviour the pure refactor must preserve.
|
||||
* The assertion below pins the throw so the extracted `ParamField`
|
||||
* continues to reproduce it exactly.
|
||||
*/
|
||||
it("block-move-type throws today (pre-existing z.enum bug)", () => {
|
||||
expect(() => render("block-move-type")).toThrow();
|
||||
});
|
||||
it("modify-movement-range renders delta", () => {
|
||||
expect(render("modify-movement-range")).toMatchSnapshot();
|
||||
});
|
||||
it("override-promotion throws today (pre-existing z.enum bug)", () => {
|
||||
expect(() => render("override-promotion")).toThrow();
|
||||
});
|
||||
it("add-aura renders radius + targetAttr + delta", () => {
|
||||
expect(render("add-aura")).toMatchSnapshot();
|
||||
});
|
||||
it("on-turn-start renders primitives-array fallback", () => {
|
||||
expect(render("on-turn-start")).toMatchSnapshot();
|
||||
});
|
||||
it("on-capture renders primitives-array fallback", () => {
|
||||
expect(render("on-capture")).toMatchSnapshot();
|
||||
});
|
||||
it("on-damaged renders primitives-array fallback", () => {
|
||||
expect(render("on-damaged")).toMatchSnapshot();
|
||||
});
|
||||
it("conditional renders complex-schema JSON fallback", () => {
|
||||
expect(render("conditional")).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
345
packages/chess/src/ui/ParamField.tsx
Normal file
345
packages/chess/src/ui/ParamField.tsx
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
import { useState, useMemo } from 'react';
|
||||
import { type ZodType, z } from 'zod';
|
||||
import { PRIMITIVE_REGISTRY } from '../modifiers/primitives/index.js';
|
||||
import type { EffectPrimitiveNode, EffectPrimitive } from '../modifiers/primitives/types.js';
|
||||
import { AttrCombobox } from './AttrCombobox.js';
|
||||
import type { AttrTypeHint } from './attr-suggestions.js';
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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:
|
||||
* basic types get inputs, complex types get a JSON textarea fallback.
|
||||
*/
|
||||
export function ParamField({
|
||||
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.
|
||||
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;
|
||||
|
||||
if (isComplex) {
|
||||
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>
|
||||
<textarea
|
||||
className="w-full h-64 p-3 text-sm font-mono border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
value={JSON.stringify(node.params, null, 2)}
|
||||
onChange={(e) => {
|
||||
try {
|
||||
const val = JSON.parse(e.target.value);
|
||||
onChange(val);
|
||||
} catch {
|
||||
// Let them type invalid JSON temporarily — typing
|
||||
// states naturally pass through partial-parse states.
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-neutral-500">Edit the JSON representation directly for complex parameters.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Reach into Zod internals to introspect the schema's shape and
|
||||
// narrow the param-type rendering. The shape of a ZodObject and the
|
||||
// _def of optional/default/enum nodes is internal API; we cast to a
|
||||
// narrow structural shape rather than `any` so the access points
|
||||
// are auditable. If Zod ever renames _def, fix the cast in one place.
|
||||
type ZodObjectInternal = { shape: Record<string, ZodType<unknown>> };
|
||||
type ZodWrappedDefInternal = {
|
||||
_def: { innerType?: ZodType<unknown>; schema?: ZodType<unknown> };
|
||||
};
|
||||
type ZodEnumDefInternal = { _def: { values: readonly string[] } };
|
||||
|
||||
const shape = (primitive.paramsSchema as unknown as ZodObjectInternal).shape;
|
||||
const params = (node.params as Record<string, unknown>) || {};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
{docsPanel}
|
||||
{Object.entries(shape).map(([key, schema]) => {
|
||||
let type = 'string';
|
||||
let options: readonly string[] = [];
|
||||
|
||||
// Basic introspection
|
||||
let currentSchema: ZodType<unknown> = schema;
|
||||
if (
|
||||
currentSchema instanceof z.ZodOptional ||
|
||||
currentSchema instanceof z.ZodNullable ||
|
||||
currentSchema instanceof z.ZodDefault
|
||||
) {
|
||||
const def = (currentSchema as unknown as ZodWrappedDefInternal)._def;
|
||||
currentSchema = def.innerType ?? def.schema ?? currentSchema;
|
||||
}
|
||||
|
||||
if (currentSchema instanceof z.ZodNumber) type = 'number';
|
||||
else if (currentSchema instanceof z.ZodBoolean) type = 'boolean';
|
||||
else if (currentSchema instanceof z.ZodEnum) {
|
||||
type = 'enum';
|
||||
options = (currentSchema as unknown as ZodEnumDefInternal)._def.values;
|
||||
}
|
||||
else if (currentSchema instanceof z.ZodArray) type = 'array';
|
||||
|
||||
const value = params[key];
|
||||
|
||||
return (
|
||||
<div key={key} className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-bold text-neutral-700">
|
||||
{key}
|
||||
</label>
|
||||
|
||||
{type === 'number' && (
|
||||
<input
|
||||
type="number"
|
||||
value={Number(value) || 0}
|
||||
onChange={(e) => onChange({ ...params, [key]: Number(e.target.value) })}
|
||||
className="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
)}
|
||||
|
||||
{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 (
|
||||
<AttrCombobox
|
||||
value={String(value || '')}
|
||||
onChange={(next) => onChange({ ...params, [key]: next })}
|
||||
testId={`primitive-${primitive.kind}-${key}`}
|
||||
placeholder="Attribute name…"
|
||||
mode={mode}
|
||||
seededAttrs={seededAttrs}
|
||||
{...(preferred !== undefined ? { preferredType: preferred } : {})}
|
||||
/>
|
||||
);
|
||||
})()
|
||||
) : type === 'string' && (
|
||||
<input
|
||||
type="text"
|
||||
value={String(value || '')}
|
||||
onChange={(e) => onChange({ ...params, [key]: e.target.value })}
|
||||
className="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
)}
|
||||
|
||||
{type === 'boolean' && (
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(value)}
|
||||
onChange={(e) => onChange({ ...params, [key]: e.target.checked })}
|
||||
className="w-4 h-4 text-blue-600 border-neutral-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-neutral-600">Enabled</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{type === 'enum' && (
|
||||
<select
|
||||
value={String(value || options[0])}
|
||||
onChange={(e) => onChange({ ...params, [key]: e.target.value })}
|
||||
className="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none bg-white"
|
||||
>
|
||||
{options.map(opt => (
|
||||
<option key={opt} value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{type === 'array' && (
|
||||
<div className="border border-neutral-200 rounded bg-white overflow-hidden flex flex-col">
|
||||
<textarea
|
||||
className="w-full h-32 p-2 text-xs font-mono border-0 focus:ring-0 resize-none"
|
||||
value={JSON.stringify(value || [], null, 2)}
|
||||
onChange={(e) => {
|
||||
try {
|
||||
const val = JSON.parse(e.target.value);
|
||||
if (Array.isArray(val)) {
|
||||
onChange({ ...params, [key]: val });
|
||||
}
|
||||
} catch {
|
||||
// Partial-typing tolerance; commit on valid JSON only.
|
||||
}
|
||||
}}
|
||||
placeholder="[ ... ]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,196 @@
|
|||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`ParamField rendering (T14 regression baseline) > absorb-damage-with-attribute renders attr + rate 1`] = `
|
||||
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">Absorb Damage with Attribute</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">absorb-damage-with-attribute</span></div><span class="text-xs text-blue-600 font-medium">Hide docs & examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">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.</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">3-charge shield (pair with seed-attribute)</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
|
||||
"attr": "ShieldCharges",
|
||||
"rate": 1
|
||||
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Pair with seed-attribute {attr: 'ShieldCharges', value: 3}. Each damage point consumes one charge; after 3 damage, HP starts taking hits.</p></div><div data-testid="custom-primitive-example-1" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Hardened armor (rate=2)</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
|
||||
"attr": "ArmorPlates",
|
||||
"rate": 2
|
||||
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Each damage point consumes 2 ArmorPlates instead of HP — makes plates deplete twice as fast but with the same absorption curve.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">attr</label><div class="relative" data-testid="primitive-absorb-damage-with-attribute-attr" data-recognized="false" data-mode="consume"><div class="flex items-center gap-2"><input type="text" placeholder="Attribute name…" class="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="primitive-absorb-damage-with-attribute-attr-input" aria-autocomplete="list" aria-expanded="false" value="ShieldCharges"/><span class="text-[10px] font-semibold px-1.5 py-0.5 rounded text-red-800 bg-red-50 border border-red-200" 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." data-testid="primitive-absorb-damage-with-attribute-attr-badge">not seeded</span></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">rate</label><input type="number" class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" value="1"/></div></div>"
|
||||
`;
|
||||
|
||||
exports[`ParamField rendering (T14 regression baseline) > add-aura renders radius + targetAttr + delta 1`] = `
|
||||
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">Add Aura</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">add-aura</span></div><span class="text-xs text-blue-600 font-medium">Hide docs & examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">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.</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">King aura: +1 HP within 2 squares</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
|
||||
"radius": 2,
|
||||
"targetAttr": "HpBonus",
|
||||
"delta": 1
|
||||
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Every friendly or enemy piece within 2 squares of this piece gains +1 HpBonus while in range.</p></div><div data-testid="custom-primitive-example-1" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Adjacent range buff</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
|
||||
"radius": 1,
|
||||
"targetAttr": "RangeBonus",
|
||||
"delta": 1
|
||||
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Anyone standing next to this piece (8 neighbouring squares) gets +1 to range.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">radius</label><input type="number" class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" value="2"/></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">targetAttr</label><div class="relative" data-testid="primitive-add-aura-targetAttr" data-recognized="true" data-mode="consume"><div class="flex items-center gap-2"><input type="text" placeholder="Attribute name…" class="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="primitive-add-aura-targetAttr-input" aria-autocomplete="list" aria-expanded="false" value="HpBonus"/></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">delta</label><input type="number" class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" value="1"/></div></div>"
|
||||
`;
|
||||
|
||||
exports[`ParamField rendering (T14 regression baseline) > add-direction renders directions array fallback 1`] = `
|
||||
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">Add Direction</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">add-direction</span></div><span class="text-xs text-blue-600 font-medium">Hide docs & examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">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.</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Backward-capable pawn</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
|
||||
"directions": [
|
||||
"backward"
|
||||
]
|
||||
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Lets a pawn step backward as well as forward.</p></div><div data-testid="custom-primitive-example-1" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Full omnidirectional king-lite</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
|
||||
"directions": [
|
||||
"forward",
|
||||
"backward",
|
||||
"left",
|
||||
"right"
|
||||
]
|
||||
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Adds all 4 orthogonal directions in one primitive. Diagonal names are listed separately if you need them.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">directions</label><div class="border border-neutral-200 rounded bg-white overflow-hidden flex flex-col"><textarea class="w-full h-32 p-2 text-xs font-mono border-0 focus:ring-0 resize-none" placeholder="[ ... ]">[
|
||||
"forward",
|
||||
"backward"
|
||||
]</textarea></div></div></div>"
|
||||
`;
|
||||
|
||||
exports[`ParamField rendering (T14 regression baseline) > add-to-attribute renders attr + delta fields 1`] = `
|
||||
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">Add To Attribute</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">add-to-attribute</span></div><span class="text-xs text-blue-600 font-medium">Hide docs & examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">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.</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">+2 HP bonus</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
|
||||
"attr": "HpBonus",
|
||||
"delta": 2
|
||||
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Adds 2 to whatever HpBonus is already there.</p></div><div data-testid="custom-primitive-example-1" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Heal 1/turn (inside on-turn-start)</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
|
||||
"attr": "Hp",
|
||||
"delta": 1
|
||||
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Wrapped in on-turn-start, restores 1 HP to this piece at the start of its color's turn.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">attr</label><div class="relative" data-testid="primitive-add-to-attribute-attr" data-recognized="true" data-mode="consume"><div class="flex items-center gap-2"><input type="text" placeholder="Attribute name…" class="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="primitive-add-to-attribute-attr-input" aria-autocomplete="list" aria-expanded="false" value="Hp"/></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">delta</label><input type="number" class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" value="2"/></div></div>"
|
||||
`;
|
||||
|
||||
exports[`ParamField rendering (T14 regression baseline) > conditional renders complex-schema JSON fallback 1`] = `
|
||||
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">Conditional</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">conditional</span></div><span class="text-xs text-blue-600 font-medium">Hide docs & examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">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).</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Low-HP fortress</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
|
||||
"condition": {
|
||||
"type": "attr-lt",
|
||||
"attr": "Hp",
|
||||
"value": 2
|
||||
},
|
||||
"then": [
|
||||
{
|
||||
"kind": "set-capture-flag",
|
||||
"params": {
|
||||
"flag": 2
|
||||
}
|
||||
}
|
||||
]
|
||||
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">When Hp drops below 2, the piece gains CANNOT_BE_CAPTURED — a last-stand invulnerability.</p></div><div data-testid="custom-primitive-example-1" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Unconditional thorns example</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
|
||||
"condition": {
|
||||
"type": "always"
|
||||
},
|
||||
"then": [
|
||||
{
|
||||
"kind": "reflect-damage",
|
||||
"params": {
|
||||
"percentage": 10
|
||||
}
|
||||
}
|
||||
]
|
||||
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Equivalent to applying reflect-damage unconditionally; useful as a template you can later tighten.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">condition</label><input type="text" class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" value="[object Object]"/></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">then</label><div class="border border-neutral-200 rounded bg-white overflow-hidden flex flex-col"><textarea class="w-full h-32 p-2 text-xs font-mono border-0 focus:ring-0 resize-none" placeholder="[ ... ]">[
|
||||
{
|
||||
"kind": "set-capture-flag",
|
||||
"params": {
|
||||
"flag": 2
|
||||
}
|
||||
}
|
||||
]</textarea></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">else</label><div class="border border-neutral-200 rounded bg-white overflow-hidden flex flex-col"><textarea class="w-full h-32 p-2 text-xs font-mono border-0 focus:ring-0 resize-none" placeholder="[ ... ]">[]</textarea></div></div></div>"
|
||||
`;
|
||||
|
||||
exports[`ParamField rendering (T14 regression baseline) > modify-movement-range renders delta 1`] = `
|
||||
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">Modify Movement Range</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">modify-movement-range</span></div><span class="text-xs text-blue-600 font-medium">Hide docs & examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">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.</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">+1 range buff</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
|
||||
"delta": 1
|
||||
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">A rook's horizontal slide reaches one square further than its baseline.</p></div><div data-testid="custom-primitive-example-1" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">-2 range debuff</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
|
||||
"delta": -2
|
||||
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Cuts 2 squares from the piece's reach (useful for 'slowed' tokens).</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">delta</label><input type="number" class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" value="1"/></div></div>"
|
||||
`;
|
||||
|
||||
exports[`ParamField rendering (T14 regression baseline) > multiply-attribute renders attr + factor fields 1`] = `
|
||||
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">Multiply Attribute</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">multiply-attribute</span></div><span class="text-xs text-blue-600 font-medium">Hide docs & examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">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.</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Double HP</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
|
||||
"attr": "Hp",
|
||||
"factor": 2
|
||||
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">If the piece already has 4 HP, becomes 8 HP.</p></div><div data-testid="custom-primitive-example-1" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Halve range bonus</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
|
||||
"attr": "RangeBonus",
|
||||
"factor": 0.5
|
||||
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">If RangeBonus is already 4, becomes 2 (rounded per attr consumer). Silently skipped if RangeBonus is unset.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">attr</label><div class="relative" data-testid="primitive-multiply-attribute-attr" data-recognized="true" data-mode="consume"><div class="flex items-center gap-2"><input type="text" placeholder="Attribute name…" class="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="primitive-multiply-attribute-attr-input" aria-autocomplete="list" aria-expanded="false" value="Hp"/></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">factor</label><input type="number" class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" value="2"/></div></div>"
|
||||
`;
|
||||
|
||||
exports[`ParamField rendering (T14 regression baseline) > on-capture renders primitives-array fallback 1`] = `
|
||||
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">On Capture</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">on-capture</span></div><span class="text-xs text-blue-600 font-medium">Hide docs & examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">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.</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Vampire lifesteal</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
|
||||
"primitives": [
|
||||
{
|
||||
"kind": "add-to-attribute",
|
||||
"params": {
|
||||
"attr": "Hp",
|
||||
"delta": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Every time this piece captures an enemy, it gains 1 HP. Stacks over a long game.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">primitives</label><div class="border border-neutral-200 rounded bg-white overflow-hidden flex flex-col"><textarea class="w-full h-32 p-2 text-xs font-mono border-0 focus:ring-0 resize-none" placeholder="[ ... ]">[
|
||||
{
|
||||
"kind": "add-to-attribute",
|
||||
"params": {
|
||||
"attr": "Hp",
|
||||
"delta": 1
|
||||
}
|
||||
}
|
||||
]</textarea></div></div></div>"
|
||||
`;
|
||||
|
||||
exports[`ParamField rendering (T14 regression baseline) > on-damaged renders primitives-array fallback 1`] = `
|
||||
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">On Damaged</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">on-damaged</span></div><span class="text-xs text-blue-600 font-medium">Hide docs & examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">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\`).</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Thorns on hit</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
|
||||
"primitives": [
|
||||
{
|
||||
"kind": "reflect-damage",
|
||||
"params": {
|
||||
"percentage": 25
|
||||
}
|
||||
}
|
||||
]
|
||||
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">When this piece takes damage, reflects 25% back to the attacker for that hit.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">primitives</label><div class="border border-neutral-200 rounded bg-white overflow-hidden flex flex-col"><textarea class="w-full h-32 p-2 text-xs font-mono border-0 focus:ring-0 resize-none" placeholder="[ ... ]">[
|
||||
{
|
||||
"kind": "reflect-damage",
|
||||
"params": {
|
||||
"percentage": 25
|
||||
}
|
||||
}
|
||||
]</textarea></div></div></div>"
|
||||
`;
|
||||
|
||||
exports[`ParamField rendering (T14 regression baseline) > on-turn-start renders primitives-array fallback 1`] = `
|
||||
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">On Turn Start</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">on-turn-start</span></div><span class="text-xs text-blue-600 font-medium">Hide docs & examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">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.</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Regenerate 1 HP/turn</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
|
||||
"primitives": [
|
||||
{
|
||||
"kind": "add-to-attribute",
|
||||
"params": {
|
||||
"attr": "Hp",
|
||||
"delta": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">At the start of every turn, this piece regains 1 HP (until capped by its damage pipeline).</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">primitives</label><div class="border border-neutral-200 rounded bg-white overflow-hidden flex flex-col"><textarea class="w-full h-32 p-2 text-xs font-mono border-0 focus:ring-0 resize-none" placeholder="[ ... ]">[
|
||||
{
|
||||
"kind": "add-to-attribute",
|
||||
"params": {
|
||||
"attr": "Hp",
|
||||
"delta": 1
|
||||
}
|
||||
}
|
||||
]</textarea></div></div></div>"
|
||||
`;
|
||||
|
||||
exports[`ParamField rendering (T14 regression baseline) > reflect-damage renders percentage 1`] = `
|
||||
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">Reflect Damage</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">reflect-damage</span></div><span class="text-xs text-blue-600 font-medium">Hide docs & examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">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.</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Half-reflective armour</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
|
||||
"percentage": 50
|
||||
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">50% of incoming damage is dealt back to the attacker.</p></div><div data-testid="custom-primitive-example-1" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Total thorns</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
|
||||
"percentage": 100
|
||||
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Full reflection — the attacker takes whatever they dealt.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">percentage</label><input type="number" class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" value="25"/></div></div>"
|
||||
`;
|
||||
|
||||
exports[`ParamField rendering (T14 regression baseline) > seed-attribute renders attr + value fields 1`] = `
|
||||
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">Seed Attribute</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">seed-attribute</span></div><span class="text-xs text-blue-600 font-medium">Hide docs & examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">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.</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Force exact HP</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
|
||||
"attr": "Hp",
|
||||
"value": 5
|
||||
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Piece always starts with 5 HP regardless of baseline.</p></div><div data-testid="custom-primitive-example-1" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Declare shield charges</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
|
||||
"attr": "ShieldCharges",
|
||||
"value": 3
|
||||
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Creates a 3-charge counter. Combine with absorb-damage-with-attribute to make each charge soak one damage point.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">attr</label><div class="relative" data-testid="primitive-seed-attribute-attr" data-recognized="true" data-mode="declare"><div class="flex items-center gap-2"><input type="text" placeholder="Attribute name…" class="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="primitive-seed-attribute-attr-input" aria-autocomplete="list" aria-expanded="false" value="ShieldCharges"/></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">value</label><input type="text" class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" value="3"/></div></div>"
|
||||
`;
|
||||
|
||||
exports[`ParamField rendering (T14 regression baseline) > set-capture-flag renders flag enum 1`] = `
|
||||
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">Set Capture Flag</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">set-capture-flag</span></div><span class="text-xs text-blue-600 font-medium">Hide docs & examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">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).</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Untouchable piece</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
|
||||
"flag": 2
|
||||
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Sets CANNOT_BE_CAPTURED — no enemy move can target this piece.</p></div><div data-testid="custom-primitive-example-1" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Friendly-fire rook</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
|
||||
"flag": 1
|
||||
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Sets CAN_CAPTURE_OWN — the piece may capture its own color's pieces.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">flag</label><input type="text" class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" value="2"/></div></div>"
|
||||
`;
|
||||
|
|
@ -3,7 +3,7 @@ import { defineConfig } from "vitest/config";
|
|||
export default defineConfig({
|
||||
test: {
|
||||
name: "chess",
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
include: ["src/**/*.test.ts", "src/**/*.test.tsx", "tests/**/*.test.ts"],
|
||||
environment: "happy-dom",
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue