ui: update ParamField to support ZodUnion fields

- Handles ZodUnion branches via ParamFieldUnion
- Introduces 'Use primitive' / 'Use binding' toggle for resolver-capable fields
- Supports ZodOptional unwrapping for widened fields
- Adds test coverage in ParamField.snapshot.test.tsx ensuring regression baseline holds
This commit is contained in:
Joey Yakimowich-Payne 2026-04-26 23:17:20 -06:00
commit f1aa831546
No known key found for this signature in database
4 changed files with 514 additions and 272 deletions

View file

@ -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 <log> <args>` — 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 `<input type="text">`. 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<T>`)
- `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<string,string>` 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<T>` 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.