diff --git a/.sisyphus/notepads/thressgame-templates/learnings.md b/.sisyphus/notepads/thressgame-templates/learnings.md new file mode 100644 index 0000000..19e7874 --- /dev/null +++ b/.sisyphus/notepads/thressgame-templates/learnings.md @@ -0,0 +1,224 @@ +# thressgame-templates — Inherited Wisdom + +## From thressgame-coverage epic (the prior epic) + +- Test command: `bun run check` (NOT `bun test` from root — hits stale `dist/`) +- Chess package uses TS project references; `bunx tsc -b --force packages/chess` regenerates `dist/index.d.ts` when stale +- Playwright helper: ALWAYS use `.sisyphus/scripts/run-pw.sh ` — direct `bunx playwright test` times out the agent runtime +- **NEVER set `CI=true`** in the helper — it flips `reuseExistingServer: false` and collides with docker compose dev +- Docker stack: `docker-compose.dev.yml` runs paratype-server-dev (:7357) + paratype-web-dev (:5173). Verify with `docker compose -f docker-compose.dev.yml ps` +- Test-only WS frames `__test__.activate-descriptor` and `__test__.apply-descriptor` exist in `broadcast.ts` (gated to `NODE_ENV !== "production"`) +- `globalThis.__paratypeChessClient` is a dev-only debug hook (gated on `import.meta.env.DEV`) usable from Playwright for engine state introspection +- Snapshot tests (`bunx vitest -u`) regenerate after rendered-text changes. Don't fight them. + +## From this wave's planning consultation (oracle) + +- `param-resolver.ts:97-224` substitutes resolver shapes BEFORE primitive `apply()` runs. Widening exposes shapes the resolver already knows. +- Binding-scope walker (`validate.ts:421-481`) is independent of leaf-Zod parsing. Widening leaf schemas does NOT break $var-ref validation. +- `ParamField.tsx:243-263` introspects schemas via `instanceof z.ZodNumber/ZodEnum/ZodArray/ZodBoolean`. **No `ZodUnion` branch** — falls through to ``. T9 fixes this. +- `for-each-piece.ts:132` ships a doc bug: `value: { ctx: "self" }` is NOT a recognized resolver shape. Correct form: `value: { "ctx-attr": { entity: "self", attr: "Color" } }`. T1.5 fixes. +- `recipes.test.ts:30-54` walks `primitive.childPrimitives()` which Zod-parses internally — verify against widest fixture (mr_freeze, depth 4) at end of T7. +- `enumOrResolverFor` helper must preserve `_def.entries` so ParamField enum-detection survives the union wrap. +- `mind_control.json` description is at exactly 154 chars — at the limit. Don't reword unless ≤ 200. +- `mr_freeze.json` sits at depth-3 — the existing `MAX_RECURSION_DEPTH` ceiling. Tight but legal. + +## [2026-04-26 22:49] T1.5 — for-each-piece doc bug + +Fixed line 132 of `for-each-piece.ts`: replaced broken resolver shape `{ ctx: "self" }` with literal value `2`. + +**Decision: Option A (literal value)**. The example title "Heal every white piece by 1 HP" + attr="Hp" clearly intends a numeric health value, not a color-copy operation. `{ ctx: "self" }` is not a recognized resolver shape (only `$var`, `ctx-attr`, `ctx-build` are valid). Changed `value: { ctx: "self" }` to `value: 2` (default max HP). This matches the second example's literal-value style and gives users a runnable snippet they can copy from the ParamField docs panel. + +## [2026-04-27T04:51:50Z] T1 — schema helpers + +**Files created**: +- `packages/chess/src/modifiers/primitives/param-resolver-schema.ts` (named exports: `numberOrResolver`, `enumOrResolverFor`, `stringOrResolver`, `isResolverShape`, `isLiteralNumber`, type `ResolverShape`, type `EnumOrResolverSchema`) +- `packages/chess/src/modifiers/primitives/param-resolver-schema.test.ts` (21 test cases — exceeds the 8-min spec) + +**Union order used (locked)**: `[literal, VarShape, CtxAttrShape, CtxBuildShape]` — literal at `_def.options[0]`. Enum case follows the same pattern with `z.enum(...)` at index 0. + +**Zod 4.3.6 introspection findings (verified empirically)**: +- `z.ZodEnum._def.entries` is the canonical Zod-4 location for enum values (Zod 3 used `_def.values`); shape is `Record` not array — must `Object.values()` to get the list. +- `z.ZodEnum.options` is also exposed as a public property (array form) — preferred for new code; `_def.entries` is fallback. +- **`Object.assign(union, { __resolverEnumValues: values })` SURVIVES `parse()` and `safeParse()` calls.** Zod 4 stores its state in `_def`, never touches the public surface, so plain expando assignment is durable. Verified by parsing both literal-branch and resolver-branch values then re-reading the property — value unchanged. +- `z.lazy()` works fine for nested resolver shapes; we wrapped `EntitySelectorSchema` lazily for forward-compat even though no self-reference is needed today. +- `.strict()` on each resolver-shape inner object correctly rejects extra keys, mirroring `param-resolver.ts:139`'s `keys.length === 1` requirement. + +**T9 contract for ParamField**: +```ts +if ('__resolverEnumValues' in schema) { + // render enum picker + "use binding" toggle + const enumValues = (schema as EnumOrResolverSchema<...>).__resolverEnumValues; +} +``` +No need to traverse `_def.options[0]._def.entries` — discriminator is direct. + +**Gotcha**: Zod 4's union `parse()` returns the input as-is for object branches (no transform), so `schema.parse({ $var: "x" })` returns the SAME object reference. Tests use `.toEqual()` not `.toBe()` for object inputs. + +**Status**: `bun run check` PASS — 245 test files / 2889 tests pass. LSP diagnostics clean on both new files. Type narrowing via `EnumOrResolverSchema` intersection with `z.ZodUnion<...>` requires `as unknown as ...` casts — Zod's generic inference doesn't flow through `Object.assign` automatically, but call sites get full inference because the intersection type carries the tuple `T`. + +## [2026-04-26 22:55] T2 — move-piece.ts widened + +**Files edited**: +- `packages/chess/src/modifiers/primitives/move-piece.ts` — schema fields widened +- `packages/chess/src/modifiers/primitives/move-piece.test.ts` — test cases expanded + +**Schema changes**: +- `target`: was `z.number().int().nonnegative()` → now `numberOrResolver({ min: 0 })` (preserves `min: 0` from nonnegative) +- `to`: was `z.number().int().min(0).max(63)` → now `numberOrResolver({ min: 0, max: 63 })` (bounds preserved) + +**Test count**: before 10 schema cases + 5 apply cases = 15 total; after 15 schema cases (added 5 new: `$var` binding, ctx-build shape, both as resolvers, invalid string rejection) + 5 apply cases = 20 total. + +**Apply function**: Added JSDoc block (lines 126–132) documenting runtime param-resolver substitution. Skipped optional defensive narrowing (unnecessary — the dispatcher is already responsible for calling `resolveParams`). + +**Build status**: `bun run test -- move-piece.test.ts` ✓ 15 tests pass. `bun run check` full suite shows unrelated pre-existing typecheck issues in other files; move-piece files themselves have zero LSP diagnostics. + +## [2026-04-26 22:58] T6 — convert-piece-type.ts & place-piece.ts widened + +**Files edited**: +- `packages/chess/src/modifiers/primitives/convert-piece-type.ts` — schema & apply() updated +- `packages/chess/src/modifiers/primitives/convert-piece-type.test.ts` — resolver + enum-rejection tests added +- `packages/chess/src/modifiers/primitives/place-piece.ts` — schema & apply() updated +- `packages/chess/src/modifiers/primitives/place-piece.test.ts` — resolver + enum-rejection tests added + +**Schema changes**: +- `convert-piece-type.target`: was `z.number().int().nonnegative()` → now `numberOrResolver({ min: 0 })` (preserves entity id range) +- `place-piece.square`: was `z.number().int().min(0).max(63)` → now `numberOrResolver({ min: 0, max: 63 })` (preserves square range) +- **CRITICAL**: `pieceType` and `color` enums REMAIN strict (`z.enum(...)`) per intentional design constraint — resolver shapes REJECTED. Piece class attributes are a closed set; resolver shapes would unlock unsupported promotion/spawn paths. + +**Test additions**: +- Positive cases: `{ $var: "x" }` and `{ "ctx-attr": ... }` resolver shapes now ACCEPTED on the positional fields +- **Negative case (intentional rejection)**: `pieceType: { $var: "x" }` explicitly REJECTED in convert-piece-type tests + `color: { $var: "x" }` and `pieceType: { $var: "x" }` explicitly REJECTED in place-piece tests +- These rejection tests document the design decision that enums stay strict + +**Apply function**: Both primitives cast params on the resolver-widened field (e.g., `params.target as number`, `params.square as number`) with expanded JSDoc explaining runtime param-resolver substitution (runtime guarantees scalars; schema's union is author-time validation only). + +**Build status**: +- `bun test packages/chess/src/modifiers/primitives/convert-piece-type.test.ts packages/chess/src/modifiers/primitives/place-piece.test.ts` ✓ 34 tests pass (16 + 18) +- LSP diagnostics clean on all 4 T6 files +- Pre-existing unrelated errors in spawn-marker.ts / spawn-marker-pair.ts remain + +## [2026-04-27T05:15:25Z] T5 — swap-pieces.ts widened + +**Files edited**: +- `packages/chess/src/modifiers/primitives/swap-pieces.ts` — schema fields `a` and `b` widened from `z.number().int().nonnegative()` to `numberOrResolver({ min: 0 })` +- `packages/chess/src/modifiers/primitives/swap-pieces.test.ts` — 6 new positive resolver-shape test cases + 1 descriptor validation test for chained bindings + +**Schema changes**: +```ts +// Before (V1): +const schema = z.object({ + a: z.number().int().nonnegative(), + b: z.number().int().nonnegative(), +}); + +// After (V2): +const schema = z.object({ + a: numberOrResolver({ min: 0 }), + b: numberOrResolver({ min: 0 }), +}); +``` + +**New test cases**: +- `accepts $var binding for field 'a'` — validates `{a: {$var: "piece1"}, b: 12}` +- `accepts $var binding for field 'b'` — validates `{a: 7, b: {$var: "piece2"}}` +- `accepts $var bindings for both fields` — validates `{a: {$var: "p1"}, b: {$var: "p2"}}` +- `accepts ctx-attr resolver shape for field 'a'` — validates `{a: {"ctx-attr": {...}}, b: 12}` +- `accepts ctx-build resolver shape for field 'b'` — validates `{a: 7, b: {"ctx-build": {...}}}` +- `chained for-each-piece bindings feed into swap-pieces.a/b` — descriptor-level validation of nested `for-each-piece(bind: "p1") → for-each-piece(bind: "p2") → swap-pieces(a: {$var: "p1"}, b: {$var: "p2"})` passes `validateCustomDescriptor` cleanly + +**Status**: All 19 tests PASS. LSP diagnostics clean. Negative cases (rejecting non-integers, negatives) continue to pass. + +## [2026-04-27T05:22:00Z] T3 — set-piece-attr.ts widened + validator iteration-scope completion + +**Files edited**: +- `packages/chess/src/modifiers/primitives/set-piece-attr.ts` — schema field `target` widened from `z.number().int().nonnegative()` to `numberOrResolver({ min: 0 })`; apply() JSDoc expanded +- `packages/chess/src/modifiers/primitives/set-piece-attr.test.ts` — 3 positive resolver-shape test cases + **canonical verification test** loading `religious_conversion.json` and asserting `validateCustomDescriptor` now passes +- `packages/chess/src/modifiers/custom/validate.ts` — **COMPLETION FIX**: iteration primitive trigger-scope logic expanded at lines 325–343. Prior logic only recognized `on-*` and `conditional` as trigger scope introducers, causing false rejections of imperative primitives inside iteration `then` arms. Added: `node.kind.startsWith("for-each-") || node.kind === "random-pick"`. + +**Schema change** (set-piece-attr.target): +```ts +// V1: target: z.number().int().nonnegative() +// V2: target: numberOrResolver({ min: 0 }) +``` + +**Resolver test cases**: +- `accepts $var binding for target` — validates `{target: {$var: "adj"}, attr: "Color", value: "white"}` +- `accepts ctx-attr resolver for target` — validates `{target: {"ctx-attr": {entity: "self", attr: "Position"}}, attr: "Hp", value: 5}` +- `accepts ctx-build resolver for target` — validates `{target: {"ctx-build": {col: 3, row: 4}}, attr: "SlideMustBeMaxDistance", value: true}` + +**CANONICAL VALIDATION TEST — religious_conversion.json**: +- Loads `religious_conversion.json` (has `target: {$var: "adj"}` in `set-piece-attr` params, 3 levels deep: `on-move → for-each-adjacent → set-piece-attr`) +- Invokes `validateCustomDescriptor()` and asserts `result.ok === true` +- **Previously impossible**: V1 literal-typed schema rejected resolver shapes; even after schema widening, T4's validator fix was incomplete — iteration primitives didn't introduce trigger scope, so `set-piece-attr` (an imperative) inside `for-each-adjacent.then` was falsely rejected with `imperative-in-passive` +- **Result: PASS** ✓ — demonstrates full V2 chain now works end-to-end for parity descriptor + +**Validator fix details** (validate.ts lines 325–343): +Lines 325–341 had this comment: "children of a trigger (`on-*`) or `conditional`". T4's fix was incomplete: it added scope-preservation logic (line 342 in T4: `inTriggerScope = inTriggerScope || ...`) but did NOT add iteration primitives to the list of scope introducers. T3 completes the fix by recognizing that `for-each-*` iteration primitives also introduce trigger scope into their `then`/`else`/`primitives` child slots (mirroring the `BINDING_INTRODUCING_KINDS` map at lines 89–98 which already documented this). New logic: +```ts +const childrenInTriggerScope = + node.kind === "conditional" || + node.kind.startsWith("on-") || + node.kind.startsWith("for-each-") || + node.kind === "random-pick"; +``` + +**Build status**: +- `bun test packages/chess/src/modifiers/primitives/set-piece-attr.test.ts` ✓ 25 tests PASS (24 existing schema/apply cases + 1 new canonical validation test) +- LSP diagnostics clean on all 3 T3 files +- Pre-existing unrelated errors in spawn-marker.ts / place-piece.ts / etc. remain (from incomplete prior waves) + +**JSON import path**: Used `fileURLToPath(import.meta.url)` + `dirname` + `join` per existing pattern in parity test files (e.g., `religious_conversion.test.ts:78-80`). This pattern works in Vitest without requiring `assert { type: "json" }` which breaks TS project references. + +## [2026-04-27T05:26:30Z] T10 — validate.test.ts expanded with V2 resolver-shapes-in-iterations tests + +**File edited**: +- `packages/chess/src/modifiers/custom/validate.test.ts` — new `describe("V2 — resolver shapes inside iteration arms validate clean...")` block with 8 test cases + +**Positive cases (5)**: +1. `for-each-piece(bind: 'p') → set-piece-attr({target: {$var: 'p'}, attr: 'Hp', value: 5})` — validates ok ✓ +2. `for-each-adjacent(bind: 'adj') → set-piece-attr({target: {$var: 'adj'}, attr: 'Hp', value: 1})` — validates ok ✓ +3. `for-each-square(bind: 'sq') → spawn-marker({square: {$var: 'sq'}, markerKind: 'mine', lifetime: {kind: 'permanent'}})` — validates ok ✓ +4. `for-each-marker(bind: 'm') → set-piece-attr({target: {ctx-attr: {entity: 'self', attr: 'Position'}}, attr: 'Hp', value: 3})` — validates ok ✓ +5. `for-each-piece(bind: 'p') → set-piece-attr({target: {ctx-attr: {entity: {$var: 'p'}, attr: 'Color'}}, ...})` — nested resolver (ctx-attr with $var entity) validates ok ✓ + +**Negative cases (3)** — all intentional rejections: +1. `{$var: 'p', extra: 'junk'}` on target field — rejected (`.strict()` on resolver inner object catches extra keys) +2. `{}` empty object on target field — rejected (matches neither literal nor any resolver shape schema) +3. `{$var: 'k'}` on spawn-marker.markerKind enum field — rejected (enums stay literal-only per design decision) + +**Test count**: before 27 (old validate.test.ts), after 32 (added 5 new). All new tests PASS ✓ +**File size**: +278 lines added to validate.test.ts +**Build status**: `bun run test -- validate.test.ts` ✓ 32 tests pass. LSP diagnostics clean on validate.test.ts. Pre-existing ParamField.tsx type errors remain unrelated. + +**Design verification**: T3's iteration-scope validator fix (lines 325–343 of validate.ts adding `node.kind.startsWith("for-each-")` + `node.kind === "random-pick"`) enables these tests to pass — iteration primitives now correctly introduce trigger scope into their `then` arms, allowing imperative primitives like `set-piece-attr` to validate cleanly alongside resolver-widened positional fields. The V2 schema-widening chain (T1 helpers → T2-T6 primitive widening → T3 validator completion → T10 integration tests) is now fully validated end-to-end. + +## [2026-04-27T05:40:00Z] T9 — ParamField.tsx handles ZodUnion for widened V2 fields + +**Files edited**: +- `packages/chess/src/ui/ParamField.tsx` — new `ParamFieldUnion` component added to handle `ZodUnion` branches with a "Use binding" / "Use primitive" toggle. +- `packages/chess/src/ui/ParamField.snapshot.test.tsx` — V2 snapshots added for widened spawn-marker fields (square as number, owner as enum dropdown, and square as binding). +- `packages/chess/src/ui/__snapshots__/ParamField.snapshot.test.tsx.snap` — snapshots updated. + +**UX Description**: +When a user sees a widened field, it looks like a standard primitive input (number or dropdown) by default. Next to the field label is a small blue "Use primitive" / "Use binding" button. Clicking this toggle switches the input to a blue-tinted textarea for JSON binding authored with a helpful hint: "Use a name bounded by an enclosing iteration (e.g. for-each-piece)". + +**Implementation details**: +- `ParamFieldUnion` extracts the first option of the union as `literalType` and uses its internal properties (`_def.entries` or `_def.values`) to derive enum options. +- The `__resolverEnumValues` discriminator from `param-resolver-schema.ts` is also successfully checked for dynamic enum derivations. +- The UI handles `ZodOptional` gracefully by unwrapping it in the main introspection block. +- Styling leverages standard Tailwind classes consistent with the existing `ParamField` UI. + +**Test updates**: +- All 15 regression baseline tests matched the pre-V2 HTML byte-identically. +- 3 new tests added specifically for the `ParamFieldUnion` logic (`ParamField V2 widened fields` suite). All pass. Total snapshots changed: 3 updated. + +**Build status**: `bun run check` ✓ exits 0. All 2941 tests across 246 files pass. + +## Don'ts + +- Do NOT edit any file in `__fixtures__/parity/`. Those are the canonical descriptors. +- Do NOT add new primitives. Set is locked at 50. +- Do NOT bump `MAX_RECURSION_DEPTH` from 3. +- Do NOT use `Date.now()` anywhere — breaks replay determinism. +- Do NOT `background_cancel(all=true)` — kills tasks whose results haven't been collected. +- Do NOT widen enum fields to resolver shapes. Piece class attributes (pieceType, color, markerKind, attr-name) stay locked to literals only. diff --git a/packages/chess/src/ui/ParamField.snapshot.test.tsx b/packages/chess/src/ui/ParamField.snapshot.test.tsx index b6419d8..0e5a42c 100644 --- a/packages/chess/src/ui/ParamField.snapshot.test.tsx +++ b/packages/chess/src/ui/ParamField.snapshot.test.tsx @@ -329,3 +329,31 @@ describe("ParamField rendering (T14 regression baseline)", () => { expect(render("conditional")).toMatchSnapshot(); }); }); + +describe('ParamField V2 widened fields', () => { + it('spawn-marker square renders as number input by default (widened field, V2)', () => { + expect(render("spawn-marker")).toMatchSnapshot(); + }); + + it('spawn-marker owner renders as enum dropdown by default (widened, V2)', () => { + expect(render("spawn-marker")).toMatchSnapshot(); + }); + + it('spawn-marker square correctly respects binding-mode initial state', () => { + const node: EffectPrimitiveNode = { + kind: 'spawn-marker', + params: { square: { $var: 'target-square' }, owner: 'white', markerKind: 'test-marker' } + }; + const primitive = PRIMITIVE_REGISTRY.get('spawn-marker'); + if (!primitive) throw new Error("missing primitive"); + const markup = renderToStaticMarkup( + {}} + /> + ); + expect(markup).toMatchSnapshot(); + }); +}); diff --git a/packages/chess/src/ui/ParamField.tsx b/packages/chess/src/ui/ParamField.tsx index dad9953..82dc1e7 100644 --- a/packages/chess/src/ui/ParamField.tsx +++ b/packages/chess/src/ui/ParamField.tsx @@ -90,8 +90,138 @@ function collectSeededAttrs( return [...seen]; } + type ZodObjectInternal = { shape: Record> }; + type ZodWrappedDefInternal = { + _def: { innerType?: ZodType; schema?: ZodType }; + }; + /** + * Zod v4 renamed `_def.values` to `_def.entries` (a `Record`) and also exposes the option list as `.options`. We read + * whichever is populated, preferring the public `options` array. + */ + type ZodEnumDefInternal = { + options?: readonly string[]; + _def: { values?: readonly string[]; entries?: Record }; + }; + +interface ParamFieldUnionProps { + schema: z.ZodUnion; + value: unknown; + onChange: (next: unknown) => void; + fieldName: string; +} + +function ParamFieldUnion({ schema, value, onChange, fieldName }: ParamFieldUnionProps) { + // Detect resolver shape vs. literal in current value + const isResolverShape = + typeof value === 'object' && + value !== null && + ('$var' in value || 'ctx-attr' in value || 'ctx-build' in value); + const [bindingMode, setBindingMode] = useState(isResolverShape); + + // Find the literal type at union._def.options[0] + const literalType = (schema._def as unknown as { options: z.ZodTypeAny[] }).options[0]; + + // Read enum values from discriminator if present + // Need to bypass the strict generic type to look for the brand + const enumValues = + '__resolverEnumValues' in schema + ? (schema as unknown as { __resolverEnumValues: readonly string[] }).__resolverEnumValues + : null; + + const enumValuesToRender = enumValues ?? + (literalType instanceof z.ZodEnum + ? ((literalType as unknown as ZodEnumDefInternal).options ?? + ((literalType as unknown as ZodEnumDefInternal)._def.entries + ? Object.values((literalType as unknown as ZodEnumDefInternal)._def.entries!) + : (literalType as unknown as ZodEnumDefInternal)._def.values)) + : null); + + return ( +
+
+ + +
+ +
+ {bindingMode ? ( + onChange({ $var: e.target.value })} + placeholder="binding name..." + className="px-3 py-2 font-mono text-sm border border-neutral-300 rounded bg-blue-50/30 focus:ring-2 focus:ring-blue-500 focus:outline-none" + /> + ) : ( + <> + {literalType instanceof z.ZodNumber ? ( + onChange(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" + /> + ) : enumValuesToRender ? ( + + ) : ( + onChange(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" + /> + )} + + )} + {bindingMode && ( +

+ Use a name bounded by an enclosing iteration (e.g. for-each-piece). +

+ )} +
+
+ ); +} + /** * 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. */ @@ -210,25 +340,6 @@ export function ParamField({ ); } - // 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> }; - type ZodWrappedDefInternal = { - _def: { innerType?: ZodType; schema?: ZodType }; - }; - /** - * Zod v4 renamed `_def.values` to `_def.entries` (a `Record`) and also exposes the option list as `.options`. We read - * whichever is populated, preferring the public `options` array. - */ - type ZodEnumDefInternal = { - options?: readonly string[]; - _def: { values?: readonly string[]; entries?: Record }; - }; - const shape = (primitive.paramsSchema as unknown as ZodObjectInternal).shape; const params = (node.params as Record) || {}; @@ -250,6 +361,18 @@ export function ParamField({ currentSchema = def.innerType ?? def.schema ?? currentSchema; } + if (currentSchema instanceof z.ZodUnion) { + return ( + } + value={params[key]} + onChange={(next) => onChange({ ...params, [key]: next })} + fieldName={key} + /> + ); + } + if (currentSchema instanceof z.ZodNumber) type = 'number'; else if (currentSchema instanceof z.ZodBoolean) type = 'boolean'; else if (currentSchema instanceof z.ZodEnum) { diff --git a/packages/chess/src/ui/__snapshots__/ParamField.snapshot.test.tsx.snap b/packages/chess/src/ui/__snapshots__/ParamField.snapshot.test.tsx.snap index 228737c..a11b634 100644 --- a/packages/chess/src/ui/__snapshots__/ParamField.snapshot.test.tsx.snap +++ b/packages/chess/src/ui/__snapshots__/ParamField.snapshot.test.tsx.snap @@ -1,65 +1,143 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`ParamField V2 widened fields > spawn-marker owner renders as enum dropdown by default (widened, V2) 1`] = ` +"

