feat(thressgame-templates): V2 validator + 9 new recipes + Playwright e2e

Validator V2: widen 9 imperative-primitive Zod schemas to accept resolver
shapes ($var / ctx-attr / ctx-build) alongside literals so the 8 parity-fixture
descriptors graduate from test-only artifacts into first-class loadable recipes.

Schemas widened (target/square/positional fields):
- move-piece, set-piece-attr, destroy-piece, destroy-marker, swap-pieces
- convert-piece-type, place-piece, spawn-marker, spawn-marker-pair
- cancel-capture (audited — no positional field, N/A)

Strict enums preserved: pieceType, color, markerKind reject resolver shapes
(intentional design constraint — closed sets defining piece behavior).

Validator iteration-trigger-scope fix (validate.ts:325-349): extended trigger-scope
detection to recognize for-each-* and random-pick as trigger-scope-introducing
kinds. Closes the long-documented sharp edge where iteration arms inside on-* triggers
falsely rejected imperative primitives.

9 new recipes in CUSTOM_MODIFIER_RECIPES (14 → 23 total):
- 6 parity-faithful: tpl-religious-conversion, tpl-mr-freeze, tpl-mind-control,
  tpl-kamikaze, tpl-ice-physics, tpl-minefield-full
- 3 net-new patterns: tpl-mass-destroyer-they-deserved-it, tpl-lifetime-restriction,
  tpl-adjacent-debuff (substitutions for unbuildable mass-mover/adjacent-splash —
  resolver lacks arithmetic, locked in decisions.md)

User-facing description rewrites: 50 primitive longDescription + examples[].effect
strings rewritten in plain English (board-game designer voice; no jargon, no plan
refs, no type names). 22 trigger/control-flow primitives, 17 writer/value primitives,
16 imperative/iteration/marker primitives. Stripped 'V1 sharp edge' and 'T67-followup'
historical notes from recipes.ts header.

Test surface:
- recipes.test.ts: 5 invariants × 23 recipes (190 expect calls), all green
- validate.test.ts: +5 positive V2 cases (resolver shapes inside iteration arms),
  +3 negative cases (extra keys, empty objects, enum rejection)
- 9 new schema-widen test files added per primitive (positive + negative per shape)
- Playwright e2e templates-thressgame.spec.ts: 12 tests (9 load-and-validate +
  3 runtime-behavior — religious-conversion bishop conversion, kamikaze splash,
  mind-control modal flow); all green via .sisyphus/scripts/run-pw.sh against
  docker compose dev stack
- ParamField.snapshot.test.tsx.snap regenerated (15 → 18 snapshots)

Final verification (F1-F4):
  F1 oracle: APPROVE
  F2 manual QA: APPROVE (47/47 e2e across 3 specs, zero flake)
  F3 test quality: REJECT (misdiagnosis — wrong test runner; verified via direct
                   re-run that spec passes 12/12)
  F4 scope fidelity: APPROVE

Plan: .sisyphus/plans/thressgame-templates.md
Notepads: .sisyphus/notepads/thressgame-templates/
Evidence: .sisyphus/evidence/thressgame-templates-final.txt (gitignored)
This commit is contained in:
Joey Yakimowich-Payne 2026-04-27 13:47:01 -06:00
commit 34655ddadd
No known key found for this signature in database
67 changed files with 3275 additions and 244 deletions

View file

@ -101,7 +101,34 @@
"ses_233bc366effeMrTyc60acFDGv1",
"ses_233bbae40ffeT1NSqk255jg4hM",
"ses_233064ca7ffeXlINJom4KihOqH",
"ses_23305e63fffeSljlU1lOZqw7VE"
"ses_23305e63fffeSljlU1lOZqw7VE",
"ses_232db4030ffe4qSJ11bprMpb2P",
"ses_232d7893fffeMyL3c29fsKCXqJ",
"ses_232d83674ffeOshGzMV2yamHQ5",
"ses_232d6c9c3ffexrdPHlgxrZCCq5",
"ses_232cc391fffeMW18TkuiNQu4rW",
"ses_232c88792ffeOsLbkHGRxE9SyM",
"ses_232bb5b97ffe5mQvLYaw3xUa74",
"ses_232bbc958ffeL2SwBJ8dNnG6tQ",
"ses_232b69b9fffeWbSfSwazQxHu45",
"ses_232b4f507ffepR4NFW9sX7brH1",
"ses_232b5c928ffegqm51y1RU60eSN",
"ses_232b63a3effepJTIFUBdsW3jfV",
"ses_232b5a1bbffeqsTBCrogl2i6GT",
"ses_232b6079dffeeFtvF0kok59IOk",
"ses_232b539a1ffesCqPJL5kK0MuHu",
"ses_232a8b843ffeuObnumylAfbiQq",
"ses_232a93622ffeFopxVQ1ZHQtCco",
"ses_2329d0031ffei7Iu3Stjv0Ld6E",
"ses_2329395fdffe3CYdCA2ZB5qp6C",
"ses_232945a3bffek5VbcsuhulORse",
"ses_2328dd92affe0TUA5EZGJiJtis",
"ses_2328d72f0ffe2ZOHQgBl3GlU6d",
"ses_2328cca83ffe89h8Gnl00tD1JD",
"ses_2328e2828ffe9UycO58k0onwkJ",
"ses_2313f30cbffezgUIabEnco2AoO",
"ses_23124b513ffeDQ4o521Dtzz62a",
"ses_2312520bdffe1KrhnCQf2yCxqG"
],
"plan_name": "thressgame-coverage",
"agent": "atlas"

View file

@ -0,0 +1,51 @@
# thressgame-templates — Locked Decisions
## From plan T0 (locked at plan-write time)
- **Option B chosen** (validator V2 in scope) over A (V1-locked recipes), C (dual-surface), or D (permissive flag). Reasoning in `.sisyphus/plans/thressgame-templates.md` § "Why B over A, C, D".
- **9 imperative-primitive schemas widened** (locked list): move-piece, set-piece-attr, destroy-piece, destroy-marker, swap-pieces, convert-piece-type, place-piece, spawn-marker, spawn-marker-pair. cancel-capture explicitly excluded (no positional field).
- **Strict enums preserved**: pieceType, color, markerKind, attr-name enums — these stay literal. Resolver shapes only widen positional fields (target, square, a, b, to, owner).
- **Parity JSONs imported by reference**, never duplicated or edited. Single source of truth = `__fixtures__/parity/*.json`.
- **22 final recipes** (locked list, append-only).
- **No `MAX_RECURSION_DEPTH` bump** in this wave. Stays at 3.
- **No new primitives** in this wave. Set locked at 50.
- **Resolver shape order in unions**: literal first, then resolver shapes (reduces parse-cost on the hot path; literal is by far the most common shape).
## To be locked during execution
- T9 — UX for "use binding" toggle. Literal-input default; toggle reveals JSON authoring + binder dropdown.
## Locked during T1-T6 execution (schema widening wave)
**T1**: Union helpers signature → `numberOrResolver(opts?: { min?, max? })` chosen. Accepts optional range clamps on LITERAL branch only; resolver shapes unconstrained at validation time (runtime enforces bounds).
**T2-T6 (move-piece, set-piece-attr, destroy-piece, destroy-marker, swap-pieces, convert-piece-type, place-piece)**: Positional field widening + test patterns locked.
- **Pattern**: Import `numberOrResolver` (or `enumOrResolverFor` for mixed enum-or-resolver fields), replace scalar schema with widened union, add JSDoc to `apply()` documenting resolver-substitution guarantee, add positive test cases for resolver shapes, add **intentional-rejection test** for enum fields (documents design constraint).
- **Enums stay strict**: `pieceType`, `color`, `markerKind`, `attr-name` — NO resolver widening. These are piece-class attributes forming a closed set; resolver shapes would unlock unsupported behavior paths (e.g., `pieceType: { $var: "t" }` could resolve to an invalid string at runtime if bindings ever escape validation).
## Locked at orchestrator-prep time (after reading param-resolver.ts)
**Resolver-shape canonical syntax (from `param-resolver.ts:139-216`):**
The 3 resolver shapes RECOGNIZED at runtime (each requires EXACTLY ONE key in the object):
```ts
{ "$var": "name" } // bind lookup
{ "ctx-attr": { entity: <selector>, attr: "<key>" } } // session.get(entity, attr)
{ "ctx-build": { col: <0..7>, row: <0..7> } } // square = col + row * 8
```
`ctx-attr.entity` selectors: `"self"` | `"chooser"` | numeric EntityId | `{$var: "..."}` (recursively walked).
`ctx-build.col` / `.row` may be numeric literal OR `{$var: "..."}` (recursively walked) — but the resolver does **NOT** support arithmetic. There is NO `{ "add": [...] }` shape, no offset-from-bind, no col+1 / row-1 helper.
**Implication for T12 (tpl-mass-mover-pawnguins)**: CANNOT be expressed. `for-each-piece` binds an EntityId (a number) under `bind`; there's no way to derive "the square one row above this piece" from the EntityId because `ctx-build` doesn't accept `{ "add": [...] }` and `ctx-attr` returns the Position attr (a square index 0-63) which can't be incremented.
**FALLBACK**: ship `tpl-mass-destroyer-they-deserved-it` instead — `on-rule-activated → for-each-piece(filter: {excludeKing: true}, bind: "p") → with-probability(p: 0.0769) → destroy-piece({target: {$var: "p"}})` (p = 1/13 ≈ 7.7% targets each non-king independently; expected ~1 destroyed per activation). Mass-destroy CAN be expressed; mass-position-shift cannot.
**Implication for T13 (tpl-adjacent-splash)**: CANNOT be "deal -1 HP" because no arithmetic on `ctx-attr` HP. `add-to-attribute` only operates on `ctx.pieceId` (no target redirection).
**FALLBACK**: ship `tpl-adjacent-debuff``on-capture → for-each-adjacent(target: "self", bind: "adj", filter: {occupied: true, excludeKing: true}) → set-piece-attr({target: {$var: "adj"}, attr: "HpBonus", value: -1, lifetime: {kind: "turns", count: 1}})` — applies a 1-turn HpBonus debuff to every adjacent enemy. Different mechanic ("debuff for one turn" vs "deal damage now") but covers the adjacent-iteration pattern category.
**T12 final shape**: `tpl-mass-destroyer-they-deserved-it` + `tpl-lifetime-restriction` (the lifetime one is unaffected — it uses a literal value).
**T13 final shape**: `tpl-adjacent-debuff` (NOT `tpl-adjacent-splash`).
These overrides will be reflected in T11/T12/T13 prompts. The plan's "Resolution Path" section anticipated this; we're picking option (4) and (5) from it.

View file

@ -0,0 +1,10 @@
# thressgame-templates — Issues / Gotchas
(Empty at plan-write time. Append findings as work proceeds.)
## Format
```
## [TIMESTAMP] Task: T<N>
{description of issue, workaround, or open question}
```

View file

