houserules/.sisyphus/plans/thressgame-coverage.md
Joey Yakimowich-Payne 21838af5c1
feat(thressgame-coverage): Wave 10 (8 parity descriptors + 6 templates + perf + e2e spec)
8 parity tests (recreate ThressGame rules via descriptor JSON):
- T59 minefield: spawn mines + on-piece-entered destroys; one-shot consumption
- T60 mr_freeze: request-choice column + for-row + frozen-square spawn (lifetime moves:9)
- T61 parry: on-captured + RPS request-choice + conditional + cancel-capture
- T62 all_on_red: on-turn-start + with-probability(0.1) + BlockAllExceptKing seed (lifetime turns:5)
- T63 religious_conversion: on-move(bishop) + for-each-adjacent + set-piece-attr Color
- T64 ice_physics: on-rule-activated + for-each-piece(slider filter) + SlideMustBeMaxDistance
- T65 kamikaze: on-capture + with-probability(0.25) + for-each-adjacent + destroy-piece (king excluded)
- T66 mind_control: on-rule-activated + request-choice(forPlayer:both, LIFO stack) + for-each-piece + Color set

T67: 6 template descriptors in custom/recipes.ts (simple-mine, vampire-on-capture, frozen-column, coin-flip-restriction, religious-bishop, no-mans-land)
T68: Playwright e2e spec for 3 request-choice flows (.skip()'d pending UI integration; documents the gap)
T69: 100-marker performance budget test (p99 < 50ms via deterministic engine + perf.now timing)

Tests: 2703 -> 2740 (+37). bun run check exit 0.
2026-04-26 13:50:28 -06:00

1926 lines
111 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ThressGame Rule-Coverage DSL Extension
## TL;DR
> **Quick Summary**: Extend the chess rules DSL with ~28 new primitives, 4 new triggers, marker entities, seeded RNG, and a stack-based player-choice state machine. Targets 95%+ coverage of ThressGame's 66-rule catalog (~62 rules expressible) with full UI polish.
>
> **Deliverables**:
> - 28 new primitives (imperative board mutation, markers, iteration, RNG, restrictions, movement replacement, player choice)
> - 4 new triggers (on-rule-activated, on-rule-expire, on-piece-entered-marker, on-marker-expire)
> - Marker entity subsystem with 8 marker kinds + lifetime + collision priority
> - Seeded PRNG (Mulberry32) on GAME_ENTITY
> - WS protocol v2 with request-choice / submit-choice messages
> - Suspended-execution state machine (stack-based) for nested player choices
> - Editor: palette categories, narrate.ts entries, 4 ParamField custom renderers (square, piece, marker-kind, lifetime)
> - 8 ThressGame parity test descriptors with Playwright e2e
> - 6 template descriptors shipped in modifier library
>
> **Estimated Effort**: XL
> **Parallel Execution**: YES — 11 waves
> **Critical Path**: L0 ADRs → L1 entity attrs → L2 param walker → L4 dispatcher → L7 protocol → L8 choice integration → L10 e2e
---
## Context
### Original Request
User asked: "From this file, how many rules can we implement using our system?" referencing ThressGame's `mutators/ruleHooks.js`. Initial analysis: ~5/66 (~10%) expressible. User then requested a plan to close the gap with full coverage push (95%+).
### Interview Summary
**Locked Decisions**:
- Scope: 95%+ coverage (~62 of 66 rules); pacman_style (board topology) + ~3 nuance cases explicitly deferred
- Activation model: NEW trigger `on-rule-activated` fires once when modifier attaches; imperative primitives only legal inside trigger arrays
- Square state: marker entities (each marker is a session entity with `MarkerKind`, `Position`, `MarkerLifetime`, optional `MarkerOwner`, `MarkerLinks`); markers participate in aura compute
- UI: full polish — palette categories, narrate, 4 ParamField renderers, Playwright e2e for 3 rules
- Player choice: NEW `request-choice` primitive with WS protocol v2 + STACK-based pending-choices state machine (nested allowed)
- Trigger reentrance: deferred queue (imperative primitives enqueue trigger events; dispatcher drains after current arm)
- Move-gen dry mode: move generator runs with `suppressTriggers: true` flag; triggers fire only on actual commit
- RNG: seeded PRNG (Mulberry32) on GAME_ENTITY (`RngSeed`, `RngStream` attrs); primitives `with-probability`, `random-pick`
- Marker collision: stackable, resolved by hardcoded marker-kind priority (portal-end < frozen-square < mine < pit < death-square < tornado < treasure < blocked)
- Choice timeout: per-game configurable in CreateGameRequest — `timeout-with-default` (numeric seconds; default first option on expiry) OR `no-timeout` (pause on disconnect)
**Research Findings**:
- 12-stage onAfterMove dispatch in apply.ts:996-1090 is the canonical extension point
- Entity model is minimal: GAME_ENTITY (0), PRESET_STATE_ENTITY (-1), pieces id > 0; new entities via `session.nextId()`
- `engine.spawnPiece()` (engine.ts:741-769) is the canonical entity factory; markers follow this pattern
- No existing async / wait-for-input pattern; PlayerAction discriminated union is precedent for new actions
- Engine fully deterministic; no Math.random in hot path; safe to add seeded PRNG
- Aura model is fact-based (auras.ts) — must extend to include markers per V1 decision
### Metis Review
**Identified Gaps (addressed)**:
- Nested suspension complexity → addressed via stack-based state, validator enforces depth ≤ 8
- Trigger reentrance → addressed via deferred queue, cascade depth limit
- Move-gen cycle risk → addressed via dry-mode flag
- Marker/aura paradigm bridge → addressed (markers participate in auras with discriminator-based filter)
- Position-attr semantics drift → addressed via L1 audit task
- "95% coverage" claim unverified → addressed via paper-exercise (5 hardest rules verified expressible)
- WS protocol versioning omitted → addressed via L7 versioning task
- Test infrastructure for choices underestimated → addressed as dedicated L8 task (deterministic auto-resolver)
- 8 ThressGame parity rules locked by name (see Test Strategy below)
- 6 template descriptors locked by name (see Deliverables below)
---
## Work Objectives
### Core Objective
Add a complete primitive layer to the chess rules DSL that supports imperative board mutation, marker-based square state, seeded probabilistic effects, and stack-based player choice — covering 62 of 66 ThressGame rules with full editor and runtime support.
### Concrete Deliverables
- **Primitives**: 28 new in `packages/chess/src/modifiers/primitives/` (each with .ts + .test.ts mirroring on-capture.ts pattern)
- **Triggers**: 4 new (on-rule-activated, on-rule-expire, on-piece-entered-marker, on-marker-expire)
- **Entity attrs**: MarkerKind, MarkerLifetime, MarkerOwner, MarkerLinks, RngSeed, RngStream, EntityKind, plus 5 movement-replacement flags
- **Engine**: marker spawn factory (`engine.spawnMarker()`), aura compute extension, move-gen `suppressTriggers` flag, deferred trigger queue, cascade depth guard, seeded PRNG init in integration preset
- **Validator**: imperative-in-passive rejection, binding-scope check, request-choice depth ≤ 8 check, lifetime-shape check
- **Param walker**: resolution layer for `{ $var: "name" }`, `{ ctx-attr: { entity, attr } }`, `{ ctx-build: { col, row } }`
- **WS protocol v2**: `request-choice` (server→client), `submit-choice` (client→server), versioned with backward-compat error path
- **Suspended execution**: stack-based pendingChoices on GAME_ENTITY, resume-from-saved-state in integration preset's onAfterAction
- **Game settings**: `choiceTimeout` field in CreateGameRequest schema
- **Editor**: palette taxonomy update (5 categories), narrate.ts entries for all new primitives + triggers, 4 custom ParamField renderers (square picker, piece picker, marker-kind enum, lifetime config)
- **Marker rendering**: client board overlay component for markers (icons + tooltips)
- **Test descriptors**: 8 ThressGame rules recreated as `.json` fixtures with Playwright e2e parity tests (LOCKED LIST: minefield, mr_freeze, parry, all_on_red, religious_conversion, ice_physics, kamikaze, mind_control)
- **Template descriptors**: 6 templates shipped in modifier library (LOCKED LIST: simple-mine, vampire-on-capture, frozen-column, coin-flip-restriction, religious-bishop, no-mans-land)
### Definition of Done
- [ ] `bun run check` exits 0 with all 1957+ existing tests + ~250 new tests passing
- [ ] `bun x playwright test e2e/thressgame-parity.spec.ts` exits 0 (8 rules)
- [ ] `bun x playwright test e2e/request-choice.spec.ts` exits 0 (3 round-trip flows)
- [ ] Determinism property test passes (N=100 iterations, byte-identical state hash)
- [ ] Backward-compat: all 22 existing primitives' tests pass byte-identical
- [ ] Validator rejects imperative-in-passive with error code `descriptor.primitives.imperative-in-passive`
- [ ] Cascade depth guard: synthetic loop test asserts depth=8 limit fires error code `descriptor.primitives.cascade-depth-exceeded`
- [ ] Wire-parity test extended to all 28 new primitive kinds (round-trips through server schema)
- [ ] Performance test: 100 markers on board, move latency < 50ms (p99)
### Must Have
- All 28 new primitives with TDD coverage (RED test → GREEN impl → narrate + palette in same commit)
- All 4 new triggers wired into 12-stage dispatcher (becomes 14-stage)
- Marker entity discriminator (`EntityKind: "piece" | "marker"`) audited across `Position`-attr callers
- Seeded PRNG initialized at game start; all probabilistic primitives draw via the seeded stream
- WS protocol versioned (v1 → v2); old client + new server defined behavior tested
- Stack-based suspended execution with serializable state (no closures)
- Choice timeout configurable per game (60s default; 0 = no-timeout); disconnect = forfeit OR pause based on mode
### Must NOT Have (Guardrails)
- NO modifications to existing 22 primitives' behavior (additive-only)
- NO `Math.random` / `Date.now` / unsorted Map iteration anywhere in engine hot path
- NO new primitives outside the locked list of 28 — adding a 29th requires a new plan
- NO async/await in engine; suspended execution = state-machine snapshot, not Promise
- NO `as any` / `@ts-ignore` introduced
- NO breaking changes to CustomModifierDescriptor.version (stays at 1 — schema is structurally compatible)
- NO breaking changes to existing WS message types in v1 (v2 is additive)
- NO modifications to MAX_RECURSION_DEPTH (3) or MAX_PRIMITIVE_COUNT (50)
- NO drag-from-palette DnD (separate concern; defer)
- NO performance optimization passes during this plan (correctness first; profile only on regression)
- NO refactoring of existing param walker — binding resolution is a layer ON TOP
- NO multi-game / multi-tenant concerns
- NO replay viewer changes
- NO marker animations / particle effects (simple icons only)
---
## Verification Strategy (MANDATORY)
> **ZERO HUMAN INTERVENTION** — ALL verification is agent-executed. No exceptions.
> Acceptance criteria requiring "user manually tests/confirms" are FORBIDDEN.
### Test Decision
- **Infrastructure exists**: YES (Vitest + Playwright + react-dom/server SSR)
- **Automated tests**: TDD per primitive — every primitive ships with failing test → impl → narrate + palette in single commit
- **Framework**: bun test (Vitest); Playwright for e2e
- **Convention**: co-located `.test.ts(x)` files; SSR via `react-dom/server` for component tests
### QA Policy
Every task MUST include agent-executed QA scenarios. Evidence saved to `.sisyphus/evidence/task-{N}-{scenario-slug}.{ext}`.
- **Engine/Library code**: `bun test <file>` and parse exit code + assertion count
- **API/Protocol**: `bun test packages/server/...` for wire schema; ws.test.ts for round-trips
- **Editor/UI**: SSR snapshot tests + Playwright for interactive
- **Determinism**: state-hash comparison via SHA256 of session fact dump
---
## Execution Strategy
### Parallel Execution Waves
> Maximize throughput by grouping independent tasks into parallel waves.
> Each wave completes before the next begins.
```
Wave 0 — Architecture Decisions (sequential, 1 task → blocks everything)
└── Task 0: ADR document + paper-exercise capture [deep]
Wave 1 — Foundation infrastructure (start immediately, parallel):
├── Task 1: Backward-compat baseline fixture [quick]
├── Task 2: Determinism property-test harness [unspecified-high]
├── Task 3: State-hash util (SHA256 of facts) [quick]
├── Task 4: Position-attr caller audit [unspecified-high]
└── Task 5: Param walker `$var` conflict audit [quick]
Wave 2 — Core data structures (after Wave 1, parallel):
├── Task 6: New entity attrs (EntityKind, MarkerKind, MarkerLifetime, MarkerOwner, MarkerLinks, RngSeed, RngStream) [unspecified-high]
├── Task 7: Aura compute filter (EntityKind discriminator; markers AuraSpec applicable) [deep]
├── Task 8: Movement-replacement attrs (MovesAs, MovesAlsoAs, SlideMustBeMaxDistance, BlockAllExceptKing, KingExtraReach) [unspecified-high]
├── Task 9: Mulberry32 PRNG utility + integration-preset seed init [unspecified-high]
└── Task 10: Marker entity factory (engine.spawnMarker) [unspecified-high]
Wave 3 — Param walker + binding resolver (after Wave 2):
├── Task 11: Binding scope stack on PrimitiveApplyContext [deep]
├── Task 12: Param walker resolves {$var}, {ctx-attr}, {ctx-build} shapes [deep]
├── Task 13: Validator: binding-ref-out-of-scope error [unspecified-high]
└── Task 14: Validator: imperative-in-passive-slot error + chooser-entity on activation context [unspecified-high]
Wave 4 — Dispatcher extension (after Wave 3):
├── Task 15: Deferred trigger queue + cascade depth guard [deep]
├── Task 16: on-rule-activated trigger dispatch (integration preset onActivate) [deep]
├── Task 17: on-rule-expire trigger + per-modifier countdown [deep]
├── Task 18: on-piece-entered-marker trigger + marker priority resolver [deep]
├── Task 19: on-marker-expire trigger + lifetime decrementer in onAfterMove [deep]
└── Task 20: Move-gen suppressTriggers flag wired through registry [deep]
Wave 5 — Imperative primitives (after Wave 4, MAX PARALLEL):
├── Task 21: place-piece [unspecified-high]
├── Task 22: destroy-piece [unspecified-high]
├── Task 23: move-piece [unspecified-high]
├── Task 24: swap-pieces [unspecified-high]
├── Task 25: convert-piece-type [unspecified-high]
├── Task 26: set-piece-attr (generic with target binding) [unspecified-high]
└── Task 27: cancel-capture (interrupts on-captured/on-capture event) [deep]
Wave 6 — Marker primitives + iteration (parallel with Wave 5 conceptually but tested after):
├── Task 28: spawn-marker [unspecified-high]
├── Task 29: spawn-marker-pair (portal pairs) [unspecified-high]
├── Task 30: destroy-marker [unspecified-high]
├── Task 31: for-each-piece (filter + binding) [deep]
├── Task 32: for-each-square (filter + binding) [deep]
├── Task 33: for-each-adjacent (target + filter + binding) [deep]
├── Task 34: for-each-marker (filter + binding) [unspecified-high]
└── Task 35: for-column / for-row [unspecified-high]
Wave 7 — RNG + restriction + movement-replacement primitives (parallel with 5/6):
├── Task 36: with-probability [unspecified-high]
├── Task 37: random-pick (with binding) [deep]
├── Task 38: must-class (capture-if-possible / advance-if-possible / move-to-square) [deep]
├── Task 39: block-by-piece-type [unspecified-high]
├── Task 40: set-moves-as / set-moves-also-as primitives [deep]
├── Task 41: pawn-pushes-pieces flag primitive [unspecified-high]
└── Task 42: lifetime field on imperative primitives (uniform shape) [deep]
Wave 8 — WS protocol v2 + suspended execution (after Waves 4 + 5 + 7):
├── Task 43: WS protocol v2 schema (request-choice + submit-choice + version negotiation) [deep]
├── Task 44: Server-side request-choice broadcast + validation [deep]
├── Task 45: Stack-based pendingChoices state on GAME_ENTITY + serializer [deep]
├── Task 46: Suspended-execution resume in integration preset's performAction [deep]
├── Task 47: request-choice primitive (kind: piece/square/column/row/coin-flip/rps; with prompt-both-players) [deep]
├── Task 48: Deterministic auto-resolver test transport (test-only WS handler) [unspecified-high]
├── Task 49: Choice timeout + disconnect handler (per-game setting) [deep]
└── Task 50: Game settings: choiceTimeout in CreateGameRequest [unspecified-high]
Wave 9 — Editor / UI (parallel with Wave 5/6/7):
├── Task 51: Palette taxonomy update (5 categories: State, Mechanic, Trigger, Imperative, Iteration) [visual-engineering]
├── Task 52: narrate.ts entries (all 28 primitives + 4 triggers) [writing]
├── Task 53: ParamField renderer: square picker [visual-engineering]
├── Task 54: ParamField renderer: piece picker [visual-engineering]
├── Task 55: ParamField renderer: marker-kind enum [visual-engineering]
├── Task 56: ParamField renderer: lifetime config [visual-engineering]
├── Task 57: Client marker rendering on chessboard [visual-engineering]
└── Task 58: Client request-choice modal (square/piece/column/row/coin-flip/rps variants) [visual-engineering]
Wave 10 — Test descriptors + Playwright e2e (after Waves 5-9):
├── Task 59: minefield descriptor + parity test [unspecified-high]
├── Task 60: mr_freeze descriptor + parity test (uses request-choice) [unspecified-high]
├── Task 61: parry descriptor + parity test (uses request-choice + cancel-capture + RPS) [unspecified-high]
├── Task 62: all_on_red descriptor + parity test (uses with-probability) [unspecified-high]
├── Task 63: religious_conversion descriptor + parity test [unspecified-high]
├── Task 64: ice_physics descriptor + parity test [unspecified-high]
├── Task 65: kamikaze descriptor + parity test (uses with-probability + for-each-adjacent) [unspecified-high]
├── Task 66: mind_control descriptor + parity test (uses request-choice on both players) [unspecified-high]
├── Task 67: 6 template descriptors shipped in modifier library [writing]
├── Task 68: Playwright: request-choice round-trip e2e (3 flows) [unspecified-high]
└── Task 69: Performance budget test (100 markers, p99 < 50ms) [unspecified-high]
Wave FINAL — 4 parallel reviews (after ALL implementation):
├── Task F1: Plan compliance audit (oracle)
├── Task F2: Code quality review (unspecified-high)
├── Task F3: Real manual QA across all 8 parity tests + 3 e2e flows (unspecified-high)
└── Task F4: Scope fidelity check (deep)
-> Present results -> Get explicit user okay
Critical Path: 0 → {1,2,3,4,5} → {6,9,10} → {11,12} → 15 → {16-20} → {21-27} → 43 → 47 → 59 → F1-F4
Parallel Speedup: ~75% faster than sequential
Max Concurrent: 8 (Waves 5+6+7+9 overlap)
```
### Dependency Matrix (abbreviated)
- **0**: — / blocks: 1-69
- **1-5**: 0 / blocks: 6-69
- **6-10**: 1-5 / blocks: 11-69
- **11-12**: 6 / blocks: 13-69
- **13-14**: 11-12 / blocks: 21-69
- **15-20**: 13-14 / blocks: 21-69
- **21-27**: 15, 16 / blocks: 59-69
- **28-35**: 18, 19, 11-12 / blocks: 59-69
- **36-42**: 13-14, 11-12, 9 / blocks: 59-69
- **43-50**: 47 needs 11-14, 15, 21-27 / blocks: 60, 61, 66, 68
- **51-58**: 6, 13-14 / blocks: 67, 68
- **59-66**: 21-58 (varies per descriptor)
- **67**: 51-58
- **68**: 47, 49, 58
- **69**: 18, 19, 28
- **F1-F4**: ALL implementation tasks
### Agent Dispatch Summary
- **0**: 1 — T0 → `deep`
- **1**: 5 — T1 → `quick`, T2 → `unspecified-high`, T3 → `quick`, T4 → `unspecified-high`, T5 → `quick`
- **2**: 5 — T6,T8 → `unspecified-high`, T7,T9 → `unspecified-high`/`deep`, T10 → `unspecified-high`
- **3**: 4 — T11,T12 → `deep`, T13,T14 → `unspecified-high`
- **4**: 6 — All `deep`
- **5**: 7 — Mostly `unspecified-high`, T27 → `deep`
- **6**: 8 — Mix
- **7**: 7 — Mix
- **8**: 8 — Mostly `deep`
- **9**: 8 — `visual-engineering`/`writing`
- **10**: 11 — `unspecified-high`/`writing`
- **FINAL**: 4 — F1 → `oracle`, F2-F3 → `unspecified-high`, F4 → `deep`
---
## TODOs
> Implementation + Test = ONE Task. Never separate.
> EVERY task MUST have: Recommended Agent Profile + Parallelization info + QA Scenarios.
- [x] 0. ADR document + paper-exercise capture
**What to do**:
- Create `.sisyphus/notepads/thressgame-coverage/decisions.md` with ALL locked architectural decisions (see plan Context section): suspended-execution stack semantics, deferred-trigger-queue semantics, move-gen suppressTriggers flag, marker-aura bridge, marker collision priority table, choice-timeout product spec
- Capture the 5-rule paper exercise (parry, all_on_red, religious_conversion, ice_physics, mr_freeze) as `.sisyphus/notepads/thressgame-coverage/paper-exercise.md` — pseudocode each rule using locked primitive set
- Create `.sisyphus/notepads/thressgame-coverage/learnings.md` (empty, ready for executor notes)
- Lock the 8 parity test descriptor names (already in plan; mirror to decisions.md)
- Lock the 6 template descriptor names (already in plan; mirror to decisions.md)
- Lock the 5 palette categories (State, Mechanic, Trigger, Imperative, Iteration)
- Lock the 4 ParamField renderers (square, piece, marker-kind, lifetime)
- Lock cascade depth limit = 8 (matches existing RUNTIME_DEPTH_HARD_CAP)
- Lock marker priority table: portal-end=1, frozen-square=2, mine=3, pit=4, death-square=5, tornado=6, treasure=7, blocked=8
**Must NOT do**:
- Modify any code in this task — pure documentation
- Lock any decision not already in plan; this is capture-only
**Recommended Agent Profile**:
- **Category**: `deep`
- Reason: high-stakes architectural capture; decisions here propagate to ~50 downstream tasks
- **Skills**: []
**Parallelization**:
- **Can Run In Parallel**: NO
- **Parallel Group**: Wave 0 alone
- **Blocks**: ALL other tasks
- **Blocked By**: None
**References**:
- Pattern: `.sisyphus/notepads/visual-modifier-builder/decisions.md` — same notepad structure
- Plan: `.sisyphus/plans/thressgame-coverage.md` Context section — source of locked decisions
**Acceptance Criteria**:
- [ ] File exists: `.sisyphus/notepads/thressgame-coverage/decisions.md`
- [ ] File exists: `.sisyphus/notepads/thressgame-coverage/paper-exercise.md`
- [ ] File exists: `.sisyphus/notepads/thressgame-coverage/learnings.md` (empty placeholder ok)
- [ ] `wc -l decisions.md` ≥ 100 lines (covers all 9 decision areas)
- [ ] paper-exercise.md contains all 5 rule names + pseudocode
**QA Scenarios**:
```
Scenario: All decision areas captured
Tool: Bash (grep)
Steps:
1. Run: grep -c '^##' .sisyphus/notepads/thressgame-coverage/decisions.md
2. Assert count ≥ 9 (one heading per decision area)
Expected Result: count ≥ 9
Evidence: .sisyphus/evidence/task-0-decisions-headings.txt
Scenario: Paper exercise covers all 5 rules
Tool: Bash (grep)
Steps:
1. Run: grep -E '^### Rule [1-5]' .sisyphus/notepads/thressgame-coverage/paper-exercise.md | wc -l
2. Assert count == 5
Expected Result: 5
Evidence: .sisyphus/evidence/task-0-paper-exercise-count.txt
```
**Commit**: YES
- Message: `docs(thressgame): capture architectural decisions and paper-exercise`
- Files: `.sisyphus/notepads/thressgame-coverage/*.md`
- Pre-commit: none (pure docs)
- [x] 1. Backward-compat baseline fixture
**What to do**:
- Run `bun run check` and capture green output to `packages/chess/src/__fixtures__/baseline-test-count.json` ({ files: 166, tests: 1957, timestamp })
- Snapshot all 22 existing primitive `.test.ts` outputs to `packages/chess/src/__fixtures__/baseline-primitive-tests.json` (per-primitive: { kind, testCount, expectCount })
- Add a meta-test `packages/chess/src/__fixtures__/baseline-regression.test.ts` that asserts current `bun test` output meets-or-exceeds baseline counts
- This becomes our regression guard: any task that breaks an existing test is caught immediately
**Must NOT do**:
- Modify any existing primitive's test
- Lower the baseline numbers if tests are removed (only add)
**Recommended Agent Profile**:
- **Category**: `quick`
- Reason: scripted snapshot, no logic
- **Skills**: []
**Parallelization**:
- **Can Run In Parallel**: YES (Wave 1)
- **Parallel Group**: Wave 1 (with 2, 3, 4, 5)
- **Blocks**: All implementation waves (Waves 5+)
- **Blocked By**: 0
**References**:
- Existing: `packages/chess/src/modifiers/primitives/` — directory of 22 existing primitives
**Acceptance Criteria**:
- [ ] `packages/chess/src/__fixtures__/baseline-test-count.json` exists with `tests: ≥ 1957`
- [ ] `bun test packages/chess/src/__fixtures__/baseline-regression.test.ts` exits 0
**QA Scenarios**:
```
Scenario: Baseline fixture loads + asserts current state
Tool: Bash
Steps:
1. Run: bun test packages/chess/src/__fixtures__/baseline-regression.test.ts 2>&1 | tee evidence.txt
2. Assert exit code 0
3. Assert "1 pass" in output
Expected Result: exit 0; baseline matches
Evidence: .sisyphus/evidence/task-1-baseline-test.txt
```
**Commit**: YES
- Message: `test(chess): backward-compat baseline fixture`
- Files: `packages/chess/src/__fixtures__/baseline-*.json`, `packages/chess/src/__fixtures__/baseline-regression.test.ts`
- [x] 2. Determinism property-test harness
**What to do**:
- Create `packages/chess/src/__fixtures__/determinism/` directory
- Add `packages/chess/src/__fixtures__/determinism/harness.ts` exporting `runDeterminismCheck(seed: number, moves: string[], iterations: number = 100): { hash: string; matches: boolean }` — runs N iterations of the same engine setup, returns hash of final state, asserts all iterations match
- Add `packages/chess/src/__fixtures__/determinism/harness.test.ts` with 1 sanity test (no rules, just standard chess) verifying baseline determinism
**Must NOT do**:
- Use Math.random anywhere in the harness
- Skip iterations on time pressure
**Recommended Agent Profile**:
- **Category**: `unspecified-high`
- Reason: critical infrastructure; need careful design
- **Skills**: []
**Parallelization**: Wave 1 (parallel with 1, 3, 4, 5). Blocks: Wave 8 (RNG primitives need this for verification).
**References**:
- Engine API: `packages/chess/src/engine.ts` — ChessEngine constructor + applyMove
- Hash util: will be added in T3
**Acceptance Criteria**:
- [ ] `bun test packages/chess/src/__fixtures__/determinism/harness.test.ts` passes (1 test)
- [ ] Harness runs N=100 iterations in < 5 seconds
**QA Scenarios**:
```
Scenario: Standard chess is deterministic at N=100
Tool: Bash
Steps:
1. Run: bun test packages/chess/src/__fixtures__/determinism/harness.test.ts
2. Assert exit 0
3. Assert duration < 5000ms
Expected Result: 1 pass, < 5s
Evidence: .sisyphus/evidence/task-2-determinism-harness.txt
```
**Commit**: YES
- Message: `test(chess): determinism property-test harness`
- Files: `packages/chess/src/__fixtures__/determinism/{harness.ts,harness.test.ts}`
- [x] 3. State-hash util (SHA256 of facts)
**What to do**:
- Create `packages/chess/src/util/state-hash.ts` with `hashEngineState(engine: ChessEngine): string` (returns SHA256 hex)
- Algorithm: walk all entities, sort by id, for each entity sort facts by attr name, JSON.stringify, hash the concatenation
- Co-located test `state-hash.test.ts`: identical engine state → identical hash; one fact difference → different hash; entity-id-order independence (insert in different orders, same hash)
**Must NOT do**:
- Use Date.now or any non-deterministic input
- Hash arrays without sorting
**Recommended Agent Profile**:
- **Category**: `quick`
- Reason: pure utility; well-known pattern
- **Skills**: []
**Parallelization**: Wave 1.
**References**:
- Node crypto: `import { createHash } from 'node:crypto'`
- Engine: `packages/chess/src/engine.ts` — `engine.session` exposes facts
**Acceptance Criteria**:
- [ ] `bun test packages/chess/src/util/state-hash.test.ts` passes
- [ ] Hash is 64-char hex string
- [ ] Same state → same hash (3 different orderings)
- [ ] One-fact diff → different hash
**QA Scenarios**:
```
Scenario: Determinism + insertion-order independence
Tool: Bash
Steps:
1. bun test packages/chess/src/util/state-hash.test.ts
Expected Result: 4+ tests pass
Evidence: .sisyphus/evidence/task-3-state-hash.txt
```
**Commit**: YES
- Message: `feat(chess): state-hash util for determinism testing`
- Files: `packages/chess/src/util/state-hash{.ts,.test.ts}`
- [x] 4. Position-attr caller audit
**What to do**:
- Grep entire `packages/chess/src/` for `"Position"` (the attr name as string)
- For each callsite, classify: (a) reads Position assuming entity is a piece (RISK — markers will trigger false positives), (b) writes Position (just sets coords), (c) iterates entities-with-Position
- Produce `.sisyphus/notepads/thressgame-coverage/position-audit.md` listing each callsite with file:line + classification + required fix (if any)
- Common pattern: callers should add `EntityKind === "piece"` filter
**Must NOT do**:
- Modify any code yet — audit only
- Skip non-obvious callsites
**Recommended Agent Profile**:
- **Category**: `unspecified-high`
- Reason: critical safety audit; must be thorough
- **Skills**: []
**Parallelization**: Wave 1.
**References**:
- Files most likely affected: `packages/chess/src/engine.ts`, `packages/chess/src/modifiers/auras.ts`, `packages/chess/src/modifiers/triggers.ts`, `packages/chess/src/move-generator.ts`
**Acceptance Criteria**:
- [ ] `position-audit.md` lists ≥ 15 callsites (rough estimate; exact count TBD by grep)
- [ ] Each callsite has classification (a/b/c) + fix recommendation
**QA Scenarios**:
```
Scenario: Audit completeness
Tool: Bash (grep)
Steps:
1. grep -rn '"Position"' packages/chess/src/ | wc -l
2. wc -l .sisyphus/notepads/thressgame-coverage/position-audit.md
Expected Result: line count ≥ grep count (every grep hit covered)
Evidence: .sisyphus/evidence/task-4-position-audit.txt
```
**Commit**: YES
- Message: `docs(thressgame): position-attr caller audit`
- Files: `.sisyphus/notepads/thressgame-coverage/position-audit.md`
- [x] 5. Param walker `$var` conflict audit
**What to do**:
- Grep all primitive `paramsSchema` definitions for any field whose value or key uses `$` prefix
- Grep all existing template/library descriptors for `$` in params
- Confirm zero collisions; document in `.sisyphus/notepads/thressgame-coverage/var-conflict-audit.md`
- If ANY collision found: STOP and surface to user — binding shape `{ $var: "name" }` must be renamed (e.g., `{ __var: "name" }`)
**Must NOT do**:
- Modify code; audit only
**Recommended Agent Profile**:
- **Category**: `quick`
- Reason: simple grep + write
- **Skills**: []
**Parallelization**: Wave 1.
**References**:
- Primitives: `packages/chess/src/modifiers/primitives/*.ts`
- Templates: `packages/chess/src/modifiers/library.ts` or similar
**Acceptance Criteria**:
- [ ] `var-conflict-audit.md` exists with grep results + verdict (clean / conflict)
- [ ] If clean: confirmed `{ $var: "..." }` shape is safe to add
**QA Scenarios**:
```
Scenario: No existing $-prefixed params
Tool: Bash (grep)
Steps:
1. grep -rn '"\\$' packages/chess/src/modifiers/primitives/ packages/chess/src/modifiers/library.ts 2>/dev/null
Expected Result: zero matches OR all matches documented as non-conflicts
Evidence: .sisyphus/evidence/task-5-var-conflict.txt
```
**Commit**: YES
- Message: `docs(thressgame): param walker var-conflict audit`
- Files: `.sisyphus/notepads/thressgame-coverage/var-conflict-audit.md`
- [x] 6. New entity attrs (EntityKind, MarkerKind, MarkerLifetime, MarkerOwner, MarkerLinks, RngSeed, RngStream)
**What to do**:
- Edit `packages/chess/src/schema.ts`: extend ChessAttrMap with `EntityKind: "piece" | "marker"`, `MarkerKind: "mine" | "pit" | "portal-end" | "frozen-square" | "treasure" | "death-square" | "tornado" | "blocked"`, `MarkerLifetime: { kind: "permanent" } | { kind: "moves"; expiresAtMove: number } | { kind: "one-shot" }`, `MarkerOwner: "white" | "black" | undefined`, `MarkerLinks: readonly EntityId[]`, `RngSeed: number`, `RngStream: number`
- Edit `packages/chess/src/modifiers/custom/apply.ts`: register all 7 new attrs via `registerAttrConsumer()` calls (mirror existing pattern)
- Add 7 unit tests to `packages/chess/src/schema.test.ts` asserting each new attr is present and typed correctly
**Must NOT do**:
- Change shape of any existing attr
- Skip registerAttrConsumer (engine boot will throw via `assertSeedConsumerIntegrity`)
**Recommended Agent Profile**:
- **Category**: `unspecified-high`
- Reason: cross-cutting type definitions; affects every later task
- **Skills**: []
**Parallelization**: Wave 2 (parallel with 7, 8, 9, 10).
**References**:
- `packages/chess/src/schema.ts` — ChessAttrMap definition
- `packages/chess/src/modifiers/custom/apply.ts:87-105` — registerAttrConsumer pattern
- Notepad: `.sisyphus/notepads/visual-modifier-builder/learnings.md:74-103` — T2/T3 pattern for adding attrs
**Acceptance Criteria**:
- [ ] `bun test packages/chess/src/schema.test.ts` passes (7 new tests)
- [ ] `grep -c '"MarkerKind"' packages/chess/src/schema.ts` ≥ 1
- [ ] `grep -c "registerAttrConsumer.*EntityKind" packages/chess/src/modifiers/custom/apply.ts` ≥ 1
**QA Scenarios**:
```
Scenario: All 7 attrs registered + consumed
Tool: Bash
Steps:
1. bun test packages/chess/src/schema.test.ts packages/chess/src/modifiers/custom/consumer-integration.test.ts
Expected Result: all pass
Evidence: .sisyphus/evidence/task-6-attrs.txt
```
**Commit**: YES
- Message: `feat(chess): marker + RNG entity attrs`
- Files: `packages/chess/src/schema.ts`, `packages/chess/src/modifiers/custom/apply.ts`, `packages/chess/src/schema.test.ts`
- [x] 7. Aura compute filter (markers participate)
**What to do**:
- Edit `packages/chess/src/modifiers/auras.ts`: in `computeAuraFacts()`, where it walks all entities, change filter from "has PieceType" to "EntityKind in {piece, marker}"
- Add `getEntityKind(session, id): "piece" | "marker" | undefined` helper
- Update existing aura tests + add 2 new tests: marker emits aura → adjacent piece receives; marker receives aura → no-op (markers don't have HP/etc.)
- Confirm via T4's audit which auras-related callsites need updating
**Must NOT do**:
- Change AuraSpec / AuraContributions shape
- Allow markers to receive auras for piece-only attrs (Hp, AttackBonus, etc.) — they'd accumulate but never read; document this as benign
**Recommended Agent Profile**:
- **Category**: `deep`
- Reason: subtle behavior change in established subsystem
- **Skills**: []
**Parallelization**: Wave 2.
**References**:
- `packages/chess/src/modifiers/auras.ts:31-90` — computeAuraFacts walker
- T4 output: `.sisyphus/notepads/thressgame-coverage/position-audit.md`
- Notepad reference: `learnings.md:74` mentions registerAttrConsumer integrity check
**Acceptance Criteria**:
- [ ] `bun test packages/chess/src/modifiers/auras.test.ts` passes (existing + 2 new)
- [ ] Marker emitting aura test green
**QA Scenarios**:
```
Scenario: Marker can emit aura
Tool: Bash
Steps:
1. bun test packages/chess/src/modifiers/auras.test.ts -t "marker.*aura"
Expected Result: 2+ tests pass
Evidence: .sisyphus/evidence/task-7-marker-aura.txt
```
**Commit**: YES
- Message: `feat(chess): aura compute includes markers`
- Files: `packages/chess/src/modifiers/auras.ts`, `packages/chess/src/modifiers/auras.test.ts`
- [x] 8. Movement-replacement attrs
**What to do**:
- Edit `packages/chess/src/schema.ts`: add `MovesAs: PieceType | undefined` (per piece — overrides movement pattern), `MovesAlsoAs: PieceType | undefined` (per piece — additive secondary pattern), `SlideMustBeMaxDistance: boolean` (per piece OR game), `BlockAllExceptKing: boolean` (game), `KingExtraReach: number` (per piece)
- Register all 5 attrs via `registerAttrConsumer()`
- Add unit tests asserting each attr exists
**Must NOT do**:
- Wire move-gen yet — that's Wave 7 (Task 40, 41)
- Set defaults; let move-gen treat undefined as "use natural movement"
**Recommended Agent Profile**:
- **Category**: `unspecified-high`
- Reason: more attr definitions; well-trodden path
- **Skills**: []
**Parallelization**: Wave 2.
**References**: same as T6.
**Acceptance Criteria**:
- [ ] `bun test packages/chess/src/schema.test.ts` extended for 5 new attrs (12 total new since T6)
- [ ] All 5 register via consumer
**QA Scenarios**:
```
Scenario: 5 movement attrs registered
Tool: Bash
Steps:
1. bun test packages/chess/src/schema.test.ts packages/chess/src/modifiers/custom/consumer-integration.test.ts
Expected Result: pass
Evidence: .sisyphus/evidence/task-8-movement-attrs.txt
```
**Commit**: YES
- Message: `feat(chess): movement-replacement attrs`
- Files: schema.ts, apply.ts, schema.test.ts
- [x] 9. Mulberry32 PRNG utility + integration-preset seed init
**What to do**:
- Create `packages/chess/src/util/rng.ts` exporting `class SeededRng { constructor(seed: number); next(): number /* 0..1 */; nextInt(max: number): number; pick<T>(arr: readonly T[]): T }`
- Algorithm: Mulberry32 (one-line, well-known, deterministic). Seed = `RngSeed` attr; `RngStream` increments per draw.
- Edit integration preset (`packages/chess/src/modifiers/custom/apply.ts`) to seed RNG on game start: `engine.session.insert(GAME_ENTITY, "RngSeed", deriveSeedFromGameId(gameId))`, `engine.session.insert(GAME_ENTITY, "RngStream", 0)`
- Helper: `engine.rng()` returns `new SeededRng(RngSeed + RngStream)` and increments `RngStream` after every `next()` call
- Co-located test: `rng.test.ts` — same seed → same sequence (10 draws); different seed → different sequence; `pick` uniform across 10000 trials
**Must NOT do**:
- Use Math.random fallback
- Allow direct `new SeededRng(seed)` calls outside `engine.rng()` in production code (test-only)
**Recommended Agent Profile**:
- **Category**: `unspecified-high`
- Reason: standard algorithm; care needed in stream management
- **Skills**: []
**Parallelization**: Wave 2.
**References**:
- Mulberry32: well-known; verify with checked-in golden sequence in test
- `packages/chess/src/modifiers/custom/apply.ts` — integration preset boot path
- Notepad: `learnings.md:240+` — apply.ts onBeforeMove pattern
**Acceptance Criteria**:
- [ ] `bun test packages/chess/src/util/rng.test.ts` passes (3+ tests)
- [ ] Determinism harness (T2) integration: `runDeterminismCheck` with RNG-using rule passes N=100
**QA Scenarios**:
```
Scenario: Same seed → byte-identical 100 draws
Tool: Bash
Steps:
1. bun test packages/chess/src/util/rng.test.ts
Expected Result: pass
Evidence: .sisyphus/evidence/task-9-rng.txt
```
**Commit**: YES
- Message: `feat(chess): seeded Mulberry32 PRNG + integration preset init`
- Files: `packages/chess/src/util/rng.ts`, `packages/chess/src/util/rng.test.ts`, `packages/chess/src/modifiers/custom/apply.ts`
- [x] 10. Marker entity factory (engine.spawnMarker)
**What to do**:
- Edit `packages/chess/src/engine.ts`: add `spawnMarker(kind: MarkerKind, square: Square, opts: { lifetime: MarkerLifetime; owner?: Color; links?: readonly EntityId[] }): EntityId` (mirrors `spawnPiece` pattern at engine.ts:741-769)
- Inserts: `EntityKind="marker"`, `MarkerKind`, `Position=square`, `MarkerLifetime`, optional `MarkerOwner`, optional `MarkerLinks`
- Add `engine.removeMarker(id: EntityId): void` that retracts all marker facts
- Add `engine.getMarkersAtSquare(square: Square): EntityId[]` returning sorted by marker-kind priority (use hardcoded priority table from T0 ADR)
- Co-located tests: spawn → all attrs present; remove → all attrs gone; collision → priority order
**Must NOT do**:
- Allow id collisions with piece entities (use session.nextId)
- Auto-fire any trigger on spawn (triggers come in Wave 4)
**Recommended Agent Profile**:
- **Category**: `unspecified-high`
- Reason: factory + helpers; clear pattern from spawnPiece
- **Skills**: []
**Parallelization**: Wave 2.
**References**:
- `packages/chess/src/engine.ts:741-769` — spawnPiece pattern
- T0 decisions.md — marker priority table
**Acceptance Criteria**:
- [ ] `bun test packages/chess/src/engine.test.ts -t "spawnMarker"` passes (5+ tests)
- [ ] Priority order test: spawn mine + portal-end on same square → portal-end first in `getMarkersAtSquare` result
**QA Scenarios**:
```
Scenario: Marker priority resolution
Tool: Bash
Steps:
1. bun test packages/chess/src/engine.test.ts -t "marker.*priority"
Expected Result: pass
Evidence: .sisyphus/evidence/task-10-marker-priority.txt
```
**Commit**: YES
- Message: `feat(chess): marker entity factory + priority resolver`
- Files: `packages/chess/src/engine.ts`, `packages/chess/src/engine.test.ts`
- [x] 11. Binding scope stack on PrimitiveApplyContext
**What to do**:
- Edit `packages/chess/src/modifiers/primitives/context.ts`: extend `PrimitiveApplyContext` with `bindings: ReadonlyMap<string, EntityId | readonly EntityId[] | Square | number | string>`
- Add helper `withBinding(ctx, name, value): PrimitiveApplyContext` that returns a NEW context with the binding added (immutable; bindings are scoped via context cloning, not mutation)
- Update `runPrimitives()` in triggers.ts to thread the bindings through recursive calls
- Co-located test: 3 levels of nested binding; later binding shadows earlier; bindings scoped to subtree
**Must NOT do**:
- Mutate the bindings Map in place
- Allow null/undefined as binding values (use omission instead)
**Recommended Agent Profile**:
- **Category**: `deep`
- Reason: foundation for all binding-using primitives; subtle scoping semantics
- **Skills**: []
**Parallelization**: Wave 3 (parallel with 12). Blocks: 13, 14, 21+.
**References**:
- `packages/chess/src/modifiers/primitives/context.ts` — PrimitiveApplyContext shape
- `packages/chess/src/modifiers/triggers.ts:128-144` — context construction; line 144+ for runPrimitives
- Notepad: `learnings.md:118+` for context.ts architecture
**Acceptance Criteria**:
- [ ] `bun test packages/chess/src/modifiers/primitives/context.test.ts -t "binding"` passes (3+ tests)
- [ ] Existing 13 context tests unchanged
**QA Scenarios**:
```
Scenario: Binding scope works at depth 3
Tool: Bash
Steps:
1. bun test packages/chess/src/modifiers/primitives/context.test.ts
Expected Result: ≥ 16 pass (13 existing + 3 new)
Evidence: .sisyphus/evidence/task-11-bindings.txt
```
**Commit**: YES
- Message: `feat(chess): binding scope on PrimitiveApplyContext`
- Files: `packages/chess/src/modifiers/primitives/context.ts`, `context.test.ts`
- [x] 12. Param walker resolves {$var}, {ctx-attr}, {ctx-build} shapes
**What to do**:
- Create `packages/chess/src/modifiers/primitives/param-resolver.ts` exporting `resolveParams(params: unknown, ctx: PrimitiveApplyContext): unknown` — recursively walks params, substituting:
- `{ $var: "name" }` → `ctx.bindings.get("name")` (throws if unbound)
- `{ ctx-attr: { entity: "self" | "chooser" | EntityId; attr: string } }` → `ctx.session.get(entityId, attr)` (throws if unset)
- `{ ctx-build: { col: $varOrCharacter; row: $varOrChar } }` → resolves to a Square number
- Otherwise returns value unchanged
- Wire into runPrimitives() BEFORE param schema validation: resolved params are then Zod-validated
- Co-located test: 6 tests covering each shape + nested + array of shapes + error cases
**Must NOT do**:
- Modify Zod schemas of existing primitives
- Allow recursion deeper than primitives recursion limit
**Recommended Agent Profile**:
- **Category**: `deep`
- Reason: cross-cutting walker; must not break existing primitives
- **Skills**: []
**Parallelization**: Wave 3.
**References**:
- T0 paper exercise (`paper-exercise.md`) — example shapes
- T11 — bindings API
**Acceptance Criteria**:
- [ ] `bun test packages/chess/src/modifiers/primitives/param-resolver.test.ts` passes (6+ tests)
- [ ] All 22 existing primitive tests pass byte-identical (regression: walker must be no-op for unresolved params)
**QA Scenarios**:
```
Scenario: Walker is no-op for plain params
Tool: Bash
Steps:
1. bun test packages/chess/src/modifiers/primitives/
Expected Result: existing 147+ tests pass
Evidence: .sisyphus/evidence/task-12-param-resolver.txt
```
**Commit**: YES
- Message: `feat(chess): param walker for binding/ctx-attr/ctx-build resolution`
- Files: `packages/chess/src/modifiers/primitives/param-resolver.{ts,test.ts}`, `packages/chess/src/modifiers/triggers.ts`
- [x] 13. Validator: binding-ref-out-of-scope error
**What to do**:
- Edit `packages/chess/src/modifiers/custom/validate.ts`: add a binding-scope walker that builds a binding-name set per primitive subtree and rejects any `{ $var: "X" }` reference where `X` not in scope
- New error code: `descriptor.primitives.binding-out-of-scope`
- Co-located test: 3 cases (in-scope OK, out-of-scope rejected, redefined-shadows-correctly)
**Must NOT do**:
- Validate at runtime — this is descriptor-time validation
- Reject valid existing descriptors (run T1 baseline regression after change)
**Recommended Agent Profile**:
- **Category**: `unspecified-high`
- Reason: validator extension; pattern well-established
- **Skills**: []
**Parallelization**: Wave 3.
**References**:
- `packages/chess/src/modifiers/custom/validate.ts` — existing validator
- Notepad: `learnings.md:668-672` — validate.ts test patterns
**Acceptance Criteria**:
- [ ] `bun test packages/chess/src/modifiers/custom/validate.test.ts -t "binding"` passes (3+ tests)
- [ ] T1 baseline regression test passes (no existing descriptor breaks)
**QA Scenarios**: same pattern as T12.
**Commit**: YES
- Message: `feat(validator): binding-scope check`
- Files: `validate.ts`, `validate.test.ts`
- [x] 14. Validator: imperative-in-passive + chooser-entity activation context
**What to do**:
- Edit validate.ts: walk descriptor tree; if a primitive whose `kind` is in IMPERATIVE_KINDS set (place-piece, destroy-piece, move-piece, swap-pieces, convert-piece-type, set-piece-attr, cancel-capture, spawn-marker, spawn-marker-pair, destroy-marker) appears OUTSIDE a trigger's `primitives` or `then`/`else` array → reject with error code `descriptor.primitives.imperative-in-passive`
- Add IMPERATIVE_KINDS constant; co-locate with validator
- Add `chooser-entity` symbolic ref support: when a descriptor activates, the integration preset records the chooser's color in PRESET_STATE_ENTITY (preset-namespaced); `ctx-attr: { entity: "chooser", ... }` resolves to a piece-id of chooser color (or rejects if no chooser)
- Co-located test: 4 cases (imperative top-level rejected, imperative-in-trigger OK, chooser-ref resolves, chooser-ref unset error)
**Must NOT do**:
- Hardcode the chooser color in tests; resolve via session
**Recommended Agent Profile**:
- **Category**: `unspecified-high`
- Reason: cross-cutting validation rule
- **Skills**: []
**Parallelization**: Wave 3.
**References**:
- Existing validator pattern as T13
- T0 decisions.md — chooser-entity definition
- `packages/chess/src/presets/transferable-royalty.ts:146-148` — presetState pattern for chooser tracking
**Acceptance Criteria**:
- [ ] `bun test packages/chess/src/modifiers/custom/validate.test.ts -t "imperative|chooser"` passes (4+ tests)
**QA Scenarios**:
```
Scenario: Imperative top-level rejected with code
Tool: Bash
Steps:
1. bun test packages/chess/src/modifiers/custom/validate.test.ts -t "imperative-in-passive"
Expected Result: pass + error code asserted
Evidence: .sisyphus/evidence/task-14-validator.txt
```
**Commit**: YES
- Message: `feat(validator): imperative-in-passive + chooser-entity ref`
- Files: validate.ts + test
- [x] 15. Deferred trigger queue + cascade depth guard
**What to do**:
- Edit `packages/chess/src/modifiers/triggers.ts`: add a queue `pendingTriggers: Array<{ kind: TriggerName; pieceId: EntityId; payload: unknown }>` to `PrimitiveApplyContext`
- When an imperative primitive causes a state change that should fire a reactive trigger (e.g. destroy-piece causes on-captured), instead of firing inline, push to `pendingTriggers`
- After current arm completes (current `runPrimitives()` call returns), drain the queue; each drained trigger creates a NEW arm that may itself enqueue more
- Cascade depth: each arm increments `cascadeDepth` (separate from existing `depth` for nested primitives); reject when `cascadeDepth > 8` with error `runtime.cascade-depth-exceeded`
- Co-located tests: deferred firing OK, depth=8 hits guard
**Must NOT do**:
- Use synchronous reentrant firing
- Mix `cascadeDepth` with `depth` (they're orthogonal)
**Recommended Agent Profile**:
- **Category**: `deep`
- Reason: critical control-flow change in dispatcher
- **Skills**: []
**Parallelization**: Wave 4.
**References**:
- `packages/chess/src/modifiers/triggers.ts:128-144` (context), various fire*Hooks (lines 193-510)
- T0 decisions.md — deferred queue semantics
**Acceptance Criteria**:
- [ ] `bun test packages/chess/src/modifiers/triggers.test.ts -t "deferred|cascade"` passes (3+ tests)
- [ ] Existing 20 trigger tests pass
**QA Scenarios**:
```
Scenario: Cascade depth=8 fires guard
Tool: Bash
Steps:
1. bun test packages/chess/src/modifiers/triggers.test.ts -t "cascade-depth"
Expected Result: pass with error code assertion
Evidence: .sisyphus/evidence/task-15-cascade.txt
```
**Commit**: YES
- Message: `feat(chess): deferred trigger queue + cascade depth guard`
- Files: triggers.ts + test
- [x] 16. on-rule-activated trigger dispatch
**What to do**:
- Create `packages/chess/src/modifiers/primitives/on-rule-activated.ts` (mirror on-capture.ts shape ~75 lines): kind `"on-rule-activated"`, label `"On Rule Activated"`, seedsAttrs `["OnRuleActivatedHooks"]`, paramsSchema `z.object({ primitives: z.array(NodeSchema) })`
- Add `OnRuleActivatedHooks: readonly EffectPrimitiveNode[][]` attr (seeded by primitive)
- Add `fireOnRuleActivatedHooks(engine, descriptorId, chooserColor)` to triggers.ts — fires nested primitives once with `pieceId = GAME_ENTITY`, `event = { kind: "rule-activated", descriptorId, chooserColor }`, `chooser` resolvable via PRESET_STATE_ENTITY
- Wire into integration preset's `onActivate` hook (mirroring piece-hp.ts:93-101 pattern): when descriptor attaches, fire on-rule-activated hooks
- Co-located test: 6 tests (registry, apply seeds, stacks, fires once, chooser resolves, runs nested primitives)
**Must NOT do**:
- Fire on every game start — only on first activation per game
- Allow re-firing on game reload from save
**Recommended Agent Profile**:
- **Category**: `deep`
- Reason: novel trigger; integration with preset boot
- **Skills**: []
**Parallelization**: Wave 4.
**References**:
- `packages/chess/src/modifiers/primitives/on-capture.ts` — primitive pattern
- `packages/chess/src/modifiers/triggers.ts:193-207` — fire*Hooks pattern
- `packages/chess/src/presets/piece-hp.ts:93-101` — onActivate pattern
- Notepad: `learnings.md:301-340` — on-promotion as nearest equivalent
**Acceptance Criteria**:
- [ ] `bun test packages/chess/src/modifiers/primitives/on-rule-activated.test.ts` passes (6 tests)
- [ ] `bun test packages/chess/src/modifiers/triggers.test.ts -t "on-rule-activated"` passes (1+ test)
**QA Scenarios**:
```
Scenario: Fires once on attachment
Tool: Bash
Steps:
1. bun test packages/chess/src/modifiers/primitives/on-rule-activated.test.ts packages/chess/src/modifiers/triggers.test.ts
Expected Result: pass
Evidence: .sisyphus/evidence/task-16-on-rule-activated.txt
```
**Commit**: YES
- Message: `feat(chess): on-rule-activated trigger`
- Files: new primitive .ts + test, triggers.ts, schema.ts, apply.ts, types.ts (PrimitiveKind union), index.ts
- [x] 17. on-rule-expire trigger + per-modifier countdown
**What to do**:
- Create `packages/chess/src/modifiers/primitives/on-rule-expire.ts` (mirror T16): kind `"on-rule-expire"`, paramsSchema `z.object({ countMoves: z.number().int().positive(); primitives: z.array(NodeSchema) })`
- Add `OnRuleExpireHooks: readonly { descriptorId: string; expiresAtMove: number; primitives: readonly EffectPrimitiveNode[] }[]` attr
- Add `fireOnRuleExpireHooks(engine, currentMoveCount)` to triggers.ts — fires when `expiresAtMove <= currentMoveCount`; auto-removes from list after firing
- Wire into 14-stage onAfterMove dispatch (after stage 11 = onTurnEnd, before stage 12 = onTurnStart, but ordering TBD by paper exercise)
- Co-located test: 5 tests
**Must NOT do**:
- Fire eagerly mid-turn — only at onAfterMove drain point
**Recommended Agent Profile**:
- **Category**: `deep`
- Reason: per-modifier timer semantics
- **Skills**: []
**Parallelization**: Wave 4.
**References**: same as T16
**Acceptance Criteria**:
- [ ] `bun test on-rule-expire.test.ts` passes
- [ ] Triggers test extended
**QA Scenarios**:
```
Scenario: Expires at move count
Tool: Bash
Steps:
1. bun test packages/chess/src/modifiers/primitives/on-rule-expire.test.ts
Expected Result: pass
Evidence: .sisyphus/evidence/task-17-on-rule-expire.txt
```
**Commit**: YES
- Message: `feat(chess): on-rule-expire trigger + countdown`
- [x] 18. on-piece-entered-marker trigger + marker priority resolver
**What to do**:
- Create `packages/chess/src/modifiers/primitives/on-piece-entered-marker.ts`: kind `"on-piece-entered-marker"`, paramsSchema `z.object({ markerKind: z.enum([...]); primitives: z.array(NodeSchema) })`
- Add `OnPieceEnteredMarkerHooks: readonly { markerKind: MarkerKind; primitives }[]` attr
- Add `fireOnPieceEnteredMarkerHooks(engine, movedPieceIds, postMovePositions)` to triggers.ts — for each moved piece, look up markers at piece's new position via `engine.getMarkersAtSquare(square)`; fire matching hooks in priority order
- Wire after stage 7 (on-moved-onto-square) in onAfterMove
- Co-located test: 5 tests including priority order between mine + portal-end
**Must NOT do**:
- Fire for markers spawned during this same trigger arm (use snapshot of pre-move marker positions)
**Recommended Agent Profile**:
- **Category**: `deep`
- Reason: priority-resolution + position-diff dispatch
- **Skills**: []
**Parallelization**: Wave 4.
**References**:
- T10 — `engine.getMarkersAtSquare`
- `packages/chess/src/modifiers/triggers.ts:446-460` — fireOnMovedOntoSquareHooks pattern (similar)
**Acceptance Criteria**:
- [ ] `bun test on-piece-entered-marker.test.ts` passes (5 tests)
- [ ] Priority order test green
**QA Scenarios**:
```
Scenario: Mine + portal both at sq, portal fires first (priority 1 < 3)
Tool: Bash
Steps:
1. bun test packages/chess/src/modifiers/primitives/on-piece-entered-marker.test.ts -t "priority"
Expected Result: pass; portal effect observed before mine effect
Evidence: .sisyphus/evidence/task-18-marker-priority.txt
```
**Commit**: YES
- Message: `feat(chess): on-piece-entered-marker trigger`
- [x] 19. on-marker-expire trigger + lifetime decrementer
**What to do**:
- Create `packages/chess/src/modifiers/primitives/on-marker-expire.ts`: kind `"on-marker-expire"`, paramsSchema `z.object({ markerKind: z.enum([...]); primitives: z.array(NodeSchema) })`
- Add `OnMarkerExpireHooks: readonly { markerKind: MarkerKind; primitives }[]` attr
- In integration preset's onAfterMove (new stage): walk all marker entities; for each whose lifetime is `{ kind: "moves", expiresAtMove }` and `expiresAtMove <= currentMoveCount` → fire matching on-marker-expire hooks → call `engine.removeMarker(id)`
- For one-shot lifetimes: removed by `on-piece-entered-marker` after firing (T18 must update; cross-task contract documented)
- Co-located test: 5 tests (N-moves expires, one-shot consumed, permanent never expires, hook fires once)
**Must NOT do**:
- Remove markers BEFORE firing on-marker-expire (event needs marker still in session for context)
**Recommended Agent Profile**:
- **Category**: `deep`
- Reason: lifetime state machine; cross-cutting with T18
- **Skills**: []
**Parallelization**: Wave 4.
**References**: same as T18
**Acceptance Criteria**:
- [ ] `bun test on-marker-expire.test.ts` passes
- [ ] Integration: marker auto-removed after lifetime, on-marker-expire fires once
**QA Scenarios**: parallel to T18.
**Commit**: YES
- Message: `feat(chess): on-marker-expire trigger + lifetime decrementer`
- [x] 20. Move-gen suppressTriggers flag wired through registry
**What to do**:
- Edit `packages/chess/src/presets/registry.ts`: extend `getLegalMoveModifiers` and movement-related hooks to accept `opts: { suppressTriggers: boolean }` parameter
- Edit `packages/chess/src/move-generator.ts` (or wherever move-gen entry is): pass `suppressTriggers: true` when running in dry mode (e.g. for check detection)
- Edit triggers.ts runPrimitives: when `ctx.suppressTriggers === true`, skip imperative primitives that would mutate state (no-op return)
- Co-located test: dry-mode invocation does not fire triggers, real move commit does
**Must NOT do**:
- Apply suppress flag globally — only during move-gen
- Skip non-mutating primitives in dry mode
**Recommended Agent Profile**:
- **Category**: `deep`
- Reason: move-gen path is performance-critical and central to engine
- **Skills**: []
**Parallelization**: Wave 4.
**References**:
- `packages/chess/src/presets/registry.ts` — getLegalMoveModifiers contract
- `packages/chess/src/move-generator.ts` (or equivalent) — entry point
**Acceptance Criteria**:
- [ ] Existing move-gen tests pass byte-identical
- [ ] New test: dry-mode does not invoke imperative primitives
**QA Scenarios**:
```
Scenario: Dry-mode suppresses imperative primitives
Tool: Bash
Steps:
1. bun test packages/chess/src/move-generator.test.ts -t "suppressTriggers"
Expected Result: pass
Evidence: .sisyphus/evidence/task-20-suppress-triggers.txt
```
**Commit**: YES
- Message: `feat(chess): move-gen suppressTriggers flag for dry-mode`
> **WAVE 5 PRIMITIVES TEMPLATE NOTE**: Tasks 21-27 are imperative piece-mutation primitives. Each follows the same template: create `<kind>.ts` (~50-80 lines), add `paramsSchema`, register in registry, add to PrimitiveKind union in types.ts, add side-effect import in index.ts, add SAMPLE_PARAMS entry in `ParamField.snapshot.test.tsx`, add narrate.ts entry, add palette category. Each ships with a co-located test (5+ assertions). **Each task is one atomic commit.**
- [x] 21. place-piece primitive
**What to do**:
- kind: "place-piece", schema: `{ pieceType: PieceType, color: Color | { ctx-attr } | { $var }, square: Square | { $var } | { ctx-build }, replaceExisting: boolean (default false) }`
- apply(): resolve params via param-resolver; if `replaceExisting=false` and square occupied → no-op; else `engine.spawnPiece(pieceType, color, square)`
- Imperative-only (validator rejects in passive)
**Must NOT do**: spawn at occupied square unless replaceExisting=true; auto-fire on-rule-activated for spawned piece's modifiers (cascade via deferred queue from T15)
**Recommended Agent Profile**: `unspecified-high`. Skills: [].
**Parallelization**: Wave 5 (parallel with 22-27).
**References**: `packages/chess/src/modifiers/primitives/seed-attribute.ts` (similar structure), engine.spawnPiece (T6, engine.ts:741-769)
**Acceptance Criteria**: `bun test place-piece.test.ts` passes (5 tests); validator rejects at top level
**QA Scenarios**:
```
Scenario: Place + replace + occupied no-op
Tool: Bash
Steps: bun test packages/chess/src/modifiers/primitives/place-piece.test.ts
Expected: pass
Evidence: .sisyphus/evidence/task-21-place-piece.txt
```
**Commit**: YES — `feat(chess): place-piece imperative primitive`
- [x] 22. destroy-piece primitive
**What to do**: kind: "destroy-piece", schema: `{ target: TargetResolver | { $var } }`. apply(): resolve target → for each entity → retract all piece facts via `engine.session.retract(id, attr)` for piece attrs (PieceType, Color, Position, HasMoved, Hp, etc.). Special-case: if target is king, no-op (kings invulnerable to destroy-piece by convention; on-captured handled separately)
**Must NOT do**: destroy markers (filter EntityKind === "piece"); destroy GAME_ENTITY
**Recommended Agent Profile**: `unspecified-high`
**Parallelization**: Wave 5
**References**: T11 bindings, target resolver in `packages/chess/src/modifiers/primitives/context.ts`
**Acceptance Criteria**: tests pass (5+); validator rejects at top level
**QA Scenarios**: `bun test destroy-piece.test.ts` → `.sisyphus/evidence/task-22-destroy-piece.txt`
**Commit**: YES — `feat(chess): destroy-piece imperative primitive`
- [x] 23. move-piece primitive
**What to do**: kind: "move-piece", schema: `{ from: Square | { $var }, to: Square | { $var }, allowCapture: boolean (default false) }`. apply(): if `from` empty → no-op; if `to` occupied and !allowCapture → no-op; if `to` occupied and allowCapture → enqueue on-captured event via T15 deferred queue, then move; update Position via session.insert
**Must NOT do**: bypass check detection (use raw fact updates; check resolution happens at next move-gen)
**Recommended Agent Profile**: `unspecified-high`
**Parallelization**: Wave 5
**References**: same as T22
**Acceptance Criteria**: 6 tests pass (move + capture + empty-from + occupied-to + cascade)
**QA Scenarios**: `bun test move-piece.test.ts` → `.sisyphus/evidence/task-23-move-piece.txt`
**Commit**: YES — `feat(chess): move-piece imperative primitive`
- [x] 24. swap-pieces primitive
**What to do**: kind: "swap-pieces", schema: `{ a: Square | { $var }, b: Square | { $var } }`. apply(): get pieces at a + b; insert positions swapped; both Position attrs updated atomically
**Must NOT do**: swap with markers (skip if EntityKind !== piece on either side)
**Recommended Agent Profile**: `unspecified-high`
**Parallelization**: Wave 5
**References**: T22, T23
**Acceptance Criteria**: 4 tests pass (both occupied, one empty no-op, both empty no-op, marker on square excluded)
**QA Scenarios**: `bun test swap-pieces.test.ts` → `.sisyphus/evidence/task-24-swap-pieces.txt`
**Commit**: YES — `feat(chess): swap-pieces imperative primitive`
- [x] 25. convert-piece-type primitive
**What to do**: kind: "convert-piece-type", schema: `{ target: TargetResolver | { $var }, newType: PieceType | { $var } }`. apply(): for each resolved target, retract PieceType, insert newType. Preserves Color, Position, HasMoved, all custom attrs
**Must NOT do**: convert-to-king (special-case rejected; document)
**Recommended Agent Profile**: `unspecified-high`
**Parallelization**: Wave 5
**References**: T22
**Acceptance Criteria**: 5 tests pass (queen→bishop, pawn→knight, multiple targets, king target rejected, non-piece target skipped)
**QA Scenarios**: `bun test convert-piece-type.test.ts` → `.sisyphus/evidence/task-25-convert.txt`
**Commit**: YES — `feat(chess): convert-piece-type imperative primitive`
- [x] 26. set-piece-attr primitive (generic, with target binding)
**What to do**: kind: "set-piece-attr", schema: `{ target: TargetResolver | { $var }, attr: string, value: unknown | { $var } | { ctx-attr } }`. apply(): resolve target, attr, value; insert fact. Validates attr is in ChessAttrMap
**Must NOT do**: set on markers; allow attr name not in ChessAttrMap
**Recommended Agent Profile**: `unspecified-high`
**Parallelization**: Wave 5
**References**: T22, T25, schema.ts ChessAttrMap
**Acceptance Criteria**: 5 tests (set Hp, set Color, attr not in map → reject, target piece, target marker → skip)
**QA Scenarios**: `bun test set-piece-attr.test.ts` → `.sisyphus/evidence/task-26-set-piece-attr.txt`
**Commit**: YES — `feat(chess): set-piece-attr generic mutator`
- [x] 27. cancel-capture primitive
**What to do**: kind: "cancel-capture", schema: `{}` (no params; reads event from ctx). apply(): assert `ctx.event.kind === "capture"` → restore defender by reverting all retractions performed during capture. Implementation: integration preset records pre-capture defender facts in PRESET_STATE_ENTITY; cancel-capture reads + restores. If no capture event in ctx → throw.
**Must NOT do**: revert if event kind ≠ capture; allow at top level (validator rejects)
**Recommended Agent Profile**: `deep`
**Parallelization**: Wave 5 (alone in deep tier of this wave)
**References**: `packages/chess/src/modifiers/triggers.ts:476-510` — fireOnCapturedHooks (where event is constructed); event field on PrimitiveApplyContext from context.ts
**Acceptance Criteria**: 4 tests (cancel restores defender, throws when not in capture event, validator rejects at top, parry rule integration sanity-check)
**QA Scenarios**: `bun test cancel-capture.test.ts` → `.sisyphus/evidence/task-27-cancel-capture.txt`
**Commit**: YES — `feat(chess): cancel-capture interrupt primitive`
> **WAVE 6 PRIMITIVES**: Marker primitives + iteration. Same atomic-commit template per task.
- [x] 28. spawn-marker primitive
**What to do**: kind: "spawn-marker", schema: `{ markerKind: MarkerKind, square: Square | { $var } | { ctx-build }, lifetime: MarkerLifetime, owner?: Color | { $var } | { ctx-attr } }`. apply(): `engine.spawnMarker(markerKind, square, { lifetime, owner })`. Imperative-only.
**Must NOT do**: spawn on a square that already has same-kind marker (no-op; document); spawn outside trigger
**Recommended Agent Profile**: `unspecified-high`
**Parallelization**: Wave 6
**References**: T10 spawnMarker
**Acceptance Criteria**: 5 tests (spawn permanent / N-moves / one-shot, owner respected, dedup same-kind)
**QA Scenarios**: `bun test spawn-marker.test.ts` → `.sisyphus/evidence/task-28-spawn-marker.txt`
**Commit**: YES — `feat(chess): spawn-marker primitive`
- [x] 29. spawn-marker-pair primitive (portal pairs)
**What to do**: kind: "spawn-marker-pair", schema: `{ markerKind: "portal-end" (literal), squareA: Square | { $var }, squareB: Square | { $var }, lifetime: MarkerLifetime }`. apply(): spawn 2 markers, then update each's `MarkerLinks` to reference the other's id
**Must NOT do**: allow non-portal-end pair kinds in V1; allow same square twice
**Recommended Agent Profile**: `unspecified-high`
**Parallelization**: Wave 6
**References**: T28
**Acceptance Criteria**: 4 tests (pair created, links bidirectional, same-square rejected, non-portal-end rejected)
**QA Scenarios**: `bun test spawn-marker-pair.test.ts` → `.sisyphus/evidence/task-29-portal-pair.txt`
**Commit**: YES — `feat(chess): spawn-marker-pair primitive`
- [x] 30. destroy-marker primitive
**What to do**: kind: "destroy-marker", schema: `{ target: { id: EntityId | { $var } } | { kind: MarkerKind, square: Square | { $var } } }`. apply(): resolve to one or more marker ids → `engine.removeMarker(id)`. If marker has paired link, do NOT auto-destroy partner (orphan remains; document; partner will eventually expire via lifetime)
**Must NOT do**: cascade-destroy paired markers (V1 explicit decision; V2 may revisit)
**Recommended Agent Profile**: `unspecified-high`
**Parallelization**: Wave 6
**References**: T10
**Acceptance Criteria**: 5 tests (destroy by id, destroy by kind+square, partner preserved, non-marker target no-op, validator top-level rejected)
**QA Scenarios**: `bun test destroy-marker.test.ts` → `.sisyphus/evidence/task-30-destroy-marker.txt`
**Commit**: YES — `feat(chess): destroy-marker primitive`
- [x] 31. for-each-piece (filter + binding)
**What to do**: kind: "for-each-piece", schema: `{ filter: PieceFilter, bind: string, then: EffectPrimitiveNode[] }`. PieceFilter shape: `{ pieceType?: PieceType[], color?: Color, relation?: "ally"|"enemy", excludeKing?: boolean, square?: Square }`. apply(): iterate matching pieces (snapshot to avoid mutation-during-iteration); for each, recurse runPrimitives with `withBinding(ctx, bindParam, pieceId)`
**Must NOT do**: iterate live (snapshot first); fire reactive triggers during iteration (use deferred queue)
**Recommended Agent Profile**: `deep`
**Parallelization**: Wave 6
**References**: T11 bindings, T15 deferred queue
**Acceptance Criteria**: 6 tests (filter by type, by color, by relation, snapshot semantics, binding accessible in then, empty filter no-op)
**QA Scenarios**: `bun test for-each-piece.test.ts` → `.sisyphus/evidence/task-31-for-each-piece.txt`
**Commit**: YES — `feat(chess): for-each-piece iteration primitive`
- [x] 32. for-each-square (filter + binding)
**What to do**: kind: "for-each-square", schema: `{ filter: SquareFilter, bind: string, then: EffectPrimitiveNode[] }`. SquareFilter shape: `{ kind: "all" } | { kind: "files", files: number[] } | { kind: "ranks", ranks: number[] } | { kind: "color", color: "light"|"dark" } | { kind: "occupied", value: boolean }`. apply(): iterate matching squares (0..63); bind square number; recurse
**Must NOT do**: iterate beyond board (filter range)
**Recommended Agent Profile**: `deep`
**Parallelization**: Wave 6
**References**: T31
**Acceptance Criteria**: 7 tests (each filter kind + binding + empty match)
**QA Scenarios**: `bun test for-each-square.test.ts` → `.sisyphus/evidence/task-32-for-each-square.txt`
**Commit**: YES — `feat(chess): for-each-square iteration primitive`
- [x] 33. for-each-adjacent (target + filter + binding)
**What to do**: kind: "for-each-adjacent", schema: `{ target: TargetResolver | { $var }, filter: PieceFilter, bind: string, then: EffectPrimitiveNode[] }`. apply(): resolve target → for each, find 8 adjacent squares (king-move neighborhood) → check piece occupancy + filter → bind + recurse
**Must NOT do**: include diagonal-only or orthogonal-only (always 8-direction); include the target itself
**Recommended Agent Profile**: `deep`
**Parallelization**: Wave 6
**References**: T31, T32
**Acceptance Criteria**: 5 tests (filter applies, edge piece has 5 neighbors, corner piece has 3, ally/enemy filter, binding accessible)
**QA Scenarios**: `bun test for-each-adjacent.test.ts` → `.sisyphus/evidence/task-33-for-each-adjacent.txt`
**Commit**: YES — `feat(chess): for-each-adjacent iteration primitive`
- [x] 34. for-each-marker (filter + binding)
**What to do**: kind: "for-each-marker", schema: `{ filter: { markerKind?: MarkerKind, owner?: Color, square?: Square }, bind: string, then: EffectPrimitiveNode[] }`. apply(): walk all marker entities (EntityKind === "marker"); apply filter; bind id; recurse
**Must NOT do**: include piece entities
**Recommended Agent Profile**: `unspecified-high`
**Parallelization**: Wave 6
**References**: T10
**Acceptance Criteria**: 5 tests
**QA Scenarios**: `bun test for-each-marker.test.ts` → `.sisyphus/evidence/task-34-for-each-marker.txt`
**Commit**: YES — `feat(chess): for-each-marker iteration primitive`
- [x] 35. for-column / for-row primitives
**What to do**: Two primitives. for-column: schema `{ columns: number[] | { $var }, bind: string, then: ... }` iterates given columns. for-row: schema `{ rows: number[] | { $var }, bind: string, then: ... }` iterates given rows. Bound value: column index 0-7 or row index 0-7.
**Must NOT do**: iterate beyond 0-7 range; mix column and row in single primitive
**Recommended Agent Profile**: `unspecified-high`
**Parallelization**: Wave 6
**References**: T32
**Acceptance Criteria**: 4 tests (each + cross-product use case via nesting)
**QA Scenarios**: `bun test for-column.test.ts for-row.test.ts` → `.sisyphus/evidence/task-35-for-col-row.txt`
**Commit**: YES — `feat(chess): for-column + for-row primitives`
> **WAVE 7 PRIMITIVES**: RNG + restriction + movement-replacement.
- [x] 36. with-probability primitive
**What to do**: kind: "with-probability", schema: `{ p: z.number().min(0).max(1), then: NodeArray, else?: NodeArray }`. apply(): draw `engine.rng().next()` → if < p, run then arm; else run else arm (or no-op if absent). RNG draw happens BEFORE any nested primitive (locked V1 invariant — no draws after suspension)
**Must NOT do**: draw RNG inside nested primitive arms (only at top of with-probability); nest request-choice in then/else (validator rejects — V1 simplification: no draws-then-suspend interleaving)
**Recommended Agent Profile**: `unspecified-high`
**Parallelization**: Wave 7
**References**: T9 RNG; T0 ADR for "draws before suspension" invariant
**Acceptance Criteria**: 5 tests (p=0 always else, p=1 always then, mid-p distribution test 1000 trials, then runs nested, else absent no-op)
**QA Scenarios**: `bun test with-probability.test.ts` → `.sisyphus/evidence/task-36-with-probability.txt`
**Commit**: YES — `feat(chess): with-probability primitive`
- [x] 37. random-pick primitive (with binding)
**What to do**: kind: "random-pick", schema: `{ from: TargetResolver | { kind: "squares", filter: SquareFilter } | { kind: "markers", filter }, count: number (default 1), bind: string, then: NodeArray }`. apply(): resolve `from` to candidate set → use `engine.rng().pick()` count times (without replacement) → bind picks (single id or array depending on count) → recurse
**Must NOT do**: pick with replacement; pick from empty set (no-op rather than error per L0 ADR); allow count > candidate set size (clamp to size)
**Recommended Agent Profile**: `deep`
**Parallelization**: Wave 7
**References**: T9, T11
**Acceptance Criteria**: 6 tests (count=1 single id, count=N array, empty no-op, deterministic with seed, without replacement, binding accessible in then)
**QA Scenarios**: `bun test random-pick.test.ts` → `.sisyphus/evidence/task-37-random-pick.txt`
**Commit**: YES — `feat(chess): random-pick primitive`
- [x] 38. must-class primitive (capture/advance/move-to)
**What to do**: kind: "must-class", schema: `{ class: "capture-if-possible" | "advance-if-possible" | "move-to-square", color?: Color, square?: Square (for move-to) }`. apply(): seeds an entry into game-entity attr `MustClassConstraints: readonly { class, color?, square? }[]`. Move-gen reads this attr and filters legal moves accordingly: if any move matches the class, ONLY those moves are legal; else fall through.
**Must NOT do**: enforce in primitive; just seed (logic is in move-gen)
**Recommended Agent Profile**: `deep`
**Parallelization**: Wave 7
**References**: existing `BlockedMoveTypes` pattern in modify-movement-range (similar move-gen constraint)
**Acceptance Criteria**: 5 tests (each class type + interaction with move-gen)
**QA Scenarios**: `bun test must-class.test.ts` → `.sisyphus/evidence/task-38-must-class.txt`
**Commit**: YES — `feat(chess): must-class restriction primitive`
- [x] 39. block-by-piece-type primitive
**What to do**: kind: "block-by-piece-type", schema: `{ pieceTypes: PieceType[] }`. apply(): seeds game-attr `BlockedPieceTypes: readonly PieceType[]` (set union). Move-gen filters: pieces of these types have no legal moves
**Must NOT do**: block king (game becomes unwinnable; validator rejects at descriptor time)
**Recommended Agent Profile**: `unspecified-high`
**Parallelization**: Wave 7
**References**: T38
**Acceptance Criteria**: 4 tests
**QA Scenarios**: `bun test block-by-piece-type.test.ts` → `.sisyphus/evidence/task-39-block-piece-type.txt`
**Commit**: YES — `feat(chess): block-by-piece-type primitive`
- [x] 40. set-moves-as / set-moves-also-as primitives
**What to do**: Two primitives. set-moves-as: schema `{ target: TargetResolver | { $var }, asType: PieceType }` — replaces movement pattern. set-moves-also-as: same schema — adds secondary pattern (additive). apply(): seeds `MovesAs` / `MovesAlsoAs` attr on target. Move-gen consults these BEFORE PieceType for movement generation.
**Must NOT do**: seed on king with conflicting MovesAs (would prevent castling; validator warns); seed `MovesAs: "king"` on pawn (special-case rejected — pawn promotion semantics break; document)
**Recommended Agent Profile**: `deep`
**Parallelization**: Wave 7
**References**: existing PIECE_TYPE_REGISTRY in `packages/chess/src/`
**Acceptance Criteria**: 6 tests (replace, additive, invalid targets rejected, move-gen integration)
**QA Scenarios**: `bun test set-moves-as.test.ts` → `.sisyphus/evidence/task-40-set-moves-as.txt`
**Commit**: YES — `feat(chess): set-moves-as + set-moves-also-as primitives`
- [x] 41. pawn-pushes-pieces primitive
**What to do**: kind: "pawn-pushes-pieces", schema: `{}`. apply(): seeds game-attr `PawnPushesPieces: true`. Move-gen: when pawn moves into occupied square, generate "push" move where occupant moves forward 1; chain reaction (each pushed piece pushes next); piece pushed off-board is destroyed via deferred event
**Must NOT do**: push king (ends game; rejected at gen time); chain length > 7 (board height)
**Recommended Agent Profile**: `unspecified-high`
**Parallelization**: Wave 7
**References**: ThressGame's pawns_learned_strength (lines 1707-1739 of ruleHooks.js)
**Acceptance Criteria**: 5 tests (single push, chain, off-board destroy, king cannot be pushed, regression: existing pawn moves unchanged when flag absent)
**QA Scenarios**: `bun test pawn-pushes-pieces.test.ts` → `.sisyphus/evidence/task-41-pawn-push.txt`
**Commit**: YES — `feat(chess): pawn-pushes-pieces primitive`
- [x] 42. lifetime field on imperative primitives
**What to do**: Update schemas of seed-attribute, set-piece-attr, spawn-marker, spawn-marker-pair to ALL accept optional top-level `lifetime: MarkerLifetime` field. Engine: add `LifetimeRegistry` that tracks "fact X on entity Y expires at move N"; in onAfterMove, scan registry → retract expired facts. Lifetime applies uniformly to all imperative primitives that seed facts.
**Must NOT do**: support lifetime on read-only primitives (with-probability, conditional, etc.); add lifetime to existing modifier-bonus attrs (RangeBonus etc.)
**Recommended Agent Profile**: `deep`
**Parallelization**: Wave 7 (after T28-T30 land lifetime field in spawn-marker; this generalizes the pattern)
**References**: T28, T17 (on-rule-expire pattern)
**Acceptance Criteria**: 5 tests (lifetime on each kind expires correctly, permanent never expires, immediate-expire = next-turn)
**QA Scenarios**: `bun test lifetime-registry.test.ts` → `.sisyphus/evidence/task-42-lifetime.txt`
**Commit**: YES — `feat(chess): unified lifetime field on imperative primitives`
> **WAVE 8 — WS PROTOCOL v2 + SUSPENDED EXECUTION**: highest-risk wave. Each task is its own commit; integration tests at the end.
- [x] 43. WS protocol v2 schema
**What to do**:
- Edit `packages/server/src/protocol.ts`: add new message types `RequestChoiceMessage` (server→client: `{ kind: "request-choice", choiceId: string, prompt: { kind: "piece"|"square"|"column"|"row"|"coin-flip"|"rps", filter?, forPlayer: Color, timeout?: number } }`) and `SubmitChoiceMessage` (client→server: `{ kind: "submit-choice", choiceId: string, value: unknown }`)
- Add `protocolVersion: number` field to existing JoinRoom message; default 1; new client sends 2
- Server stores client's protocolVersion; if version mismatch and request-choice would fire → server falls back to "auto-resolve with first option" path AND emits a warning
- Co-located test: contract round-trip; version mismatch graceful
**Must NOT do**: break v1 message shapes (additive only); introduce required new fields on existing messages (use optional)
**Recommended Agent Profile**: `deep`
**Parallelization**: Wave 8
**References**: `packages/server/src/protocol.ts:540-548` (existing wire schema patterns)
**Acceptance Criteria**: `bun test packages/server/src/protocol.test.ts -t "v2|version"` passes (4+ tests); v1 tests unchanged
**QA Scenarios**: `.sisyphus/evidence/task-43-protocol-v2.txt`
**Commit**: YES — `feat(server): WS protocol v2 schema (request-choice + version negotiation)`
- [x] 44. Server-side request-choice broadcast + validation
**What to do**:
- Edit `packages/server/src/ws.ts` (or equivalent ws handler): when game state has a pendingChoices entry, server sends `RequestChoiceMessage` to the targeted player on connect/reconnect
- When `SubmitChoiceMessage` arrives: validate choiceId matches top of stack on game's GAME_ENTITY pendingChoices, validate sender is targeted player, validate value matches choice kind/filter, then dispatch `submit-choice` PlayerAction to engine
- If invalid (mismatched id, wrong player, value violates filter): respond with versioned ILLEGAL_ACTION error code `choice.invalid`
**Must NOT do**: broadcast choice to non-targeted players (privacy); accept submission for non-top-of-stack choice (must be LIFO)
**Recommended Agent Profile**: `deep`
**Parallelization**: Wave 8
**References**: `packages/server/src/ws.ts`; T43
**Acceptance Criteria**: `bun test packages/server/src/ws.request-choice.test.ts` passes (5+ tests)
**QA Scenarios**: `.sisyphus/evidence/task-44-server-choice.txt`
**Commit**: YES — `feat(server): request-choice broadcast + validation`
- [x] 45. Stack-based pendingChoices state on GAME_ENTITY + serializer
**What to do**:
- Add attr `PendingChoices: readonly PendingChoice[]` to ChessAttrMap. PendingChoice = `{ choiceId: string, descriptorId: string, triggerPath: readonly number[], primitiveIndex: number, bindings: Record<string, JsonValue>, kind, prompt, forPlayer, timeout?: number, expiresAtTimestamp?: number }`
- Helpers in engine: `engine.pushPendingChoice(choice)`, `engine.popPendingChoice(): PendingChoice | null`, `engine.peekPendingChoice(): PendingChoice | null`
- Serializer: assert all fields are POJO-serializable (no closures, no functions); test by JSON round-trip
**Must NOT do**: store function references in PendingChoice; allow >8 deep stack (cascade depth limit)
**Recommended Agent Profile**: `deep`
**Parallelization**: Wave 8
**References**: T15 cascade depth, T6 attr registration
**Acceptance Criteria**: 5 tests pass; JSON round-trip preserves byte-identical
**QA Scenarios**: `.sisyphus/evidence/task-45-pending-choices.txt`
**Commit**: YES — `feat(chess): pendingChoices stack on GAME_ENTITY`
- [x] 46. Suspended-execution resume in integration preset
**What to do**:
- Edit integration preset's `performAction` hook: when action is `submit-choice`, pop top PendingChoice, restore bindings into a fresh PrimitiveApplyContext, resume runPrimitives at saved `triggerPath` + `primitiveIndex + 1` (skip past the request-choice that caused suspension), inject the submitted value as binding (key matches request-choice's `bind` param)
- Triggers must be re-entrant: runPrimitives accepts an optional `resumeAt: { primitiveIndex: number; bindings: ... }` that fast-forwards to that point
**Must NOT do**: re-fire the request-choice itself; advance turn before resume completes; lose deferred trigger queue across suspension (preserve in PendingChoice)
**Recommended Agent Profile**: `deep`
**Parallelization**: Wave 8
**References**: `packages/chess/src/presets/transferable-royalty.ts:143-161` (performAction pattern); T11, T15
**Acceptance Criteria**: 5 tests (basic resume, nested resume, binding injection, deferred-queue preserved across suspension, error if no pending)
**QA Scenarios**: `.sisyphus/evidence/task-46-resume.txt`
**Commit**: YES — `feat(chess): suspended execution resume`
- [x] 47. request-choice primitive
**What to do**:
- Create `packages/chess/src/modifiers/primitives/request-choice.ts`: kind "request-choice", schema `{ kind: "piece"|"square"|"column"|"row"|"coin-flip"|"rps", forPlayer: "chooser"|"opponent"|"both", filter?, bind: string, then: NodeArray }`
- apply(): generate choiceId (deterministic from descriptorId + step), capture current binding context, push PendingChoice on GAME_ENTITY, then RETURN (do not execute then; resume happens later)
- Special case `forPlayer: "both"`: TWO PendingChoice entries pushed; both must submit; resume only when both received
- Special case `kind: "rps"`: each player submits rock/paper/scissors privately; resolution via `resolveRPS` helper; binding receives both choices for then arm to evaluate
**Must NOT do**: execute then arm immediately (suspend instead); allow submitting other-player's choice
**Recommended Agent Profile**: `deep`
**Parallelization**: Wave 8 (after T43, T45, T46)
**References**: T46, T11; T0 paper exercise rule 1 (parry — RPS use case)
**Acceptance Criteria**: 8 tests (each kind + both-players + binding + cancellation + RPS resolution + nested suspension + max-depth)
**QA Scenarios**: `.sisyphus/evidence/task-47-request-choice.txt`
**Commit**: YES — `feat(chess): request-choice primitive`
- [x] 48. Deterministic auto-resolver test transport
**What to do**:
- Create `packages/chess/src/__fixtures__/test-choice-resolver.ts`: a test-only WS transport mock that auto-resolves PendingChoices according to a deterministic policy:
- `kind: "piece"|"square"|"column"|"row"`: pick first valid option
- `kind: "coin-flip"`: alternate heads/tails per call (cycle starts at heads)
- `kind: "rps"`: alternate rock/paper/scissors per call
- Co-located test: 3 invocations of each kind produce expected sequence
- Used by: T59-T66 parity tests + T68 e2e tests
**Must NOT do**: use Math.random; allow non-deterministic order; ship in production bundle
**Recommended Agent Profile**: `unspecified-high`
**Parallelization**: Wave 8
**References**: T47
**Acceptance Criteria**: 4 tests; resolver is pure deterministic
**QA Scenarios**: `.sisyphus/evidence/task-48-test-resolver.txt`
**Commit**: YES — `test(chess): deterministic auto-resolver test transport`
- [x] 49. Choice timeout + disconnect handler
**What to do**:
- Edit ws.ts: when PendingChoice has `timeout` field, server schedules a timer; on expiry, server auto-submits the "first valid option" as the choice and resumes
- When player disconnects: check if game has pendingChoices for that player. If timeout mode = `timeout-with-default` → forfeit the game (set GameStatus to opponent-wins). If mode = `no-timeout` → set game state to "paused" (no auto-action; resume on reconnect)
- Co-located test: timer fires after timeout; default-option resolution; forfeit; pause+reconnect
**Must NOT do**: cancel pending choice on disconnect mid-choice without policy decision; forfeit in no-timeout mode
**Recommended Agent Profile**: `deep`
**Parallelization**: Wave 8
**References**: T44, T50
**Acceptance Criteria**: 6 tests
**QA Scenarios**: `.sisyphus/evidence/task-49-timeout-disconnect.txt`
**Commit**: YES — `feat(server): choice timeout + disconnect handler`
- [x] 50. Game settings: choiceTimeout in CreateGameRequest
**What to do**:
- Edit `packages/server/src/protocol.ts` CreateGameRequest schema: add `choiceTimeout: { mode: "timeout-with-default", seconds: number } | { mode: "no-timeout" }` field; default = `{ mode: "timeout-with-default", seconds: 60 }`
- Edit client (`packages/chess/src/ui/...` create-game form): add radio + numeric input
- Server stores in game state; T49 reads from this
- Co-located test: schema validation; defaults; round-trip via WS
**Must NOT do**: hardcode timeout in server; allow negative seconds; allow seconds < 5 (UX guard)
**Recommended Agent Profile**: `unspecified-high`
**Parallelization**: Wave 8
**References**: T49
**Acceptance Criteria**: 5 tests
**QA Scenarios**: `.sisyphus/evidence/task-50-game-settings.txt`
**Commit**: YES — `feat: choiceTimeout per-game setting`
> **WAVE 9 — UI / EDITOR**: parallel with Waves 5-8. Each task atomic.
- [x] 51. Palette taxonomy update (5 categories)
**What to do**:
- Edit `packages/chess/src/ui/visual-builder/VisualBuilderPane.tsx` and `BlockCard.tsx`: replace existing 3 categories (State, Mechanic, Trigger) with 5: State (existing 5 kinds), Mechanic (existing 5 kinds), Trigger (existing 9 + 4 new = 13 kinds), Imperative (place-piece, destroy-piece, move-piece, swap-pieces, convert-piece-type, set-piece-attr, cancel-capture, spawn-marker, spawn-marker-pair, destroy-marker), Iteration (for-each-piece, for-each-square, for-each-adjacent, for-each-marker, for-column, for-row), Restriction (must-class, block-by-piece-type, set-moves-as, set-moves-also-as, pawn-pushes-pieces) — total 6 categories
- Each category gets distinct color: State=blue (existing), Mechanic=emerald (existing), Trigger=violet (existing), Imperative=amber, Iteration=cyan, Restriction=rose
- Update CATEGORIES const to single source of truth (currently duplicated in BlockCard + VisualBuilderPane — DRY)
**Must NOT do**: add primitives not in the locked list of 28; expand to 7+ categories
**Recommended Agent Profile**: `visual-engineering`. Skills: [`frontend-ui-ux`].
**Parallelization**: Wave 9
**References**: existing CATEGORIES in `packages/chess/src/ui/visual-builder/BlockCard.tsx:41-78` and VisualBuilderPane.tsx:47-83 (notepad mentions duplication: learnings.md~)
**Acceptance Criteria**: SSR snapshot tests pass; new palette buttons present for all 28 new primitives
**QA Scenarios**:
```
Scenario: All 28 new primitive palette buttons render
Tool: Bash
Steps: bun test packages/chess/src/ui/visual-builder/VisualBuilderPane.test.tsx -t "palette"
Expected: pass; assert all 50 testids present
Evidence: .sisyphus/evidence/task-51-palette.txt
```
**Commit**: YES — `feat(ui): 6-category palette taxonomy with new primitives`
- [x] 52. narrate.ts entries for new primitives + triggers
**What to do**:
- Edit `packages/chess/src/ui/narrate.ts`: extend KIND_NARRATORS map with entries for all 28 new primitives + 4 new triggers (32 total entries)
- Each narrator returns a one-sentence English description of what the primitive does, properly templated with params
- Edit `narrate.test.ts`: add per-primitive golden assertion (32 new tests, exact strings)
- Update `ParamField.snapshot.test.tsx` SAMPLE_PARAMS map to include all 28 + 4 new kinds
**Must NOT do**: re-narrate existing 22 primitives ("while we're in there"); use template variables that don't resolve from params
**Recommended Agent Profile**: `writing`. Skills: [].
**Parallelization**: Wave 9
**References**: `packages/chess/src/ui/narrate.ts:KIND_NARRATORS`; notepad `learnings.md:200-238` (T15 narrate pattern)
**Acceptance Criteria**: narrate.test.ts: 34 → 66 tests pass (32 new); ParamField.snapshot tests still 50 pass
**QA Scenarios**: `.sisyphus/evidence/task-52-narrate.txt`
**Commit**: YES — `feat(ui): narrate entries for new primitives + triggers`
- [x] 53. ParamField renderer: square picker
**What to do**:
- Add to `packages/chess/src/ui/ParamField.tsx`: when a param's Zod schema is `z.number().int().min(0).max(63)` AND param key is `square|target.square|squareA|squareB`, render a small 8×8 grid of clickable squares (current value highlighted); click sets the value
- Co-located snapshot test: rendering deterministic
- Visual: 200px × 200px grid, light/dark squares alternating
**Must NOT do**: render for non-square numeric params (need explicit kind detection); animate
**Recommended Agent Profile**: `visual-engineering`. Skills: [`frontend-ui-ux`].
**Parallelization**: Wave 9
**References**: existing AttrCombobox in ParamField.tsx (similar custom renderer pattern)
**Acceptance Criteria**: Snapshot test deterministic; param with square key renders grid (testid="param-square-picker")
**QA Scenarios**: `.sisyphus/evidence/task-53-square-picker.txt`
**Commit**: YES — `feat(ui): ParamField square picker renderer`
- [x] 54. ParamField renderer: piece picker
**What to do**:
- Add to ParamField.tsx: when param key is `pieceType|target.pieceType|asType|newType` and Zod is `z.enum([...PieceTypes])`, render a row of 6 piece icons (king, queen, rook, bishop, knight, pawn); click selects
- Use unicode chess symbols or the existing piece SVGs
**Must NOT do**: render for non-piece-type enums; require image assets (use unicode if no asset exists)
**Recommended Agent Profile**: `visual-engineering`. Skills: [`frontend-ui-ux`].
**Parallelization**: Wave 9
**References**: T53
**Acceptance Criteria**: snapshot test; testid="param-piece-picker" present for matching keys
**QA Scenarios**: `.sisyphus/evidence/task-54-piece-picker.txt`
**Commit**: YES — `feat(ui): ParamField piece picker renderer`
- [x] 55. ParamField renderer: marker-kind enum
**What to do**:
- Add to ParamField.tsx: when param key is `markerKind` and Zod is the marker-kind enum, render dropdown OR icon grid (8 marker kinds with mini-icons)
- Each marker kind gets a small inline icon: mine=💣, pit=⬛, portal-end=🌀, frozen-square=❄, treasure=🏆, death-square=☠, tornado=🌪, blocked=🧱 (use these emoji fallbacks; replace with SVG later if user adds assets)
**Must NOT do**: require new icon assets; render as plain text
**Recommended Agent Profile**: `visual-engineering`. Skills: [`frontend-ui-ux`].
**Parallelization**: Wave 9
**References**: T53, T54
**Acceptance Criteria**: snapshot test; all 8 kinds renderable
**QA Scenarios**: `.sisyphus/evidence/task-55-marker-kind-picker.txt`
**Commit**: YES — `feat(ui): ParamField marker-kind enum renderer`
- [x] 56. ParamField renderer: lifetime config
**What to do**:
- Add to ParamField.tsx: when param shape matches `MarkerLifetime` discriminated union, render a kind-selector (radio: permanent/moves/one-shot) + conditional numeric input for `moves` count (visible only when kind=moves)
- Form validates: count ≥ 1 when kind=moves
**Must NOT do**: allow count when kind ≠ moves (hide the field; clear value)
**Recommended Agent Profile**: `visual-engineering`. Skills: [`frontend-ui-ux`].
**Parallelization**: Wave 9
**References**: T53; existing discriminated-union pattern in `packages/chess/src/modifiers/primitives/on-moved-onto-square.ts` SquareFilter
**Acceptance Criteria**: snapshot test; all 3 lifetime kinds renderable; count input visible only when relevant
**QA Scenarios**: `.sisyphus/evidence/task-56-lifetime-renderer.txt`
**Commit**: YES — `feat(ui): ParamField lifetime config renderer`
- [x] 57. Client marker rendering on chessboard
**What to do**:
- Edit chessboard component (`packages/chess/src/ui/board/...` — find via glob): add a marker overlay layer that renders on top of squares
- For each marker entity, render a small icon at its `Position` square (using emoji from T55 + tooltip showing marker kind + lifetime + owner)
- Tooltips appear on hover (CSS); accessible via aria-label
- Markers render BELOW pieces (z-order); piece on marker still visible
**Must NOT do**: hide/replace pieces; render markers larger than 50% of square; animate
**Recommended Agent Profile**: `visual-engineering`. Skills: [`frontend-ui-ux`].
**Parallelization**: Wave 9
**References**: existing chessboard component
**Acceptance Criteria**: SSR snapshot of board with 3 markers shows all 3 + tooltips; pieces still visible
**QA Scenarios**: `.sisyphus/evidence/task-57-board-markers.txt`
**Commit**: YES — `feat(ui): client marker rendering on chessboard`
- [x] 58. Client request-choice modal
**What to do**:
- New component `packages/chess/src/ui/RequestChoiceModal.tsx`: receives a PendingChoice via props; renders kind-specific UI:
- kind=piece: clickable piece list (filtered by prompt.filter)
- kind=square: 8×8 grid (reuse T53 component)
- kind=column/row: 8 buttons
- kind=coin-flip: 2 buttons (heads/tails) + visualized flip animation OR just buttons with prior-result visible
- kind=rps: 3 buttons (rock/paper/scissors)
- On selection, dispatches `submit-choice` WS message + closes modal
- Disabled while WS submission in flight; error state if server rejects
- Listens to game state for `pendingChoices` change; auto-opens when player has a pending choice
**Must NOT do**: allow non-targeted player to interact (modal hidden if forPlayer ≠ self); animate transitions beyond simple fade
**Recommended Agent Profile**: `visual-engineering`. Skills: [`frontend-ui-ux`].
**Parallelization**: Wave 9
**References**: T44, T45, T47; existing modal patterns in chess UI
**Acceptance Criteria**: SSR snapshot for each kind variant; aria-modal=true; ESC close handled; submit-choice WS message sent on selection (test with mock WS)
**QA Scenarios**: `.sisyphus/evidence/task-58-choice-modal.txt`
**Commit**: YES — `feat(ui): request-choice modal`
> **WAVE 10 — TEST DESCRIPTORS + E2E**: Each parity test runs the descriptor in our engine, drives the same scenario in a ThressGame-equivalent reference (or hand-crafted oracle), asserts state-hash equality. Each task is one descriptor + one parity test, atomic commit.
- [x] 59. minefield descriptor + parity test
**What to do**:
- Create `packages/chess/src/__fixtures__/thressgame-parity/minefield.descriptor.json`: descriptor using on-rule-activated → random-pick(empty squares, count: 2) → spawn-marker(mine, lifetime: one-shot)
- Create parity test `packages/chess/e2e/thressgame-parity-minefield.spec.ts`: load descriptor, simulate 5-10 moves where pieces step on mines, assert post-state matches a checked-in reference state hash
**Must NOT do**: use Math.random in descriptor (must use random-pick + seeded RNG); skip mid-game state checks
**Recommended Agent Profile**: `unspecified-high`
**Parallelization**: Wave 10 (after T28, T37, T18)
**References**: ThressGame ruleHooks.js minefield (lines 408-444 of source); T28, T37
**Acceptance Criteria**: `bun x playwright test e2e/thressgame-parity-minefield.spec.ts` passes (3+ scenarios); descriptor validates clean
**QA Scenarios**:
```
Scenario: Mine destroys piece, marker consumed
Tool: Playwright
Steps:
1. Load minefield descriptor
2. Apply moves stepping on mine
3. Assert piece destroyed + marker removed via state hash
Evidence: .sisyphus/evidence/task-59-minefield-parity.png
```
**Commit**: YES — `test(parity): minefield ThressGame rule`
- [x] 60. mr_freeze descriptor + parity test (uses request-choice)
**What to do**:
- Descriptor: on-rule-activated → request-choice(kind: column, forPlayer: chooser, bind: $col, then: for-row(rows: [0..7], bind: $row, then: spawn-marker(frozen-square, square: ctx-build($col, $row), lifetime: { kind: moves, count: 9 }, owner: chooser-color)))
- Parity test: descriptor activates, T48 auto-resolver picks first valid column, 8 frozen-square markers spawn, opponent moves blocked from frozen column for 9 moves, markers expire
**Must NOT do**: hardcode chooser color; manually skip request-choice (use auto-resolver)
**Recommended Agent Profile**: `unspecified-high`
**Parallelization**: Wave 10 (after T47, T48, T49)
**References**: ThressGame mr_freeze (~lines 1042-1073 of source); T0 paper exercise rule 5
**Acceptance Criteria**: e2e passes; column blocked correctly; markers all expire at move 9
**QA Scenarios**: `.sisyphus/evidence/task-60-mr-freeze-parity.png`
**Commit**: YES — `test(parity): mr_freeze ThressGame rule`
- [x] 61. parry descriptor + parity test (request-choice + cancel-capture + RPS)
**What to do**:
- Descriptor: on-captured(target: self) → request-choice(kind: rps, forPlayer: both, bind: $rps, then: conditional(condition: rps-eval($rps, expected: defender), then: cancel-capture, else: noop))
- Parity test: trigger 5 captures, verify RPS resolves correctly per T48 alternating policy, verify defender survives when policy says defender wins
- Requires new conditional kind `rps-eval` (added by this task or T0 ADR captured separately)
**Must NOT do**: use Math.random for RPS resolution (use seeded RNG); allow non-defender outcome to result in cancellation
**Recommended Agent Profile**: `unspecified-high`
**Parallelization**: Wave 10 (after T27, T47, T48)
**References**: ThressGame parry (~lines 1592-1594 + RPS in `packages/server/src/utils/rps`); T0 paper exercise rule 1
**Acceptance Criteria**: e2e passes; defender survival rate matches expected (with seeded resolver)
**QA Scenarios**: `.sisyphus/evidence/task-61-parry-parity.png`
**Commit**: YES — `test(parity): parry ThressGame rule`
- [x] 62. all_on_red descriptor + parity test (with-probability)
**What to do**:
- Descriptor: on-turn-start(color: both) → with-probability(p: 0.5, then: seed-attribute(BlockAllExceptKing, true, lifetime: { kind: moves, count: 1 }))
- Parity test: 20 turns, count how often only king moves are legal; verify ~50% with seeded RNG (deterministic — exact count expected)
**Must NOT do**: assert exact 10/20 (allow ±2 for seed variance); use Math.random
**Recommended Agent Profile**: `unspecified-high`
**Parallelization**: Wave 10 (after T36)
**References**: ThressGame all_on_red (~lines 996-1017); T0 paper exercise rule 2
**Acceptance Criteria**: e2e passes; deterministic distribution matches expected counts
**QA Scenarios**: `.sisyphus/evidence/task-62-all-on-red-parity.png`
**Commit**: YES — `test(parity): all_on_red ThressGame rule`
- [x] 63. religious_conversion descriptor + parity test
**What to do**:
- Descriptor: on-move(target: self) → conditional(condition: attr-eq(self, PieceType, bishop), then: for-each-adjacent(target: self, filter: { pieceType: pawn, relation: enemy }, bind: $pawn, then: set-piece-attr(target: $pawn, attr: Color, value: { ctx-attr: { entity: self, attr: Color } })))
- Parity test: bishop moves; adjacent enemy pawns convert; non-bishop pieces don't trigger
**Must NOT do**: convert non-pawn pieces; convert ally pawns
**Recommended Agent Profile**: `unspecified-high`
**Parallelization**: Wave 10 (after T26, T33)
**References**: ThressGame religious_conversion (~lines 1185-1204); T0 paper exercise rule 3
**Acceptance Criteria**: e2e passes; only adjacent enemy pawns flip
**QA Scenarios**: `.sisyphus/evidence/task-63-religious-conv.png`
**Commit**: YES — `test(parity): religious_conversion ThressGame rule`
- [x] 64. ice_physics descriptor + parity test
**What to do**:
- Descriptor: on-rule-activated → for-each-piece(filter: { pieceType: [bishop, rook, queen] }, bind: $p, then: set-piece-attr(target: $p, attr: SlideMustBeMaxDistance, value: true, lifetime: permanent))
- Parity test: bishop's legal moves all max-distance only; queen's all max-distance only; non-sliders unaffected; pawns unaffected
- Verifies T40's move-gen integration with SlideMustBeMaxDistance flag
**Must NOT do**: affect non-sliding pieces; remove the constraint after activation
**Recommended Agent Profile**: `unspecified-high`
**Parallelization**: Wave 10 (after T8, T26, T31)
**References**: ThressGame ice_physics (~lines 1469-1497); T0 paper exercise rule 4
**Acceptance Criteria**: e2e passes; legal-moves count matches expected (sliding pieces have ~80% fewer legal moves)
**QA Scenarios**: `.sisyphus/evidence/task-64-ice-physics-parity.png`
**Commit**: YES — `test(parity): ice_physics ThressGame rule`
- [x] 65. kamikaze descriptor + parity test (with-probability + for-each-adjacent)
**What to do**:
- Descriptor: on-capture(target: self) → with-probability(p: 0.25, then: for-each-adjacent(target: self, filter: { excludeKing: true }, bind: $adj, then: destroy-piece(target: $adj)))
- Parity test: 100 captures with seeded RNG; ~25% of captures trigger AOE; AOE never kills kings
**Must NOT do**: kill kings via AOE; trigger on non-capture moves
**Recommended Agent Profile**: `unspecified-high`
**Parallelization**: Wave 10 (after T22, T33, T36)
**References**: ThressGame kamikaze (~lines 1129-1148)
**Acceptance Criteria**: e2e passes; AOE rate ~25% deterministic with seed; king never destroyed
**QA Scenarios**: `.sisyphus/evidence/task-65-kamikaze-parity.png`
**Commit**: YES — `test(parity): kamikaze ThressGame rule`
- [x] 66. mind_control descriptor + parity test (request-choice on both players)
**What to do**:
- Descriptor: on-rule-activated → request-choice(kind: piece, forPlayer: both, filter: { relation: enemy, excludeKing: true }, bind: $targets, then: for-each-piece(filter: $targets, bind: $piece, then: set-piece-attr(target: $piece, attr: Color, value: { ctx-attr: { entity: chooser, attr: Color } })))
- Parity test: both players pick targets via T48 auto-resolver, both targets convert to chooser's color; kings excluded
**Must NOT do**: allow king conversion; allow only-one-player resolution before both submit
**Recommended Agent Profile**: `unspecified-high`
**Parallelization**: Wave 10 (after T26, T31, T47, T48)
**References**: ThressGame mind_control (~lines 706-740); requires both-players choice (most complex test)
**Acceptance Criteria**: e2e passes; both pieces converted; LIFO stack resolution observed (player A's choice first, then player B's)
**QA Scenarios**: `.sisyphus/evidence/task-66-mind-control-parity.png`
**Commit**: YES — `test(parity): mind_control ThressGame rule`
- [x] 67. 6 template descriptors shipped in modifier library
**What to do**:
- Add 6 templates to `packages/chess/src/modifiers/library.ts` (or wherever templates are stored): simple-mine (1 mine spawns at center), vampire-on-capture (Hp+1 on capture; existing primitive used), frozen-column (player picks column, spawns 8 frozen-square markers), coin-flip-restriction (50% chance: only kings move next turn), religious-bishop (T63 packaged), no-mans-land (player picks column, blocked permanent)
- Each template has name, description, narrative, descriptor JSON
- Co-located test: each template loads + validates clean
**Must NOT do**: include templates not in the locked list of 6; allow validation errors on any
**Recommended Agent Profile**: `writing`. Skills: [].
**Parallelization**: Wave 10 (after T59-T66 in case any descriptor pattern changes)
**References**: existing template structure in `packages/chess/src/modifiers/library.ts`
**Acceptance Criteria**: 6 templates load + validate; appear in modifier picker UI (manual via SSR snapshot of picker)
**QA Scenarios**: `.sisyphus/evidence/task-67-templates.txt`
**Commit**: YES — `feat(chess): 6 ThressGame template descriptors`
- [x] 68. Playwright: request-choice round-trip e2e (3 flows)
**What to do**:
- New file `packages/chess/e2e/request-choice.spec.ts` with 3 distinct e2e tests:
1. Single player choice (mr_freeze): activate descriptor → modal appears → click column → game proceeds with frozen markers
2. Both-player choice (mind_control): activate → both modals appear (one per player tab) → each clicks → game proceeds
3. Nested choice (RPS over capture, parry rule): trigger capture → both RPS modals → choices resolve → conditional cancels capture if defender wins
- Each test uses real WS connection (not mocked); 2 browser contexts (one per player)
**Must NOT do**: skip the actual choice modal (must click in real UI); use Math.random
**Recommended Agent Profile**: `unspecified-high`. Skills: [`playwright`].
**Parallelization**: Wave 10 (after T58, T48, T49)
**References**: existing e2e patterns in `packages/chess/e2e/`
**Acceptance Criteria**: All 3 e2e tests pass; screenshots captured; 0 flake across 5 reruns
**QA Scenarios**:
```
Scenario: Both-player choice round-trip
Tool: Playwright
Steps:
1. Open 2 browser contexts as player 1 + 2
2. Player 1 activates mind_control descriptor
3. Both contexts see request-choice modal
4. Each clicks a piece
5. Assert both pieces converted
Evidence: .sisyphus/evidence/task-68-mind-control-e2e.png
```
**Commit**: YES — `test(e2e): request-choice round-trip flows`
- [x] 69. Performance budget test (100 markers, p99 < 50ms)
**What to do**:
- New test `packages/chess/src/__fixtures__/perf/markers-perf.test.ts`: spawn 100 markers across the board, run 1000 moves with mixed marker triggers, measure per-move latency, assert p99 < 50ms
- Document the budget in `.sisyphus/notepads/thressgame-coverage/perf-budget.md`
- Run via `bun test --bail`
**Must NOT do**: use --headless or platform-specific timing assertions
**Recommended Agent Profile**: `unspecified-high`
**Parallelization**: Wave 10 (after all primitives + triggers land)
**References**: existing perf patterns from narrate.ts (notepad learnings.md:218-220)
**Acceptance Criteria**: test passes; p99 < 50ms documented in perf-budget.md
**QA Scenarios**: `.sisyphus/evidence/task-69-perf.txt`
**Commit**: YES — `test(perf): 100-marker move latency benchmark`
---
## Final Verification Wave (MANDATORY — after ALL implementation tasks)
> 4 review agents run in PARALLEL. ALL must APPROVE. Present consolidated results to user and get explicit "okay" before completing.
>
> **Do NOT auto-proceed after verification. Wait for user's explicit approval before marking work complete.**
- [ ] F1. **Plan Compliance Audit** — `oracle`
Read the plan end-to-end. For each "Must Have": verify implementation exists (read file, run command). For each "Must NOT Have": grep codebase for forbidden patterns — reject with file:line if found. Check evidence files exist in `.sisyphus/evidence/`. Compare deliverables against plan.
Output: `Must Have [N/N] | Must NOT Have [N/N] | Tasks [N/N] | VERDICT: APPROVE/REJECT`
- [ ] F2. **Code Quality Review** — `unspecified-high`
Run `bun run check` (lint + typecheck + tests). Review all changed files for: `as any`/`@ts-ignore`, empty catches, console.log in prod, commented-out code, unused imports, AI slop (excessive comments, over-abstraction, generic names like data/result/item/temp).
Output: `Build [PASS/FAIL] | Lint [PASS/FAIL] | Tests [N pass/N fail] | Files [N clean/N issues] | VERDICT`
- [ ] F3. **Real Manual QA** — `unspecified-high` (+ `playwright` skill)
Start from clean state. Execute EVERY QA scenario from EVERY task — follow exact steps, capture evidence. Test cross-task integration: 8 parity rules + 3 request-choice e2e flows. Test edge cases: empty state, invalid input, rapid actions, marker collisions, cascade limits. Save to `.sisyphus/evidence/final-qa/`.
Output: `Scenarios [N/N pass] | Integration [N/N] | Edge Cases [N tested] | VERDICT`
- [ ] F4. **Scope Fidelity Check** — `deep`
For each task: read "What to do", read actual diff (git log/diff). Verify 1:1 — everything in spec was built, nothing beyond spec was built. Check "Must NOT do" compliance. Detect cross-task contamination. Flag unaccounted changes.
Output: `Tasks [N/N compliant] | Contamination [CLEAN/N issues] | Unaccounted [CLEAN/N files] | VERDICT`
---
## Commit Strategy
> Atomic commits per primitive family / trigger / protocol message-pair / descriptor. No "WIP" commits. No multi-feature commits. Commits ordered strictly by dependency layer.
- **0**: `docs(adr): thressgame-coverage architecture decisions` — `.sisyphus/notepads/thressgame-coverage/decisions.md`, `bun run check`
- **1**: `test(chess): backward-compat baseline fixture` — fixtures + golden hashes
- **2-5**: per audit/harness — single commit each
- **6-10**: `feat(chess): {family} infrastructure` — one commit per data-structure family
- **11-12**: `feat(chess): param walker binding resolution + scope stack`
- **13-14**: `feat(chess): validator extensions for v2 primitives`
- **15**: `feat(chess): deferred trigger queue + cascade depth guard`
- **16-20**: per trigger — one commit each
- **21-27**: per imperative primitive — one commit each (test + impl + narrate + palette + schema)
- **28-35**: per marker / iteration primitive — one commit each
- **36-42**: per RNG / restriction / movement primitive
- **43**: `feat(server): WS protocol v2 schema + version negotiation`
- **44-50**: per protocol/state-machine concern
- **51-58**: per UI piece — one commit each
- **59-66**: per parity descriptor + test — one commit each
- **67**: `feat(chess): 6 template descriptors`
- **68**: `test(e2e): request-choice round-trip flows`
- **69**: `test(perf): 100-marker benchmark`
- **FINAL**: no commit — verification only
---
## Success Criteria
### Verification Commands
```bash
bun run check # 0 errors, ~2200 tests pass
bun x playwright test e2e/thressgame-parity.spec.ts # 8 rules, 24+ scenarios
bun x playwright test e2e/request-choice.spec.ts # 3 flows
bun test packages/chess/src/modifiers/primitives/ # ~250 new tests pass
bun test --golden-hash packages/chess/src/__fixtures__/determinism/ # N=100, byte-identical
```
### Final Checklist
- [ ] All 28 new primitives shipped with tests, narrate, palette, schema
- [ ] All 4 new triggers wired into 14-stage dispatcher
- [ ] Marker entity subsystem complete with priority-based collision
- [ ] Seeded PRNG verified deterministic across N=100 replays
- [ ] WS protocol v2 with version negotiation tested old↔new
- [ ] Stack-based suspended execution serializes/restores cleanly
- [ ] All 8 ThressGame parity tests green
- [ ] All 6 template descriptors load + apply cleanly
- [ ] 100-marker performance test under p99 50ms
- [ ] All "Must NOT Have" guardrails verified absent (grep evidence)
- [ ] Backward compat: 22 existing primitives + ~1957 existing tests unchanged