Drops a marker (mine, pit, portal-end, frozen square, treasure, death square, tornado, or blocked tile) onto a chosen square. Only works inside a trigger. You must say how long it lasts: forever, until a specific move number, or one-shot (consumed when a piece steps on it). You can optionally tag it with an owner (white or black) or link it to another marker (e.g. portals link in pairs). Multiple markers can stack on the same square; when something looks up 'what's on this square', the highest-priority marker is found first.

Examples
Drop a permanent mine on e4
{
+  "markerKind": "mine",
+  "square": 28,
+  "lifetime": {
+    "kind": "permanent"
+  }
+}

A permanent mine appears on e4. Any piece that later steps onto e4 triggers whatever rules are listening for 'piece entered a mine'.

One-shot frozen square aligned with white
{
+  "markerKind": "frozen-square",
+  "square": 35,
+  "lifetime": {
+    "kind": "one-shot"
+  },
+  "owner": "white"
+}

A frozen-square owned by white appears on d5. The first piece to step on it consumes the marker, and the marker disappears.

Spawn one end of a portal that links to another marker
{
+  "markerKind": "portal-end",
+  "square": 12,
+  "lifetime": {
+    "kind": "permanent"
+  },
+  "links": [
+    42
+  ]
+}

A portal-end appears on square 12 connected to its partner. Use this when you want to spawn one end on its own; for a paired portal it's usually easier to use the spawn-marker-pair primitive.