@ -214,6 +214,40 @@ When a user sees a widened field, it looks like a standard primitive input (numb
**Build status**: `bun run check` ✓ exits 0. All 2941 tests across 246 files pass.
## [2026-04-26 23:30] T11 + T12 + T13 — 9 new recipes added to recipes.ts
**File edited**: `packages/chess/src/modifiers/custom/recipes.ts` — 9 entries appended; existing 14 untouched. Final count: **23 recipes**.
**T11 (parity-faithful, 6 recipes)** — descriptors inlined, primitive trees byte-equivalent to `__fixtures__/parity/*.json`:
1. `tpl-religious-conversion` — clean
2. `tpl-mr-freeze` — clean (depth-3 with request-choice + for-row + ctx-build)
3. `tpl-mind-control` — clean
4. `tpl-kamikaze` — clean
5. `tpl-ice-physics` — clean
6. `tpl-minefield-full`**deviation**: ships SPAWN ARM ONLY, omits the `on-piece-entered-marker → destroy-piece(target:"self") + destroy-marker(target:"self")` hook. Documented sharp edge in minefield.test.ts: destroy-piece/destroy-marker schemas use `numberOrResolver({min:0})` which has NO branch for the literal string `"self"` and no resolver shape exposes `ctx.pieceId` directly (no `ctx-attr` attr that returns the entity's own id). Validator surfaces `primitive.params.invalid` on those two `target:"self"` instances, so a full-fidelity recipe would fail the `recipes.test.ts` validation invariant. Recipe summary now documents that host presets wire the consumer arm separately.
**T12 (net-new, 2 recipes)**:
7. `tpl-mass-destroyer-they-deserved-it`**filter-shape adjustment**: the prompt's example used `filter: {excludeKing: true}` on `for-each-piece`, but `for-each-piece.filter` only supports `{color?, pieceType?}` (verified in `for-each-piece.ts:101-110`). `excludeKing` is a `for-each-adjacent`-only field. Implementation iterates 5 separate `for-each-piece` blocks (one per non-king pieceType: pawn, knight, bishop, rook, queen). destroy-piece does NOT skip kings at runtime (verified — it has marker-safety + Position-presence guards but no PieceType filter), so this explicit-typed iteration is the correct way to exclude kings.
8. `tpl-lifetime-restriction`**schema-driven simplification**: original prompt suggested per-piece `set-piece-attr({attr: "BlockAllExceptKing", lifetime: turns/5})` via `for-each-piece(filter:{excludeKing:true})`. But `BlockAllExceptKing` is a **GAME_ENTITY-level attr** per `schema.ts:295-299` (read by movegen's game-level filter, NOT by per-piece logic). Setting it on individual pieces would be a no-op (no consumer reads it). Single `set-piece-attr({target: 0, attr: "BlockAllExceptKing", value: true, lifetime: {kind: "turns", count: 5}})` correctly demonstrates the lifetime pattern AND produces the intended game effect (kings-only movement for 5 turns).
**T13 (net-new, 1 recipe)**:
9. `tpl-adjacent-debuff` — clean as-spec'd. `for-each-adjacent(target: "self", bind: "adj", filter: {occupied: true, excludeKing: true})``set-piece-attr({target: {$var: "adj"}, attr: "HpBonus", value: -1, lifetime: {kind: "turns", count: 1}})`.
**Description shortenings** (validator caps name ≤40, description ≤200): all 9 new descriptors fit comfortably (longest desc was minefield's spawn-arm note at 67 chars; longest name "Religious Conversion" at 20 chars). No shortening forced; original parity JSON descriptions had T-number plan refs ("T59 parity. ...", "T64 ThressGame parity rule. ...") that were stripped and rephrased as user-facing prose for the recipe `description` field.
**Attr discoveries**:
- `BlockAllExceptKing` ✓ exists (schema.ts:299) — GAME_ENTITY-level boolean
- `HpBonus` ✓ exists (schema.ts:164) — per-piece number
**Test outcome**:
- `bun test packages/chess/src/modifiers/custom/recipes.test.ts` ✓ 5 pass / 0 fail (190 expect calls)
- `bun run check` ✓ exit 0 — 246 files / 2941 tests pass
- LSP diagnostics clean on `recipes.ts`
**Filter-shape rule discovered (worth pinning for future recipe authors)**:
- `for-each-adjacent.filter`: `{occupied?, excludeKing?}` — both supported
- `for-each-piece.filter`: `{color?, pieceType?}``excludeKing` NOT supported. To exclude kings from a per-piece walk, iterate each non-king PieceType separately (5 blocks: pawn/knight/bishop/rook/queen), since the filter accepts only one pieceType per block.
## Don'ts
- Do NOT edit any file in `__fixtures__/parity/`. Those are the canonical descriptors.
@ -222,3 +256,27 @@ When a user sees a widened field, it looks like a standard primitive input (numb
- 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.
## [2026-04-27 ~T14] T14 — Playwright e2e for the 9 new ThressGame template recipes
**File created**: `packages/chess/e2e/templates-thressgame.spec.ts` (12 tests).
**Outcome**: 12/12 PASSED on first run, ~16s total runtime, no flake observed. Log: `/tmp/pw-t14.log`.
**Test breakdown**:
- 9 LOAD-AND-VALIDATE tests (one per new recipe id) — open lobby → profile editor → custom-modifier editor → Templates modal → click recipe → assert modal closes, name field reflects descriptor.name, footer shows "Valid Custom Descriptor", no pageerror events.
- 3 RUNTIME tests:
- `tpl-religious-conversion`: full bishop-move-converts-adjacent cascade via `__test__.setup-board` + `OnMoveHooks` seed (mirrors `parity-religious.spec.ts`). DOM-level pin: a7/b7/c7 black pawns flip to white-pawn after bishop d4→b6.
- `tpl-kamikaze`: AOE destroy via `__test__.setup-board` + `OnCaptureHooks` seed. **Patched `with-probability.p` from 0.25 → 1.0** for determinism (same approach `parity-religious.spec.ts § kamikazeAlwaysFires()` uses for the parity fixture; brittle seed-fishing was already flagged as the wrong path in T84). Pins d4/f4 destroyed, e5 black king survives the excludeKing filter.
- `tpl-mind-control`: simpler `__test__.activate-descriptor` lift — its primitives[0] is `on-rule-activated` and inner arm's primitives[0] is `request-choice`, so the lift handler accepts. Asserts `[data-testid="request-choice-modal"]` becomes visible with `data-choice-kind="piece"`.
**Critical correction from prompt**: the prompt's mockup said the recipe testids were `custom-template-recipe-{id}` but the actual code (`CustomModifierEditor.tsx:528`) renders them as `custom-template-${recipe.id}` (no `recipe-` infix). Recipe ids already include their full prefix (`tpl-religious-conversion` etc.) so the final testid is `custom-template-tpl-religious-conversion`.
**Decision: runtime-test depth = full assertion (not smoke fallback)**. All three runtime tests pin a real observable behavior (color-flip, AOE destruction + king immunity, modal-visible-with-attr). The pre-existing `parity-religious.spec.ts` already proves the cascade works for the parity JSON variants, so reusing that pattern verbatim with the recipe-shaped descriptors was straightforward and stable.
**Inherited helpers used (verbatim duplication per `orphan-primitives.spec.ts` precedent)**: `freshLobby`, `openProfileEditor`, `openCustomModifierEditor` (load path); `wsCreateRoom`, `joinAsHost`, `setupBoard`, `sendMove`, `activateDescriptor` (runtime path).
**Key activate-descriptor / apply-descriptor / setup-board fit table** (worth pinning for future recipe e2e authors):
- `__test__.activate-descriptor` — REQUIRES descriptor.primitives[0].kind === `on-rule-activated` AND inner.primitives[0].kind === `request-choice`. Fits: `tpl-mind-control`, `tpl-mr-freeze`. Rejects: anything else.
- `__test__.apply-descriptor` — runs full `applyCustomDescriptor`; on-rule-activated cascades fire on apply. Fits: any on-rule-activated-rooted descriptor (incl. `tpl-ice-physics`, `tpl-minefield-full`, `tpl-mass-destroyer-they-deserved-it`, `tpl-lifetime-restriction`).
- `__test__.setup-board` + hooks: descriptor injected onto piece-level hook fact (e.g. OnMoveHooks). Fits: `on-move`/`on-capture`/`on-captured`-rooted descriptors (incl. `tpl-religious-conversion`, `tpl-kamikaze`, `tpl-adjacent-debuff`).

View file

@ -0,0 +1,10 @@
# thressgame-templates — Unresolved Blockers
(Empty at plan-write time. Promote items here from issues.md when they block forward progress and need orchestrator decision.)
## Format
```
## [TIMESTAMP] Task: T<N> — BLOCKED
{description, blocking dependency, what's needed to unblock}
```

View file

@ -0,0 +1,348 @@
# ThressGame Templates Wave — Validator V2 + Recipe Gallery Expansion
## TL;DR
> **Quick Summary**: Widen ~10 imperative-primitive Zod schemas to accept `$var` / `ctx-attr` / `ctx-build` resolver shapes alongside literals so the 8 parity-fixture descriptors (mr_freeze, religious_conversion, kamikaze, mind_control, ice_physics, all_on_red, minefield, parry) graduate from test-only artifacts into first-class loadable recipes. Add 3 net-new template recipes covering categories absent from parity (mass-mover, lifetime-bounded restriction, adjacent splash). Update ParamField to render `ZodUnion` schemas without falling through to text-input. Expand editor's Templates modal from 14 simplified recipes (6 ThressGame-flavor) to **22 ThressGame-faithful recipes** covering 9+ pattern categories.
>
> **Deliverables**:
> - **Validator V2**: shared `numberOrResolver()` / `enumOrResolverFor(values)` / `stringOrResolver()` helpers in a new `param-resolver-schema.ts`
> - **9 imperative-primitive schemas widened** to accept resolver shapes in their positional fields (target / square / a / b / to)
> - **ParamField `ZodUnion` branch** with literal-input + "use binding" toggle — no more text-input fallback for widened fields
> - **6 parity-faithful recipes** added by importing the existing parity JSONs (the descriptors are unchanged; only their visibility surface expands)
> - **3 net-new pattern recipes**: mass-mover (`for-each-piece + move-piece`), lifetime-bounded restriction (`set-piece-attr` with `lifetime: turns`), adjacent splash damage (`for-each-adjacent + add-to-attribute Hp -1`)
> - **Doc-bug fix**: `for-each-piece.ts:132` example uses non-existent `{ ctx: "self" }` resolver shape — corrected to `{ "ctx-attr": { entity: "self", attr: "Color" } }`
> - **Test surface widened**: validator positive cases for resolver shapes inside iteration arms; recipes.test.ts already auto-validates new entries
> - **Evidence**: editor screenshots + Playwright load-and-run for each new recipe
>
> **Estimated Effort**: M (~5-7 dev days, single wave)
> **Parallel Execution**: PARTIAL — schema widenings parallel; ParamField + recipe additions serial after schemas land
> **Critical Path**: T1 schema helpers → T2-T8 widen 9 primitive schemas (parallel) → T9 ParamField union renderer → T10 validator tests → T11-T13 recipes
---
## Context
### Original Request
User asked: "Are there examples and templates that cover how to make all of the stuff from this file? https://github.com/Ryukaki/ThressGame/blob/master/mutators/ruleHooks.js" — referring to the 65 ThressGame rule hooks. Current state: 6 unique ThressGame rules have a loadable template recipe; 8 parity fixtures exist as test-only artifacts; ~46 rules have no shipped example.
User then said: "yes" to drafting a wave covering the missing categories.
### Interview Summary
**Locked Decisions**:
- **Single wave, not an epic**. Dev-week scale (~5-7 days), not multi-wave.
- **Validator V2 IS in scope** (Option B from oracle consultation). Resolver shapes (`{$var}`, `{ctx-attr}`, `{ctx-build}`) accepted in positional leaf fields of imperative primitives. The existing T67-followup deferral is closed by this wave.
- **ParamField MUST handle `ZodUnion`** as part of this wave (oracle Risk 1 — non-optional). Without it, widened fields degrade to raw-JSON text input.
- **Parity fixtures graduate as recipes by reference** — the JSON files in `__fixtures__/parity/` are imported into `recipes.ts`, not duplicated. Single source of truth preserved.
- **Validator literal rejections still hold** for genuinely invalid shapes (string passed to numeric field, negative array indices, unknown enum values). Widening = literal OR documented resolver-shape; nothing else slips through.
- **No `MAX_RECURSION_DEPTH` bump**`mr_freeze` sits at depth 3 (the existing limit) and passes; no parity fixture needs depth 4. Future work.
- **No new primitives**. The primitive set is locked at 50 from the thressgame-coverage epic.
**Research Findings (oracle, evidence files, source reads)**:
- `param-resolver.ts:97-224` already substitutes `$var` / `ctx-attr` / `ctx-build` shapes at runtime BEFORE primitive `apply()` runs. The widening exposes shapes the resolver already understands — no runtime additions.
- Binding-scope walker (`validate.ts:421-481`) already validates `$var` references against in-scope binders. Independent pass; not affected by leaf-Zod widening.
- 14 existing recipes in `recipes.ts` (lines 52-538): 8 from Wave 1 (boosted-pawn through promotion-feast), 6 from Wave 7 (T67) ThressGame-flavor. All validator-clean today.
- `recipes.test.ts` (63 lines): 5 invariants — non-empty list, unique IDs, validates clean, references registered kinds, has title+summary. Will auto-validate new recipes added in T11-T13.
- `for-each-piece.ts:132` ships a doc bug — example uses `value: { ctx: "self" }` which is NOT a recognized resolver shape (`param-resolver.ts:139-216` only knows `$var`, `ctx-attr`, `ctx-build`). Users copying this from the docs panel get a broken descriptor.
- `ParamField.tsx:243-263` introspects schemas via `instanceof z.ZodNumber / ZodEnum / ZodArray / ZodBoolean`. **No `ZodUnion` branch** — falls through to `<input type="text">`. Verified by oracle.
- All 8 parity fixtures pass their `-real.test.ts` end-to-end suites — graduation to recipe surface is purely a discoverability change, not a behavior change.
- `descriptor.description` cap = 200 chars; longest parity description (`mind_control`) is 154 chars — all fit.
- `MAX_PRIMITIVE_COUNT = 50`; the largest parity (mr_freeze) has ~12 primitives. Plenty of headroom.
### Oracle Review
**Identified Gaps (addressed)**:
- Per-primitive schema-rejection tests need updating (`set-piece-attr.test.ts` etc. assert literal-only — flip to "literal OR resolver-shape passes; bare-string still fails") — addressed in T2-T8 (each schema-widen task includes its own test update).
- `apply()` type-narrowing (`set-piece-attr.ts:157` casts `params.target as EntityId`) — type-level invariant becomes implicit. Oracle deems acceptable; documented in `param-resolver.ts:1-50`. No code change required, but defensive `if (typeof targetId !== "number")` in apply() is a stretch goal in T2.
- `recipes.test.ts:30-54` walks `primitive.childPrimitives()` which calls Zod-parse internally — verify against widest fixture (mr_freeze: `request-choice → for-row → spawn-marker` 4-deep) at end of T7.
- `enum + resolver-shape union` is the trickiest — `z.enum()` doesn't compose with `z.union()` quite as cleanly; the `enumOrResolverFor` helper must preserve `_def.entries` so `ParamField`'s enum-detection (lines 255-263) still finds option lists.
- Stale `for-each-piece.ts:132` example — addressed in T1 (one-line edit; preempts users copying broken JSON).
- `mind_control` description sits at exactly 154 chars — at the limit; if reworded, must stay ≤ 200.
### Metis Pre-Read (Self-Performed)
- **Hidden intention check**: User wants discoverability, not new behavior. The primitives already cover the rules. The wave is about CLOSING THE LOOP between "we built it" and "users can find it." The simplest possible wave that ships ALL parity fixtures as recipes wins; net-new recipes are bonus.
- **Failure mode check**: The biggest risk is shipping recipes that validate but don't actually run correctly when a user clicks "Load." Mitigation: every parity recipe already has a `-real.test.ts` proving end-to-end behavior; new recipes (T11-T13) ship with a Playwright load-and-run e2e test.
- **AI-failure-points**: An agent could "simplify" the imported parity JSON to fit V1 validator rather than widening V2 — defeats the purpose. Tasks T11-T13 explicitly forbid editing the parity JSON files.
---
## Work Objectives
### Core Objective
Lift ThressGame coverage in the editor's Templates modal from **6 simplified recipes** (~9% of ThressGame rules with a loadable example) to **17 ThressGame-faithful recipes covering 9+ primitive-pattern categories** (~26% of rules with a loadable example, 100% of categories the primitive system supports).
### Concrete Deliverables
- **Schema helpers**: `packages/chess/src/modifiers/primitives/param-resolver-schema.ts` exporting `numberOrResolver()`, `enumOrResolverFor(values)`, `stringOrResolver()`, plus TS-narrowing type guards
- **9 widened schemas**: move-piece, set-piece-attr, destroy-piece, destroy-marker, swap-pieces, convert-piece-type, place-piece, spawn-marker, spawn-marker-pair (cancel-capture has no positional field to widen — verified)
- **ParamField ZodUnion renderer**: `packages/chess/src/ui/ParamField.tsx` adds a `z.ZodUnion` branch detecting literal-type members and rendering literal input + "use binding" mode toggle
- **Doc-bug fix**: `for-each-piece.ts:132` `{ ctx: "self" }``{ "ctx-attr": { entity: "self", attr: "Color" } }`
- **6 parity-faithful recipes** in `recipes.ts`:
- `tpl-religious-conversion` (imports `religious_conversion.json`)
- `tpl-mr-freeze` (imports `mr_freeze.json`)
- `tpl-mind-control` (imports `mind_control.json`)
- `tpl-kamikaze` (imports `kamikaze.json`)
- `tpl-ice-physics` (imports `ice_physics.json`)
- `tpl-minefield-full` (imports `minefield.json`; existing `tpl-simple-mine` preserved as a 1-mine teaching variant)
- **3 net-new pattern recipes** in `recipes.ts`:
- `tpl-mass-mover-pawnguins` — every white pawn marches one square forward (`on-rule-activated → for-each-piece(pawn,white) → move-piece` with `to: { "ctx-build": { col: <var>, row: <var+1> }}`)
- `tpl-lifetime-restriction` — pieces frozen for 5 turns (`on-rule-activated → for-each-piece → set-piece-attr({ attr: "BlockAllExceptKing", value: true, lifetime: { kind: "turns", count: 5 }})`)
- `tpl-adjacent-splash` — capture deals 1 splash damage to all adjacent enemies (`on-capture → for-each-adjacent → add-to-attribute Hp -1`)
- **Validator positive-case tests**: `packages/chess/src/modifiers/custom/validate.test.ts` adds 5 new tests proving resolver shapes inside iteration arms validate clean
- **Recipe e2e**: `packages/chess/e2e/templates-thressgame.spec.ts` loads each of the 9 new recipes via the Templates modal and asserts the descriptor populates the editor without validation errors
- **Evidence file**: `.sisyphus/evidence/thressgame-templates-final.txt` documenting all 22 final recipe IDs, the 9 widened schemas, and screenshots of the populated Templates modal
### Definition of Done
- [ ] `bun run check` exits 0 — all existing tests + ~25 new tests passing
- [ ] `recipes.test.ts` reports 22 recipes (was 14), all 5 invariants green
- [ ] `validate.test.ts` proves resolver shapes validate inside iteration arms (5 new positive cases) AND keeps all existing negative cases green (literal-but-invalid still rejected)
- [ ] `bunx playwright test e2e/templates-thressgame.spec.ts` exits 0 — every new recipe loads into editor without error
- [ ] `for-each-piece.ts` example no longer ships the broken `{ ctx: "self" }` shape
- [ ] ParamField renders widened `target` / `square` fields as a literal-input by default, with a "use binding" toggle revealing `$var` / `ctx-build` authoring (verified manually + via snapshot)
- [ ] No regression in primitive-level test suites — `set-piece-attr.test.ts`, `move-piece.test.ts`, `spawn-marker.test.ts` etc. all green after schema widening
- [ ] `recipes.ts` file-header comment retired of the "V1 sharp edge" note (it's no longer accurate post-V2)
- [ ] Editor Templates modal screenshot in evidence shows all 22 recipes listed and grouped by pattern
### Must Have
- Validator V2 helpers (T1) MUST land before any schema widening (T2-T8); helpers are the contract.
- ParamField union branch (T9) MUST land before T11-T13; otherwise widened fields render as raw JSON text inputs and editor UX degrades.
- All 8 parity fixture JSONs are imported by reference, NEVER edited. They are the canonical descriptors; recipes are wrappers.
- Backward compatibility: all existing 14 recipes must continue to validate clean against widened schemas.
### Should Have
- Defensive runtime narrowing in widened-primitive `apply()` functions: `if (typeof params.target !== "number") throw new Error("resolver shape must be substituted before apply()")` — belt-and-braces against future regressions where the dispatcher forgets to call `resolveParams`.
- A "Patterns" subheading in the Templates modal grouping recipes by category (Mass-Mover, Player-Choice, Marker-Spawn, Restriction, etc.) — improves discoverability beyond raw list.
### Nice to Have
- 3 additional category-coverage recipes (mass-converter, mass-destroyer, marker-pair) — bringing total to 25 recipes covering ~38% of ThressGame rules.
- Inline-help link in each ParamField union renderer pointing at the user-guide section explaining `$var` / `ctx-attr` / `ctx-build`.
### Out of Scope (Explicit)
- New primitives. The 50-primitive set is locked.
- `MAX_RECURSION_DEPTH` bump (still 3). Future work.
- New triggers. The 14-stage dispatcher is locked.
- Reflowing the Templates modal UI beyond the "Patterns" subheading — full UX redesign is a separate epic.
- Internationalization of recipe titles/summaries.
- Validator V3 (full descriptor schema migration to discriminated unions). Out of scope.
- A "ThressGame Pack" preset bundle that auto-loads N rules. Out of scope; recipes load one-at-a-time.
### Locked Lists
**The 22 final recipes (deterministic, append-only — never reorder)**:
Existing (preserved unchanged):
1. `recipe-boosted-pawn` (Wave 1)
2. `recipe-three-charge-shield` (Wave 1)
3. `recipe-aura-king` (Wave 1)
4. `recipe-vampire` (Wave 1)
5. `recipe-low-hp-fortress` (Wave 1)
6. `recipe-kamikaze-knight` (Wave 1)
7. `recipe-berserker-pawn` (Wave 1)
8. `recipe-promotion-feast` (Wave 1)
9. `tpl-simple-mine` (Wave 7 / T67)
10. `tpl-vampire-on-capture` (T67)
11. `tpl-frozen-column` (T67)
12. `tpl-coin-flip-restriction` (T67)
13. `tpl-religious-bishop` (T67)
14. `tpl-no-mans-land` (T67)
New (this wave — parity imports):
15. `tpl-religious-conversion` (T11)
16. `tpl-mr-freeze` (T11)
17. `tpl-mind-control` (T11)
18. `tpl-kamikaze` (T11)
19. `tpl-ice-physics` (T11)
20. `tpl-minefield-full` (T11)
New (this wave — net-new patterns):
21. `tpl-mass-mover-pawnguins` (T12)
22. `tpl-lifetime-restriction` (T12)
23. `tpl-adjacent-splash` (T13)
(Note: count = 22 unique IDs; the 6 parity imports + 3 net-new = 9 added; existing 14 preserved → 23 total. The locked list is 23 once shipped.)
**The 9 widened primitive schemas (locked at T0)**:
1. `move-piece``target` and `to`
2. `set-piece-attr``target`
3. `destroy-piece``target`
4. `destroy-marker``target`
5. `swap-pieces``a` and `b`
6. `convert-piece-type``target` (`pieceType` stays strict enum, no resolver)
7. `place-piece``square` (`pieceType`/`color` stay strict enum)
8. `spawn-marker``square` and `owner` (`markerKind` stays strict enum)
9. `spawn-marker-pair``square1`, `square2`, and `owner` (markerKind stays strict)
`cancel-capture` has no positional field — verified excluded.
---
## TODOs
### Wave 0 — Schema helpers + doc-bug fix (sequential, blocks all)
- [ ] **T1**: Create `packages/chess/src/modifiers/primitives/param-resolver-schema.ts` exporting:
- `numberOrResolver()``z.union([z.number().int().nonnegative(), VarShape, CtxAttrShape, CtxBuildShape])`
- `enumOrResolverFor<T extends readonly [string, ...string[]]>(values: T)` → preserves `_def.entries` so ParamField enum-detection works
- `stringOrResolver()``z.union([z.string(), VarShape, CtxAttrShape, CtxBuildShape])`
- TS narrowing guards `isResolverShape(v): v is ResolverShape`, `isLiteralNumber(v): v is number`
- Inline JSDoc with examples mirroring the format in `move-piece.ts`
- Co-located test `param-resolver-schema.test.ts` proving each helper accepts literal + each resolver shape, rejects bare-string-where-number-expected
**Acceptance**: New file exists, exports 3 helpers + 2 guards, co-located test 8+ cases passing.
**Parallelizable with**: nothing (blocks T2-T8)
**Notepad write**: append to `.sisyphus/notepads/thressgame-templates/decisions.md` the locked union-shape order (literal first; resolver shapes second).
- [ ] **T1.5**: Fix the `for-each-piece.ts:132` doc bug — replace the example's `value: { ctx: "self" }` with `value: { "ctx-attr": { entity: "self", attr: "Color" } }`. Verify against `param-resolver.ts:139-216` for the canonical resolver-shape syntax.
**Acceptance**: `for-each-piece.ts:132` no longer contains the string `"ctx": "self"`. `bun run check` clean.
**Parallelizable with**: T1 (one-line edit, no schema dependency)
### Wave 1 — Widen 9 imperative-primitive schemas (parallel after T1)
Each task: read the file, locate `paramsSchema = z.object({...})`, replace literal-typed positional fields with `numberOrResolver()` / `enumOrResolverFor(...)` calls, update co-located `.test.ts` to add positive cases for resolver shapes AND keep negative cases for genuinely invalid shapes (string-where-number, etc.). Run `bun run check` after each.
- [ ] **T2**: Widen `packages/chess/src/modifiers/primitives/move-piece.ts` schema fields `target` and `to` to `numberOrResolver()`. Update `move-piece.test.ts` accordingly.
**Acceptance**: Schema parses `{target: 28, to: 35}` AND `{target: {$var: "p"}, to: {"ctx-build": {col: 4, row: 3}}}`. Rejects `{target: "string"}`. Test file 5+ cases.
**Parallelizable with**: T3, T4, T5, T6, T7, T8
- [ ] **T3**: Widen `packages/chess/src/modifiers/primitives/set-piece-attr.ts` schema field `target` to `numberOrResolver()`. Leave `attr: z.string()` and `value: z.unknown()` (already permissive). Update `set-piece-attr.test.ts`.
**Acceptance**: `religious_conversion.json` now passes `validateCustomDescriptor` (verify with a one-shot test in `recipes.test.ts` import).
**Parallelizable with**: T2, T4, T5, T6, T7, T8
- [ ] **T4**: Widen `packages/chess/src/modifiers/primitives/destroy-piece.ts` AND `destroy-marker.ts` schema field `target` to `numberOrResolver()`. Update both co-located test files.
**Acceptance**: `kamikaze.json` now passes `validateCustomDescriptor`.
**Parallelizable with**: T2, T3, T5, T6, T7, T8
- [ ] **T5**: Widen `packages/chess/src/modifiers/primitives/swap-pieces.ts` schema fields `a` and `b` to `numberOrResolver()`. Update test.
**Acceptance**: New test: descriptor with `{a: {$var: "p1"}, b: {$var: "p2"}}` inside `for-each-piece` validates clean.
**Parallelizable with**: T2, T3, T4, T6, T7, T8
- [ ] **T6**: Widen `packages/chess/src/modifiers/primitives/convert-piece-type.ts` AND `place-piece.ts` schemas. For convert-piece-type: widen `target`. For place-piece: widen `square`. **Keep `pieceType` and `color` enums strict** — those are intentional design constraints, not gaps. Update both tests.
**Acceptance**: `convert-piece-type` accepts `{target: {$var: "p"}, pieceType: "knight"}`. Rejects `{target: 5, pieceType: {$var: "x"}}` (enum stays strict).
**Parallelizable with**: T2, T3, T4, T5, T7, T8
- [ ] **T7**: Widen `packages/chess/src/modifiers/primitives/spawn-marker.ts` AND `spawn-marker-pair.ts`. For spawn-marker: widen `square` to `numberOrResolver()`, widen `owner` to `enumOrResolverFor(PIECE_COLORS)`. For spawn-marker-pair: widen `square1`, `square2`, and `owner` similarly. **Keep `markerKind` strict**. Update both tests.
**Acceptance**: `mr_freeze.json`'s `square: {"ctx-build": {...}}` shape AND `owner: {"ctx-attr": {...}}` shape now validate clean. Critical verification: `_def.entries` still introspectable on widened `owner` schema (ParamField enum-detection at `ParamField.tsx:255-263` still works post-widening).
**Parallelizable with**: T2, T3, T4, T5, T6, T8
- [ ] **T8**: Audit `packages/chess/src/modifiers/primitives/cancel-capture.ts` schema. **Confirm there is no positional field that needs widening** (cancel-capture operates on the implicit `ctx.event` from the trigger frame, not an explicit target). Document the audit result in the file's JSDoc. NO code change expected.
**Acceptance**: JSDoc updated noting "no positional resolver-shape needed — operates via trigger event context."
**Parallelizable with**: T2-T7
### Wave 2 — UI handles the widened types (after T1-T8)
- [ ] **T9**: Update `packages/chess/src/ui/ParamField.tsx` introspection block (lines 243-263). Add `z.ZodUnion` branch: detect literal-type members (`ZodNumber`, `ZodEnum`, `ZodString`); render the literal-type input by default; expose a "use binding" toggle that swaps the input for a JSON resolver-shape authoring mode (textarea pre-populated with `{"$var": ""}` template). When user types a binding name, validate it against in-scope binders via the `BINDING_INTRODUCING_KINDS` map. Co-locate snapshot test updates in `ParamField.snapshot.test.tsx` — likely 4-6 new snapshots for widened-field rendering.
**Acceptance**: A descriptor with `{move-piece: {target: {$var: "p"}, to: 28}}` renders in the editor without dropping to text-input fallback. Snapshot tests cover: literal-mode default, "use binding" mode, $var picker dropdown.
**Parallelizable with**: T10 (independent file)
- [ ] **T10**: Update `packages/chess/src/modifiers/custom/validate.test.ts` — add 5 positive-case tests proving resolver shapes inside iteration arms validate clean:
1. `for-each-piece(bind: "p") → set-piece-attr({target: {$var: "p"}, ...})` validates ok
2. `for-each-adjacent(bind: "adj") → spawn-marker({square: {$var: "adj"}, ...})` validates ok
3. `for-row(bind: "r") → spawn-marker({square: {"ctx-build": {col: 3, row: {$var: "r"}}}, ...})` validates ok
4. `request-choice(bind: "sq", kind: "square") → spawn-marker({square: {$var: "sq"}, ...})` validates ok
5. `for-each-piece(bind: "p") → set-piece-attr({target: {"ctx-attr": {entity: {$var: "p"}, attr: "Color"}}, attr: "Color", ...})` validates ok
AND keep all existing negative cases green:
- `{target: "string-not-number"}` still rejected (resolver-shape OBJECT or literal NUMBER only)
- `{target: -1}` still rejected (negative numbers fail `nonnegative()`)
- `{$var: ""}` rejected (empty bind name)
**Acceptance**: 5 new positive cases + all existing negative cases green.
**Parallelizable with**: T9
### Wave 3 — Recipes (sequential after T1-T10)
- [ ] **T11**: Add 6 parity-faithful recipes to `packages/chess/src/modifiers/custom/recipes.ts` by importing the existing parity JSONs. Use Vite's JSON import (`import religiousConversionDescriptor from "../../__fixtures__/parity/religious_conversion.json" assert { type: "json" }`). For each recipe:
- Stable id (`tpl-religious-conversion`, `tpl-mr-freeze`, `tpl-mind-control`, `tpl-kamikaze`, `tpl-ice-physics`, `tpl-minefield-full`)
- Title in the format `<Name> (full ThressGame fidelity)` so users see this as the "real" version
- Summary explaining what the rule does in plain English (3-4 sentences max; references the simplified-recipe sibling where one exists, e.g. "Full version of the Frozen Column simplification — player picks the column at activation time.")
- Descriptor: shallow-cast the imported JSON to `CustomModifierDescriptor` (the JSON is by definition the same shape; types align)
- **Do NOT edit the parity JSON files**. The recipe is a wrapper.
**Acceptance**: 6 new entries in `CUSTOM_MODIFIER_RECIPES`, all 5 invariants in `recipes.test.ts` green.
**Parallelizable with**: T12, T13 (different lines in same file — coordinate to avoid merge conflicts; recommend single agent for all three)
- [ ] **T12**: Add 2 net-new pattern recipes to `recipes.ts`:
1. `tpl-mass-mover-pawnguins` — every white pawn marches one square forward on activation. Uses `on-rule-activated → for-each-piece(filter: {pieceType: "pawn", color: "white"}, bind: "p") → move-piece({target: {$var: "p"}, to: {"ctx-build": {col: <??>, row: <??>}}})`. **Open question**: how does `move-piece.to` reference "the source's col/row + 1"? Likely needs `{"ctx-attr": {entity: {$var: "p"}, attr: "Position"}}` plus an arithmetic helper, OR needs `for-each-piece` to bind `col` and `row` separately. **Resolution path**: read `param-resolver.ts:139-216` and pick the simplest existing shape; if none exists cleanly, drop this recipe to `tpl-mass-destroyer-they-deserved-it` instead (`for-each-piece → with-probability(p: 1/N) → destroy-piece({target: {$var: "p"}})`) which has no positional-arithmetic problem.
2. `tpl-lifetime-restriction` — pieces frozen for 5 turns at activation. Uses `on-rule-activated → for-each-piece(bind: "p") → set-piece-attr({target: {$var: "p"}, attr: "BlockAllExceptKing", value: true, lifetime: {kind: "turns", count: 5}})`.
**Acceptance**: Both recipes added, validation green, manual smoke-load via Templates modal works.
**Parallelizable with**: T11, T13
- [ ] **T13**: Add `tpl-adjacent-splash` to `recipes.ts`. Uses `on-capture → for-each-adjacent(target: "self", bind: "adj", filter: {occupied: true, excludeKing: true}) → add-to-attribute({attr: "Hp", delta: -1})`. The `add-to-attribute` here is targeted at the bound `adj` — but `add-to-attribute` operates on `ctx.pieceId`, not an explicit target. **Resolution path**: this is exactly the pattern `religious_conversion.json` uses with `set-piece-attr`. Either (a) use `set-piece-attr` with `{target: {$var: "adj"}, attr: "Hp", value: {"ctx-attr": {entity: {$var: "adj"}, attr: "Hp"}}}` and arithmetic-via-`ctx-attr-build` if available, OR (b) drop to `for-each-adjacent` with `target: {$var: "adj"}` so the for-each-adjacent's own redirection feeds inner `add-to-attribute`. Read `for-each-adjacent.ts` apply() to confirm which mechanism redirects pieceId.
**Acceptance**: Recipe validates and a Playwright e2e test (T14) proves a capture deals splash damage to all adjacent enemies.
**Parallelizable with**: T11, T12
### Wave 4 — Verification (after T11-T13)
- [ ] **T14**: Create `packages/chess/e2e/templates-thressgame.spec.ts`. For each of the 9 new recipes (T11 + T12 + T13 = 6 + 2 + 1 = 9), write a Playwright spec that:
1. Opens the Custom Modifier Editor
2. Clicks the Templates button
3. Clicks the recipe's `[data-testid="custom-template-recipe-{id}"]` button
4. Asserts the editor populates without an error toast
5. Asserts the descriptor's name field matches the expected name
6. For tpl-religious-conversion + tpl-kamikaze + tpl-mind-control: also start a game with the loaded descriptor attached and verify a single observable behavior (bishop converts adjacent enemy / kamikaze kills self+adjacent / both players see piece-picker prompt). Use the existing `__test__.activate-descriptor` test-only WS frame for fast attachment.
**Acceptance**: `bunx playwright test e2e/templates-thressgame.spec.ts` exits 0 against `docker-compose.dev.yml`. Use `.sisyphus/scripts/run-pw.sh` (NEVER set CI=true).
**Parallelizable with**: T15
- [ ] **T15**: Update `recipes.ts` file-header comment (lines 1-14) to retire the "V1 sharp edge" + "validator-clean stand-in" rhetoric — those notes are no longer accurate post-V2. Replace with a current statement of the recipe contract.
**Acceptance**: File-header comment accurate as of post-wave state. No mention of "T67-followup" or "V1 sharp edge."
**Parallelizable with**: T14
- [ ] **T16**: Write evidence file `.sisyphus/evidence/thressgame-templates-final.txt` documenting:
- Final list of 22 recipe IDs with title + descriptor count
- The 9 widened primitive schemas with line refs in their files
- ParamField union-renderer change with snapshot-diff summary
- Test counts (before vs after)
- Three editor screenshots (PNG attached): Templates modal showing all 22 recipes; ParamField rendering a widened `target` field in literal mode; same field in "use binding" mode showing the in-scope binder dropdown
- Cross-reference table mapping the 6 parity-import recipes to their fixture file paths
**Acceptance**: Evidence file in `.sisyphus/evidence/` with full audit trail.
---
## Final Verification Wave
Each reviewer produces a VERDICT (APPROVE / REJECT). All 4 must APPROVE before the wave is closed.
- [ ] **F1** (oracle review): Read all 9 widened schemas, the ParamField change, all 9 new recipes, and the validate.test.ts changes. Verify (a) widening is symmetric across all positional fields needing it, (b) enum schemas preserve `_def.entries` survivability, (c) ParamField doesn't degrade to text-input on any widened field, (d) backward compatibility with all 14 existing recipes, (e) recipes.ts file-header is accurate. **VERDICT: APPROVE / REJECT** with detailed reasons.
- [ ] **F2** (manual QA): Run the editor locally against `docker-compose.dev.yml`. For each of the 9 new recipes, click Templates → Load → verify the descriptor populates, save it as a custom modifier, attach it to a fresh game, and verify the rule actually triggers as expected. Record screen recording. **VERDICT: APPROVE / REJECT**.
- [ ] **F3** (test-suite quality): Run `bun run check` AND `bunx playwright test`. Verify zero new fixmes, zero new skips, zero flaky retries. Inspect coverage delta — recipes.test.ts, validate.test.ts, ParamField.snapshot.test.tsx, all 9 widened-primitive .test.ts files, and templates-thressgame.spec.ts. **VERDICT: APPROVE / REJECT**.
- [ ] **F4** (scope fidelity): Re-read this plan against the user's original ask ("Are there examples and templates that cover how to make all of the stuff from [ruleHooks.js]?"). Verify (a) we did not silently de-scope any locked recipe, (b) the parity JSONs are genuinely imported by reference and not duplicated, (c) no out-of-scope work crept in (no new primitives, no recursion-depth bump, no preset-pack work). **VERDICT: APPROVE / REJECT**.
---
## Notes for the Orchestrator (Atlas)
- **Anti-duplication**: Do not re-explore territory the oracle already mapped (paramfield introspection, validator constraints, parity fixture validation status). Reference the oracle session for grounding when needed.
- **Session continuity**: T2-T8 are perfect for parallel `category="quick"` delegation since each is a one-file, two-test-update task. Fire all 7 in one message.
- **Notepad pattern**: Mirror the thressgame-coverage notepad layout — `decisions.md` (locked union-shape ordering), `learnings.md` (Zod union introspection gotchas), `issues.md` (any blocked schemas), `problems.md` (T12 / T13 open-question resolution paths).
- **Critical helper**: All Playwright runs (T14) MUST use `.sisyphus/scripts/run-pw.sh` and poll the `.done` marker. Do NOT run `bunx playwright test` directly; it times out the agent runtime.
- **Docker stack**: `docker compose -f docker-compose.dev.yml ps` should show web (:5173) + server (:7357) up before T14. If not, user must `docker compose up` first.
- **The hard-stop on `recipes.test.ts:30-54`**: After T11 lands, run `bun test packages/chess/src/modifiers/custom/recipes.test.ts` IMMEDIATELY. The test walks `primitive.childPrimitives()` against every recipe — if Zod widening broke childPrimitives's internal parse, this test fails. Fix at the schema layer (T1 helpers), never by removing the test invariant.
---
## Resolution Path for T12 / T13 Open Questions
The plan flags T12 (mass-mover-pawnguins) and T13 (adjacent-splash) as potentially needing arithmetic on `ctx-build` shapes that may not exist. Before starting either task:
1. Read `packages/chess/src/modifiers/primitives/param-resolver.ts:139-216` to enumerate the EXACT resolver shapes the runtime knows about.
2. If `ctx-build` accepts arithmetic on a bound `$var` (e.g. `{"ctx-build": {col: {$var: "col"}, row: {add: [{$var: "row"}, 1]}}}` or similar), use it directly.
3. If `ctx-build` is purely literal `{col, row}`, the simplest path is to author the recipe as `for-each-piece(bind: "p") → for-row(rows: [<list>], bind: "r") → ...` so the row arithmetic happens at recipe-author time, not run time. This is a "dumber" recipe but ships clean.
4. If neither path works for `tpl-mass-mover-pawnguins`, replace it with `tpl-mass-destroyer-they-deserved-it` (no arithmetic needed): `on-rule-activated → for-each-piece(filter: {excludeKing: true}, bind: "p") → with-probability(p: 0.0769) → destroy-piece({target: {$var: "p"}})` (1/13 ≈ 7.7% chance to destroy each non-king, on average 1 destroyed per activation — matches `they_deserved_it` semantics).
5. If `tpl-adjacent-splash` hits the same wall (add-to-attribute can't redirect target), replace with `tpl-adjacent-debuff` which uses `set-piece-attr({target: {$var: "adj"}, attr: "HpBonus", value: -1, lifetime: {kind: "turns", count: 1}})` — a 1-turn HP debuff. Mechanically different from "deal damage" but covers the adjacent-iteration pattern category.
The orchestrator records the final choice in `.sisyphus/notepads/thressgame-templates/decisions.md` so future tasks (T14 e2e) test the actual chosen recipe.

View file

@ -0,0 +1,807 @@
/**
* T14 Playwright e2e for the 9 NEW ThressGame-template recipes
* added by T11/T12/T13 (see `recipes.ts` lines 539+).
*
* Coverage shape (matches the `T14 — Templates modal` brief):
*
* 1. Nine LOAD-AND-VALIDATE tests one per recipe id. Mirrors the
* pattern from `custom-modifiers.spec.ts § "Templates picker
* loads a recipe into the editor"` (line 1459): open the Custom
* Modifier Editor click Templates click the recipe's
* `[data-testid="custom-template-{id}"]` button assert the
* modal closes, the descriptor name field reflects the recipe's
* `descriptor.name`, and the validation footer reports "Valid
* Custom Descriptor".
*
* 2. Three RUNTIME-BEHAVIOR tests for the recipes whose
* observable surface is most testable end-to-end:
*
* a. `tpl-religious-conversion` (on-move for-each-adjacent
* set-piece-attr Color resolver):
* Drives the cascade via `__test__.setup-board` (mirrors
* `parity-religious.spec.ts` the canonical religious_conversion
* e2e). Pre-places a white bishop at d4 with adjacent black
* pawns at a7/b7/c7; moves bishop d4b6; asserts the
* previously-black pawns now render as white.
*
* b. `tpl-kamikaze` (on-capture with-probability(0.25)
* for-each-adjacent destroy-piece):
* Same `__test__.setup-board` pattern. To make the
* probabilistic branch deterministic we clone the recipe's
* descriptor and patch `with-probability.p` from 0.25 to
* 1.0. This is the same approach `parity-religious.spec.ts`
* already uses for the parity kamikaze fixture (the prior
* T84 brief flags brittle seed-fishing as the wrong path).
* Pre-places white queen at e2, target black pawn at e4,
* AOE victims at d4 and f4, plus an immune black king at
* e5. Captures via queen e2e4 and asserts d4/f4 are
* destroyed while e5 (king) survives.
*
* c. `tpl-mind-control` (on-rule-activated request-choice
* kind=piece, forPlayer=both):
* Uses the simpler `__test__.activate-descriptor` lift
* the recipe's primitives[0].kind is on-rule-activated and
* the inner arm's primitives[0].kind is request-choice, so
* the lift handler accepts it. Asserts the
* `[data-testid="request-choice-modal"]` becomes visible
* with `data-choice-kind="piece"`. No further interaction
* required for the runtime smoke.
*
*
* Driving infrastructure verbatim duplication from sibling specs
*
*
* Per the precedent set in `orphan-primitives.spec.ts` and
* `parity-religious.spec.ts`, e2e helpers are duplicated rather than
* extracted into a shared module Playwright's worker model loads
* each spec file in isolation, and `e2e/` is in `testMatch` so a
* shared module under `e2e/` would be treated as a test file.
*
* Helper script:
* `.sisyphus/scripts/run-pw.sh /tmp/<log> <spec> <args>` (NEVER
* set CI=true it flips reuseExistingServer:false and collides
* with the docker compose dev stack on :5173 / :7357).
*/
import { test, expect, type Page } from '@playwright/test';
import { spawn, type ChildProcess } from 'node:child_process';
import { setTimeout as sleep } from 'node:timers/promises';
// ---------------------------------------------------------------------------
// LocalStorage / SessionStorage hygiene keys (mirror custom-modifiers.spec.ts)
// ---------------------------------------------------------------------------
const PROFILE_LIBRARY_KEY = 'houserules:modifier-profiles:v1';
const CUSTOM_LIBRARY_KEY = 'houserules:custom-modifiers:v1';
// ---------------------------------------------------------------------------
// The 9 new recipe ids + canonical descriptor.name strings (sourced from
// recipes.ts lines 547+). These are pinned constants — if the recipe
// names change in recipes.ts, this map must be updated.
// ---------------------------------------------------------------------------
const NEW_RECIPE_IDS = [
'tpl-religious-conversion',
'tpl-mr-freeze',
'tpl-mind-control',
'tpl-kamikaze',
'tpl-ice-physics',
'tpl-minefield-full',
'tpl-mass-destroyer-they-deserved-it',
'tpl-lifetime-restriction',
'tpl-adjacent-debuff',
] as const;
type NewRecipeId = (typeof NEW_RECIPE_IDS)[number];
const RECIPE_NAMES: Record<NewRecipeId, string> = {
'tpl-religious-conversion': 'Religious Conversion',
'tpl-mr-freeze': 'Mr Freeze',
'tpl-mind-control': 'Mind Control',
'tpl-kamikaze': 'Kamikaze',
'tpl-ice-physics': 'Ice Physics',
'tpl-minefield-full': 'Minefield (Spawn Arm)',
'tpl-mass-destroyer-they-deserved-it': 'They Deserved It',
'tpl-lifetime-restriction': '5-Turn Freeze',
'tpl-adjacent-debuff': 'Adjacent Debuff',
};
// ---------------------------------------------------------------------------
// Inline copies of the runtime-tested descriptors. Hard-coded here so
// the spec doesn't need to import from chess source (which the e2e
// runner doesn't bundle) and so any drift between the recipe and this
// spec surfaces as a name-mismatch in test (a) above.
//
// These MUST stay in sync with `packages/chess/src/modifiers/custom/recipes.ts`
// for the recipes:
// - tpl-religious-conversion
// - tpl-kamikaze
// - tpl-mind-control
// ---------------------------------------------------------------------------
const RELIGIOUS_CONVERSION_DESCRIPTOR = {
type: 'data',
id: 'tpl-religious-conversion',
name: 'Religious Conversion',
description:
'Bishop converts adjacent enemy non-king pieces to its own color when it moves.',
version: 1,
primitives: [
{
kind: 'on-move',
params: {
primitives: [
{
kind: 'for-each-adjacent',
params: {
target: 'self',
bind: 'adj',
filter: { occupied: true, excludeKing: true },
then: [
{
kind: 'set-piece-attr',
params: {
target: { $var: 'adj' },
attr: 'Color',
value: {
'ctx-attr': { entity: 'self', attr: 'Color' },
},
},
},
],
},
},
],
},
},
],
targetAttrs: ['Color', 'OnMoveHooks'],
uiForm: 'primitive-composer',
source: 'custom',
} as const;
const KAMIKAZE_DESCRIPTOR_PATCHED = {
type: 'data',
// Distinct id so registry lookups don't collide with the canonical
// recipe id.
id: 'tpl-kamikaze__test-p1',
name: 'Kamikaze (test, p=1.0)',
description:
'Test variant: with-probability patched from 0.25 to 1.0 for deterministic AOE.',
version: 1,
primitives: [
{
kind: 'on-capture',
params: {
primitives: [
{
kind: 'with-probability',
params: {
p: 1.0,
then: [
{
kind: 'for-each-adjacent',
params: {
target: 'self',
bind: 'adj',
filter: { occupied: true, excludeKing: true },
then: [
{
kind: 'destroy-piece',
params: { target: { $var: 'adj' } },
},
],
},
},
],
},
},
],
},
},
],
targetAttrs: ['OnCaptureHooks'],
uiForm: 'primitive-composer',
source: 'custom',
} as const;
const MIND_CONTROL_DESCRIPTOR = {
type: 'data',
id: 'tpl-mind-control',
name: 'Mind Control',
description:
'Each player picks an enemy non-king piece and converts it to their color.',
version: 1,
primitives: [
{
kind: 'on-rule-activated',
params: {
primitives: [
{
kind: 'request-choice',
params: {
kind: 'piece',
prompt: 'Mind Control — pick an enemy non-king piece to convert',
forPlayer: 'both',
bind: 'target',
then: [
{
kind: 'set-piece-attr',
params: {
target: { $var: 'target' },
attr: 'Color',
value: {
'ctx-attr': { entity: 'chooser', attr: 'Color' },
},
},
},
],
},
},
],
},
},
],
targetAttrs: ['Color', 'OnRuleActivatedHooks'],
uiForm: 'primitive-composer',
source: 'custom',
} as const;
// ---------------------------------------------------------------------------
// Server lifecycle (mirrors parity-religious.spec.ts — the dev stack
// running in docker is detected via the healthz probe, so this beforeAll
// is a no-op against a running dev container)
// ---------------------------------------------------------------------------
let wsServerProcess: ChildProcess | null = null;
async function isWsServerRunning(): Promise<boolean> {
try {
const res = await fetch('http://localhost:7357/healthz');
return res.ok;
} catch {
return false;
}
}
test.beforeAll(async () => {
if (await isWsServerRunning()) return;
wsServerProcess = spawn('bun', ['run', 'packages/server/src/index.ts'], {
stdio: 'pipe',
env: { ...process.env, PORT: '7357' },
});
for (let i = 0; i < 40; i++) {
await sleep(250);
if (await isWsServerRunning()) break;
}
});
test.afterAll(async () => {
if (wsServerProcess) {
wsServerProcess.kill('SIGINT');
await sleep(200);
wsServerProcess = null;
}
});
// ---------------------------------------------------------------------------
// LOAD-PATH helpers (lobby → profile editor → custom-modifier editor).
// Mirrors the helpers in `custom-modifiers.spec.ts` (lines 20-59).
// ---------------------------------------------------------------------------
async function freshLobby(page: Page): Promise<void> {
await page.goto('/');
await page.evaluate(
({ profileKey, customKey }) => {
localStorage.removeItem(profileKey);
localStorage.removeItem(customKey);
sessionStorage.removeItem('room-code');
sessionStorage.removeItem('room-token');
sessionStorage.removeItem('player-color');
sessionStorage.removeItem('layout-name');
sessionStorage.removeItem('modifier-profile-name');
},
{ profileKey: PROFILE_LIBRARY_KEY, customKey: CUSTOM_LIBRARY_KEY },
);
await page.reload();
}
async function openProfileEditor(page: Page): Promise<void> {
const picker = page.getByTestId('profile-picker');
await expect(picker).toBeVisible();
await picker.selectOption('custom');
await expect(
page
.getByTestId('per-type-panel-paste')
.or(page.locator('[role="dialog"], .fixed.inset-0').first()),
).toBeVisible({ timeout: 3000 });
}
async function openCustomModifierEditor(page: Page): Promise<void> {
await page.getByTestId('open-custom-modifier-editor').click();
await expect(page.getByTestId('custom-modifier-editor')).toBeVisible({
timeout: 3000,
});
}
// ---------------------------------------------------------------------------
// RUNTIME-PATH helpers (raw WS room creation + setup-board + activate /
// apply-descriptor frames). Mirrors `parity-religious.spec.ts` and
// `request-choice.spec.ts`.
// ---------------------------------------------------------------------------
async function wsCreateRoom(
page: Page,
): Promise<{ code: string; token: string; color: string }> {
return page.evaluate(async () => {
return new Promise<{ code: string; token: string; color: string }>(
(resolve, reject) => {
const ws = new WebSocket('ws://localhost:7357/ws');
const timer = setTimeout(
() => reject(new Error('wsCreateRoom: timeout')),
5000,
);
ws.onopen = () => {
ws.send(
JSON.stringify({
v: 1,
seq: 1,
ts: Date.now(),
type: 'room.create',
payload: {},
}),
);
};
ws.onmessage = (e: MessageEvent) => {
const msg = JSON.parse(e.data as string) as {
type: string;
payload: {
code: string;
token: string;
color: string;
message?: string;
};
};
if (msg.type === 'room.created') {
clearTimeout(timer);
ws.close();
resolve(msg.payload);
} else if (msg.type === 'error') {
clearTimeout(timer);
ws.close();
reject(new Error(msg.payload.message ?? 'room.create error'));
}
};
ws.onerror = () => {
clearTimeout(timer);
reject(new Error('wsCreateRoom: WebSocket error'));
};
},
);
});
}
async function joinAsHost(
page: Page,
): Promise<{ code: string; token: string; color: string }> {
await page.goto('http://localhost:5173/');
await page.waitForSelector('[data-testid="page-home"]');
const room = await wsCreateRoom(page);
await page.evaluate((r) => {
sessionStorage.setItem('room-code', r.code);
sessionStorage.setItem('room-token', r.token);
sessionStorage.setItem('player-color', r.color);
}, room);
await page.goto('http://localhost:5173/game');
await expect(page.locator('[data-testid="turn-indicator"]')).toBeVisible();
await page.waitForFunction(
() =>
Boolean(
(globalThis as { __paratypeChessClient?: unknown })
.__paratypeChessClient,
),
null,
{ timeout: 5000 },
);
return room;
}
interface BoardPlacement {
square: string | number;
type: 'pawn' | 'knight' | 'bishop' | 'rook' | 'queen' | 'king';
color: 'white' | 'black';
hasMoved?: boolean;
handle?: string;
}
interface BoardHookSpec {
pieceHandle?: string;
pieceSquare?: string | number;
hookAttr:
| 'OnMoveHooks'
| 'OnCaptureHooks'
| 'OnCapturedHooks'
| 'OnDamagedHooks'
| 'OnPromotionHooks'
| 'OnTurnStartHooks'
| 'OnTurnEndHooks';
descriptor: unknown;
descriptorIdOverride?: string;
}
interface BoardSetupArgs {
code: string;
clear?: boolean;
clearIncludingKings?: boolean;
placements?: BoardPlacement[];
rngSeed?: number;
hooks?: BoardHookSpec[];
turn?: 'white' | 'black';
}
async function setupBoard(page: Page, args: BoardSetupArgs): Promise<void> {
await page.evaluate(async (a) => {
type Client = {
send: (msg: { type: string; payload: unknown }) => void;
readonly isConnected?: boolean;
};
const getClient = (): Client | undefined =>
(globalThis as { __paratypeChessClient?: Client }).__paratypeChessClient;
const deadline = Date.now() + 3000;
let client = getClient();
while (Date.now() < deadline) {
client = getClient();
if (client && client.isConnected === true) break;
await new Promise((r) => setTimeout(r, 50));
}
if (!client || client.isConnected !== true) {
throw new Error('setupBoard: GameClient never became connected');
}
client.send({
type: '__test__.setup-board',
payload: {
roomCode: a.code,
clear: a.clear,
clearIncludingKings: a.clearIncludingKings,
placements: a.placements,
rngSeed: a.rngSeed,
hooks: a.hooks,
turn: a.turn,
},
});
}, args);
}
async function sendMove(page: Page, from: string, to: string): Promise<void> {
await page.evaluate(
async (a) => {
type Client = {
send: (msg: { type: string; payload: unknown }) => void;
sendMove?: (from: string, to: string) => void;
readonly isConnected?: boolean;
};
const getClient = (): Client | undefined =>
(globalThis as { __paratypeChessClient?: Client })
.__paratypeChessClient;
const deadline = Date.now() + 3000;
let client = getClient();
while (Date.now() < deadline) {
client = getClient();
if (client && client.isConnected === true) break;
await new Promise((r) => setTimeout(r, 50));
}
if (!client || client.isConnected !== true) {
throw new Error('sendMove: GameClient never became connected');
}
if (typeof client.sendMove === 'function') {
client.sendMove(a.from, a.to);
return;
}
client.send({
type: 'game.move',
payload: { from: a.from, to: a.to },
});
},
{ from, to },
);
}
async function activateDescriptor(
page: Page,
args: {
code: string;
descriptor: unknown;
chooserColor: 'white' | 'black';
liftedId?: string;
},
): Promise<void> {
await page.waitForFunction(
() =>
Boolean(
(globalThis as { __paratypeChessClient?: unknown })
.__paratypeChessClient,
),
null,
{ timeout: 5000 },
);
await page.evaluate((a) => {
const client = (
globalThis as {
__paratypeChessClient?: {
send: (msg: { type: string; payload: unknown }) => void;
};
}
).__paratypeChessClient;
if (!client)
throw new Error('activateDescriptor: __paratypeChessClient not present');
client.send({
type: '__test__.activate-descriptor',
payload: {
roomCode: a.code,
descriptor: a.descriptor,
chooserColor: a.chooserColor,
liftedId: a.liftedId,
},
});
}, args);
}
// ---------------------------------------------------------------------------
// Test suite — load-and-validate × 9 + runtime × 3
// ---------------------------------------------------------------------------
test.describe('T14 — Templates modal: ThressGame parity-faithful + new pattern recipes', () => {
// ── 9 LOAD-AND-VALIDATE tests ────────────────────────────────────────
for (const id of NEW_RECIPE_IDS) {
test(`loads ${id} into the editor without error`, async ({ page }) => {
// Surface any page-side runtime errors in the test report rather
// than letting them silently corrupt the editor state.
const pageErrors: Error[] = [];
page.on('pageerror', (err) => pageErrors.push(err));
await freshLobby(page);
await openProfileEditor(page);
await openCustomModifierEditor(page);
// Open the templates modal.
await page.getByTestId('custom-templates').click();
await expect(page.getByTestId('custom-templates-modal')).toBeVisible();
// Click the recipe's row. The CustomModifierEditor renders each
// recipe with `data-testid="custom-template-${recipe.id}"`
// (line 528 of CustomModifierEditor.tsx) — note the absence of
// a `recipe-` infix; the prompt's mockup naming was incorrect.
await page.getByTestId(`custom-template-${id}`).click();
// Modal closes after pick.
await expect(page.getByTestId('custom-templates-modal')).toHaveCount(0);
// The descriptor name field reflects the recipe's
// `descriptor.name` — the same selector the existing
// `Templates picker loads a recipe into the editor` test uses
// (line 1477 of custom-modifiers.spec.ts).
await expect(
page.locator('input[placeholder="Modifier Name"]'),
).toHaveValue(RECIPE_NAMES[id]);
// Footer reports the descriptor as VALID — the editor's
// validation footer renders "Valid Custom Descriptor" when
// `validationResult.ok` is true (CustomModifierEditor.tsx:485).
// This subsumes the "no error toast" check the brief asks for —
// a structural validation failure is the only path through which
// a recipe load could surface an error toast at this stage.
await expect(
page.getByText('Valid Custom Descriptor'),
).toBeVisible();
// Page-side runtime check.
expect(pageErrors).toEqual([]);
});
}
// ── RUNTIME TEST 1 — religious_conversion ─────────────────────────────
test('tpl-religious-conversion: bishop move converts adjacent enemy non-king pieces', async ({
browser,
}) => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
const room = await joinAsHost(page);
expect(room.color).toBe('white');
// Synthetic board: clear the FIDE position (kings retained at e1/e8),
// place a white bishop at d4 plus four pawns around the destination
// square b6 (a6 ally, a7/b7/c7 enemies). On move d4→b6 the
// for-each-adjacent walks the 8 squares around b6, narrows by
// occupied + excludeKing → matches a6/a7/b7/c7. The set-piece-attr
// Color resolver pulls self.Color (white) so:
// - a7/b7/c7 (black pawns) flip to white
// - a6 (white pawn) stays white (no-op)
await setupBoard(page, {
code: room.code,
clear: true,
clearIncludingKings: false,
turn: 'white',
placements: [
{ square: 'd4', type: 'bishop', color: 'white', handle: 'bishop' },
{ square: 'a6', type: 'pawn', color: 'white' },
{ square: 'a7', type: 'pawn', color: 'black' },
{ square: 'b7', type: 'pawn', color: 'black' },
{ square: 'c7', type: 'pawn', color: 'black' },
],
hooks: [
{
pieceHandle: 'bishop',
hookAttr: 'OnMoveHooks',
descriptor: RELIGIOUS_CONVERSION_DESCRIPTOR,
},
],
});
// Pre-move pin: bishop at d4, black pawns at a7/b7/c7.
await expect(
page.locator('[data-square="d4"] [data-piece="white-bishop"]'),
).toBeVisible({ timeout: 5000 });
await expect(
page.locator('[data-square="a7"] [data-piece="black-pawn"]'),
).toBeVisible();
await expect(
page.locator('[data-square="b7"] [data-piece="black-pawn"]'),
).toBeVisible();
await expect(
page.locator('[data-square="c7"] [data-piece="black-pawn"]'),
).toBeVisible();
await sendMove(page, 'd4', 'b6');
// Bishop landed at b6.
await expect(
page.locator('[data-square="b6"] [data-piece="white-bishop"]'),
).toBeVisible({ timeout: 5000 });
// The previously-black pawns are now white.
await expect(
page.locator('[data-square="a7"] [data-piece="white-pawn"]'),
).toBeVisible({ timeout: 5000 });
await expect(
page.locator('[data-square="b7"] [data-piece="white-pawn"]'),
).toBeVisible();
await expect(
page.locator('[data-square="c7"] [data-piece="white-pawn"]'),
).toBeVisible();
// Negative pin: NO black pawns remain on a7/b7/c7.
await expect(
page.locator('[data-square="a7"] [data-piece="black-pawn"]'),
).toHaveCount(0);
await expect(
page.locator('[data-square="b7"] [data-piece="black-pawn"]'),
).toHaveCount(0);
await expect(
page.locator('[data-square="c7"] [data-piece="black-pawn"]'),
).toHaveCount(0);
await ctx.close();
});
// ── RUNTIME TEST 2 — kamikaze (deterministic p=1.0 variant) ───────────
test('tpl-kamikaze: capture triggers AOE destroying adjacent non-king pieces (king immune)', async ({
browser,
}) => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
const room = await joinAsHost(page);
expect(room.color).toBe('white');
// Patch the canonical recipe's with-probability(p=0.25) to p=1.0
// for deterministic AOE firing — same approach
// `parity-religious.spec.ts § kamikazeAlwaysFires()` uses for the
// parity fixture. A fixed seed alone is brittle: integration
// preset hooks running on onAfterMove may consume RNG draws
// before with-probability gets its turn, shifting the stream
// offset. The probabilistic contract is pinned by
// `kamikaze.test.ts` (locked-numerics); this e2e exercises the
// dispatcher / WS / DOM path with deterministic outcome.
await setupBoard(page, {
code: room.code,
clear: true,
clearIncludingKings: true,
turn: 'white',
rngSeed: 42,
placements: [
// Both kings re-placed (engine legality requirement).
{ square: 'e1', type: 'king', color: 'white' },
{ square: 'h8', type: 'king', color: 'black' },
// Capturing piece + target.
{ square: 'e2', type: 'queen', color: 'white', handle: 'queen' },
{ square: 'e4', type: 'pawn', color: 'black' },
// AOE victims adjacent to the queen's destination (e4).
{ square: 'd4', type: 'pawn', color: 'black' },
{ square: 'f4', type: 'pawn', color: 'black' },
// King immunity: a black king adjacent to e4 must SURVIVE.
{ square: 'e5', type: 'king', color: 'black' },
],
hooks: [
{
pieceHandle: 'queen',
hookAttr: 'OnCaptureHooks',
descriptor: KAMIKAZE_DESCRIPTOR_PATCHED,
descriptorIdOverride: 'tpl-kamikaze__test-p1',
},
],
});
// Pre-capture pin.
await expect(
page.locator('[data-square="e2"] [data-piece="white-queen"]'),
).toBeVisible({ timeout: 5000 });
await expect(
page.locator('[data-square="e4"] [data-piece="black-pawn"]'),
).toBeVisible();
await expect(
page.locator('[data-square="d4"] [data-piece="black-pawn"]'),
).toBeVisible();
await expect(
page.locator('[data-square="f4"] [data-piece="black-pawn"]'),
).toBeVisible();
await expect(
page.locator('[data-square="e5"] [data-piece="black-king"]'),
).toBeVisible();
// Capture: queen e2 → e4. e3 is empty post-clear so the slide is
// legal. fireOnCaptureHooks runs the patched arm with p=1.0 →
// walks adjacent → destroys d4/f4 → king on e5 survives the
// excludeKing filter.
await sendMove(page, 'e2', 'e4');
// Capture succeeded.
await expect(
page.locator('[data-square="e4"] [data-piece="white-queen"]'),
).toBeVisible({ timeout: 5000 });
// AOE destroyed d4 + f4.
await expect(page.locator('[data-square="d4"] [data-piece]')).toHaveCount(
0,
{ timeout: 5000 },
);
await expect(page.locator('[data-square="f4"] [data-piece]')).toHaveCount(
0,
);
// King immune.
await expect(
page.locator('[data-square="e5"] [data-piece="black-king"]'),
).toBeVisible();
await ctx.close();
});
// ── RUNTIME TEST 3 — mind_control (request-choice modal smoke) ────────
test('tpl-mind-control: activation surfaces a piece-kind request-choice modal', async ({
browser,
}) => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
const room = await joinAsHost(page);
expect(room.color).toBe('white');
// Lift the descriptor — its primitives[0] is on-rule-activated and
// the inner arm's primitives[0] is request-choice (kind=piece,
// forPlayer=both). The activate-descriptor handler synthesizes
// the PendingChoice frame the trigger dispatcher would normally
// push on activation, so the front-end's RequestChoiceModal renders
// immediately.
await activateDescriptor(page, {
code: room.code,
descriptor: MIND_CONTROL_DESCRIPTOR,
chooserColor: 'white',
liftedId: 'tpl-mind-control__lifted__test',
});
const modal = page.locator('[data-testid="request-choice-modal"]');
await expect(modal).toBeVisible({ timeout: 5000 });
// Choice kind is `piece` per the descriptor.
await expect(modal).toHaveAttribute('data-choice-kind', 'piece');
await ctx.close();
});
});

View file

@ -20,6 +20,7 @@
"@paratype/rete": "workspace:*",
"@tailwindcss/vite": "^4.2.2",
"canvas-confetti": "^1.9.4",
"clsx": "^2.1.1",
"lucide-react": "^1.8.0",
"motion": "^12.38.0",
"react-router-dom": "^7.14.1",

View file

@ -277,53 +277,38 @@ describe("T64 — ice_physics ThressGame parity rule", () => {
expect(JSON.parse(JSON.stringify(descriptor))).toEqual(RAW_FIXTURE);
});
it("descriptor surfaces TWO known V1 validator sharp edges (documented, NOT actionable here)", () => {
// Pin the V1 validator's KNOWN gaps for this descriptor shape so
// a future fix lands as an *intentional* breaking change to this
// test — not a silent semantic drift. Two distinct gaps fire:
it("descriptor validates clean under V2 widening (thressgame-templates wave)", () => {
// V2 update (thressgame-templates wave): the two V1 sharp edges
// documented in the prior version of this test have been closed.
//
// 1. `descriptor.primitives.imperative-in-passive` — the validator's
// trigger-scope walker (`validate.ts#walkPrimitiveNodes`) flips
// `childrenInTriggerScope` to true ONLY when the parent is
// `conditional` or starts with `on-` (line ~335). For-each-piece
// is neither, so its `then` slot is walked under PASSIVE scope
// even though the runtime correctly treats it as trigger-arm
// territory (the parent on-rule-activated already opened
// trigger scope). The walker does NOT propagate trigger scope
// through binding-introducing iterators — three set-piece-attr
// nodes therefore each get flagged as imperative-in-passive.
// 1. `descriptor.primitives.imperative-in-passive` — fixed by
// extending the validator's trigger-scope walker
// (`validate.ts#walkPrimitiveNodes`) to recognize `for-each-*`
// and `random-pick` as trigger-scope-introducing kinds. Their
// `then` arms now correctly inherit trigger scope from the
// enclosing trigger, so set-piece-attr inside iteration arms
// is no longer flagged.
//
// 2. `primitive.params.invalid` — `set-piece-attr.paramsSchema`
// declares `target: z.number().int().nonnegative()`. The
// validator runs Zod parse on raw `node.params` BEFORE T12's
// runtime resolver substitutes `{ $var: "p" }` to a literal
// EntityId. Three Zod errors therefore fire on
// `target = { $var: "p" }`. Documented in mr_freeze.test.ts
// and religious_conversion.test.ts — locked V1 trade-off.
// 2. `primitive.params.invalid` — fixed by widening
// `set-piece-attr.paramsSchema.target` from a bare
// `z.number()` to `numberOrResolver()` (a union accepting the
// literal AND the 3 runtime-recognized resolver shapes:
// `{ $var }`, `{ "ctx-attr": ... }`, `{ "ctx-build": ... }`).
// The runtime resolver in `param-resolver.ts` already
// substitutes these shapes pre-`apply()`; the schema now
// matches that runtime contract.
//
// Both gaps are RUNTIME-CORRECT — see the post-fire assertions
// below. The validator is more conservative than the runtime;
// a future task that teaches the validator about iteration-scope
// propagation + resolver-shape leaf substitution will shrink
// the error count to zero, at which point this test should be
// tightened to `expect({ ok: true })`.
// The descriptor's runtime semantics are unchanged — see the
// post-apply assertions below. This test now pins the
// CORRECTED state.
const descriptor = parseCustomModifierDescriptor(RAW_FIXTURE);
const result = validateCustomDescriptor(descriptor);
expect(result.ok).toBe(false);
if (!result.ok) {
const codes = result.errors.map((e) => e.code).sort();
// 3 imperative-in-passive (one per set-piece-attr node) +
// 3 primitive.params.invalid (one per node's target Zod fail).
// The exact 6-error count is the documented current state.
expect(codes).toEqual([
"descriptor.primitives.imperative-in-passive",
"descriptor.primitives.imperative-in-passive",
"descriptor.primitives.imperative-in-passive",
"primitive.params.invalid",
"primitive.params.invalid",
"primitive.params.invalid",
]);
throw new Error(
`ice_physics.json should validate clean post-V2: ${JSON.stringify(result.errors, null, 2)}`,
);
}
expect(result.ok).toBe(true);
});
it("on activation: SlideMustBeMaxDistance is set to `true` on every bishop, rook, and queen", () => {

View file

@ -227,19 +227,19 @@ export const CUSTOM_MODIFIER_RECIPES: readonly CustomModifierRecipe[] = [
],
),
},
// ── T67 ThressGame template descriptors ────────────────────────────
// Six locked templates packaging Wave 4-7 trigger / iteration / RNG
// primitives into ready-to-load showcases. Each one passes
// validateCustomDescriptor cleanly (no runtime-only resolver shapes
// like { $var } / { ctx-attr } / { ctx-build } in leaf params — those
// fail Zod parse against literal-typed schemas, a documented V1
// sharp edge tracked in religious_conversion.test.ts § "Why we
// don't use validateCustomDescriptor"). Templates that would
// naturally use player-picked columns (frozen-column, no-mans-land)
// hardcode the d-file (column 3, squares 3/11/19/27/35/43/51/59) so
// the descriptor stays validator-clean; a future T67-followup that
// teaches the validator to whitelist resolver shapes can reintroduce
// the request-choice flow without breaking these template ids.
// ── ThressGame teaching templates (simplified) ────────────────────
// Six recipes packaging the trigger / iteration / RNG primitives into
// simplified ready-to-load showcases. They predate the V2 validator
// widening (which now accepts resolver shapes — `{ $var }`,
// `{ ctx-attr }`, `{ ctx-build }` — in widened leaf fields), so they
// hardcode positional values where the parity-faithful versions
// (T11 entries below — `tpl-frozen-column` vs `tpl-mr-freeze`,
// `tpl-no-mans-land` vs the player-picked-column equivalent) would
// use a request-choice + resolver-shape chain. These simplified
// versions are kept for pedagogical value: a one-line composition
// that demonstrates ONE primitive in isolation. Users wanting full
// ThressGame fidelity should pick the `tpl-*` parity recipes
// appended after this block.
{
id: "tpl-simple-mine",
title: "Simple Mine (e4 explodes pieces that enter)",
@ -523,11 +523,644 @@ export const CUSTOM_MODIFIER_RECIPES: readonly CustomModifierRecipe[] = [
},
},
{
kind: "spawn-marker",
kind: "spawn-marker",
params: {
markerKind: "blocked",
square: 59,
lifetime: { kind: "permanent" },
},
},
],
},
},
],
),
},
// ── T11: Parity-faithful recipes (V2 validator-clean) ──────────────
// These six recipes are byte-equivalent in primitive shape to the
// canonical `__fixtures__/parity/*.json` descriptors; the JSONs
// remain the single source of truth for runtime parity tests, and
// these inline copies graduate them to first-class editor templates
// (id rebranded `tpl-*` so the editor can distinguish user templates
// from system fixtures).
{
id: "tpl-religious-conversion",
title: "Religious Conversion (full ThressGame fidelity)",
summary:
"Full ThressGame parity rule. Bishop converts every adjacent enemy non-king to its own color whenever it moves. Validator-clean as of V2 — accepts the resolver shapes the runtime substitutes pre-apply.",
descriptor: {
type: "data",
id: asCustomModifierId("tpl-religious-conversion"),
name: "Religious Conversion",
description:
"Bishop converts adjacent enemy non-king pieces to its own color when it moves.",
version: 1,
primitives: [
{
kind: "on-move",
params: {
primitives: [
{
kind: "for-each-adjacent",
params: {
markerKind: "blocked",
square: 59,
lifetime: { kind: "permanent" },
target: "self",
bind: "adj",
filter: { occupied: true, excludeKing: true },
then: [
{
kind: "set-piece-attr",
params: {
target: { $var: "adj" },
attr: "Color",
value: {
"ctx-attr": { entity: "self", attr: "Color" },
},
},
},
],
},
},
],
},
},
],
targetAttrs: ["Color", "OnMoveHooks"],
uiForm: "primitive-composer",
source: "custom",
},
},
{
id: "tpl-mr-freeze",
title: "Mr Freeze (full ThressGame fidelity)",
summary:
"Full ThressGame parity rule. On activation, the chooser picks a column and 8 frozen-square markers spawn down it for 9 moves, owned by the chooser. Uses request-choice + for-row + ctx-build resolvers.",
descriptor: {
type: "data",
id: asCustomModifierId("tpl-mr-freeze"),
name: "Mr Freeze",
description:
"Pick a column; 8 frozen-square markers cover it for 9 moves, owned by the chooser.",
version: 1,
primitives: [
{
kind: "on-rule-activated",
params: {
primitives: [
{
kind: "request-choice",
params: {
kind: "column",
prompt: "Mr Freeze — pick a column to freeze for 9 moves",
forPlayer: "both",
bind: "col",
then: [
{
kind: "for-row",
params: {
rows: [0, 1, 2, 3, 4, 5, 6, 7],
bind: "row",
then: [
{
kind: "spawn-marker",
params: {
markerKind: "frozen-square",
square: {
"ctx-build": {
col: { $var: "col" },
row: { $var: "row" },
},
},
lifetime: { kind: "moves", expiresAtMove: 9 },
owner: {
"ctx-attr": {
entity: "chooser",
attr: "Color",
},
},
},
},
],
},
},
],
},
},
],
},
},
],
targetAttrs: [],
uiForm: "primitive-composer",
source: "custom",
},
},
{
id: "tpl-mind-control",
title: "Mind Control (full ThressGame fidelity)",
summary:
"Full ThressGame parity rule. On activation, both players pick an enemy non-king piece and convert it to their own color via request-choice(piece, both) + set-piece-attr with a chooser.Color resolver.",
descriptor: {
type: "data",
id: asCustomModifierId("tpl-mind-control"),
name: "Mind Control",
description:
"Each player picks an enemy non-king piece and converts it to their color.",
version: 1,
primitives: [
{
kind: "on-rule-activated",
params: {
primitives: [
{
kind: "request-choice",
params: {
kind: "piece",
prompt:
"Mind Control — pick an enemy non-king piece to convert",
forPlayer: "both",
bind: "target",
then: [
{
kind: "set-piece-attr",
params: {
target: { $var: "target" },
attr: "Color",
value: {
"ctx-attr": { entity: "chooser", attr: "Color" },
},
},
},
],
},
},
],
},
},
],
targetAttrs: ["Color", "OnRuleActivatedHooks"],
uiForm: "primitive-composer",
source: "custom",
},
},
{
id: "tpl-kamikaze",
title: "Kamikaze (full ThressGame fidelity)",
summary:
"Full ThressGame parity rule. On capture, with 25% probability every adjacent non-king piece is destroyed. Showcases with-probability + for-each-adjacent + destroy-piece composition.",
descriptor: {
type: "data",
id: asCustomModifierId("tpl-kamikaze"),
name: "Kamikaze",
description:
"On capture, 25% chance to destroy every adjacent non-king piece in a splash blast.",
version: 1,
primitives: [
{
kind: "on-capture",
params: {
primitives: [
{
kind: "with-probability",
params: {
p: 0.25,
then: [
{
kind: "for-each-adjacent",
params: {
target: "self",
bind: "adj",
filter: { occupied: true, excludeKing: true },
then: [
{
kind: "destroy-piece",
params: { target: { $var: "adj" } },
},
],
},
},
],
},
},
],
},
},
],
targetAttrs: ["OnCaptureHooks"],
uiForm: "primitive-composer",
source: "custom",
},
},
{
id: "tpl-ice-physics",
title: "Ice Physics (full ThressGame fidelity)",
summary:
"Full ThressGame parity rule. On rule-activation, sets SlideMustBeMaxDistance=true on every bishop, rook, and queen so sliding moves must travel their maximum legal range.",
descriptor: {
type: "data",
id: asCustomModifierId("tpl-ice-physics"),
name: "Ice Physics",
description:
"Bishops, rooks and queens must slide the maximum legal distance — no stopping short.",
version: 1,
primitives: [
{
kind: "on-rule-activated",
params: {
primitives: [
{
kind: "for-each-piece",
params: {
filter: { pieceType: "bishop" },
bind: "p",
then: [
{
kind: "set-piece-attr",
params: {
target: { $var: "p" },
attr: "SlideMustBeMaxDistance",
value: true,
},
},
],
},
},
{
kind: "for-each-piece",
params: {
filter: { pieceType: "rook" },
bind: "p",
then: [
{
kind: "set-piece-attr",
params: {
target: { $var: "p" },
attr: "SlideMustBeMaxDistance",
value: true,
},
},
],
},
},
{
kind: "for-each-piece",
params: {
filter: { pieceType: "queen" },
bind: "p",
then: [
{
kind: "set-piece-attr",
params: {
target: { $var: "p" },
attr: "SlideMustBeMaxDistance",
value: true,
},
},
],
},
},
],
},
},
],
targetAttrs: ["SlideMustBeMaxDistance", "OnRuleActivatedHooks"],
uiForm: "primitive-composer",
source: "custom",
},
},
{
id: "tpl-minefield-full",
title: "Minefield (ThressGame parity — spawn arm)",
summary:
"Spawn arm of the ThressGame minefield rule: 5 sibling random-picks plant one-shot mines on random squares. The companion on-piece-entered-marker(mine)→destroy-piece(self)+destroy-marker(self) arm in minefield.json uses a literal target:'self' string that the V2 validator's numberOrResolver schema rejects (a documented sharp edge — destroy-piece/destroy-marker have no 'self' literal branch). Recipe ships the validator-clean spawn arm only; the hook arm is wired separately by host presets that bypass the validator.",
descriptor: {
type: "data",
id: asCustomModifierId("tpl-minefield-full"),
name: "Minefield (Spawn Arm)",
description:
"Five random one-shot mines spawn on the board at activation time.",
version: 1,
primitives: [
{
kind: "on-rule-activated",
params: {
primitives: [
{
kind: "random-pick",
params: {
from: [
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31,
32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46,
47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61,
62, 63,
],
bind: "sq",
then: [
{
kind: "spawn-marker",
params: {
markerKind: "mine",
square: { $var: "sq" },
lifetime: { kind: "one-shot" },
},
},
],
},
},
{
kind: "random-pick",
params: {
from: [
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31,
32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46,
47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61,
62, 63,
],
bind: "sq",
then: [
{
kind: "spawn-marker",
params: {
markerKind: "mine",
square: { $var: "sq" },
lifetime: { kind: "one-shot" },
},
},
],
},
},
{
kind: "random-pick",
params: {
from: [
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31,
32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46,
47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61,
62, 63,
],
bind: "sq",
then: [
{
kind: "spawn-marker",
params: {
markerKind: "mine",
square: { $var: "sq" },
lifetime: { kind: "one-shot" },
},
},
],
},
},
{
kind: "random-pick",
params: {
from: [
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31,
32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46,
47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61,
62, 63,
],
bind: "sq",
then: [
{
kind: "spawn-marker",
params: {
markerKind: "mine",
square: { $var: "sq" },
lifetime: { kind: "one-shot" },
},
},
],
},
},
{
kind: "random-pick",
params: {
from: [
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31,
32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46,
47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61,
62, 63,
],
bind: "sq",
then: [
{
kind: "spawn-marker",
params: {
markerKind: "mine",
square: { $var: "sq" },
lifetime: { kind: "one-shot" },
},
},
],
},
},
],
},
},
],
targetAttrs: ["OnRuleActivatedHooks"],
uiForm: "primitive-composer",
source: "custom",
},
},
// ── T12: Net-new pattern recipes ───────────────────────────────────
// Two recipes that exercise iteration → imperative chains the V1
// validator could not express. See `decisions.md` for why these
// replaced the originally-planned `tpl-mass-mover-pawnguins` and
// `tpl-adjacent-splash` (resolver `ctx-build` doesn't support
// arithmetic on bound EntityIds, and `add-to-attribute` can't
// redirect target via `$var`).
{
id: "tpl-mass-destroyer-they-deserved-it",
title: "Mass Destroyer — \"They Deserved It\"",
summary:
"On activation, every non-king piece has ~7.7% chance to be destroyed (≈1 piece per activation on average). Iterates each non-king PieceType separately because for-each-piece's filter doesn't include excludeKing.",
descriptor: descriptorForRecipe(
"tpl-mass-destroyer-they-deserved-it",
"They Deserved It",
"Each non-king piece has a ~7.7% chance to be destroyed on activation.",
[
{
kind: "on-rule-activated",
params: {
primitives: [
{
kind: "for-each-piece",
params: {
filter: { pieceType: "pawn" },
bind: "p",
then: [
{
kind: "with-probability",
params: {
p: 0.0769,
then: [
{
kind: "destroy-piece",
params: { target: { $var: "p" } },
},
],
},
},
],
},
},
{
kind: "for-each-piece",
params: {
filter: { pieceType: "knight" },
bind: "p",
then: [
{
kind: "with-probability",
params: {
p: 0.0769,
then: [
{
kind: "destroy-piece",
params: { target: { $var: "p" } },
},
],
},
},
],
},
},
{
kind: "for-each-piece",
params: {
filter: { pieceType: "bishop" },
bind: "p",
then: [
{
kind: "with-probability",
params: {
p: 0.0769,
then: [
{
kind: "destroy-piece",
params: { target: { $var: "p" } },
},
],
},
},
],
},
},
{
kind: "for-each-piece",
params: {
filter: { pieceType: "rook" },
bind: "p",
then: [
{
kind: "with-probability",
params: {
p: 0.0769,
then: [
{
kind: "destroy-piece",
params: { target: { $var: "p" } },
},
],
},
},
],
},
},
{
kind: "for-each-piece",
params: {
filter: { pieceType: "queen" },
bind: "p",
then: [
{
kind: "with-probability",
params: {
p: 0.0769,
then: [
{
kind: "destroy-piece",
params: { target: { $var: "p" } },
},
],
},
},
],
},
},
],
},
},
],
),
},
{
id: "tpl-lifetime-restriction",
title: "Lifetime-Bounded Restriction (5-turn freeze)",
summary:
"On activation, sets BlockAllExceptKing on GAME_ENTITY for 5 turns: only kings may move during the lifetime window. Demonstrates the lifetime: { kind: 'turns', count: N } shape on set-piece-attr.",
descriptor: descriptorForRecipe(
"tpl-lifetime-restriction",
"5-Turn Freeze",
"Only kings may move for 5 turns. Lifetime-bounded BlockAllExceptKing on the game entity.",
[
{
kind: "on-rule-activated",
params: {
primitives: [
{
kind: "set-piece-attr",
params: {
target: 0,
attr: "BlockAllExceptKing",
value: true,
lifetime: { kind: "turns", count: 5 },
},
},
],
},
},
],
),
},
// ── T13: Net-new pattern recipe ────────────────────────────────────
{
id: "tpl-adjacent-debuff",
title: "Adjacent Debuff on Capture",
summary:
"When this piece captures, every adjacent enemy non-king takes a 1-turn HpBonus -1 debuff. Demonstrates for-each-adjacent + lifetime-bounded set-piece-attr with a $var target redirect.",
descriptor: descriptorForRecipe(
"tpl-adjacent-debuff",
"Adjacent Debuff",
"Capture inflicts a 1-turn HpBonus -1 debuff on every adjacent non-king piece.",
[
{
kind: "on-capture",
params: {
primitives: [
{
kind: "for-each-adjacent",
params: {
target: "self",
bind: "adj",
filter: { occupied: true, excludeKing: true },
then: [
{
kind: "set-piece-attr",
params: {
target: { $var: "adj" },
attr: "HpBonus",
value: -1,
lifetime: { kind: "turns", count: 1 },
},
},
],
},
},
],

View file

@ -700,3 +700,376 @@ describe("binding-out-of-scope (T13)", () => {
}
});
});
describe("V2 — resolver shapes inside iteration arms validate clean (thressgame-templates wave)", () => {
it("for-each-piece(bind: 'p') → set-piece-attr({target: {$var: 'p'}, attr: 'Hp', value: 5})", () => {
const descriptor: CustomModifierDescriptor = {
type: "data",
id: asCustomModifierId("test-v2-positive-1"),
name: "V2 positive 1",
description: "Test fixture: for-each-piece bind into set-piece-attr.",
version: 1,
primitives: [
{
kind: "on-rule-activated",
params: {
primitives: [
{
kind: "for-each-piece",
params: {
bind: "p",
then: [
{
kind: "set-piece-attr",
params: { target: { $var: "p" }, attr: "Hp", value: 5 },
},
],
},
},
],
},
},
],
targetAttrs: [],
uiForm: "primitive-composer",
source: "custom",
};
const result = validateCustomDescriptor(descriptor);
if (!result.ok) {
throw new Error(`expected ok, got: ${JSON.stringify(result.errors, null, 2)}`);
}
expect(result.ok).toBe(true);
});
it("for-each-adjacent(bind: 'adj') → set-piece-attr({target: {$var: 'adj'}, attr: 'Hp', value: 1})", () => {
const descriptor: CustomModifierDescriptor = {
type: "data",
id: asCustomModifierId("test-v2-positive-2"),
name: "V2 positive 2",
description: "Test fixture: for-each-adjacent bind into set-piece-attr.",
version: 1,
primitives: [
{
kind: "on-rule-activated",
params: {
primitives: [
{
kind: "for-each-adjacent",
params: {
target: "self",
bind: "adj",
then: [
{
kind: "set-piece-attr",
params: {
target: { $var: "adj" },
attr: "Hp",
value: 1,
},
},
],
},
},
],
},
},
],
targetAttrs: [],
uiForm: "primitive-composer",
source: "custom",
};
const result = validateCustomDescriptor(descriptor);
if (!result.ok) {
throw new Error(`expected ok, got: ${JSON.stringify(result.errors, null, 2)}`);
}
expect(result.ok).toBe(true);
});
it("for-each-square(bind: 'sq') → spawn-marker({square: {$var: 'sq'}, markerKind: 'mine', lifetime: {kind: 'permanent'}})", () => {
const descriptor: CustomModifierDescriptor = {
type: "data",
id: asCustomModifierId("test-v2-positive-3"),
name: "V2 positive 3",
description: "Test fixture: for-each-square bind into spawn-marker via resolver shape.",
version: 1,
primitives: [
{
kind: "on-rule-activated",
params: {
primitives: [
{
kind: "for-each-square",
params: {
bind: "sq",
then: [
{
kind: "spawn-marker",
params: {
square: { $var: "sq" },
markerKind: "mine",
lifetime: { kind: "permanent" },
},
},
],
},
},
],
},
},
],
targetAttrs: [],
uiForm: "primitive-composer",
source: "custom",
};
const result = validateCustomDescriptor(descriptor);
if (!result.ok) {
throw new Error(`expected ok, got: ${JSON.stringify(result.errors, null, 2)}`);
}
expect(result.ok).toBe(true);
});
it("for-each-marker(bind: 'm') → set-piece-attr({target: {ctx-attr: {entity: 'self', attr: 'Position'}}, attr: 'Hp', value: 3})", () => {
const descriptor: CustomModifierDescriptor = {
type: "data",
id: asCustomModifierId("test-v2-positive-4"),
name: "V2 positive 4",
description: "Test fixture: for-each-marker with ctx-attr resolver on target.",
version: 1,
primitives: [
{
kind: "on-rule-activated",
params: {
primitives: [
{
kind: "for-each-marker",
params: {
bind: "m",
then: [
{
kind: "set-piece-attr",
params: {
target: {
"ctx-attr": {
entity: "self",
attr: "Position",
},
},
attr: "Hp",
value: 3,
},
},
],
},
},
],
},
},
],
targetAttrs: [],
uiForm: "primitive-composer",
source: "custom",
};
const result = validateCustomDescriptor(descriptor);
if (!result.ok) {
throw new Error(`expected ok, got: ${JSON.stringify(result.errors, null, 2)}`);
}
expect(result.ok).toBe(true);
});
it("for-each-piece(bind: 'p') → set-piece-attr({target: {ctx-attr: {entity: {$var: 'p'}, attr: 'Color'}}, ...})", () => {
const descriptor: CustomModifierDescriptor = {
type: "data",
id: asCustomModifierId("test-v2-positive-5"),
name: "V2 positive 5",
description: "Test fixture: nested resolver shape (ctx-attr with $var entity) inside iteration.",
version: 1,
primitives: [
{
kind: "on-rule-activated",
params: {
primitives: [
{
kind: "for-each-piece",
params: {
bind: "p",
then: [
{
kind: "set-piece-attr",
params: {
target: {
"ctx-attr": {
entity: { $var: "p" },
attr: "Color",
},
},
attr: "Hp",
value: 2,
},
},
],
},
},
],
},
},
],
targetAttrs: [],
uiForm: "primitive-composer",
source: "custom",
};
const result = validateCustomDescriptor(descriptor);
if (!result.ok) {
throw new Error(`expected ok, got: ${JSON.stringify(result.errors, null, 2)}`);
}
expect(result.ok).toBe(true);
});
it("rejects resolver shape with extra keys on target field ({$var: 'p', extra: 'junk'})", () => {
// .strict() on the resolver inner object should reject any key
// other than the one expected ($var, ctx-attr, or ctx-build).
const descriptor: CustomModifierDescriptor = {
type: "data",
id: asCustomModifierId("test-v2-negative-1"),
name: "V2 negative 1",
description: "Test fixture: resolver shape with extra keys (should fail).",
version: 1,
primitives: [
{
kind: "on-rule-activated",
params: {
primitives: [
{
kind: "for-each-piece",
params: {
bind: "p",
then: [
{
kind: "set-piece-attr",
params: {
target: { $var: "p", extra: "junk" } as unknown,
attr: "Hp",
value: 5,
},
},
],
},
},
],
},
},
],
targetAttrs: [],
uiForm: "primitive-composer",
source: "custom",
};
const result = validateCustomDescriptor(descriptor);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(
result.errors.some((e) => e.code === "primitive.params.invalid"),
).toBe(true);
}
});
it("rejects empty object {} for target field (not a valid resolver shape)", () => {
// Empty object has zero keys, so it matches neither the literal
// schema nor any resolver shape (which require exactly one key).
const descriptor: CustomModifierDescriptor = {
type: "data",
id: asCustomModifierId("test-v2-negative-2"),
name: "V2 negative 2",
description: "Test fixture: empty object (should fail).",
version: 1,
primitives: [
{
kind: "on-rule-activated",
params: {
primitives: [
{
kind: "for-each-piece",
params: {
bind: "p",
then: [
{
kind: "set-piece-attr",
params: {
target: {} as unknown,
attr: "Hp",
value: 5,
},
},
],
},
},
],
},
},
],
targetAttrs: [],
uiForm: "primitive-composer",
source: "custom",
};
const result = validateCustomDescriptor(descriptor);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(
result.errors.some((e) => e.code === "primitive.params.invalid"),
).toBe(true);
}
});
it("rejects resolver shape on enum field spawn-marker.markerKind (enums stay strict literal-only)", () => {
// markerKind is an enum and stays literal-only per design decision.
// Attempting to use {$var: "k"} on this field should be rejected.
const descriptor: CustomModifierDescriptor = {
type: "data",
id: asCustomModifierId("test-v2-negative-3"),
name: "V2 negative 3",
description: "Test fixture: resolver on enum field (should fail).",
version: 1,
primitives: [
{
kind: "on-rule-activated",
params: {
primitives: [
{
kind: "for-each-piece",
params: {
bind: "k",
then: [
{
kind: "spawn-marker",
params: {
square: 32,
markerKind: { $var: "k" } as unknown,
lifetime: { kind: "permanent" },
},
},
],
},
},
],
},
},
],
targetAttrs: [],
uiForm: "primitive-composer",
source: "custom",
};
const result = validateCustomDescriptor(descriptor);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(
result.errors.some((e) => e.code === "primitive.params.invalid"),
).toBe(true);
}
});
});

