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:
parent
9d408b5996
commit
34655ddadd
67 changed files with 3275 additions and 244 deletions
51
.sisyphus/notepads/thressgame-templates/decisions.md
Normal file
51
.sisyphus/notepads/thressgame-templates/decisions.md
Normal 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.
|
||||
10
.sisyphus/notepads/thressgame-templates/issues.md
Normal file
10
.sisyphus/notepads/thressgame-templates/issues.md
Normal 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}
|
||||
```
|
||||
|
|
@ -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`).
|
||||
|
|
|
|||
10
.sisyphus/notepads/thressgame-templates/problems.md
Normal file
10
.sisyphus/notepads/thressgame-templates/problems.md
Normal 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}
|
||||
```
|
||||
Loading…
Add table
Add a link
Reference in a new issue