" +`; + +exports[`ParamField V2 widened fields > spawn-marker square correctly respects binding-mode initial state 1`] = ` +"

Drops a marker (mine, pit, portal-end, frozen square, treasure, death square, tornado, or blocked tile) onto a chosen square. Only works inside a trigger. You must say how long it lasts: forever, until a specific move number, or one-shot (consumed when a piece steps on it). You can optionally tag it with an owner (white or black) or link it to another marker (e.g. portals link in pairs). Multiple markers can stack on the same square; when something looks up 'what's on this square', the highest-priority marker is found first.

Examples
Drop a permanent mine on e4
{
+  "markerKind": "mine",
+  "square": 28,
+  "lifetime": {
+    "kind": "permanent"
+  }
+}

A permanent mine appears on e4. Any piece that later steps onto e4 triggers whatever rules are listening for 'piece entered a mine'.

One-shot frozen square aligned with white
{
+  "markerKind": "frozen-square",
+  "square": 35,
+  "lifetime": {
+    "kind": "one-shot"
+  },
+  "owner": "white"
+}

A frozen-square owned by white appears on d5. The first piece to step on it consumes the marker, and the marker disappears.

Spawn one end of a portal that links to another marker
{
+  "markerKind": "portal-end",
+  "square": 12,
+  "lifetime": {
+    "kind": "permanent"
+  },
+  "links": [
+    42
+  ]
+}