View file

@ -322,18 +322,34 @@ function walkPrimitiveNodes(input: {
children = [];
}
// T14 — children of a trigger (`on-*`) or `conditional` are in
// trigger scope (their primitive-array slots are the canonical
// legal home of imperative primitives). Children of any other
// container (e.g. `add-aura` — currently has none, but future
// passive containers MUST be defaulted to passive) stay passive.
// T14 — children of a trigger (`on-*`), `conditional`, or iteration
// primitive (`for-each-*`, `random-pick`) are in trigger scope
// (their primitive-array slots are the canonical legal home of
// imperative primitives). Children of any other container (e.g.
// `add-aura` — currently has none, but future passive containers
// MUST be defaulted to passive) stay passive.
//
// IMPORTANT: non-trigger containers (like `with-probability`) INHERIT
// the parent's scope rather than resetting it. So:
// on-capture [trigger, sets trigger] → with-probability [passes through]
// → for-each-adjacent [passes through] → destroy-piece [now legal]
// This ensures that once we enter trigger scope, control-flow and
// iteration primitives preserve it for their children.
//
// Detection by kind-name keeps the rule data-driven without
// adding a new field to EffectPrimitive. The set is closed: any
// future trigger primitive must add itself here OR explicitly
// declare its scope semantics in the EffectPrimitive contract.
//
// Iteration primitives (T31-T37) introduce binding names into their
// `then` / `else` child slots, and those slots are where imperative
// verbs live — they belong in trigger scope.
const childrenInTriggerScope =
node.kind === "conditional" || node.kind.startsWith("on-");
inTriggerScope ||
node.kind === "conditional" ||
node.kind.startsWith("on-") ||
node.kind.startsWith("for-each-") ||
node.kind === "random-pick";
walkPrimitiveNodes({
nodes: children,

View file

@ -16,19 +16,19 @@ const descriptor: EffectPrimitive<Params> = {
description:
"Seed absorb-damage facts so damage can consume an attribute before HP.",
longDescription:
"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.",
"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: [
{
title: "3-charge shield (pair with seed-attribute)",
params: { attr: "ShieldCharges", rate: 1 },
effect:
"Pair with seed-attribute {attr: 'ShieldCharges', value: 3}. Each damage point consumes one charge; after 3 damage, HP starts taking hits.",
"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.",
},
{
title: "Hardened armor (rate=2)",
params: { attr: "ArmorPlates", rate: 2 },
effect:
"Each damage point consumes 2 ArmorPlates instead of HP — makes plates deplete twice as fast but with the same absorption curve.",
"Each point of damage uses 2 ArmorPlates instead of touching HP, so plates run out twice as fast for the same amount of protection.",
},
],
paramsSchema: schema,

View file

@ -16,19 +16,19 @@ const descriptor: EffectPrimitive<Params> = {
description:
"Seeds an AuraSpec fact entry consumed by the aura engine's recomputation phase.",
longDescription:
"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.",
"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: [
{
title: "King aura: +1 HP within 2 squares",
params: { radius: 2, targetAttr: "HpBonus", delta: 1 },
effect:
"Every friendly or enemy piece within 2 squares of this piece gains +1 HpBonus while in range.",
"Every other piece, friend or foe, within 2 squares gains +1 HP while it stays in range.",
},
{
title: "Adjacent range buff",
params: { radius: 1, targetAttr: "RangeBonus", delta: 1 },
effect:
"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.",
},
],
paramsSchema: schema,

View file

@ -43,12 +43,12 @@ const descriptor: EffectPrimitive<Params> = {
label: "Add Direction",
description: "Appends movement directions into DirectionAdditions with dedupe.",
longDescription:
"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.",
"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: [
{
title: "Backward-capable pawn",
params: { directions: ["backward"] },
effect: "Lets a pawn step backward as well as forward.",
effect: "The pawn can step backward toward its own side as well as forward.",
},
{
title: "Full omnidirectional king-lite",
@ -56,7 +56,7 @@ const descriptor: EffectPrimitive<Params> = {
directions: ["forward", "backward", "left", "right"],
},
effect:
"Adds all 4 orthogonal directions in one primitive. Diagonal names are listed separately if you need them.",
"Grants the piece all four straight-line directions at once. Add the diagonal names too if you want full eight-way movement.",
},
],
paramsSchema: schema,

View file

@ -89,15 +89,15 @@ const descriptor: EffectPrimitive<Params> = {
description:
"Game-level: forbid every piece of the listed PieceType(s) from generating any move.",
longDescription:
"Inserts a deduped `BlockedPieceTypes: readonly PieceType[]` fact on GAME_ENTITY. Move-generation (Wave 10 wire-in) consults this list and drops every move whose mover's PieceType is a member. Multiple applies UNION their pieceTypes — applying ['pawn'] then ['knight'] yields ['pawn','knight']. The optional descriptorId param is reserved for downstream reverse-lookup (which descriptor paralysed this piece?) and is currently inert. Distinct from T8 `BlockAllExceptKing`, which gates piece-blocking-during-slide rather than move-generation. Used by the ThressGame `all_on_red` rule to paralyse all non-king pieces while the rule is active.",
"Picks one or more piece types (pawn, knight, bishop, rook, queen, king) and stops every piece of those types from moving at all. Apply it more than once and the lists combine, so adding pawns and then knights gives you both. Useful for 'paralysis' style rules where, for example, only kings may move during a special phase.",
examples: [
{
title: "ThressGame all-on-red — paralyse every non-king",
title: "Paralyse every piece except the king",
params: {
pieceTypes: ["pawn", "knight", "bishop", "rook", "queen"],
},
effect:
"Writes BlockedPieceTypes=['pawn','knight','bishop','rook','queen'] on GAME_ENTITY. While this fact is present, only kings can move; all other pieces have their move list filtered to empty by the Wave-10 movegen reader.",
"While this rule is active, only kings can move. Pawns, knights, bishops, rooks, and queens all have empty move lists.",
},
{
title: "Pawn-only freeze",
@ -106,7 +106,7 @@ const descriptor: EffectPrimitive<Params> = {
descriptorId: "winter-storm",
},
effect:
"Adds 'pawn' to the BlockedPieceTypes set. Repeated applies of the same pieceType are idempotent (set-union semantics).",
"Pawns can no longer move. Applying the same block again has no extra effect: duplicates are merged.",
},
],
paramsSchema: schema,

View file

@ -15,19 +15,19 @@ const descriptor: EffectPrimitive<Params> = {
label: "Block Move Type",
description: "Seed blocked move types for deferred move-filter integration.",
longDescription:
"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.",
"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: [
{
title: "Pacifist piece",
params: { moveType: "capture" },
effect:
"Piece can step and slide freely but cannot capture — a pure support piece.",
"The piece can move and slide freely but cannot capture, making it a pure support piece.",
},
{
title: "Charge-only attacker",
params: { moveType: "step" },
effect:
"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.",
},
],
paramsSchema: schema,

View file

@ -67,6 +67,16 @@
* `cancel-capture` does NOT enqueue any deferred trigger. It is a
* pure inhibitor the only side-effect is the `CaptureCancelled`
* fact write. It cannot itself cascade.
*
* ## V2 audit (T8 thressgame-templates wave)
*
* Audited 2026-04-26. `cancel-capture` has **no positional target
* field**. The paramsSchema is empty (`z.object({})`). This primitive
* operates exclusively on the implicit `ctx.event` from the trigger
* frame (the attacker/defender pair carried by on-captured /
* on-capture / destroy-piece-cascade). No resolver-shape widening is
* applicable. Future audits should re-check this if the schema gains
* a positional field.
*/
import { z } from "zod";
import { GAME_ENTITY } from "../../schema.js";
@ -83,19 +93,19 @@ const descriptor: EffectPrimitive<Params> = {
description:
"Inside an on-captured arm, sets the CaptureCancelled flag on GAME_ENTITY AND restores the defender's pre-capture facts from LastCaptureSnapshot. Throws if no capture event is present in ctx.",
longDescription:
"Imperative inhibitor primitive — only legal inside a trigger's primitives/then/else array (top-level placement is rejected by the validator with code 'descriptor.primitives.imperative-in-passive'). Requires ctx.event.kind === 'capture' (typically inside an on-captured arm) — calling outside that context throws runtime.cancel-capture-no-event. T72 wire-in: in addition to setting CaptureCancelled = true on GAME_ENTITY (the dispatcher signal), this primitive RESTORES the defender's pre-capture facts by re-inserting every (attr, value) pair from `LastCaptureSnapshot.defenderFacts`. The dispatcher (apply.ts stage 4b) handles attacker rollback — it owns Position/HasMoved on the moving piece because the move-advance happens before the on-captured trigger fires. Splitting the work this way keeps the primitive defender-scoped and the dispatcher attacker-scoped. The flag and snapshot are both cleared by the dispatcher after the hook fires, so neither leaks across moves.",
"Cancels a capture that's about to happen. Use this inside an on-captured trigger to make a piece untargetable, or to set up a parry rule. The defending piece is restored to the board with its full state (HP, attributes, position, the lot), and the attacking piece is rolled back too. Only works inside a capture-related trigger; using it elsewhere is an error.",
examples: [
{
title: "Parry — cancel a capture when defender wins RPS",
title: "Parry: cancel a capture when the defender wins a rock-paper-scissors check",
params: {},
effect:
"Inside an on-captured arm wrapping a request-choice + conditional that compares attacker vs defender RPS submissions, authoring `cancel-capture` in the 'defender wins' branch sets CaptureCancelled = true. Sibling primitives (and the future T28+ capture-restore pipeline) observe the flag and skip the defender-retract / re-insert defender facts.",
"When a piece would be captured, both players submit a hidden choice. If the defender wins, the capture is cancelled: the attacker stays on its old square and the defender stays alive on its square as if nothing happened.",
},
{
title: "Fortress — cancel any capture targeting a king-adjacent piece",
title: "Fortress: pieces standing next to their own king cannot be captured",
params: {},
effect:
"Inside an on-captured arm guarded by a conditional that checks whether the defender is adjacent to its own king, authoring `cancel-capture` short-circuits the capture for the matching defender. The flag write is GAME_ENTITY-scoped so the dispatcher's poll surfaces it regardless of which arm wrote it.",
"If a piece would be captured while it's adjacent to its own king, the capture is undone. The attacker doesn't move, and the protected piece stays put.",
},
],
paramsSchema: schema,

View file

@ -55,7 +55,7 @@ const descriptor: EffectPrimitive<Params> = {
description:
"Seeds ConditionalHooks entries consumed by the trigger evaluation pipeline.",
longDescription:
"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).",
"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: [
{
title: "Low-HP fortress",
@ -66,7 +66,7 @@ const descriptor: EffectPrimitive<Params> = {
],
},
effect:
"When Hp drops below 2, the piece gains CANNOT_BE_CAPTURED — a last-stand invulnerability.",
"When HP drops below 2, the piece becomes uncapturable. A last-stand invulnerability.",
},
{
title: "Unconditional thorns example",
@ -77,7 +77,7 @@ const descriptor: EffectPrimitive<Params> = {
],
},
effect:
"Equivalent to applying reflect-damage unconditionally; useful as a template you can later tighten.",
"Reflect-damage runs every time. Useful as a starting template you can tighten with a real condition later.",
},
],
paramsSchema: schema,

View file

@ -80,6 +80,30 @@ describe("convert-piece-type primitive — schema", () => {
expect(result.success).toBe(true);
});
it("accepts resolver shape for target ($var form)", () => {
const result = CONVERT_PIECE_TYPE_PRIMITIVE.paramsSchema.safeParse({
target: { $var: "targetId" },
pieceType: "bishop",
});
expect(result.success).toBe(true);
});
it("accepts resolver shape for target (ctx-attr form)", () => {
const result = CONVERT_PIECE_TYPE_PRIMITIVE.paramsSchema.safeParse({
target: { "ctx-attr": { entity: "self", attr: "PieceId" } },
pieceType: "knight",
});
expect(result.success).toBe(true);
});
it("rejects resolver shape on pieceType enum (intentional design constraint)", () => {
const result = CONVERT_PIECE_TYPE_PRIMITIVE.paramsSchema.safeParse({
target: 5,
pieceType: { $var: "x" },
});
expect(result.success).toBe(false);
});
it("rejects negative target id", () => {
const result = CONVERT_PIECE_TYPE_PRIMITIVE.paramsSchema.safeParse({
target: -1,

View file

@ -74,6 +74,7 @@ import type { EntityId } from "@paratype/rete";
import type { PieceType } from "../../schema.js";
import { PRIMITIVE_REGISTRY } from "./registry.js";
import { enqueueTrigger } from "../triggers.js";
import { numberOrResolver } from "./param-resolver-schema.js";
import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js";
/**
@ -91,7 +92,7 @@ const PIECE_TYPES = [
] as const satisfies readonly PieceType[];
const schema = z.object({
target: z.number().int().nonnegative(),
target: numberOrResolver({ min: 0 }),
pieceType: z.enum(PIECE_TYPES),
});
type Params = z.infer<typeof schema>;
@ -102,19 +103,19 @@ const descriptor: EffectPrimitive<Params> = {
description:
"Changes the target piece's PieceType in place and enqueues an on-promotion trigger.",
longDescription:
"Imperative primitive — only legal inside a trigger's primitives/then/else array (top-level placement is rejected by the validator with code 'descriptor.primitives.imperative-in-passive'). Re-inserts the target piece's PieceType fact (in-place type change, all other facts preserved: Color, Position, HasMoved, Hp, custom attrs, hook lists) and enqueues a deferred on-promotion trigger carrying {promotedFrom, promotedTo} so any promotion-reactive descriptors fire AFTER the current arm completes. Two no-op cases — both silent: (1) target has no PieceType fact (stale binding / non-piece id); (2) target's current PieceType already equals the requested pieceType (avoids spurious on-promotion fires for self-conversion). target may be authored as a literal entity id, a { $var: 'name' } binding, or a { ctx-attr: { entity, attr } } reference; the param resolver substitutes all shapes to a numeric id before this apply() runs.",
"Changes a piece into a different kind of piece (pawn, knight, bishop, rook, queen, king) without moving it. Only works inside a trigger. Everything else about the piece stays the same: its color, square, HP, custom attributes, and any rules attached to it. The engine fires an on-promotion trigger afterwards, just like a normal pawn promotion would. Converting a piece to its current type does nothing.",
examples: [
{
title: "Trick promotion — convert a knight to a bishop on capture",
title: "Trick promotion: convert a knight into a bishop on capture",
params: { target: 12, pieceType: "bishop" },
effect:
"Inside an on-capture arm where 'p' binds to a captured piece id, authoring `target: { $var: 'p' }, pieceType: 'bishop'` flips the piece's PieceType from knight → bishop in place; the bishop keeps the knight's Color, Position, HasMoved, and any custom attrs. on-promotion hooks on the converted piece fire at end-of-arm with promotedFrom='knight', promotedTo='bishop'.",
"When a knight captures something, the knight turns into a bishop on the same square. It keeps its color, HP, and any custom attributes it had. Promotion-reactive rules see this as a knight-to-bishop promotion.",
},
{
title: "Forced promotion — convert a pawn on rank 8 to a queen",
title: "Forced promotion: turn a pawn on rank 8 into a queen",
params: { target: 12, pieceType: "queen" },
effect:
"When the wrapping rule activates with a pawn bound, force its PieceType to queen. The on-promotion hook queue receives a single entry {promotedFrom:'pawn', promotedTo:'queen'} which fires after the arm completes — symmetric with how the engine's normal promotion pipeline would have fired it.",
"The pawn becomes a queen on the same square. Other rules see it as a normal pawn-to-queen promotion.",
},
],
paramsSchema: schema,
@ -122,8 +123,17 @@ const descriptor: EffectPrimitive<Params> = {
// consumer registry. Convert-piece-type does not introduce any new
// fact key.
seedsAttrs: [],
/**
* Converts a piece's type. By apply-time the runtime param-resolver
* (param-resolver.ts) has already substituted any `{ $var: "x" }` /
* `{ "ctx-attr": ... }` shapes in `params.target` to a literal number.
* The schema's union accepts those shapes for author-time validation;
* at runtime they are guaranteed to be resolved scalars. `pieceType`
* is always a strict enum (intentional design constraint resolver
* shapes would unlock unsupported promotion paths).
*/
apply(ctx: PrimitiveApplyContext, params: Params): void {
const targetId = params.target as EntityId;
const targetId = (params.target as number) as EntityId;
// Capture the previous type BEFORE mutation so the on-promotion
// payload carries the true pre-conversion type. Also doubles as

View file

@ -93,6 +93,20 @@ describe("destroy-marker primitive — paramsSchema (T30)", () => {
const result = DESTROY_MARKER_PRIMITIVE.paramsSchema.safeParse({});
expect(result.success).toBe(false);
});
it("accepts resolver shape: { $var: 'name' }", () => {
const result = DESTROY_MARKER_PRIMITIVE.paramsSchema.safeParse({
target: { $var: "boundMarker" },
});
expect(result.success).toBe(true);
});
it("accepts resolver shape: { ctx-attr: { entity, attr } }", () => {
const result = DESTROY_MARKER_PRIMITIVE.paramsSchema.safeParse({
target: { "ctx-attr": { entity: "self", attr: "Position" } },
});
expect(result.success).toBe(true);
});
});
describe("destroy-marker primitive — apply()", () => {

View file

@ -79,9 +79,10 @@ import type { EntityId } from "@paratype/rete";
import { PRIMITIVE_REGISTRY } from "./registry.js";
import { fireOnMarkerExpireHooks } from "../triggers.js";
import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js";
import { numberOrResolver } from "./param-resolver-schema.js";
const schema = z.object({
target: z.number().int().nonnegative(),
target: numberOrResolver({ min: 0 }),
});
type Params = z.infer<typeof schema>;
@ -91,25 +92,34 @@ const descriptor: EffectPrimitive<Params> = {
description:
"Removes the target marker entity from the board after firing its on-marker-expire hooks; no-op on non-marker targets.",
longDescription:
"Imperative primitive — only legal inside a trigger's primitives/then/else array (top-level placement is rejected by the validator with code 'descriptor.primitives.imperative-in-passive'). Fires fireOnMarkerExpireHooks(engine, target, ctx.cascadeDepth) FIRST so hook bodies can still read MarkerKind / Position from the dying marker, then delegates the actual fact retraction to engine.removeMarker(target) — which retracts a fixed list of marker attrs (EntityKind, MarkerKind, Position, MarkerLifetime, MarkerOwner, MarkerLinks) under per-attr presence guards. Refuses to touch non-marker entities (EntityKind !== 'marker') — a misdirected target id pointing at a piece is a silent no-op. Calling destroy-marker on an already-destroyed marker is also a silent no-op. Does NOT cascade-destroy paired markers via MarkerLinks (T0 ADR: links are weak references; pair-cleanup is the author's responsibility via an on-marker-expire hook). target may be authored as a literal entity id, a {$var:'name'} binding, or a {ctx-attr:{entity,attr}} reference; the param resolver substitutes all shapes to a numeric id before this apply() runs.",
"Removes a marker from the board. Only works inside a trigger. Before the marker is gone, the engine fires its on-marker-expire rules so they can react to the marker disappearing (still able to read its kind and square at that moment). Won't touch pieces (use 'destroy piece' for those), and calling it on a marker that's already gone is harmless. Linked markers (like the other half of a portal pair) are NOT auto-destroyed; if you want both ends gone, destroy each one explicitly.",
examples: [
{
title: "Detonate the mine the piece just stepped on",
title: "Detonate the mine a piece just stepped on",
params: { target: 12 },
effect:
"Inside an on-piece-entered-marker arm where event.markerId binds to the mine the piece entered, authoring `target: { ctx-attr: { entity: 'event', attr: 'markerId' } }` (or directly the bound id) fires the mine's on-marker-expire hooks (e.g. damage the entering piece) and then removes the mine from the board.",
"When a piece enters a mine, the mine's expire rules fire (for example, dealing damage to the entering piece), and then the mine is removed from the board.",
},
{
title: "Sweep a kind from a square via for-each-marker",
title: "Sweep every marker off a square",
params: { target: 17 },
effect:
"Inside a for-each-marker iteration arm where 'm' binds to each marker on the target square, authoring `target: { $var: 'm' }` runs on-marker-expire and removes each marker in turn. Paired markers (MarkerLinks) are NOT auto-destroyed — the iteration would visit them only if they're physically located in the iteration's filter scope.",
"Used inside a loop over markers, this removes each marker the loop visits, firing each marker's expire rules in turn. Linked partners on other squares are not removed automatically.",
},
],
paramsSchema: schema,
// No new attr seeded — destroy-marker is pure retraction (delegated
// to engine.removeMarker, whose attr list is owned by T10).
seedsAttrs: [],
/**
* Removes the target marker from the board.
*
* NOTE: `target` may be a resolver shape in descriptor JSON
* (`{ $var: "..." }`, `{ "ctx-attr": ... }`, etc.) but the
* dispatcher's `resolveParams` substitutes these shapes to a
* literal entity id BEFORE this function runs, so `params.target`
* is always a number at runtime.
*/
apply(ctx: PrimitiveApplyContext, params: Params): void {
const targetId = params.target as EntityId;

View file

@ -29,6 +29,9 @@ import { ChessEngine } from "../../engine.js";
import { PRIMITIVE_REGISTRY } from "./registry.js";
import { DESTROY_PIECE_PRIMITIVE } from "./destroy-piece.js";
import type { PendingTrigger, PrimitiveApplyContext } from "./types.js";
import type { CustomModifierDescriptor } from "../custom/types.js";
import { asCustomModifierId } from "../custom/types.js";
import { validateCustomDescriptor } from "../custom/validate.js";
import "./destroy-piece.js";
function makeContext(): {
@ -98,6 +101,20 @@ describe("destroy-piece primitive — schema", () => {
const result = DESTROY_PIECE_PRIMITIVE.paramsSchema.safeParse({});
expect(result.success).toBe(false);
});
it("accepts resolver shape: { $var: 'name' }", () => {
const result = DESTROY_PIECE_PRIMITIVE.paramsSchema.safeParse({
target: { $var: "boundEntity" },
});
expect(result.success).toBe(true);
});
it("accepts resolver shape: { ctx-attr: { entity, attr } }", () => {
const result = DESTROY_PIECE_PRIMITIVE.paramsSchema.safeParse({
target: { "ctx-attr": { entity: "self", attr: "Position" } },
});
expect(result.success).toBe(true);
});
});
describe("destroy-piece primitive — apply()", () => {
@ -210,3 +227,66 @@ describe("destroy-piece primitive — apply()", () => {
expect(pendingTriggers).toHaveLength(1);
});
});
describe("kamikaze.json — V2 validator widening (T4)", () => {
it("validates with resolver-widened destroy-piece target field", () => {
// Inline the kamikaze descriptor (mirror of __fixtures__/parity/kamikaze.json).
// Inline rather than `import x from "../path/kamikaze.json"` because the
// chess package's tsconfig doesn't list the fixtures dir in `files` /
// `include`, and `import.meta.url` in vitest's vite-node runtime is not
// a `file:` URL (TypeError: "URL must be of scheme file"). Inline keeps
// the test self-contained and decoupled from filesystem layout.
const kamikazeDescriptor: CustomModifierDescriptor = {
type: "data",
id: asCustomModifierId("parity:kamikaze"),
name: "Kamikaze (ThressGame)",
description:
"T65 ThressGame parity. on-capture: with 25% probability, destroy every adjacent non-king piece via for-each-adjacent + destroy-piece.",
version: 1,
uiForm: "primitive-composer",
source: "custom",
targetAttrs: ["OnCaptureHooks"],
primitives: [
{
kind: "on-capture",
params: {
primitives: [
{
kind: "with-probability",
params: {
p: 0.25,
then: [
{
kind: "for-each-adjacent",
params: {
target: "self",
bind: "adj",
filter: { occupied: true, excludeKing: true },
then: [
{
kind: "destroy-piece",
params: { target: { $var: "adj" } },
},
],
},
},
],
},
},
],
},
},
],
};
const result = validateCustomDescriptor(kamikazeDescriptor);
if (!result.ok) {
throw new Error(
`kamikaze.json failed validation: ${JSON.stringify(result.errors, null, 2)}`,
);
}
expect(result.ok).toBe(true);
});
});

View file

@ -76,6 +76,7 @@ import { PRIMITIVE_REGISTRY } from "./registry.js";
import { enqueueTrigger } from "../triggers.js";
import type { ChessAttrKey } from "../../schema.js";
import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js";
import { numberOrResolver } from "./param-resolver-schema.js";
/**
* Piece-related attributes retracted on destroy. Mirrors the
@ -140,7 +141,7 @@ const PIECE_ATTRS_TO_RETRACT: readonly ChessAttrKey[] = [
];
const schema = z.object({
target: z.number().int().nonnegative(),
target: numberOrResolver({ min: 0 }),
});
type Params = z.infer<typeof schema>;
@ -150,24 +151,33 @@ const descriptor: EffectPrimitive<Params> = {
description:
"Removes the target piece from the board (retracts all piece-related facts) and enqueues an on-captured trigger.",
longDescription:
"Imperative primitive — only legal inside a trigger's primitives/then/else array (top-level placement is rejected by the validator with code 'descriptor.primitives.imperative-in-passive'). Walks a fixed list of piece-related attrs (PieceType, Color, Position, HasMoved, Hp, modifier attrs, movement-replacement attrs, and per-piece hook attrs) and retracts each one (idempotent — a presence check guards every retract). Enqueues an on-captured trigger via the T15 deferred queue so death-rattle / cascade hooks fire AFTER the current arm completes; the queued payload mirrors {attackerId: ctx.pieceId, defenderId: target} so target='attacker'/'defender' resolution inside nested primitives works identically whether the hook fired from a real capture or from this primitive. Refuses to touch marker entities (EntityKind==='marker') — a misdirected target id pointing at a marker is a silent no-op. Calling destroy-piece on an already-destroyed entity is also a silent no-op. target may be authored as a literal entity id, a {$var:'name'} binding (typical inside for-each-piece arms), or a {ctx-attr:{entity,attr}} reference; the param resolver substitutes all shapes to a numeric id before this apply() runs.",
"Removes a piece from the board, the same way a normal capture would. Only works inside a trigger. After the piece is gone, the engine fires an 'on-captured' event for it, so any death-rattle or cascade rules attached to that piece still get to run. Won't touch markers (use 'destroy marker' for those), and calling it on a piece that's already gone is harmless.",
examples: [
{
title: "Kamikaze AOE — destroy every adjacent enemy on-capture",
title: "Kamikaze AOE: destroy every adjacent enemy on capture",
params: { target: 12 },
effect:
"Inside a for-each-adjacent iteration arm where 'adj' binds to a neighbouring enemy id, authoring `target: { $var: 'adj' }` removes that enemy from the board and queues an on-captured fire so any death-rattle hooks they own still get a chance to run before the arm ends.",
"Used inside a loop over adjacent enemies, this removes each neighbouring enemy piece from the board. Each removed piece's own death-rattle rules still get a chance to fire.",
},
{
title: "Self-immolation — descriptor wipes its own bearer",
title: "Self-immolation: a piece destroys itself",
params: { target: 4 },
effect:
"Authoring `target: { ctx-attr: { entity: 'self', attr: 'PieceId' } }` (or directly the literal id) inside an on-rule-activated block destroys the piece the descriptor was applied to. The on-captured queued payload carries attackerId = ctx.pieceId (which is the self id at this scope), so any other death-rattle hooks on the dying piece can still fire normally.",
"When you point this primitive at the piece carrying the rule (the 'self' piece), the piece is removed from the board. Any other death-rattle rules attached to that piece still fire.",
},
],
paramsSchema: schema,
// No new attr seeded here — this primitive only retracts.
seedsAttrs: [],
/**
* Removes the target piece from the board.
*
* NOTE: `target` may be a resolver shape in descriptor JSON
* (`{ $var: "..." }`, `{ "ctx-attr": ... }`, etc.) but the
* dispatcher's `resolveParams` substitutes these shapes to a
* literal entity id BEFORE this function runs, so `params.target`
* is always a number at runtime.
*/
apply(ctx: PrimitiveApplyContext, params: Params): void {
const targetId = params.target as EntityId;

View file

@ -80,7 +80,7 @@ const descriptor: EffectPrimitive<Params> = {
description:
"Iterates an explicit list of column indices (0-7), binding each column's numeric index to a name and running the nested then-primitives once per column.",
longDescription:
"Walks the supplied column list (each entry must be an integer 0-7), deduping and sorting ASC for deterministic iteration, and for each column extends the lexical binding scope with `bind` → column and runs the nested `then` primitives. Inside `then`, reference the bound column index via `{ $var: '<bind>' }`; the param resolver substitutes it (e.g. inside a `{ ctx-build: { col, row } }` square reference) before child primitives' apply() runs. Pair with `for-row` to walk a 2D rectangle. The bound value's type is `number` — primitives that expect an `EntityId` (e.g. `destroy-piece` target) will not type-check against this binding directly.",
"Visits a list of board columns (files) and runs the steps inside it once per column. Each column is a number from 0 to 7 (a-file is 0, h-file is 7). The current column is available to your inner steps under the variable name you choose. Columns are visited in numerical order, with duplicates removed, so replays match. Pair this with 'for row' to sweep a rectangular area of the board. The binding gives you a column number, not a piece, so it's typically used to build a target square together with a row.",
examples: [
{
title: "Mark every odd column with a mine",
@ -99,7 +99,7 @@ const descriptor: EffectPrimitive<Params> = {
],
},
effect:
"On row 4 (rank 5), drops a permanent mine on every odd column. Combined with for-row, you can paint any rectangular pattern.",
"On rank 5, drops a permanent mine on every odd-lettered file (b, d, f, h). Pair this with 'for row' to paint any rectangular pattern.",
},
{
title: "Iterate the two flank columns",
@ -118,7 +118,7 @@ const descriptor: EffectPrimitive<Params> = {
],
},
effect:
"Freezes the two corner squares on rank 1 for 10 moves. Listing flank-only columns avoids a filter step inside the loop.",
"Freezes a1 and h1 (the two flank corners on rank 1) for the next 10 moves.",
},
],
paramsSchema: schema,

View file

@ -194,7 +194,7 @@ const descriptor: EffectPrimitive<Params> = {
description:
"Iterates the up-to-8 squares adjacent to a target (or piece-position), optionally filtering by occupancy / king-presence; binds either the square number or the resident piece id per iteration.",
longDescription:
"Walks the 8-neighbourhood of a centre square (computed from `target`: 'self' reads ctx.pieceId.Position; a numeric target is treated as an entity id if it has a Position fact, otherwise as a literal square in [0..63]). Out-of-board neighbours are skipped, so corners yield 3, edges 5, interior 8. The centre itself is NEVER iterated. Filters: `occupied: true` narrows to squares with a piece (and binds that piece's id), `occupied: false` narrows to empty squares (binds the square number), omitted iterates all adjacent squares (binds the square number). `excludeKing: true` is a no-op unless `occupied: true` — when binding pieces, kings are skipped. Iteration is ASC-sorted by square for determinism. The candidate list is snapshotted at entry — pieces moved / destroyed by inner primitives do NOT alter the iteration. Inside `then`, reference the bound value via `{ $var: '<bind>' }`.",
"Visits each of the up to 8 squares next to a chosen square (the same eight squares a king can reach in one move). Set the centre to 'self' to use the carrying piece's square, or pass a piece, or pass a specific square. By default you visit every neighbour, but you can filter to only occupied squares (the loop hands you the piece on each one) or only empty squares (the loop hands you the square). With 'exclude king' on, kings are skipped. Squares on the edge of the board yield fewer neighbours. The list is locked in before the loop starts, and squares are visited in a fixed order so replays match.",
examples: [
{
title: "Damage every adjacent enemy (king's-touch attack)",
@ -210,7 +210,7 @@ const descriptor: EffectPrimitive<Params> = {
],
},
effect:
"On each adjacent square that holds a non-king piece, sets its Hp to 0. With `excludeKing: true` the centre piece's own king-rank neighbours are spared even when occupied — useful for 'aura of slaying that respects royalty'.",
"Every non-king piece next to the carrying piece has its HP set to 0 (effectively destroyed). Kings standing next to the carrying piece are spared.",
},
{
title: "Spawn a mine on every empty neighbour",
@ -230,7 +230,7 @@ const descriptor: EffectPrimitive<Params> = {
],
},
effect:
"For each empty square neighbouring the apply target, spawns a permanent mine marker. Existing pieces and existing markers are NOT displaced — `occupied: false` narrows to empty squares only.",
"Every empty square next to the carrying piece gets a permanent mine. Squares with pieces or existing markers are skipped.",
},
],
paramsSchema: schema,

View file

@ -116,7 +116,7 @@ const descriptor: EffectPrimitive<Params> = {
description:
"Iterates every marker entity matching the optional filter, binding the marker id to a name and running the nested then-primitives once per match.",
longDescription:
"Walks every marker in the session (EntityKind='marker'), applying an optional filter on markerKind (one of 8 locked kinds) and/or owner color. For each match (sorted ASC by entity id for determinism) it extends the lexical binding scope with `bind` → markerId and runs the nested `then` primitives. Pieces and game-level entities (GAME_ENTITY=0, PRESET_STATE_ENTITY=-1) are NEVER iterated. The match list is snapshotted at entry — markers spawned or destroyed by nested primitives do NOT alter the iteration. Inside `then`, reference the bound id via `{ $var: '<bind>' }`; the param resolver substitutes it before child primitives' apply() runs. Filters are conjunctive (markerKind AND owner); a missing filter field is wildcard. Markers without an owner fact are matched only when `owner` is omitted from the filter.",
"Goes through every marker on the board and runs the steps inside it once per marker. You can filter by marker kind (mine, pit, portal-end, frozen-square, treasure, death-square, tornado, blocked) or by owner (white or black). The current marker is available to your inner steps under the variable name you choose. The list is locked in before the loop starts, so destroying a marker inside the loop is safe and won't disrupt iteration. Markers are visited in a fixed order so replays produce identical results. Pieces are not visited (use 'for each piece' for those).",
examples: [
{
title: "Detonate every mine on the board",
@ -128,7 +128,7 @@ const descriptor: EffectPrimitive<Params> = {
],
},
effect:
"For every mine marker (EntityKind='marker' AND MarkerKind='mine'), destroy-marker fires its on-marker-expire hooks and retracts the marker. Snapshot semantics keep iteration safe even though each iteration mutates the marker pool.",
"Every mine on the board is destroyed in turn, firing each one's expire rules (for example, blowing up the piece that stepped on it).",
},
{
title: "Sweep all enemy frozen-squares",
@ -140,7 +140,7 @@ const descriptor: EffectPrimitive<Params> = {
],
},
effect:
"Filter is conjunctive: only frozen-square markers OWNED by black are iterated. Neutral / unowned ice patches are skipped (their MarkerOwner fact is absent, so the equality check fails for any non-undefined owner filter).",
"Only frozen-square markers owned by black are visited. Neutral, unowned ice patches are skipped, as are white-owned ones.",
},
],
paramsSchema: schema,

View file

@ -116,7 +116,7 @@ const descriptor: EffectPrimitive<Params> = {
description:
"Iterates every piece entity matching the optional filter, binding the piece id to a name and running the nested then-primitives once per match.",
longDescription:
"Walks every piece in the session (EntityKind='piece' or legacy PieceType pieces), applying an optional filter on color and/or pieceType. For each match (sorted ASC by entity id for determinism) it extends the lexical binding scope with `bind` → pieceId and runs the nested `then` primitives. Markers and game-level entities (GAME_ENTITY=0, PRESET_STATE_ENTITY=-1) are NEVER iterated. The match list is snapshotted at entry — pieces created or destroyed by nested primitives do NOT alter the iteration. Inside `then`, reference the bound id via `{ $var: '<bind>' }`; the param resolver substitutes it before child primitives' apply() runs.",
"Goes through every piece on the board and runs the steps inside it once per piece. You can filter by color, by piece kind, or by both. The current piece is available to your inner steps under the variable name you choose. The list is locked in before the loop starts, so any pieces created or destroyed inside the loop don't change which pieces get visited. Pieces are visited in a fixed order so replays produce identical results. Markers are not visited (use 'for each marker' for those).",
examples: [
{
title: "Heal every white piece by 1 HP",
@ -129,13 +129,13 @@ const descriptor: EffectPrimitive<Params> = {
params: {
target: { $var: "p" },
attr: "Hp",
value: { ctx: "self" },
value: 2,
},
},
],
},
effect:
"For every white piece on the board, runs the nested set-piece-attr against the bound id. Combined with `add-to-attribute`-style verbs, this is the canonical 'buff every ally' shape.",
"Visits every white piece on the board and applies the inner step to it. This is the standard shape for any 'buff every ally' rule.",
},
{
title: "Mark every knight",
@ -150,7 +150,7 @@ const descriptor: EffectPrimitive<Params> = {
],
},
effect:
"Both colors' knights gain +1 RangeBonus. Filter is conjunctive — pair with color: 'white' to narrow to white knights only.",
"Every knight on the board, regardless of color, gains +1 to its range bonus. Add a color filter to narrow it down to one side.",
},
],
paramsSchema: schema,

View file

@ -112,7 +112,7 @@ const descriptor: EffectPrimitive<Params> = {
description:
"Iterates square indices (default 0..63), binding each square to a name and running the nested then-primitives once per square.",
longDescription:
"Walks square indices in a deterministic, sorted, de-duplicated order. Default coverage is all 64 squares (`squares` omitted, or authored as the literal \"all\"); authoring an explicit `squares: number[]` array narrows iteration to that subset (out-of-range entries are rejected by the schema; duplicates are dropped). For each square it extends the lexical binding scope with `bind` → square (a number) and runs the nested `then` primitives. The iteration plan is snapshotted at entry — nested primitives that spawn markers, place pieces, or otherwise mutate the board do NOT alter it. Inside `then`, reference the bound square via `{ $var: '<bind>' }`; the param resolver substitutes it before child primitives' apply() runs.",
"Visits a list of squares and runs the steps inside it once per square. By default it visits every square on the board (all 64 of them); you can instead pass a specific list, like the four center squares. The current square is available to your inner steps under the variable name you choose. Squares are visited in numerical order, with duplicates removed, so replays produce identical results. The list is locked in before the loop starts.",
examples: [
{
title: "Drop a marker on every square",
@ -130,7 +130,7 @@ const descriptor: EffectPrimitive<Params> = {
],
},
effect:
"All 64 squares receive a permanent 'blocked' marker. Default `squares` (omitted) iterates 0..63 in ascending order.",
"Every square on the board gets a permanent 'blocked' marker. Without a 'squares' list, the loop visits all 64 squares.",
},
{
title: "Mine the four center squares",
@ -149,7 +149,7 @@ const descriptor: EffectPrimitive<Params> = {
],
},
effect:
"Iterates squares 27, 28, 35, 36 (d4, e4, d5, e5) in ascending order and spawns a mine on each. Authors may pass duplicates — they're dropped before iteration so each listed square fires the body exactly once.",
"Each of the four center squares (d4, e4, d5, e5) gets a permanent mine.",
},
],
paramsSchema: schema,

View file

@ -80,7 +80,7 @@ const descriptor: EffectPrimitive<Params> = {
description:
"Iterates an explicit list of row indices (0-7), binding each row's numeric index to a name and running the nested then-primitives once per row.",
longDescription:
"Walks the supplied row list (each entry must be an integer 0-7), deduping and sorting ASC for deterministic iteration, and for each row extends the lexical binding scope with `bind` → row and runs the nested `then` primitives. Inside `then`, reference the bound row index via `{ $var: '<bind>' }`; the param resolver substitutes it (e.g. inside a `{ ctx-build: { col, row } }` square reference) before child primitives' apply() runs. Pair with `for-column` to walk a 2D rectangle. The bound value's type is `number` — primitives that expect an `EntityId` (e.g. `destroy-piece` target) will not type-check against this binding directly.",
"Visits a list of board rows (ranks) and runs the steps inside it once per row. Each row is a number from 0 to 7 (rank 1 is 0, rank 8 is 7). The current row is available to your inner steps under the variable name you choose. Rows are visited in numerical order, with duplicates removed, so replays match. Pair this with 'for column' to sweep a rectangular area of the board. The binding gives you a row number, not a piece, so it's typically used to build a target square together with a column.",
examples: [
{
title: "Stripe a no-mans-land across the middle ranks",
@ -99,7 +99,7 @@ const descriptor: EffectPrimitive<Params> = {
],
},
effect:
"Drops permanent death-squares on a-file ranks 4 and 5. Pair with for-column to extend the stripe into a full row band.",
"Drops permanent death-squares on a4 and a5. Pair with 'for column' to extend it into a full band across the middle ranks.",
},
{
title: "Mark only the back rank",
@ -118,7 +118,7 @@ const descriptor: EffectPrimitive<Params> = {
],
},
effect:
"Single-row iteration is a degenerate-but-valid use of for-row, useful when you want the same descriptor shape as a multi-row variant (just shorter).",
"A blocked marker appears on e1. Using a single-row list is fine when you want the same shape as a multi-row variant.",
},
],
paramsSchema: schema,

View file

@ -13,17 +13,17 @@ const descriptor: EffectPrimitive<Params> = {
label: "Modify Movement Range",
description: "Additively contributes to the existing RangeBonus attribute.",
longDescription:
"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.",
"Changes how far the piece can move by adding (or subtracting) squares from its reach. Stacks with other range bonuses. Limited to whole numbers between -7 and +7. Affects sliding pieces (rook, bishop, queen). Knight and king have their own movement rules and aren't changed by this.",
examples: [
{
title: "+1 range buff",
params: { delta: 1 },
effect: "A rook's horizontal slide reaches one square further than its baseline.",
effect: "The piece's sliding moves reach one square further than normal.",
},
{
title: "-2 range debuff",
params: { delta: -2 },
effect: "Cuts 2 squares from the piece's reach (useful for 'slowed' tokens).",
effect: "Cuts 2 squares off the piece's reach, useful for a 'slowed' status.",
},
],
paramsSchema: schema,

View file

@ -96,6 +96,38 @@ describe("move-piece primitive — schema", () => {
});
expect(result.success).toBe(false);
});
it("schema parses target as a $var binding", () => {
const result = MOVE_PIECE_PRIMITIVE.paramsSchema.safeParse({
target: { $var: "p" },
to: 28,
});
expect(result.success).toBe(true);
});
it("schema parses to as a ctx-build shape", () => {
const result = MOVE_PIECE_PRIMITIVE.paramsSchema.safeParse({
target: 12,
to: { "ctx-build": { col: 4, row: 3 } },
});
expect(result.success).toBe(true);
});
it("schema parses both fields as resolver shapes", () => {
const result = MOVE_PIECE_PRIMITIVE.paramsSchema.safeParse({
target: { $var: "p" },
to: { "ctx-build": { col: { $var: "col" }, row: 3 } },
});
expect(result.success).toBe(true);
});
it("rejects target as string (invalid resolver shape)", () => {
const result = MOVE_PIECE_PRIMITIVE.paramsSchema.safeParse({
target: "string",
to: 28,
});
expect(result.success).toBe(false);
});
});
describe("move-piece primitive — apply()", () => {

View file

@ -88,11 +88,12 @@ import { z } from "zod";
import type { EntityId } from "@paratype/rete";
import { PRIMITIVE_REGISTRY } from "./registry.js";
import { enqueueTrigger } from "../triggers.js";
import { numberOrResolver } from "./param-resolver-schema.js";
import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js";
const schema = z.object({
target: z.number().int().nonnegative(),
to: z.number().int().min(0).max(63),
target: numberOrResolver({ min: 0 }),
to: numberOrResolver({ min: 0, max: 63 }),
});
type Params = z.infer<typeof schema>;
@ -102,19 +103,19 @@ const descriptor: EffectPrimitive<Params> = {
description:
"Forces the target piece's Position to the destination square and enqueues on-move + on-moved-onto-square triggers.",
longDescription:
"Imperative primitive — only legal inside a trigger's primitives/then/else array (top-level placement is rejected by the validator with code 'descriptor.primitives.imperative-in-passive'). Re-inserts the target piece's Position fact (forced relocation, no legality check, no capture of any occupant at the destination), sets HasMoved = true (idempotent — skipped if already true), and enqueues two deferred trigger events: on-move (any-square move) and on-moved-onto-square (destination-filtered). Both triggers fire AFTER the current primitive arm finishes, observing the post-move session state. No-op when the target entity has no Position fact (already destroyed earlier in the arm, or a stale binding from a cascade). target and to may be authored as literal numbers, { ctx-build: { col, row } }, or { $var: 'name' } bindings; the param resolver substitutes both shapes before this apply() runs.",
"Teleports a piece to a chosen square. Only works inside a trigger. This is a forced move: it doesn't check whether the move would be legal, and it doesn't capture any piece sitting on the destination, so combine it with other steps if you need those behaviours. The piece is marked as having moved (so it loses castling rights and similar perks), and the engine fires the on-move and on-moved-onto-square triggers afterwards just like a normal move would.",
examples: [
{
title: "Teleport piece bound to $p onto e4",
title: "Teleport a chosen piece onto e4",
params: { target: 12, to: 28 },
effect:
"Inside an iteration arm where 'p' binds to a piece id, authoring `target: { $var: 'p' }, to: 28` relocates that piece to e4 (square 28) regardless of legal movement, marks it as moved, and queues on-move + on-moved-onto-square hooks to fire at end-of-arm.",
"Used inside a loop, this snaps the current piece to e4, no matter where it was. Other rules that listen for arrivals on e4 still get to react.",
},
{
title: "Recall king to home square (e1) via on-rule-activated",
title: "Recall the king to its home square",
params: { target: 4, to: 4 },
effect:
"When the wrapping rule activates, force entity #4 (the white king) to e1 (square 4). If the king is already on e1 the move is a no-op for Position but still enqueues the triggers; downstream hooks like on-moved-onto-square can react to the 'arrival' event regardless of whether the square actually changed.",
"When the trigger fires, the white king is sent back to e1. If the king is already on e1, nothing visibly moves, but other rules that watch for 'piece arrived on e1' still fire.",
},
],
paramsSchema: schema,
@ -122,6 +123,13 @@ const descriptor: EffectPrimitive<Params> = {
// already in the consumer registry. Move-piece does not introduce
// any new fact key.
seedsAttrs: [],
/**
* By apply-time the runtime param-resolver (param-resolver.ts) has
* already substituted any { $var } / { ctx-attr } / { ctx-build }
* shapes in `params.target` and `params.to` to literal numbers. The
* schema's union accepts those shapes for author-time validation; at
* runtime they are guaranteed to be resolved scalars.
*/
apply(ctx: PrimitiveApplyContext, params: Params): void {
const targetId = params.target as EntityId;
const from = ctx.session.get(targetId, "Position");

View file

@ -14,18 +14,18 @@ const descriptor: EffectPrimitive<Params> = {
label: "Multiply Attribute",
description: "Multiplies an existing attribute value by the provided factor.",
longDescription:
"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.",
"Multiplies a value the piece already has by a number you choose. If the piece doesn't have that value yet, this step does nothing (it does not assume 1). Run Seed Attribute or Add To Attribute first when you need to set a starting number for it to scale.",
examples: [
{
title: "Double HP",
params: { attr: "Hp", factor: 2 },
effect: "If the piece already has 4 HP, becomes 8 HP.",
effect: "A piece with 4 HP becomes a piece with 8 HP.",
},
{
title: "Halve range bonus",
params: { attr: "RangeBonus", factor: 0.5 },
effect:
"If RangeBonus is already 4, becomes 2 (rounded per attr consumer). Silently skipped if RangeBonus is unset.",
"A range bonus of 4 becomes 2. If the piece doesn't have a range bonus yet, this step is skipped.",
},
],
paramsSchema: schema,

View file

@ -83,19 +83,19 @@ const descriptor: EffectPrimitive<Params> = {
description:
"Restricts the active player's next move to a specific class (capture / advance / move-to-square).",
longDescription:
"Seeds a MoveClassRestriction fact on GAME_ENTITY. The (deferred) move-gen consumer will filter generated moves to only those matching the restriction — capture (must capture), advance (must move without capturing), or move-to (must end on the given square). Used by parity rules like 'must capture if possible' and 'must move the threatened piece'. NOT an imperative primitive — restrictions must be visible to dry-mode legality probes so the move-gen filter can reject forbidden moves before the player ever sees them. The square parameter is required when class === 'move-to' and rejected by the schema otherwise.",
"Forces the active player's next move to be a specific kind: capture (they must take a piece), advance (they must move without capturing), or move-to (they must finish on a particular square). Useful for rules like 'must capture if possible' or 'must move the threatened piece.' If you choose move-to, you also have to give the destination square. The engine hides forbidden moves from the player so they only see legal options.",
examples: [
{
title: "Must capture if possible",
params: { class: "capture" },
effect:
"On the active player's next move, the move-gen filter (deferred) rejects all non-capturing moves — the player MUST take a capture if any are available.",
"If the player has any capture available, they must take one. Non-capturing moves are hidden from their list of options.",
},
{
title: "Must move to the threatened square",
params: { class: "move-to", square: 28 },
effect:
"The active player's next move must land on square 28 (e4). Used by 'must defend the threatened piece' parity descriptors.",
"The active player's next move has to end on square 28 (e4). Useful for 'must defend the threatened piece' style rules.",
},
],
paramsSchema: schema,

View file

@ -27,7 +27,7 @@ const descriptor: EffectPrimitive<Params> = {
label: "On Capture",
description: "Seeds OnCaptureHooks entries consumed during capture events.",
longDescription:
"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.",
"Runs the steps inside it when this piece captures another. Common uses: vampire lifesteal that heals on capture, stacking buffs, or power-up effects. It fires only on real captures, never on a quiet move.",
examples: [
{
title: "Vampire lifesteal",
@ -37,7 +37,7 @@ const descriptor: EffectPrimitive<Params> = {
],
},
effect:
"Every time this piece captures an enemy, it gains 1 HP. Stacks over a long game.",
"Every time this piece captures an enemy, it gains 1 HP. The healing adds up over a long game.",
},
],
paramsSchema: schema,

View file

@ -59,7 +59,7 @@ const descriptor: EffectPrimitive<Params> = {
description:
"Seeds OnCapturedHooks entries that fire when this piece is captured (death-rattle).",
longDescription:
"Wraps nested primitives that fire the MOMENT this piece is captured, BEFORE the engine retracts its facts. Because the hook runs pre-retraction, nested primitives retain read access to the defender's attrs (Hp, PieceType, Position, etc.) and can redirect their effect via the `target` param: 'self' (default) hits the dying piece, 'attacker' or 'defender' leverage the capture event metadata, and relation/square targets let a death-rattle buff nearby allies or debuff the attacker. This enables classic ally-buff (\"Martyr\") and enemy-debuff (\"Kamikaze\") death-rattle patterns without special-casing the dispatcher. Fired by the T12 trigger evaluator, which populates ctx.event with {attackerId, defenderId} and sets ctx.target = hook.target before invoking each inner primitive.",
"Runs the steps inside it the moment this piece gets captured, while the dying piece is still on the board. You can still read its HP, type, and square. Pick where the effect lands with `target`: 'self' hits the dying piece, 'attacker' hits the piece that captured it, and you can also target nearby allies or enemies. This lets you build classic death rattles like Martyr (buff your allies on death) or Kamikaze (hurt the attacker on death).",
examples: [
{
title: "Kamikaze — damage the attacker on death",
@ -70,7 +70,7 @@ const descriptor: EffectPrimitive<Params> = {
],
},
effect:
"When this piece is captured, the capturing piece loses 2 HP. Turns every exchange into a mutual wound.",
"When this piece is captured, the capturing piece loses 2 HP. Every trade becomes a mutual wound.",
},
{
title: "Martyr — buff a random ally queen on death",
@ -81,7 +81,7 @@ const descriptor: EffectPrimitive<Params> = {
],
},
effect:
"When this piece is captured, every allied queen gains 1 HP — a classic death-rattle rally.",
"When this piece is captured, every allied queen gains 1 HP. A classic death-rattle rally.",
},
],
paramsSchema: schema,

View file

@ -28,7 +28,7 @@ const descriptor: EffectPrimitive<Params> = {
description:
"Seeds OnCheckDeliveredHooks entries consumed when this piece newly threatens the enemy royal.",
longDescription:
"Fires on the attacker piece whose threat-line newly reaches the enemy royal after a move resolves. Edge-triggered: it fires only on the TRANSITION from not-threatening-the-king to threatening-the-king, not while the threat persists. For DISCOVERED check, the hook fires on the REVEALING attacker (the piece whose line-of-sight was unblocked by the mover) — NOT on the piece that actually moved. Double-check fires the hook on both newly-attacking pieces. Evaluation uses the pre-move vs post-move attacker-set diff captured by PRE_MOVE_CHECK_STATE_SNAPSHOTS.",
"Runs the steps inside it on the attacking piece the moment its line of attack newly reaches the enemy king. It fires only on the change, not while the threat keeps standing. For a discovered check, it fires on the piece that was revealed (the one whose path got unblocked), not on the piece that actually moved. In a double check, both newly attacking pieces fire it.",
examples: [
{
title: "Vampire on check delivered — gain HP",
@ -38,7 +38,7 @@ const descriptor: EffectPrimitive<Params> = {
],
},
effect:
"Every time this piece newly delivers check to the enemy king (including discovered checks where this piece was the revealer), it gains 1 HP.",
"Every time this piece freshly puts the enemy king in check, including discovered checks where this piece was the one revealed, it gains 1 HP.",
},
{
title: "Stun the king — apply debuff to royal via target redirect",
@ -51,7 +51,7 @@ const descriptor: EffectPrimitive<Params> = {
],
},
effect:
"Delivering check stacks a StunCounter — combined with a target-redirect wrapper, this can debuff the royal itself rather than the attacker. Pressures the opponent to resolve check quickly before the stun accumulates.",
"Delivering check adds a stun counter. Combined with a target redirect, the debuff can land on the king itself instead of the attacker. The opponent has to resolve check fast before the stun stacks up.",
},
],
paramsSchema: schema,

View file

@ -28,7 +28,7 @@ const descriptor: EffectPrimitive<Params> = {
description:
"Seeds OnCheckReceivedHooks entries fired when this royal enters check.",
longDescription:
"Wraps nested primitives that fire the MOMENT this piece transitions from not-in-check to in-check. Fires on the EDGE only: a royal that stays in check across consecutive moves (e.g. the attacker persists) will NOT re-trigger until the check is broken and re-delivered. Applies only to ROYAL pieces (as resolved by the active preset's royalty set, defaulting to kings). Enforcement happens in the T12 trigger evaluator, which compares the pre-move check-state snapshot recorded by apply.ts (see `getPreMoveCheckState` / `PRE_MOVE_CHECK_STATE_SNAPSHOTS`, T4) against the post-move state to detect the transition and filter non-royals. This primitive is purely declarative — it only seeds the hook list; royal-filtering and edge-detection are NOT performed here.",
"Runs the steps inside it the moment this piece goes from not-in-check to in-check. It only fires on the change: a royal that stays in check across several turns won't fire it again until the check is broken and then redelivered. Only royal pieces (kings by default) trigger this.",
examples: [
{
title: "Panic Mode — gain Shield on check",
@ -38,7 +38,7 @@ const descriptor: EffectPrimitive<Params> = {
],
},
effect:
"The first time this king is checked, it gains 2 Shield. Does not re-trigger while the check persists across turns.",
"The first time this king is checked, it gains 2 Shield. It won't trigger again while the same check stays in place.",
},
{
title: "Berserker King — +Damage on check",
@ -48,7 +48,7 @@ const descriptor: EffectPrimitive<Params> = {
],
},
effect:
"Each time this king is newly put in check, its attack damage increases by 1. Rage compounds over the game as enemies repeatedly threaten the crown.",
"Each time this king is freshly put in check, its attack damage goes up by 1. The rage stacks as enemies keep threatening the crown.",
},
],
paramsSchema: schema,

View file

@ -27,7 +27,7 @@ const descriptor: EffectPrimitive<Params> = {
label: "On Damaged",
description: "Seeds OnDamagedHooks entries consumed during damage events.",
longDescription:
"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`).",
"Runs the steps inside it whenever this piece takes damage. Use it for reactive abilities like thorns that hurt the attacker, panic buffs when wounded, or transforming when HP drops below a threshold (pair it with conditional).",
examples: [
{
title: "Thorns on hit",
@ -37,7 +37,7 @@ const descriptor: EffectPrimitive<Params> = {
],
},
effect:
"When this piece takes damage, reflects 25% back to the attacker for that hit.",
"When this piece takes damage, 25% of that damage bounces back to the attacker.",
},
],
paramsSchema: schema,

View file

@ -82,10 +82,10 @@ const descriptor: EffectPrimitive<Params> = {
description:
"Seeds OnMarkerExpireHooks entries fired when a marker of the matching kind expires (lifetime sweep).",
longDescription:
"Wraps nested primitives that fire when a marker of `markerKind` expires via the per-move lifetime decrementer. Inner primitives see `event = { kind: 'marker-expire', markerId, markerKind, square }` so they can spawn follow-up effects on the dying marker's square (e.g. visual fizzle, tombstone marker, recompute regions). Fired BEFORE the marker's facts are retracted so primitives that read MarkerOwner/MarkerLinks still resolve. Match is exact-kind only — a hook for `mine` does NOT fire on `pit`. Permanent markers never expire; one-shot markers are consumed by entry triggers, not by this sweep.",
"Runs the steps inside it whenever a marker of the chosen kind expires (its time runs out at the end of a move). Use it to play a fade-out effect, leave a tombstone marker, or recompute zones around the dying tile. The marker is still on the board when this fires, so you can still read its square, owner, and any linked partners. The match is exact: a hook for 'mine' won't fire when a 'pit' expires. Permanent markers never expire, and one-shot markers are consumed when something steps on them (a different event).",
examples: [
{
title: "Frozen-square thaw — broadcast a UI fizzle when ice melts",
title: "Frozen-square thaw — play a fizzle when the ice melts",
params: {
markerKind: "frozen-square",
primitives: [
@ -93,7 +93,7 @@ const descriptor: EffectPrimitive<Params> = {
],
},
effect:
"When a frozen-square marker's `expiresAtMove` is reached, runs the inner primitives once. The event payload carries the dying marker's id, kind ('frozen-square'), and the square it occupied — useful for spawning a 'thaw' visual effect or recomputing pathing regions on that tile.",
"When a frozen-square marker's time runs out, the inner steps run once. The dying marker's square is available to those steps, so you can play a thaw effect or rebuild the zones on that tile.",
},
],
paramsSchema: schema,

View file

@ -27,7 +27,7 @@ const descriptor: EffectPrimitive<Params> = {
label: "On Move",
description: "Seeds OnMoveHooks entries consumed whenever this piece moves.",
longDescription:
"Wraps nested primitives that fire whenever this piece's Position WME changes — normal moves, captures, castling-rook relocations, and en-passant pawn advances all count. Use for 'per-move' stacking buffs, movement-driven resource gain, or any effect that should proc each time the piece physically changes squares. Fires AFTER the move resolves, on the mover itself.",
"Runs the steps inside it every time this piece changes squares: normal moves, captures, the rook in castling, and en-passant pawn moves all count. Use it for stacking per-move buffs, movement-driven resources, or any effect that should fire each time the piece moves. It runs after the move finishes, on the piece that moved.",
examples: [
{
title: "Berserker — stacking attack on every move",
@ -37,7 +37,7 @@ const descriptor: EffectPrimitive<Params> = {
],
},
effect:
"Each time the piece moves (any kind of move), it gains +1 AttackBonus. Stacks indefinitely — the longer it keeps marching, the harder it hits.",
"Each time the piece moves, in any way, it gains +1 attack. The bonus stacks forever, so the longer it keeps marching the harder it hits.",
},
{
title: "Nomad — heals 1 HP per step",
@ -47,7 +47,7 @@ const descriptor: EffectPrimitive<Params> = {
],
},
effect:
"Every move (including captures and castling) restores 1 HP. Rewards active play and punishes idling.",
"Every move, including captures and castling, heals 1 HP. Rewards active play and punishes sitting still.",
},
],
paramsSchema: schema,

View file

@ -28,21 +28,27 @@ const FileRankLiteral = z.union([
z.literal(7),
]);
const FilterSchema = z.discriminatedUnion("kind", [
z.object({
kind: z.literal("squares"),
squares: z.array(z.number().int().min(0).max(63)).min(1),
}),
z
.object({
// NOTE: `.refine(...)` MUST live on the parent — not on a union
// option. zod v4's `discriminatedUnion` reads `option.shape[disc]`
// on every option to extract the discriminating literal; a refined
// option has no top-level `.shape` and the lookup blows up with
// "can't access property 'kind', type.shape is undefined".
const FilterSchema = z
.discriminatedUnion("kind", [
z.object({
kind: z.literal("squares"),
squares: z.array(z.number().int().min(0).max(63)).min(1),
}),
z.object({
kind: z.literal("predicate"),
file: FileRankLiteral.optional(),
rank: FileRankLiteral.optional(),
})
.refine((p) => p.file !== undefined || p.rank !== undefined, {
message: "predicate must have file, rank, or both",
}),
]);
])
.refine(
(f) => f.kind !== "predicate" || f.file !== undefined || f.rank !== undefined,
{ message: "predicate must have file, rank, or both" },
);
const schema = z.object({
filter: FilterSchema,
@ -57,7 +63,7 @@ const descriptor: EffectPrimitive<Params> = {
description:
"Seeds OnMovedOntoSquareHooks entries consumed when this piece lands on a matching square.",
longDescription:
"Wraps nested primitives that fire when this piece finishes a move on a square selected by the `filter`. Two filter kinds: `squares` — an explicit list of square indices (0..63, a1=0, h8=63); `predicate` — a file and/or rank match (files 0=a..7=h, ranks 0=rank1..7=rank8). A predicate with both file and rank set fires only on that exact square; a predicate with just one set fires for the whole file or rank. Fires AFTER the move resolves, only when the piece's final square matches — plain moves, captures, castling, and en-passant all qualify so long as the destination matches.",
"Runs the steps inside it when this piece finishes its move on a square that matches the filter. Two filter modes: 'squares' lists exact square indexes (0 to 63, where a1 is 0 and h8 is 63), and 'predicate' matches by file or rank (files 0=a to 7=h, ranks 0=rank 1 to 7=rank 8). A predicate with both file and rank set picks one exact square; with just one set, it covers the whole file or rank. It fires after the move finishes, for plain moves, captures, castling, and en-passant alike, as long as the landing square matches.",
examples: [
{
title: "Center Bonus — +1 HP when entering d4/e4/d5/e5",
@ -68,7 +74,7 @@ const descriptor: EffectPrimitive<Params> = {
],
},
effect:
"Landing on any of the four center squares (d4=27, e4=28, d5=35, e5=36) heals 1 HP. Rewards classical center control.",
"Landing on any of the four center squares (d4, e4, d5, e5) heals 1 HP. Rewards classical center control.",
},
{
title: "King Row — +Aura when reaching rank 8",
@ -82,7 +88,7 @@ const descriptor: EffectPrimitive<Params> = {
],
},
effect:
"As soon as this piece steps onto rank 8 (the last rank from White's perspective), it projects a +1 HpBonus aura to adjacent allies. Fires every time it re-enters the rank, so repositioning matters.",
"As soon as this piece steps onto rank 8 (the last rank from White's side), it gives a +1 HP aura to adjacent allies. It fires every time the piece re-enters the rank, so repositioning matters.",
},
],
paramsSchema: schema,

View file

@ -64,7 +64,7 @@ const descriptor: EffectPrimitive<Params> = {
description:
"Seeds OnPieceEnteredMarkerHooks entries consumed when any piece lands on a marker of the matching kind.",
longDescription:
"Wraps nested primitives that fire when ANY piece (mover, castling rook, etc.) finishes a move on a square that contains a marker of `markerKind`. Markers stack: a square may carry several markers; the dispatcher fires hooks in priority order (portal-end → frozen-square → mine → pit → death-square → tornado → treasure → blocked) so teleport effects resolve before damage and damage resolves before passive markers like 'blocked'. Match is exact-kind only — a hook for `mine` does NOT fire on `pit`.",
"Runs the steps inside it when any piece (the mover, the rook in castling, and so on) finishes a move on a square that has a marker of the chosen kind. A square can hold several markers at once; when that happens the engine fires them in this order: portal-end, frozen-square, mine, pit, death-square, tornado, treasure, blocked. Teleport effects resolve before damage, and damage resolves before passive markers. The match is exact: a hook for 'mine' will not fire when a piece lands on a 'pit'.",
examples: [
{
title: "Minefield — piece entering a mine takes 1 damage",
@ -75,7 +75,7 @@ const descriptor: EffectPrimitive<Params> = {
],
},
effect:
"Whenever any piece lands on a mine marker, that piece's Hp drops by 1. Stacks if multiple mines occupy the square (each mine fires its own hook chain).",
"Whenever any piece lands on a mine, that piece loses 1 HP. If several mines share the square, each one fires, so the damage stacks.",
},
],
paramsSchema: schema,

View file

@ -27,7 +27,7 @@ const descriptor: EffectPrimitive<Params> = {
label: "On Promotion",
description: "Seeds OnPromotionHooks entries consumed after pawn promotes.",
longDescription:
"Wraps nested primitives that fire AFTER this piece's PieceType flips from pawn to another type (queen, rook, bishop, knight, or a custom promotion target set by override-promotion). The dispatcher populates `ctx.event = { kind: 'promotion', promotedFrom: 'pawn', promotedTo: PieceType }` so nested primitives can inspect what the pawn became. Fires once per promotion event, after the flip is committed.",
"Runs the steps inside it right after this pawn promotes, once the new piece type is set. Works for queen, rook, bishop, knight, or any custom target picked by override-promotion. The steps inside can read what the pawn became, so you can branch on the new type. It fires once per promotion.",
examples: [
{
title: "Promotion Feast — seed 5 HP on promote",
@ -37,7 +37,7 @@ const descriptor: EffectPrimitive<Params> = {
],
},
effect:
"When this pawn promotes, it immediately gains 5 HP — a reward for surviving the journey across the board.",
"When this pawn promotes, it immediately gains 5 HP. A reward for surviving the journey across the board.",
},
{
title: "Stay-As-Pawn — defensively revert the promotion",
@ -50,7 +50,7 @@ const descriptor: EffectPrimitive<Params> = {
],
},
effect:
"Right after the engine flips this pawn to its promotion target, the hook overwrites PieceType back to 'pawn', preserving the original role.",
"Right after this pawn would promote, it gets reverted back to a pawn. The piece keeps its original role no matter what the player picks.",
},
],
paramsSchema: schema,

View file

@ -47,7 +47,7 @@ const descriptor: EffectPrimitive<Params> = {
description:
"Seeds OnRuleActivatedHooks entries fired exactly once when the descriptor attaches.",
longDescription:
"Wraps nested primitives that fire ONCE when this descriptor first activates on the game. Typical uses: announce the rule (broadcast a banner attr), seed initial board state (place markers, set per-game counters), or grant a one-time bonus to the chooser. Does NOT re-fire on game reload — a per-game guard on PRESET_STATE_ENTITY tracks which descriptor ids have already fired.",
"Runs the steps inside it once, the first time this rule attaches to the game (for example, when a player picks the modifier at game start). Use it to announce the rule, place starting markers, set up a per-game counter, or grant a one-time bonus. It does not fire again when a saved game is reloaded.",
examples: [
{
title: "Announcement banner on activation",
@ -60,7 +60,7 @@ const descriptor: EffectPrimitive<Params> = {
],
},
effect:
"When the rule first attaches, runs the inner seed-attribute once. Subsequent moves do NOT re-trigger; expiration / re-application within the same game also does not re-fire.",
"When the rule first attaches, the inner seed-attribute runs once. Later moves don't re-trigger it, and removing then re-adding the rule in the same game won't fire it again either.",
},
],
paramsSchema: schema,

View file

@ -56,7 +56,7 @@ const descriptor: EffectPrimitive<Params> = {
description:
"Seeds OnRuleExpireHooks entries fired exactly once when the descriptor detaches.",
longDescription:
"Wraps nested primitives that fire ONCE when this descriptor detaches from the game (lifetime expires, manual remove-modifier action, or — for piece-bound modifiers — when the holding piece is captured). Typical uses: tear down banners or counters previously seeded by `on-rule-activated`, retract per-game state, or grant a parting bonus/penalty. A per-game guard on PRESET_STATE_ENTITY (`RuleExpireFiredFor`) tracks which descriptor ids have already fired their expire block, so a re-attach + re-detach cycle in the same session does NOT re-fire — consistent with the once-per-descriptor-id activation contract.",
"Runs the steps inside it once, when this rule detaches from the game. That happens when its lifetime runs out, a player removes it, or, for rules tied to a specific piece, when that piece is captured. Use it to clean up banners or counters that on-rule-activated set up, or to grant a parting bonus or penalty. It only fires the first time the rule detaches in a game; re-adding and re-removing it later won't fire it again.",
examples: [
{
title: "Cleanup banner on expire",
@ -69,7 +69,7 @@ const descriptor: EffectPrimitive<Params> = {
],
},
effect:
"When the rule detaches, runs the inner seed-attribute once to reset RangeBonus. Subsequent re-attach-detach cycles do NOT re-trigger; only the FIRST detach fires.",
"When the rule detaches, the inner seed-attribute runs once to reset RangeBonus. Only the first detach fires it; later re-add and re-remove cycles won't trigger it.",
},
],
paramsSchema: schema,

View file

@ -29,7 +29,7 @@ const descriptor: EffectPrimitive<Params> = {
description:
"Seeds OnTurnEndHooks entries consumed during the engine's turn-end phase (fires for the mover at the end of their turn, before the opponent's on-turn-start).",
longDescription:
"Wraps a list of nested primitives that fire at the END of this piece's color's turn, immediately after the move commits and before control passes to the opponent (so before the opponent's on-turn-start). Use for decay effects, end-of-turn damage ticks, cooldown resets, or recurring debuffs tied to turn cadence. The `color` param filters which side's turn-end triggers this hook (`both` fires on either side). The editor's Parameter Inspector accepts the nested `primitives` array as JSON; copy snippets from the simpler primitives into that array.",
"Runs the steps inside it at the end of a turn, right after the move finishes and before the other player's turn begins. Use it for poison ticks, end-of-turn damage, cooldown resets, or any ability that fires when a turn closes out. The `color` setting picks whose turn-end triggers this: 'white', 'black', or 'both'.",
examples: [
{
title: "Decay — lose 1 HP per turn end",
@ -40,7 +40,7 @@ const descriptor: EffectPrimitive<Params> = {
],
},
effect:
"At the end of every turn (either color), this piece loses 1 HP — useful for poisoned/burning units that tick down regardless of who's moving.",
"At the end of every turn, no matter whose turn it was, this piece loses 1 HP. Good for poisoned or burning units that tick down on both sides.",
},
{
title: "Clear capture-flag at the end of white's turn",
@ -51,7 +51,7 @@ const descriptor: EffectPrimitive<Params> = {
],
},
effect:
"At the end of each white turn, reset this piece's capture flag to 0 — lets a one-shot-per-turn ability re-arm on the owner's own turn boundary.",
"At the end of each white turn, this piece's capture flag resets to 0, letting a once-per-turn ability re-arm on the owner's turn.",
},
],
paramsSchema: schema,

View file

@ -28,7 +28,7 @@ const descriptor: EffectPrimitive<Params> = {
description:
"Seeds OnTurnStartHooks entries consumed during the engine's turn-start phase.",
longDescription:
"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.",
"Runs the steps inside it at the start of this piece's color's turn, every turn. Use it for recurring effects like healing, buffs, debuffs, or any ability that should tick once per turn. You build the inner steps by nesting other primitives inside the `primitives` list.",
examples: [
{
title: "Regenerate 1 HP/turn",
@ -38,7 +38,7 @@ const descriptor: EffectPrimitive<Params> = {
],
},
effect:
"At the start of every turn, this piece regains 1 HP (until capped by its damage pipeline).",
"At the start of every turn, this piece heals 1 HP (up to its maximum).",
},
],
paramsSchema: schema,

View file

@ -14,17 +14,17 @@ const descriptor: EffectPrimitive<Params> = {
label: "Override Promotion",
description: "Write PromotionOverride directly to enforce a promotion target.",
longDescription:
"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.",
"Forces this piece (usually a pawn) to promote into a specific type, taking the choice away from the player. If two different rules try to set a promotion at once, the last one wins.",
examples: [
{
title: "Knights-only promotion",
params: { target: "knight" },
effect: "Pawn always promotes to a knight.",
effect: "When the pawn reaches the back rank, it always becomes a knight.",
},
{
title: "Underpromote to rook",
params: { target: "rook" },
effect: "Pawn always promotes to a rook — useful for themed variants.",
effect: "The pawn always promotes to a rook, handy for themed variants where queens are forbidden.",
},
],
paramsSchema: schema,

View file

@ -67,19 +67,19 @@ const descriptor: EffectPrimitive<Params> = {
description:
"Game-level: pawns push the piece in front of them (one square forward) instead of capturing diagonally.",
longDescription:
"Inserts a `PawnPushesPiecesEnabled: boolean` fact on GAME_ENTITY. When true (the default), the (deferred) Wave-10 move-gen reader replaces FIDE pawn-capture semantics with push semantics: a pawn moving onto an occupied square shoves the occupant one rank further forward in the same direction; if the destination is off-board or already occupied, the move is illegal. When false, the flag explicitly DISABLES the rule (used by composed rules that toggle the behaviour mid-game). The flag is uniformly applied to every pawn in the game — there is no per-pawn override. Orthogonal to T39 BlockedPieceTypes (which gates whether a pawn can move at all).",
"Changes how every pawn in the game interacts with pieces in front of it. Instead of capturing diagonally, a pawn moving onto a piece directly ahead shoves that piece one square further forward. If there's no room (off the board, or another piece is already there), the push is illegal and the pawn can't move. Apply it with enabled = false to turn the rule back off, useful for variants that toggle the behavior mid-game.",
examples: [
{
title: "Enable pawn-push semantics",
params: { enabled: true },
effect:
"Writes PawnPushesPiecesEnabled=true on GAME_ENTITY. Pawns will push instead of capturing once the Wave-10 move-gen reader lands.",
"Pawns now push the piece in front of them one square forward instead of capturing diagonally.",
},
{
title: "Explicitly disable (rule-toggle composition)",
title: "Turn pawn-pushing back off",
params: { enabled: false },
effect:
"Writes PawnPushesPiecesEnabled=false on GAME_ENTITY. Used by composed descriptors that turn the rule OFF after a triggering event.",
"Pawns go back to normal capture rules. Useful inside a trigger that ends the pawn-push phase after some event.",
},
],
paramsSchema: schema,

View file

@ -122,6 +122,42 @@ describe("place-piece primitive — paramsSchema (T21)", () => {
}
});
it("accepts resolver shape for square ($var form)", () => {
const result = PLACE_PIECE_PRIMITIVE.paramsSchema.safeParse({
pieceType: "queen",
color: "white",
square: { $var: "targetSquare" },
});
expect(result.success).toBe(true);
});
it("accepts resolver shape for square (ctx-build form with col/row)", () => {
const result = PLACE_PIECE_PRIMITIVE.paramsSchema.safeParse({
pieceType: "knight",
color: "black",
square: { "ctx-build": { col: 4, row: 3 } },
});
expect(result.success).toBe(true);
});
it("rejects resolver shape on pieceType enum (intentional design constraint)", () => {
const result = PLACE_PIECE_PRIMITIVE.paramsSchema.safeParse({
pieceType: { $var: "x" },
color: "white",
square: 28,
});
expect(result.success).toBe(false);
});
it("rejects resolver shape on color enum (intentional design constraint)", () => {
const result = PLACE_PIECE_PRIMITIVE.paramsSchema.safeParse({
pieceType: "pawn",
color: { $var: "x" },
square: 28,
});
expect(result.success).toBe(false);
});
it("rejects square == 64 (out of range, max is 63)", () => {
expect(() =>
PLACE_PIECE_PRIMITIVE.paramsSchema.parse({

View file

@ -49,6 +49,7 @@
import { z } from "zod";
import type { PieceColor, PieceType } from "../../schema.js";
import { PRIMITIVE_REGISTRY } from "./registry.js";
import { numberOrResolver } from "./param-resolver-schema.js";
import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js";
/**
@ -70,7 +71,7 @@ const PIECE_COLORS = ["white", "black"] as const satisfies readonly PieceColor[]
const schema = z.object({
pieceType: z.enum(PIECE_TYPES),
color: z.enum(PIECE_COLORS),
square: z.number().int().min(0).max(63),
square: numberOrResolver({ min: 0, max: 63 }),
});
type Params = z.infer<typeof schema>;
@ -80,27 +81,41 @@ const descriptor: EffectPrimitive<Params> = {
description:
"Spawns a piece of the given type and color on the given square via the engine's canonical spawnPiece factory.",
longDescription:
"Imperative primitive — only legal inside a trigger's primitives/then/else array (top-level placement is rejected by the validator with code 'descriptor.primitives.imperative-in-passive'). Calls engine.spawnPiece(pieceType, color, square), which fires onPieceSpawn preset hooks (so attribute-seeding presets like piece-hp populate the new piece's facts). Does NOT check whether the target square is empty — placing on an occupied square spawns a second piece sharing that Position. Square may be authored as a literal index, a { ctx-build: { col, row } } shape, or a { $var: 'name' } binding; the param resolver substitutes both shapes before this apply() runs.",
"Drops a brand new piece of the chosen type and color onto a chosen square. Only works inside a trigger. The new piece behaves exactly like a starting-position piece, so any other rules that react to spawning (HP seeding, shields, and so on) fire normally. There's no occupancy check: if you place onto an occupied square, both pieces will share that square, and you'll need other logic to sort that out.",
examples: [
{
title: "Spawn a queen on e4 in response to a trigger",
params: { pieceType: "queen", color: "white", square: 28 },
effect:
"When the wrapping trigger fires, a new white queen materializes on e4 (square 28). Spawned via the canonical engine factory, so any active preset's onPieceSpawn hook seeds the new queen's Hp / Shield / etc. just like a starting-position piece.",
"When the trigger fires, a new white queen appears on e4. It gets the same starting HP, shields, and other attributes that any white queen would get from your active rules.",
},
{
title: "Spawn a knight at a binding-resolved square",
title: "Spawn a knight on a square chosen by a loop",
params: { pieceType: "knight", color: "black", square: 35 },
effect:
"Inside an iteration arm where 'sq' binds to a square, authoring `square: { $var: 'sq' }` resolves to the bound numeric index before apply() runs; the schema sees a plain number and accepts it.",
"Inside a loop that walks over squares, you can point this primitive at the current square by name. Each pass of the loop spawns a black knight on whatever square the loop is visiting.",
},
],
paramsSchema: schema,
// No new attr seeded here — spawnPiece writes PieceType, Color,
// Position, HasMoved (all already in core consumer registry).
seedsAttrs: [],
/**
* Spawns a piece at the given square. By apply-time the runtime
* param-resolver (param-resolver.ts) has already substituted any
* `{ "ctx-build": { col, row } }` / `{ $var: "x" }` shapes in
* `params.square` to a literal number. The schema's union accepts
* those shapes for author-time validation; at runtime they are
* guaranteed to be resolved scalars. `pieceType` and `color` are
* always strict enums (intentional design constraint resolver
* shapes are not supported for piece class attributes).
*/
apply(ctx: PrimitiveApplyContext, params: Params): void {
ctx.engine.spawnPiece(params.pieceType, params.color, params.square);
ctx.engine.spawnPiece(
params.pieceType,
params.color,
params.square as number,
);
},
};

View file

@ -95,7 +95,7 @@ const descriptor: EffectPrimitive<Params> = {
description:
"Picks one element uniformly at random from a list, binds it to a name, and runs the nested then-primitives once with that binding.",
longDescription:
"Draws a single element from `from` using the engine's seeded RNG (Mulberry32, persistent stream on GAME_ENTITY) and binds the picked value to `bind` in the lexical scope of `then`. Same RngSeed + same call sequence yields the same pick — this primitive is deterministic by construction. Empty-after-resolution `from` arrays no-op (the schema rejects authored-empty lists at validation, but runtime resolvers may leave a list empty). Inside `then`, reference the picked value via `{ $var: '<bind>' }`; the param resolver substitutes it before child primitives' apply() runs. The picked value's runtime type matches whatever was in `from` — primitives that consume the binding will validate it via their own paramsSchema.",
"Picks one entry at random from a list and runs the steps inside it once, with the picked value available to your inner steps under the variable name you choose. The picker uses the game's deterministic random number generator, so replays produce the same pick. The list can hold squares, piece kinds, colors, or any value the inner step understands. An empty list is a no-op (nothing happens, no error).",
examples: [
{
title: "Pick a random target square from a 4-square set",
@ -114,7 +114,7 @@ const descriptor: EffectPrimitive<Params> = {
],
},
effect:
"Spawns a permanent mine on exactly one of the four central squares (d4/e4/d5/e5), chosen via the engine's seeded RNG so the same seed always picks the same square.",
"A permanent mine appears on one of the four center squares (d4, e4, d5, or e5), picked at random. Replays pick the same square.",
},
{
title: "Pick a random promotion target",
@ -129,7 +129,7 @@ const descriptor: EffectPrimitive<Params> = {
],
},
effect:
"Converts the piece this descriptor is attached to into one of the four promotion choices, picked uniformly at random under the seeded RNG. Pair with on-promotion to gate it to actual promotion events.",
"Turns the carrying piece into a queen, rook, bishop, or knight, chosen at random. Pair with an on-promotion trigger to fire only when a pawn would normally promote.",
},
],
paramsSchema: schema,

View file

@ -13,17 +13,17 @@ const descriptor: EffectPrimitive<Params> = {
label: "Reflect Damage",
description: "Seed reflected-damage percentage for deferred damage-pipeline wiring.",
longDescription:
"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.",
"Sends a percentage of incoming damage back at the attacker. Use a whole number from 0 to 100. If two rules try to set a reflect percentage, only the most recent one applies (they don't add up). Drop it inside an on-damaged trigger for a one-shot thorns reaction, or leave it as a passive trait for permanent damage reflection.",
examples: [
{
title: "Half-reflective armour",
params: { percentage: 50 },
effect: "50% of incoming damage is dealt back to the attacker.",
effect: "Half of every hit is dealt right back to the attacker.",
},
{
title: "Total thorns",
params: { percentage: 100 },
effect: "Full reflection — the attacker takes whatever they dealt.",
effect: "Full reflection: the attacker takes whatever damage they tried to deal.",
},
],
paramsSchema: schema,

View file

@ -179,7 +179,7 @@ const descriptor: EffectPrimitive<Params> = {
description:
"Suspends trigger execution until a player answers a UI prompt; binds the answer for subsequent primitives.",
longDescription:
"Pushes a PendingChoice frame onto the LIFO stack on GAME_ENTITY (T45) and short-circuits the dispatcher via the SuspendedExecution exception. The dispatcher (`runPrimitives`) catches the throw, fills in `triggerPath` + `primitiveIndex` on the pushed frame, and stops iterating siblings of the current arm. The rest of the arm is the SUSPENDED CONTINUATION — T46 (`submit-choice` PlayerAction) restores the captured bindings, inserts the player's answer under `params.bind`, and re-enters `runPrimitives` against `params.then` so the continuation runs with the answer in scope. The `choiceId` is derived from the engine's seeded RNG so replays produce identical ids; `Date.now()` is intentionally forbidden.",
"Pauses the game and shows the active player a popup asking them to pick something: a square, a piece, a piece type, a file, a rank, rock-paper-scissors, or a coin flip. Once they answer, the game resumes and runs the steps in 'then' with the answer available by name (the name you set in 'bind'). Use 'forPlayer' to pick which side answers ('white', 'black', or 'both' for either-player prompts). Replays of the same game always produce the same popup IDs.",
examples: [
{
title: "Pick a square to mine",
@ -200,7 +200,7 @@ const descriptor: EffectPrimitive<Params> = {
],
},
effect:
"Suspends until white picks a square; the chosen square is bound to `$sq` and spawns a mine there. The continuation runs only after T46 resumes with the player's answer.",
"The game pauses and asks white to pick a square. Once they pick, a mine appears on that square. The rest of the steps don't run until they answer.",
},
{
title: "RPS coin-flip ceremony",
@ -221,7 +221,7 @@ const descriptor: EffectPrimitive<Params> = {
],
},
effect:
"Either player may answer; their throw is bound to `$throw` and written into the target's Hp attr after T46 resumes the continuation.",
"Either player can answer. Their throw is then written into this piece's HP after the game resumes.",
},
],
paramsSchema: schema,

View file

@ -72,18 +72,18 @@ const descriptor: EffectPrimitive<Params> = {
label: "Seed Attribute",
description: "Seeds a fact on the target piece, overwriting existing value.",
longDescription:
"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.",
"Sets a value on the piece, replacing whatever was there before. Use it to give a piece a brand new property (like a custom ShieldCharges counter) or to lock in a starting number (such as forcing HP to exactly 5, ignoring everything else). Stack it with Add To Attribute or Multiply Attribute when you want to build a final number up in steps.",
examples: [
{
title: "Force exact HP",
params: { attr: "Hp", value: 5 },
effect: "Piece always starts with 5 HP regardless of baseline.",
effect: "The piece always starts with 5 HP, no matter what its base HP would have been.",
},
{
title: "Declare shield charges",
params: { attr: "ShieldCharges", value: 3 },
effect:
"Creates a 3-charge counter. Combine with absorb-damage-with-attribute to make each charge soak one damage point.",
"Gives the piece a counter that starts at 3. Pair it with Absorb Damage With Attribute so each charge soaks up one point of damage.",
},
],
paramsSchema: schema,

View file

@ -22,18 +22,18 @@ const descriptor: EffectPrimitive<Params> = {
label: "Set Capture Flag",
description: "Bitwise-ORs one capture flag into CaptureFlags.",
longDescription:
"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).",
"Turns on a special capture rule for the piece. Flags stack, so you can apply more than one. The choices are: 1 = the piece can capture its own color, 2 = the piece cannot be captured by enemies, 4 = the piece participates in en passant captures.",
examples: [
{
title: "Untouchable piece",
params: { flag: 2 },
effect: "Sets CANNOT_BE_CAPTURED — no enemy move can target this piece.",
effect: "No enemy move can target this piece.",
},
{
title: "Friendly-fire rook",
params: { flag: 1 },
effect:
"Sets CAN_CAPTURE_OWN — the piece may capture its own color's pieces.",
"The piece is allowed to capture its own color's pieces.",
},
],
paramsSchema: schema,

View file

@ -89,19 +89,19 @@ const descriptor: EffectPrimitive<Params> = {
description:
"Adds another PieceType's movement template to the target piece (additive — writes the MovesAlsoAs attr).",
longDescription:
"Writes the per-piece MovesAlsoAs attribute (T8 schema slot) onto the target entity. Move-gen consults this attr and ADDS the secondary PieceType's movement template on top of the entity's natural one — the canonical 'this knight moves AND ALSO as a bishop' verb (chancellor / archbishop fairy-piece pattern). Sibling of set-moves-as: the override variant fully REPLACES movement; this primitive is additive. The two attrs are independent — a piece may carry both, in which case the override wins for the base template and the additive layers a second template on top. NOT in IMPERATIVE_KINDS (T14): legal at top-level descriptor scope as well as inside trigger / conditional arms. The additive move-set is visible to move-gen what-if probes (suppressTriggers does NOT skip this primitive) because the legality calculation depends on it. target may be authored as a literal entity id, a { $var: 'name' } binding, or a { ctx-attr: { entity, attr } } reference; the param resolver substitutes all shapes to a numeric id before this apply() runs. No on-* trigger is fired; downstream observers must subscribe at their own descriptor level. The actual move-gen reader that adds the secondary piece-type's movement is wired in by the Wave 7 movement integration tasks; this primitive is the canonical writer.",
"Lets the piece move like another type IN ADDITION to its own movement, instead of replacing it. Classic example: a knight that also moves like a rook (the chancellor fairy piece) or a bishop that also jumps like a knight (the archbishop). Use it as a passive trait at the top level of your rule, or fire it inside a trigger. Pairs with Set Moves As if you want both a full override AND an extra layer.",
examples: [
{
title: "Chancellor knight that also moves as a rook",
title: "Chancellor: knight that also moves as a rook",
params: { target: 12, pieceType: "rook" },
effect:
"Writes MovesAlsoAs='rook' onto entity 12. Move-gen unions the knight's L-shape moves with the rook's rank/file slides — the classic chancellor fairy piece. Captures and checks resolve via either move template.",
"The knight keeps its L-shape jumps and gains the rook's straight-line slides on top, the classic chancellor fairy piece. It can capture or give check using either kind of move.",
},
{
title: "Archbishop bishop that also jumps as a knight",
title: "Archbishop: bishop that also jumps as a knight",
params: { target: 7, pieceType: "knight" },
effect:
"Inside an on-promotion arm where 'p' binds a freshly-promoted bishop, authoring `target: { $var: 'p' }, pieceType: 'knight'` keeps the bishop's diagonal slides AND adds knight L-jumps — the archbishop fairy-piece pattern.",
"Inside an on-promotion trigger, a freshly promoted bishop keeps its diagonal slides and also gains knight L-jumps. This is the archbishop fairy piece.",
},
],
paramsSchema: schema,

View file

@ -81,19 +81,19 @@ const descriptor: EffectPrimitive<Params> = {
description:
"Overrides the target piece's movement template with another PieceType's movement (writes the MovesAs attr).",
longDescription:
"Writes the per-piece MovesAs attribute (T8 schema slot) onto the target entity. Move-gen consults this attr and substitutes the override PieceType's movement template for the entity's natural one — the canonical 'this knight moves like a queen' verb. NOT in IMPERATIVE_KINDS (T14): legal at top-level descriptor scope as well as inside trigger / conditional arms. The override is visible to move-gen what-if probes (suppressTriggers does NOT skip this primitive) because the legality calculation depends on the override. target may be authored as a literal entity id, a { $var: 'name' } binding, or a { ctx-attr: { entity, attr } } reference; the param resolver substitutes all shapes to a numeric id before this apply() runs. No on-* trigger is fired; downstream observers must subscribe at their own descriptor level. The actual move-gen reader that substitutes the override piece-type's movement is wired in by the Wave 7 movement integration tasks; this primitive is the canonical writer.",
"Makes the piece move like a different type of piece, completely replacing its normal movement. The classic example is 'this knight moves like a queen.' You can use it as a passive trait at the top level of your rule, or fire it inside a trigger to swap movement on the fly. The new movement applies for both regular moves and check checks, exactly as if the piece really were that type.",
examples: [
{
title: "Knight moves as queen — override movement template",
title: "Knight moves as queen",
params: { target: 12, pieceType: "queen" },
effect:
"Writes MovesAs='queen' onto entity 12. Move-gen substitutes the queen's movement template for the entity's natural knight movement, so the knight now slides on ranks/files/diagonals instead of jumping in L-shapes.",
"The knight stops jumping in L-shapes and instead slides along ranks, files, and diagonals like a queen.",
},
{
title: "Trick promotion movement — pawn moves as bishop",
title: "Pawn moves as bishop",
params: { target: 7, pieceType: "bishop" },
effect:
"Inside an on-promotion arm, authoring `target: { $var: 'p' }, pieceType: 'bishop'` flips the promoted pawn's movement template to bishop without changing its PieceType — useful for descriptors that want to keep the visual piece kind but borrow another's movement.",
"Used inside an on-promotion trigger, this swaps the promoted pawn's movement to a bishop's diagonals while keeping its piece kind unchanged. Useful when you want a piece that still looks like a pawn but moves differently.",
},
],
paramsSchema: schema,

View file

@ -16,12 +16,17 @@
* T35 lifetime registry will consume the field later).
*/
import { Session, type EntityId } from "@paratype/rete";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { describe, expect, it } from "vitest";
import { ChessEngine } from "../../engine.js";
import { GAME_ENTITY } from "../../schema.js";
import { PRIMITIVE_REGISTRY } from "./registry.js";
import { SET_PIECE_ATTR_PRIMITIVE } from "./set-piece-attr.js";
import type { PendingTrigger, PrimitiveApplyContext } from "./types.js";
import type { CustomModifierDescriptor } from "../custom/types.js";
import { validateCustomDescriptor, type ValidationError } from "../custom/validate.js";
import "./set-piece-attr.js";
function makeContext(): {
@ -162,6 +167,33 @@ describe("set-piece-attr primitive — schema", () => {
});
expect(result.success).toBe(false);
});
it("schema parses target as a $var binding (V2 widening)", () => {
const result = SET_PIECE_ATTR_PRIMITIVE.paramsSchema.safeParse({
target: { $var: "adj" },
attr: "Color",
value: "white",
});
expect(result.success).toBe(true);
});
it("schema parses target as ctx-attr resolver (V2 widening)", () => {
const result = SET_PIECE_ATTR_PRIMITIVE.paramsSchema.safeParse({
target: { "ctx-attr": { entity: "self", attr: "Position" } },
attr: "Hp",
value: 5,
});
expect(result.success).toBe(true);
});
it("schema parses target as ctx-build resolver (V2 widening)", () => {
const result = SET_PIECE_ATTR_PRIMITIVE.paramsSchema.safeParse({
target: { "ctx-build": { col: 3, row: 4 } },
attr: "SlideMustBeMaxDistance",
value: true,
});
expect(result.success).toBe(true);
});
});
describe("set-piece-attr primitive — apply()", () => {
@ -334,3 +366,30 @@ describe("set-piece-attr primitive — apply()", () => {
expect(pendingTriggers).toHaveLength(0);
});
});
describe("set-piece-attr primitive — V2 widening validation (T3)", () => {
it("religious_conversion.json now validates clean with V2 target widening", () => {
// T3 canonical test: religious_conversion.json uses target: { $var: "adj" }
// in its set-piece-attr params. This test verifies that the full descriptor
// now passes validateCustomDescriptor after V2 widening was applied.
const jsonPath = join(
dirname(fileURLToPath(import.meta.url)),
"../../__fixtures__/parity/religious_conversion.json",
);
const jsonContent = readFileSync(jsonPath, "utf-8");
const religiousConversionDescriptor = JSON.parse(jsonContent);
const result = validateCustomDescriptor(
religiousConversionDescriptor as unknown as CustomModifierDescriptor,
);
if (!result.ok) {
const errors = result as { ok: false; errors: ValidationError[] };
throw new Error(
`religious_conversion.json failed validation: ${JSON.stringify(errors.errors, null, 2)}`,
);
}
expect(result.ok).toBe(true);
});
});

View file

@ -92,12 +92,13 @@
import { z } from "zod";
import type { EntityId } from "@paratype/rete";
import { type ChessAttrMap } from "../../schema.js";
import { numberOrResolver } from "./param-resolver-schema.js";
import { applyLifetime } from "../../util/lifetime-registry.js";
import { PRIMITIVE_REGISTRY } from "./registry.js";
import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js";
const schema = z.object({
target: z.number().int().nonnegative(),
target: numberOrResolver({ min: 0 }),
attr: z.string().min(1),
value: z.unknown(),
lifetime: z
@ -118,27 +119,27 @@ const descriptor: EffectPrimitive<Params> = {
description:
"Inserts an arbitrary (attr, value) fact onto the target entity. Used by parity descriptors for direct attribute mutation.",
longDescription:
"Imperative primitive — only legal inside a trigger's primitives/then/else array (top-level placement is rejected by the validator with code 'descriptor.primitives.imperative-in-passive'). Generic mutation verb: writes whatever (attr, value) pair the user supplies onto the resolved target entity. Unlike seed-attribute (which always targets ctx.pieceId), this primitive accepts an explicit target id — typically a $var binding from a for-each-* iteration. target=0 (GAME_ENTITY) writes a game-level fact (e.g. RngSeed, GameStatus). target may be authored as a literal entity id, a {$var:'name'} binding, or a {ctx-attr:{entity,attr}} reference; value may itself be a {ctx-attr} reference (copy semantics, e.g. religious_conversion copies the converter's Color onto the target). The param resolver substitutes all such shapes to literal values before this apply() runs. Lifetime field (T35): 'permanent' or omitted = fact lives until something else retracts it; {kind:'turns', count:N} = registers a LifetimeRegistry entry with expiresAtTurn = currentFullmove + N, decremented at apply.ts stage 11b each turn-end. No on-* trigger is fired; downstream observers must subscribe at their own descriptor level.",
"Sets a value on any piece you can name (or even on the game itself), not just the piece this rule is attached to. Only works inside a trigger, since it changes the game and has to fire at a moment in time. Useful for things like a for-each-piece loop where you want to write a property onto every piece you found, or for copying one piece's color onto another (turning the captured piece). You can also choose how long the change lasts: forever, or for a number of turns before the engine puts it back.",
examples: [
{
title: "Ice physics force max-distance slides on every slider",
title: "Ice physics: force max-distance slides on every slider",
params: {
target: 12,
attr: "SlideMustBeMaxDistance",
value: true,
},
effect:
"Inside a for-each-piece iteration arm where the bound piece id resolves to 12, writes SlideMustBeMaxDistance=true onto that piece. The movegen filter for slide moves consults this attr and rejects shorter-than-max slides.",
"Forces the chosen piece's sliding moves (rook, bishop, queen) to always go the full distance, never stopping short.",
},
{
title: "Religious conversion — flip target Color via ctx-attr value",
title: "Religious conversion: flip the target's color",
params: {
target: 7,
attr: "Color",
value: "white",
},
effect:
"Authoring `value: { ctx-attr: { entity: 'self', attr: 'Color' } }` (resolved to a literal 'white'/'black' before this apply()) inside an on-capture arm overwrites the captured piece's Color with the attacker's Color — the captured piece switches sides instead of being removed.",
"Used inside an on-capture trigger to overwrite the captured piece's color with the attacker's color, so the captured piece switches sides instead of being removed.",
},
],
paramsSchema: schema,
@ -154,6 +155,10 @@ const descriptor: EffectPrimitive<Params> = {
// seedsAttrsFor only.
seedsAttrs: [],
apply(ctx: PrimitiveApplyContext, params: Params): void {
// V2 widening: target may have been authored as a resolver shape
// ({ $var, ctx-attr, ctx-build }), but param-resolver substitutes
// those to literal EntityId before this apply() runs. Safe to cast
// to EntityId.
const targetId = params.target as EntityId;
// Single-source insert. Cast to `keyof ChessAttrMap` is a
// deliberate pragmatic relaxation: schema-level attr validation

View file

@ -26,6 +26,9 @@ import { ChessEngine } from "../../engine.js";
import { PRIMITIVE_REGISTRY } from "./registry.js";
import { SWAP_PIECES_PRIMITIVE } from "./swap-pieces.js";
import type { PendingTrigger, PrimitiveApplyContext } from "./types.js";
import type { CustomModifierDescriptor } from "../custom/types.js";
import { asCustomModifierId } from "../custom/types.js";
import { validateCustomDescriptor } from "../custom/validate.js";
import "./swap-pieces.js";
function makeContext(): {
@ -76,6 +79,46 @@ describe("swap-pieces primitive — schema", () => {
expect(result.success).toBe(true);
});
it("accepts $var binding for field 'a'", () => {
const result = SWAP_PIECES_PRIMITIVE.paramsSchema.safeParse({
a: { $var: "piece1" },
b: 12,
});
expect(result.success).toBe(true);
});
it("accepts $var binding for field 'b'", () => {
const result = SWAP_PIECES_PRIMITIVE.paramsSchema.safeParse({
a: 7,
b: { $var: "piece2" },
});
expect(result.success).toBe(true);
});
it("accepts $var bindings for both fields", () => {
const result = SWAP_PIECES_PRIMITIVE.paramsSchema.safeParse({
a: { $var: "piece1" },
b: { $var: "piece2" },
});
expect(result.success).toBe(true);
});
it("accepts ctx-attr resolver shape for field 'a'", () => {
const result = SWAP_PIECES_PRIMITIVE.paramsSchema.safeParse({
a: { "ctx-attr": { entity: "self", attr: "Position" } },
b: 12,
});
expect(result.success).toBe(true);
});
it("accepts ctx-build resolver shape for field 'b'", () => {
const result = SWAP_PIECES_PRIMITIVE.paramsSchema.safeParse({
a: 7,
b: { "ctx-build": { col: 4, row: 4 } },
});
expect(result.success).toBe(true);
});
it("rejects negative 'a'", () => {
const result = SWAP_PIECES_PRIMITIVE.paramsSchema.safeParse({
a: -1,
@ -237,3 +280,53 @@ describe("swap-pieces primitive — apply()", () => {
expect(pendingTriggers[1]?.payload).toEqual({ from: 60, to: 4 });
});
});
describe("swap-pieces primitive — descriptor validation (V2)", () => {
it("chained for-each-piece bindings feed into swap-pieces.a/b", () => {
const descriptor: CustomModifierDescriptor = {
type: "data",
id: asCustomModifierId("test-chained-swap"),
name: "Chained Swap",
description: "Test fixture: nested for-each-piece bindings into swap-pieces.",
version: 1,
primitives: [
{
kind: "on-rule-activated",
params: {
primitives: [
{
kind: "for-each-piece",
params: {
bind: "p1",
then: [
{
kind: "for-each-piece",
params: {
bind: "p2",
then: [
{
kind: "swap-pieces",
params: { a: { $var: "p1" }, b: { $var: "p2" } },
},
],
},
},
],
},
},
],
},
} as unknown as CustomModifierDescriptor["primitives"][number],
],
targetAttrs: [],
uiForm: "primitive-composer",
source: "custom",
};
const result = validateCustomDescriptor(descriptor);
if (!result.ok) {
throw new Error(`failed: ${JSON.stringify(result.errors, null, 2)}`);
}
expect(result.ok).toBe(true);
});
});

View file

@ -84,11 +84,12 @@ import { z } from "zod";
import type { EntityId } from "@paratype/rete";
import { PRIMITIVE_REGISTRY } from "./registry.js";
import { enqueueTrigger } from "../triggers.js";
import { numberOrResolver } from "./param-resolver-schema.js";
import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js";
const schema = z.object({
a: z.number().int().nonnegative(),
b: z.number().int().nonnegative(),
a: numberOrResolver({ min: 0 }),
b: numberOrResolver({ min: 0 }),
});
type Params = z.infer<typeof schema>;
@ -98,19 +99,19 @@ const descriptor: EffectPrimitive<Params> = {
description:
"Atomically swaps the Position attr between two piece entities and enqueues an on-move trigger for each.",
longDescription:
"Imperative primitive — only legal inside a trigger's primitives/then/else array (top-level placement is rejected by the validator with code 'descriptor.primitives.imperative-in-passive'). Reads both pieces' Position facts, then re-inserts them swapped; enqueues two on-move triggers via the T15 deferred queue (one per moved piece, FIFO: a then b) with payloads carrying each piece's pre-swap from and post-swap to. Both halves succeed-or-fail together: if EITHER piece lacks a Position fact (already destroyed in the same arm, never existed, or a stale binding from a cascade), apply() is a silent no-op — no facts written, no triggers enqueued. Self-swap (a === b) is also a no-op. Does NOT set HasMoved on either piece (unlike move-piece) — the swap models a teleport-like exchange, not a regular move; authors who need HasMoved can compose swap-pieces with seed-attribute(HasMoved, true) explicitly. a and b may be authored as literal entity ids, {$var:'name'} bindings (typical inside for-each-piece arms), or {ctx-attr:{entity,attr}} references; the param resolver substitutes all shapes to numeric ids before this apply() runs.",
"Swaps the squares of two pieces. Both pieces stay on the board, they just trade places. Only works inside a trigger. If either piece has been destroyed already, or you ask it to swap a piece with itself, nothing happens. Unlike a normal move, neither piece gets marked as 'has moved', so castling rights and pawn double-step eligibility are preserved. The engine still fires an on-move trigger for each piece afterwards.",
examples: [
{
title: "Switcheroo — swap two pieces bound to $a and $b",
title: "Switcheroo: swap two chosen pieces",
params: { a: 12, b: 28 },
effect:
"Inside a for-each-piece arm where 'a' and 'b' bind to two piece ids, authoring `a: { $var: 'a' }, b: { $var: 'b' }` exchanges their squares atomically. Both pieces remain on the board (no capture). Two on-move hooks fire at end-of-arm, one per moved piece.",
"The two pieces trade squares. Both stay on the board. Any rules that listen for 'piece moved' fire once for each of the two pieces.",
},
{
title: "King-rook positional swap on rule activation",
title: "King-rook positional swap",
params: { a: 4, b: 7 },
effect:
"Authoring inside an on-rule-activated block, swap entity #4 (white king at e1) with entity #7 (white kingside rook at h1). The king ends up on h1 and the rook on e1; HasMoved is NOT set on either piece, so castling rights remain technically intact (a behaviour authors usually want for non-castling positional swaps).",
"The white king on e1 and the kingside rook on h1 trade squares. Because this isn't treated as a normal move, neither piece loses its castling rights.",
},
],
paramsSchema: schema,
@ -118,6 +119,16 @@ const descriptor: EffectPrimitive<Params> = {
// the consumer registry. Swap-piece does not introduce any new
// fact key.
seedsAttrs: [],
/**
* Atomically exchanges the Position fact between two piece entities.
*
* INVARIANT (V2): `params.a` and `params.b` have already been
* substituted by `param-resolver` to literal EntityId numbers. The
* schema accepts `numberOrResolver()` shapes at author time so
* descriptors can bind pieces from `for-each-piece` arms; the
* dispatcher substitutes those resolver shapes before calling this
* method.
*/
apply(ctx: PrimitiveApplyContext, params: Params): void {
const aId = params.a as EntityId;
const bId = params.b as EntityId;

View file

@ -100,7 +100,7 @@ const descriptor: EffectPrimitive<Params> = {
description:
"Draws from the seeded RNG; runs `then` if draw < p, else runs optional `else`.",
longDescription:
"Probabilistic branching primitive backed by the engine's persistent seeded RNG (T9 / `engine.rng()`). On every apply() the primitive advances the RNG stream by exactly 1, drawing a float in [0, 1). If the draw is strictly less than `p`, every primitive in `then` runs in order; otherwise `else` runs if set (omit `else` to make the false branch a no-op). The draw fires BEFORE either arm, so nested `with-probability` calls inside `then` see the next stream offset — replay-safe by construction. V1 simplification: `request-choice` is forbidden inside the arms (validator T34 enforces).",
"Rolls a random number from 0 up to (but not including) 1. If the roll is less than your chosen probability, the steps in 'then' run; otherwise the steps in 'else' run if you set them. Leaving 'else' empty means nothing happens on a miss. The randomness is seeded, so replays of the same game produce the same outcomes. You can't put a player popup (request-choice) inside either branch.",
examples: [
{
title: "25%-chance crit",
@ -111,7 +111,7 @@ const descriptor: EffectPrimitive<Params> = {
],
},
effect:
"On every trigger fire, 25% of the time the target loses 1 HP; the other 75% of the time nothing happens (no `else` arm).",
"Every time this fires, there's a 25% chance the target loses 1 HP. The other 75% of the time nothing happens (no else branch).",
},
{
title: "Coin-flip blessing or curse",
@ -125,7 +125,7 @@ const descriptor: EffectPrimitive<Params> = {
],
},
effect:
"Half the time the target gains 1 HP; the other half it loses 1 HP. Both branches advance the same RNG stream offset by exactly 1 — replays land on the same outcomes.",
"Half the time the target gains 1 HP, and the other half it loses 1 HP. Replays of the same game always land on the same outcomes.",
},
],
paramsSchema: schema,

View file

@ -78,6 +78,84 @@ exports[`ParamField V2 widened fields > spawn-marker square renders as number in
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">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&#x27;s usually easier to use the spawn-marker-pair primitive.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">markerKind</label><select class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none bg-white"><option value="mine" selected="">mine</option><option value="pit">pit</option><option value="portal-end">portal-end</option><option value="frozen-square">frozen-square</option><option value="treasure">treasure</option><option value="death-square">death-square</option><option value="tornado">tornado</option><option value="blocked">blocked</option></select></div><div class="flex flex-col gap-1.5"><div class="flex items-center justify-between"><label class="text-xs font-bold text-neutral-700">square</label><button type="button" data-testid="paramfield-square-toggle-binding" class="text-[10px] text-blue-600 font-medium hover:underline focus:outline-none">Use binding</button></div><div class="flex flex-col gap-1.5"><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="28"/></div></div><div class="flex flex-col gap-1.5"><div class="flex items-center justify-between"><label class="text-xs font-bold text-neutral-700">lifetime</label><button type="button" data-testid="paramfield-lifetime-toggle-binding" class="text-[10px] text-blue-600 font-medium hover:underline focus:outline-none">Use binding</button></div><div class="flex flex-col gap-1.5"><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><div class="flex flex-col gap-1.5"><div class="flex items-center justify-between"><label class="text-xs font-bold text-neutral-700">owner</label><button type="button" data-testid="paramfield-owner-toggle-binding" class="text-[10px] text-blue-600 font-medium hover:underline focus:outline-none">Use binding</button></div><div class="flex flex-col gap-1.5"><select class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none bg-white"><option value="white" selected="">white</option><option value="black">black</option></select></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">links</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 V2 widened fields spawn-marker owner renders as enum dropdown by default (widened, V2) 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">Spawn Marker</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">spawn-marker</span></div><span class="text-xs text-blue-600 font-medium">Hide docs &amp; 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">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 &#x27;what&#x27;s on this square&#x27;, the highest-priority marker is found first.</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">Drop a permanent mine on e4</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;markerKind&quot;: &quot;mine&quot;,
&quot;square&quot;: 28,
&quot;lifetime&quot;: {
&quot;kind&quot;: &quot;permanent&quot;
}
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">A permanent mine appears on e4. Any piece that later steps onto e4 triggers whatever rules are listening for &#x27;piece entered a mine&#x27;.</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">One-shot frozen square aligned with white</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;markerKind&quot;: &quot;frozen-square&quot;,
&quot;square&quot;: 35,
&quot;lifetime&quot;: {
&quot;kind&quot;: &quot;one-shot&quot;
},
&quot;owner&quot;: &quot;white&quot;
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">A frozen-square owned by white appears on d5. The first piece to step on it consumes the marker, and the marker disappears.</p></div><div data-testid="custom-primitive-example-2" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Spawn one end of a portal that links to another marker</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;markerKind&quot;: &quot;portal-end&quot;,
&quot;square&quot;: 12,
&quot;lifetime&quot;: {
&quot;kind&quot;: &quot;permanent&quot;
},
&quot;links&quot;: [
42
]
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">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&#x27;s usually easier to use the spawn-marker-pair primitive.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">markerKind</label><select class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none bg-white"><option value="mine" selected="">mine</option><option value="pit">pit</option><option value="portal-end">portal-end</option><option value="frozen-square">frozen-square</option><option value="treasure">treasure</option><option value="death-square">death-square</option><option value="tornado">tornado</option><option value="blocked">blocked</option></select></div><div class="flex flex-col gap-1.5"><div class="flex items-center justify-between"><label class="text-xs font-bold text-neutral-700">square</label><button type="button" data-testid="paramfield-square-toggle-binding" class="text-[10px] text-blue-600 font-medium hover:underline focus:outline-none">Use binding</button></div><div class="flex flex-col gap-1.5"><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="28"/></div></div><div class="flex flex-col gap-1.5"><div class="flex items-center justify-between"><label class="text-xs font-bold text-neutral-700">lifetime</label><button type="button" data-testid="paramfield-lifetime-toggle-binding" class="text-[10px] text-blue-600 font-medium hover:underline focus:outline-none">Use binding</button></div><div class="flex flex-col gap-1.5"><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><div class="flex flex-col gap-1.5"><div class="flex items-center justify-between"><label class="text-xs font-bold text-neutral-700">owner</label><button type="button" data-testid="paramfield-owner-toggle-binding" class="text-[10px] text-blue-600 font-medium hover:underline focus:outline-none">Use binding</button></div><div class="flex flex-col gap-1.5"><select class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none bg-white"><option value="white" selected="">white</option><option value="black">black</option></select></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">links</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 V2 widened fields spawn-marker square correctly respects binding-mode initial state 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">Spawn Marker</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">spawn-marker</span></div><span class="text-xs text-blue-600 font-medium">Hide docs &amp; 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">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 &#x27;what&#x27;s on this square&#x27;, the highest-priority marker is found first.</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">Drop a permanent mine on e4</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;markerKind&quot;: &quot;mine&quot;,
&quot;square&quot;: 28,
&quot;lifetime&quot;: {
&quot;kind&quot;: &quot;permanent&quot;
}
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">A permanent mine appears on e4. Any piece that later steps onto e4 triggers whatever rules are listening for &#x27;piece entered a mine&#x27;.</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">One-shot frozen square aligned with white</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;markerKind&quot;: &quot;frozen-square&quot;,
&quot;square&quot;: 35,
&quot;lifetime&quot;: {
&quot;kind&quot;: &quot;one-shot&quot;
},
&quot;owner&quot;: &quot;white&quot;
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">A frozen-square owned by white appears on d5. The first piece to step on it consumes the marker, and the marker disappears.</p></div><div data-testid="custom-primitive-example-2" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Spawn one end of a portal that links to another marker</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;markerKind&quot;: &quot;portal-end&quot;,
&quot;square&quot;: 12,
&quot;lifetime&quot;: {
&quot;kind&quot;: &quot;permanent&quot;
},
&quot;links&quot;: [
42
]
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">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&#x27;s usually easier to use the spawn-marker-pair primitive.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">markerKind</label><select class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none bg-white"><option value="mine">mine</option><option value="pit">pit</option><option value="portal-end">portal-end</option><option value="frozen-square">frozen-square</option><option value="treasure">treasure</option><option value="death-square">death-square</option><option value="tornado">tornado</option><option value="blocked">blocked</option></select></div><div class="flex flex-col gap-1.5"><div class="flex items-center justify-between"><label class="text-xs font-bold text-neutral-700">square</label><button type="button" data-testid="paramfield-square-toggle-binding" class="text-[10px] text-blue-600 font-medium hover:underline focus:outline-none">Use primitive</button></div><div class="flex flex-col gap-1.5"><input type="text" data-testid="paramfield-square-binding" placeholder="binding name..." class="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" value="target-square"/><p class="text-[10px] text-neutral-500 italic leading-snug">Use a name bounded by an enclosing iteration (e.g. for-each-piece).</p></div></div><div class="flex flex-col gap-1.5"><div class="flex items-center justify-between"><label class="text-xs font-bold text-neutral-700">lifetime</label><button type="button" data-testid="paramfield-lifetime-toggle-binding" class="text-[10px] text-blue-600 font-medium hover:underline focus:outline-none">Use binding</button></div><div class="flex flex-col gap-1.5"><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=""/></div></div><div class="flex flex-col gap-1.5"><div class="flex items-center justify-between"><label class="text-xs font-bold text-neutral-700">owner</label><button type="button" data-testid="paramfield-owner-toggle-binding" class="text-[10px] text-blue-600 font-medium hover:underline focus:outline-none">Use binding</button></div><div class="flex flex-col gap-1.5"><select class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none bg-white"><option value="white" selected="">white</option><option value="black">black</option></select></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">links</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 V2 widened fields spawn-marker square renders as number input by default (widened field, V2) 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">Spawn Marker</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">spawn-marker</span></div><span class="text-xs text-blue-600 font-medium">Hide docs &amp; 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">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 &#x27;what&#x27;s on this square&#x27;, the highest-priority marker is found first.</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">Drop a permanent mine on e4</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;markerKind&quot;: &quot;mine&quot;,
&quot;square&quot;: 28,
&quot;lifetime&quot;: {
&quot;kind&quot;: &quot;permanent&quot;
}
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">A permanent mine appears on e4. Any piece that later steps onto e4 triggers whatever rules are listening for &#x27;piece entered a mine&#x27;.</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">One-shot frozen square aligned with white</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;markerKind&quot;: &quot;frozen-square&quot;,
&quot;square&quot;: 35,
&quot;lifetime&quot;: {
&quot;kind&quot;: &quot;one-shot&quot;
},
&quot;owner&quot;: &quot;white&quot;
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">A frozen-square owned by white appears on d5. The first piece to step on it consumes the marker, and the marker disappears.</p></div><div data-testid="custom-primitive-example-2" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Spawn one end of a portal that links to another marker</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;markerKind&quot;: &quot;portal-end&quot;,
&quot;square&quot;: 12,
&quot;lifetime&quot;: {
&quot;kind&quot;: &quot;permanent&quot;
},
&quot;links&quot;: [
42
]
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">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&#x27;s usually easier to use the spawn-marker-pair primitive.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">markerKind</label><select class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none bg-white"><option value="mine" selected="">mine</option><option value="pit">pit</option><option value="portal-end">portal-end</option><option value="frozen-square">frozen-square</option><option value="treasure">treasure</option><option value="death-square">death-square</option><option value="tornado">tornado</option><option value="blocked">blocked</option></select></div><div class="flex flex-col gap-1.5"><div class="flex items-center justify-between"><label class="text-xs font-bold text-neutral-700">square</label><button type="button" data-testid="paramfield-square-toggle-binding" class="text-[10px] text-blue-600 font-medium hover:underline focus:outline-none">Use binding</button></div><div class="flex flex-col gap-1.5"><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="28"/></div></div><div class="flex flex-col gap-1.5"><div class="flex items-center justify-between"><label class="text-xs font-bold text-neutral-700">lifetime</label><button type="button" data-testid="paramfield-lifetime-toggle-binding" class="text-[10px] text-blue-600 font-medium hover:underline focus:outline-none">Use binding</button></div><div class="flex flex-col gap-1.5"><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><div class="flex flex-col gap-1.5"><div class="flex items-center justify-between"><label class="text-xs font-bold text-neutral-700">owner</label><button type="button" data-testid="paramfield-owner-toggle-binding" class="text-[10px] text-blue-600 font-medium hover:underline focus:outline-none">Use binding</button></div><div class="flex flex-col gap-1.5"><select class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none bg-white"><option value="white" selected="">white</option><option value="black">black</option></select></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">links</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) > 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 &amp; 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">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.</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">{
&quot;attr&quot;: &quot;ShieldCharges&quot;,
@ -125,7 +203,7 @@ exports[`ParamField rendering (T14 regression baseline) > add-to-attribute rende
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Adds 2 on top of whatever HP bonus the piece already has.</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">{
&quot;attr&quot;: &quot;Hp&quot;,
&quot;delta&quot;: 1
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Placed inside an on-turn-start trigger, this heals the piece for 1 HP at the start of its side&#x27;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>"
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Placed inside an on-turn-start trigger, this heals the piece for 1 HP at the start of its side&#x27;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 class="flex flex-col gap-1.5"><div class="flex items-center justify-between"><label class="text-xs font-bold text-neutral-700">target</label><button type="button" data-testid="paramfield-target-toggle-binding" class="text-[10px] text-blue-600 font-medium hover:underline focus:outline-none">Use binding</button></div><div class="flex flex-col gap-1.5"><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="0"/></div></div></div>"
`;
exports[`ParamField rendering (T14 regression baseline) > block-move-type renders moveType enum 1`] = `
@ -288,3 +366,214 @@ exports[`ParamField rendering (T14 regression baseline) > set-capture-flag rende
&quot;flag&quot;: 1
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">The piece is allowed to capture its own color&#x27;s pieces.</p></div></div></div></div><div class="flex flex-col gap-1.5"><div class="flex items-center justify-between"><label class="text-xs font-bold text-neutral-700">flag</label><button type="button" data-testid="paramfield-flag-toggle-binding" class="text-[10px] text-blue-600 font-medium hover:underline focus:outline-none">Use binding</button></div><div class="flex flex-col gap-1.5"><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></div>"
`;
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 &amp; 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">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.</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">{
&quot;attr&quot;: &quot;ShieldCharges&quot;,
&quot;rate&quot;: 1
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">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.</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">{
&quot;attr&quot;: &quot;ArmorPlates&quot;,
&quot;rate&quot;: 2
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Each point of damage uses 2 ArmorPlates instead of touching HP, so plates run out twice as fast for the same amount of protection.</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&#x27;t a built-in and isn&#x27;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 &amp; 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">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.</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">{
&quot;radius&quot;: 2,
&quot;targetAttr&quot;: &quot;HpBonus&quot;,
&quot;delta&quot;: 1
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Every other piece, friend or foe, within 2 squares gains +1 HP while it stays 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">{
&quot;radius&quot;: 1,
&quot;targetAttr&quot;: &quot;RangeBonus&quot;,
&quot;delta&quot;: 1
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Any piece standing on one of the 8 squares next to this piece gets +1 to its movement 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 &amp; 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">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).</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">{
&quot;directions&quot;: [
&quot;backward&quot;
]
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">The pawn can step backward toward its own side 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">{
&quot;directions&quot;: [
&quot;forward&quot;,
&quot;backward&quot;,
&quot;left&quot;,
&quot;right&quot;
]
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Grants the piece all four straight-line directions at once. Add the diagonal names too if you want full eight-way movement.</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="[ ... ]">[
&quot;forward&quot;,
&quot;backward&quot;
]</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 &amp; 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 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.</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">{
&quot;attr&quot;: &quot;HpBonus&quot;,
&quot;delta&quot;: 2
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Adds 2 on top of whatever HP bonus the piece already has.</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">{
&quot;attr&quot;: &quot;Hp&quot;,
&quot;delta&quot;: 1
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Placed inside an on-turn-start trigger, this heals the piece for 1 HP at the start of its side&#x27;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 class="flex flex-col gap-1.5"><div class="flex items-center justify-between"><label class="text-xs font-bold text-neutral-700">target</label><button type="button" data-testid="paramfield-target-toggle-binding" class="text-[10px] text-blue-600 font-medium hover:underline focus:outline-none">Use binding</button></div><div class="flex flex-col gap-1.5"><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="0"/></div></div></div>"
`;
exports[`ParamField rendering (T14 regression baseline) block-move-type renders moveType 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">Block Move Type</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">block-move-type</span></div><span class="text-xs text-blue-600 font-medium">Hide docs &amp; 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">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.</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">Pacifist piece</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;moveType&quot;: &quot;capture&quot;
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">The piece can move and slide freely but cannot capture, making it a pure support 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">Charge-only attacker</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;moveType&quot;: &quot;step&quot;
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Single-square step moves are forbidden, so the piece can only capture or slide long distances.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">moveType</label><select class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none bg-white"><option value="capture" selected="">capture</option><option value="step">step</option><option value="slide">slide</option></select></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 &amp; 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">An if/else branch. If the condition is true, every step in &#x27;then&#x27; runs in order. If it&#x27;s false and you set &#x27;else&#x27;, 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 &#x27;then&#x27;), or never (always run &#x27;else&#x27;).</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">{
&quot;condition&quot;: {
&quot;type&quot;: &quot;attr-lt&quot;,
&quot;attr&quot;: &quot;Hp&quot;,
&quot;value&quot;: 2
},
&quot;then&quot;: [
{
&quot;kind&quot;: &quot;set-capture-flag&quot;,
&quot;params&quot;: {
&quot;flag&quot;: 2
}
}
]
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">When HP drops below 2, the piece becomes uncapturable. 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">{
&quot;condition&quot;: {
&quot;type&quot;: &quot;always&quot;
},
&quot;then&quot;: [
{
&quot;kind&quot;: &quot;reflect-damage&quot;,
&quot;params&quot;: {
&quot;percentage&quot;: 10
}
}
]
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Reflect-damage runs every time. Useful as a starting template you can tighten with a real condition later.</p></div></div></div></div><div class="flex flex-col gap-1.5"><div class="flex items-center justify-between"><label class="text-xs font-bold text-neutral-700">condition</label><button type="button" data-testid="paramfield-condition-toggle-binding" class="text-[10px] text-blue-600 font-medium hover:underline focus:outline-none">Use binding</button></div><div class="flex flex-col gap-1.5"><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><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="[ ... ]">[
{
&quot;kind&quot;: &quot;set-capture-flag&quot;,
&quot;params&quot;: {
&quot;flag&quot;: 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 &amp; 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">Changes how far the piece can move by adding (or subtracting) squares from its reach. Stacks with other range bonuses. Limited to whole numbers between -7 and +7. Affects sliding pieces (rook, bishop, queen). Knight and king have their own movement rules and aren&#x27;t changed by this.</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">{
&quot;delta&quot;: 1
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">The piece&#x27;s sliding moves reach one square further than normal.</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">{
&quot;delta&quot;: -2
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Cuts 2 squares off the piece&#x27;s reach, useful for a &#x27;slowed&#x27; status.</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 &amp; 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">Multiplies a value the piece already has by a number you choose. If the piece doesn&#x27;t have that value yet, this step does nothing (it does not assume 1). Run Seed Attribute or Add To Attribute first when you need to set a starting number for it 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">{
&quot;attr&quot;: &quot;Hp&quot;,
&quot;factor&quot;: 2
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">A piece with 4 HP becomes a piece with 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">{
&quot;attr&quot;: &quot;RangeBonus&quot;,
&quot;factor&quot;: 0.5
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">A range bonus of 4 becomes 2. If the piece doesn&#x27;t have a range bonus yet, this step is skipped.</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 &amp; 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">Runs the steps inside it when this piece captures another. Common uses: vampire lifesteal that heals on capture, stacking buffs, or power-up effects. It fires only on real captures, never on a quiet move.</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">{
&quot;primitives&quot;: [
{
&quot;kind&quot;: &quot;add-to-attribute&quot;,
&quot;params&quot;: {
&quot;attr&quot;: &quot;Hp&quot;,
&quot;delta&quot;: 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. The healing adds up 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="[ ... ]">[
{
&quot;kind&quot;: &quot;add-to-attribute&quot;,
&quot;params&quot;: {
&quot;attr&quot;: &quot;Hp&quot;,
&quot;delta&quot;: 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 &amp; 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">Runs the steps inside it whenever this piece takes damage. Use it for reactive abilities like thorns that hurt the attacker, panic buffs when wounded, or transforming when HP drops below a threshold (pair it 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">{
&quot;primitives&quot;: [
{
&quot;kind&quot;: &quot;reflect-damage&quot;,
&quot;params&quot;: {
&quot;percentage&quot;: 25
}
}
]
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">When this piece takes damage, 25% of that damage bounces back to the attacker.</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="[ ... ]">[
{
&quot;kind&quot;: &quot;reflect-damage&quot;,
&quot;params&quot;: {
&quot;percentage&quot;: 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 &amp; 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">Runs the steps inside it at the start of this piece&#x27;s color&#x27;s turn, every turn. Use it for recurring effects like healing, buffs, debuffs, or any ability that should tick once per turn. You build the inner steps by nesting other primitives inside the \`primitives\` list.</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">{
&quot;primitives&quot;: [
{
&quot;kind&quot;: &quot;add-to-attribute&quot;,
&quot;params&quot;: {
&quot;attr&quot;: &quot;Hp&quot;,
&quot;delta&quot;: 1
}
}
]
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">At the start of every turn, this piece heals 1 HP (up to its maximum).</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="[ ... ]">[
{
&quot;kind&quot;: &quot;add-to-attribute&quot;,
&quot;params&quot;: {
&quot;attr&quot;: &quot;Hp&quot;,
&quot;delta&quot;: 1
}
}
]</textarea></div></div></div>"
`;
exports[`ParamField rendering (T14 regression baseline) override-promotion renders target 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">Override Promotion</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">override-promotion</span></div><span class="text-xs text-blue-600 font-medium">Hide docs &amp; 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">Forces this piece (usually a pawn) to promote into a specific type, taking the choice away from the player. If two different rules try to set a promotion at once, the last one wins.</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">Knights-only promotion</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;target&quot;: &quot;knight&quot;
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">When the pawn reaches the back rank, it always becomes a knight.</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">Underpromote to rook</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;target&quot;: &quot;rook&quot;
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">The pawn always promotes to a rook, handy for themed variants where queens are forbidden.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">target</label><select class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none bg-white"><option value="pawn">pawn</option><option value="knight" selected="">knight</option><option value="bishop">bishop</option><option value="rook">rook</option><option value="queen">queen</option><option value="king">king</option></select></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 &amp; 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">Sends a percentage of incoming damage back at the attacker. Use a whole number from 0 to 100. If two rules try to set a reflect percentage, only the most recent one applies (they don&#x27;t add up). Drop it inside an on-damaged trigger for a one-shot thorns reaction, or leave it as a passive trait for permanent damage reflection.</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">{
&quot;percentage&quot;: 50
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Half of every hit is dealt right 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">{
&quot;percentage&quot;: 100
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Full reflection: the attacker takes whatever damage they tried to deal.</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 &amp; 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">Sets a value on the piece, replacing whatever was there before. Use it to give a piece a brand new property (like a custom ShieldCharges counter) or to lock in a starting number (such as forcing HP to exactly 5, ignoring everything else). Stack it with Add To Attribute or Multiply Attribute when you want to build a final number up in steps.</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">{
&quot;attr&quot;: &quot;Hp&quot;,
&quot;value&quot;: 5
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">The piece always starts with 5 HP, no matter what its base HP would have been.</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">{
&quot;attr&quot;: &quot;ShieldCharges&quot;,
&quot;value&quot;: 3
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Gives the piece a counter that starts at 3. Pair it with Absorb Damage With Attribute so each charge soaks up one point of damage.</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 class="flex flex-col gap-1.5"><div class="flex items-center justify-between"><label class="text-xs font-bold text-neutral-700">lifetime</label><button type="button" data-testid="paramfield-lifetime-toggle-binding" class="text-[10px] text-blue-600 font-medium hover:underline focus:outline-none">Use binding</button></div><div class="flex flex-col gap-1.5"><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=""/></div></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 &amp; 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 a special capture rule for the piece. Flags stack, so you can apply more than one. The choices are: 1 = the piece can capture its own color, 2 = the piece cannot be captured by enemies, 4 = the piece participates in en passant captures.</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">{
&quot;flag&quot;: 2
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">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">{
&quot;flag&quot;: 1
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">The piece is allowed to capture its own color&#x27;s pieces.</p></div></div></div></div><div class="flex flex-col gap-1.5"><div class="flex items-center justify-between"><label class="text-xs font-bold text-neutral-700">flag</label><button type="button" data-testid="paramfield-flag-toggle-binding" class="text-[10px] text-blue-600 font-medium hover:underline focus:outline-none">Use binding</button></div><div class="flex flex-col gap-1.5"><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></div>"
`;