From 776c1928747a02c62e8e9d376842c7d530cbadfa Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Tue, 21 Apr 2026 16:57:42 -0600 Subject: [PATCH] refactor(chess/ui): extract ParamField from CustomModifierEditor (no behavior change) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../chess/src/ui/CustomModifierEditor.tsx | 345 +----------------- .../chess/src/ui/ParamField.snapshot.test.tsx | 142 +++++++ packages/chess/src/ui/ParamField.tsx | 345 ++++++++++++++++++ .../ParamField.snapshot.test.tsx.snap | 196 ++++++++++ packages/chess/vitest.config.ts | 2 +- 5 files changed, 687 insertions(+), 343 deletions(-) create mode 100644 packages/chess/src/ui/ParamField.snapshot.test.tsx create mode 100644 packages/chess/src/ui/ParamField.tsx create mode 100644 packages/chess/src/ui/__snapshots__/ParamField.snapshot.test.tsx.snap diff --git a/packages/chess/src/ui/CustomModifierEditor.tsx b/packages/chess/src/ui/CustomModifierEditor.tsx index 50d79be..4b37bb6 100644 --- a/packages/chess/src/ui/CustomModifierEditor.tsx +++ b/packages/chess/src/ui/CustomModifierEditor.tsx @@ -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. ) : ( - (['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(); - 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
Unknown primitive kind: {node.kind}
; - } - - // Docs panel rendered once for every primitive, above whatever - // param-form we fall into below (object form vs JSON fallback). - const docsPanel = ( -
- - {docsExpanded && ( -
-

- {primitive.longDescription ?? primitive.description} -

- {primitive.examples && primitive.examples.length > 0 && ( -
-
- Examples -
- {primitive.examples.map((ex, i) => ( -
-
- {ex.title} -
-
-                    {JSON.stringify(ex.params, null, 2)}
-                  
-

- {ex.effect} -

-
- ))} -
- )} -
- )} -
- ); - - // 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 ( -
- {docsPanel} - -
" +`; + +exports[`ParamField rendering (T14 regression baseline) > add-to-attribute renders attr + delta fields 1`] = ` +"

Reads the current numeric value of attr (0 if unset) and writes existing + delta. Delta may be negative. Composes additively with other primitives and built-in modifiers — multiple add-to-attribute primitives for the same attr simply accumulate.

Examples
+2 HP bonus
{
+  "attr": "HpBonus",
+  "delta": 2
+}

Adds 2 to whatever HpBonus is already there.

Heal 1/turn (inside on-turn-start)
{
+  "attr": "Hp",
+  "delta": 1
+}

Wrapped in on-turn-start, restores 1 HP to this piece at the start of its color's turn.

" +`; + +exports[`ParamField rendering (T14 regression baseline) > conditional renders complex-schema JSON fallback 1`] = ` +"

Branches on a condition. If true → runs every primitive in \`then\`; if false and \`else\` is set → runs \`else\`. Condition types: attr-lt (numeric less-than), attr-gt (numeric greater-than), attr-eq (exact match against string/number/boolean/null), always (unconditional then), never (forces else path only).

Examples
Low-HP fortress
{
+  "condition": {
+    "type": "attr-lt",
+    "attr": "Hp",
+    "value": 2
+  },
+  "then": [
+    {
+      "kind": "set-capture-flag",
+      "params": {
+        "flag": 2
+      }
+    }
+  ]
+}

When Hp drops below 2, the piece gains CANNOT_BE_CAPTURED — a last-stand invulnerability.

Unconditional thorns example
{
+  "condition": {
+    "type": "always"
+  },
+  "then": [
+    {
+      "kind": "reflect-damage",
+      "params": {
+        "percentage": 10
+      }
+    }
+  ]
+}

Equivalent to applying reflect-damage unconditionally; useful as a template you can later tighten.

" +`; + +exports[`ParamField rendering (T14 regression baseline) > modify-movement-range renders delta 1`] = ` +"

Adds delta to the piece's RangeBonus. Composes additively with the built-in Range Bonus modifier and with other modify-movement-range primitives. Delta is clamped to integer range [-7, 7]. Rook/bishop/queen sliding is extended/reduced by this amount; knight/king ranges are treated by their own pipeline.

Examples
+1 range buff
{
+  "delta": 1
+}

A rook's horizontal slide reaches one square further than its baseline.

-2 range debuff
{
+  "delta": -2
+}

Cuts 2 squares from the piece's reach (useful for 'slowed' tokens).

" +`; + +exports[`ParamField rendering (T14 regression baseline) > multiply-attribute renders attr + factor fields 1`] = ` +"

Reads the existing numeric value of attr and writes existing * factor. No-op if the attribute is unset — it does NOT treat absent as 1. Use after seed-attribute or add-to-attribute when you need a baseline to scale.

Examples
Double HP
{
+  "attr": "Hp",
+  "factor": 2
+}

If the piece already has 4 HP, becomes 8 HP.

Halve range bonus
{
+  "attr": "RangeBonus",
+  "factor": 0.5
+}

If RangeBonus is already 4, becomes 2 (rounded per attr consumer). Silently skipped if RangeBonus is unset.

" +`; + +exports[`ParamField rendering (T14 regression baseline) > on-capture renders primitives-array fallback 1`] = ` +"

Wraps nested primitives that fire when this piece captures another. Typical uses: 'vampire' lifesteal (heal on capture), stacking buffs, or power-up triggers. Fires only on actual captures, not on quiet moves.

Examples
Vampire lifesteal
{
+  "primitives": [
+    {
+      "kind": "add-to-attribute",
+      "params": {
+        "attr": "Hp",
+        "delta": 1
+      }
+    }
+  ]
+}

Every time this piece captures an enemy, it gains 1 HP. Stacks over a long game.

" +`; + +exports[`ParamField rendering (T14 regression baseline) > on-damaged renders primitives-array fallback 1`] = ` +"

Wraps nested primitives that fire whenever this piece takes damage. Useful for reactive behaviours: auto-thorns, emergency buffs, or conditional transformations when HP crosses a threshold (combine with \`conditional\`).

Examples
Thorns on hit
{
+  "primitives": [
+    {
+      "kind": "reflect-damage",
+      "params": {
+        "percentage": 25
+      }
+    }
+  ]
+}

When this piece takes damage, reflects 25% back to the attacker for that hit.

" +`; + +exports[`ParamField rendering (T14 regression baseline) > on-turn-start renders primitives-array fallback 1`] = ` +"

Wraps a list of nested primitives that fire at the start of this piece's color's turn. Use for recurring buffs/healing/debuffs tied to turn cadence. The editor's Parameter Inspector accepts the nested \`primitives\` array as JSON; copy snippets from the simpler primitives into that array.

Examples
Regenerate 1 HP/turn
{
+  "primitives": [
+    {
+      "kind": "add-to-attribute",
+      "params": {
+        "attr": "Hp",
+        "delta": 1
+      }
+    }
+  ]
+}

At the start of every turn, this piece regains 1 HP (until capped by its damage pipeline).

" +`; + +exports[`ParamField rendering (T14 regression baseline) > reflect-damage renders percentage 1`] = ` +"

Reflects a percentage of incoming damage back to the attacker. Integer percent, 0-100. Multiple reflect primitives on the same piece do NOT stack — the most recent value wins. Great inside on-damaged if you want a one-time thorns reaction instead of a permanent aura.

Examples
Half-reflective armour
{
+  "percentage": 50
+}

50% of incoming damage is dealt back to the attacker.

Total thorns
{
+  "percentage": 100
+}

Full reflection — the attacker takes whatever they dealt.

" +`; + +exports[`ParamField rendering (T14 regression baseline) > seed-attribute renders attr + value fields 1`] = ` +"

Writes { attr, value } directly onto the piece, overwriting any existing value. Use to introduce new attributes (like a custom ShieldCharges counter) or to force a baseline (e.g. set HP to an exact number regardless of inheritance). Pair with add-to-attribute / multiply-attribute to build up a final value.

Examples
Force exact HP
{
+  "attr": "Hp",
+  "value": 5
+}

Piece always starts with 5 HP regardless of baseline.

Declare shield charges
{
+  "attr": "ShieldCharges",
+  "value": 3
+}

Creates a 3-charge counter. Combine with absorb-damage-with-attribute to make each charge soak one damage point.

" +`; + +exports[`ParamField rendering (T14 regression baseline) > set-capture-flag renders flag enum 1`] = ` +"

Turns on one capture-flag bit. Flags combine (OR) so stacking multiple primitives is fine. Supported: 1 = CAN_CAPTURE_OWN (piece may capture its own color), 2 = CANNOT_BE_CAPTURED (untargetable by enemies), 4 = EN_PASSANT (piece participates in en-passant capture resolution).

Examples
Untouchable piece
{
+  "flag": 2
+}

Sets CANNOT_BE_CAPTURED — no enemy move can target this piece.

Friendly-fire rook
{
+  "flag": 1
+}

Sets CAN_CAPTURE_OWN — the piece may capture its own color's pieces.

" +`; diff --git a/packages/chess/vitest.config.ts b/packages/chess/vitest.config.ts index 9a44e84..1bd46d8 100644 --- a/packages/chess/vitest.config.ts +++ b/packages/chess/vitest.config.ts @@ -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", }, });