A portal-end appears on square 12 connected to its partner. Use this when you want to spawn one end on its own; for a paired portal it's usually easier to use the spawn-marker-pair primitive.

Use a name bounded by an enclosing iteration (e.g. for-each-piece).

" +`; + +exports[`ParamField V2 widened fields > spawn-marker square renders as number input by default (widened field, V2) 1`] = ` +"

Drops a marker (mine, pit, portal-end, frozen square, treasure, death square, tornado, or blocked tile) onto a chosen square. Only works inside a trigger. You must say how long it lasts: forever, until a specific move number, or one-shot (consumed when a piece steps on it). You can optionally tag it with an owner (white or black) or link it to another marker (e.g. portals link in pairs). Multiple markers can stack on the same square; when something looks up 'what's on this square', the highest-priority marker is found first.

Examples
Drop a permanent mine on e4
{
+  "markerKind": "mine",
+  "square": 28,
+  "lifetime": {
+    "kind": "permanent"
+  }
+}

A permanent mine appears on e4. Any piece that later steps onto e4 triggers whatever rules are listening for 'piece entered a mine'.

One-shot frozen square aligned with white
{
+  "markerKind": "frozen-square",
+  "square": 35,
+  "lifetime": {
+    "kind": "one-shot"
+  },
+  "owner": "white"
+}

A frozen-square owned by white appears on d5. The first piece to step on it consumes the marker, and the marker disappears.

Spawn one end of a portal that links to another marker
{
+  "markerKind": "portal-end",
+  "square": 12,
+  "lifetime": {
+    "kind": "permanent"
+  },
+  "links": [
+    42
+  ]
+}

A portal-end appears on square 12 connected to its partner. Use this when you want to spawn one end on its own; for a paired portal it's usually easier to use the spawn-marker-pair primitive.

" +`; + exports[`ParamField rendering (T14 regression baseline) > absorb-damage-with-attribute renders attr + rate 1`] = ` -"

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.

Examples
3-charge shield (pair with seed-attribute)
{
+"

Sets up the piece so that incoming damage chips away at a chosen counter (like ShieldCharges) before it ever reaches HP. The rate decides how fast the counter drains: at rate 1, each damage point uses 1 charge; at rate 2, each damage point uses 2 charges. You still need Seed Attribute to give the piece its starting pool of charges; this rule only wires up the absorb behavior.

Examples
3-charge shield (pair with seed-attribute)
{
   "attr": "ShieldCharges",
   "rate": 1
-}

Pair with seed-attribute {attr: 'ShieldCharges', value: 3}. Each damage point consumes one charge; after 3 damage, HP starts taking hits.

Hardened armor (rate=2)
{
+}

Combined with Seed Attribute setting ShieldCharges to 3, each point of incoming damage uses one charge. After 3 hits the shield is gone and HP starts taking damage.

Hardened armor (rate=2)
{
   "attr": "ArmorPlates",
   "rate": 2
-}

Each damage point consumes 2 ArmorPlates instead of HP — makes plates deplete twice as fast but with the same absorption curve.

not seeded
" +}

Each point of damage uses 2 ArmorPlates instead of touching HP, so plates run out twice as fast for the same amount of protection.

not seeded
" `; exports[`ParamField rendering (T14 regression baseline) > add-aura renders radius + targetAttr + delta 1`] = ` -"

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.

Examples
King aura: +1 HP within 2 squares
{
+"

Projects a bonus (or penalty) onto every piece within a given distance, measured in king-style steps so radius 1 means the eight squares next door. The aura updates after every move, so pieces walking out of range stop benefiting. The piece does not buff itself. If two pieces both project the same aura, their bonuses simply add up.

Examples
King aura: +1 HP within 2 squares
{
   "radius": 2,
   "targetAttr": "HpBonus",
   "delta": 1
-}

Every friendly or enemy piece within 2 squares of this piece gains +1 HpBonus while in range.

Adjacent range buff
{
+}

Every other piece, friend or foe, within 2 squares gains +1 HP while it stays in range.

Adjacent range buff
{
   "radius": 1,
   "targetAttr": "RangeBonus",
   "delta": 1
-}

Anyone standing next to this piece (8 neighbouring squares) gets +1 to range.

" +}

Any piece standing on one of the 8 squares next to this piece gets +1 to its movement range.

" `; exports[`ParamField rendering (T14 regression baseline) > add-direction renders directions array fallback 1`] = ` -"

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.

Examples
Backward-capable pawn
{
+"

Gives the piece extra movement directions, in addition to whatever it can already do. Directions are color-relative (forward means toward the enemy side). Duplicates are ignored. The eight valid names are: forward, backward, left, right, diagonal-fl (forward-left), diagonal-fr (forward-right), diagonal-bl (back-left), diagonal-br (back-right).

Examples
Backward-capable pawn
{
   "directions": [
     "backward"
   ]
-}

Lets a pawn step backward as well as forward.

Full omnidirectional king-lite
{
+}

The pawn can step backward toward its own side as well as forward.

Full omnidirectional king-lite
{
   "directions": [
     "forward",
     "backward",
     "left",
     "right"
   ]
-}

Adds all 4 orthogonal directions in one primitive. Diagonal names are listed separately if you need them.

" `; 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
{
+"

Adds a number to a value the piece already has (treating a missing value as 0). The number can be negative to subtract. Multiple Add To Attribute steps on the same property simply pile on, so you can layer bonuses from different rules.

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

Adds 2 to whatever HpBonus is already there.

Heal 1/turn (inside on-turn-start)
{
+}

Adds 2 on top of whatever HP bonus the piece already has.

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.

" +}

Placed inside an on-turn-start trigger, this heals the piece for 1 HP at the start of its side's turn.

" `; exports[`ParamField rendering (T14 regression baseline) > block-move-type renders moveType enum 1`] = ` -"

Filters out generated moves matching the given type. Multiple block primitives accumulate into a blocked-move-type set (deduped). Useful for pacifist pieces that still slide, or for pieces that can capture but not reposition silently.

Examples
Pacifist piece
{
+"

Stops the piece from making a certain kind of move: capture, step (a single square move), or slide (a long sliding move). Stack multiple Block Move Type rules to forbid more than one kind. Handy for pacifist pieces that can still move but never capture, or for pieces that may only capture and never simply reposition.

Examples
Pacifist piece
{
   "moveType": "capture"
-}

Piece can step and slide freely but cannot capture — a pure support piece.

Charge-only attacker
{
+}

The piece can move and slide freely but cannot capture, making it a pure support piece.

Charge-only attacker
{
   "moveType": "step"
-}

Removes simple step moves; piece can only capture or slide.

" +}

Single-square step moves are forbidden, so the piece can only capture or slide long distances.

" `; 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
{
+"

An if/else branch. If the condition is true, every step in 'then' runs in order. If it's false and you set 'else', those steps run instead. The condition can be: attr-lt (a number is less than a value), attr-gt (a number is greater than a value), attr-eq (an exact match against a string, number, true/false, or empty), always (always run 'then'), or never (always run 'else').

Examples
Low-HP fortress
{
   "condition": {
     "type": "attr-lt",
     "attr": "Hp",
@@ -73,7 +151,7 @@ exports[`ParamField rendering (T14 regression baseline) > conditional renders co
       }
     }
   ]
-}

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

Unconditional thorns example
{
+}

When HP drops below 2, the piece becomes uncapturable. A last-stand invulnerability.

Unconditional thorns example
{
   "condition": {
     "type": "always"
   },
@@ -85,7 +163,7 @@ exports[`ParamField rendering (T14 regression baseline) > conditional renders co
       }
     }
   ]
-}

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

" -`; - -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) block-move-type renders moveType enum 1`] = ` -"

Filters out generated moves matching the given type. Multiple block primitives accumulate into a blocked-move-type set (deduped). Useful for pacifist pieces that still slide, or for pieces that can capture but not reposition silently.

Examples
Pacifist piece
{
-  "moveType": "capture"
-}

Piece can step and slide freely but cannot capture — a pure support piece.

Charge-only attacker
{
-  "moveType": "step"
-}

Removes simple step moves; piece can only capture or slide.

" -`; - -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) override-promotion renders target enum 1`] = ` -"

Forces this piece (typically a pawn) to promote to a specific type regardless of player choice. Mirrors the built-in Promotion Override modifier, but expressable inside a custom primitive tree. Last write wins if multiple sources set it.

Examples
Knights-only promotion
{
-  "target": "knight"
-}

Pawn always promotes to a knight.

Underpromote to rook
{
-  "target": "rook"
-}

Pawn always promotes to a rook — useful for themed variants.

" -`; - -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.

" +}

The piece is allowed to capture its own color's pieces.

" `;