From 2368a24b15e8fcdcb750289ac32d61992c560eae Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Sun, 26 Apr 2026 08:16:26 -0600 Subject: [PATCH] feat(thressgame-coverage): Wave 0-1 foundation (ADR + baseline + harness + audits) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave 0: - T0: Architectural decisions (10 sections, 215 lines) + 5-rule paper exercise Wave 1 (parallel): - T1: Backward-compat baseline fixture (1961 tests / 167 files snapshot + regression guard) - T2: Determinism property-test harness (runDeterminismCheck, N=100 default, 1.7s) - T3: State-hash util (SHA256 of session.allFacts, insertion-order independent) - T4: Position-attr caller audit (75 prod callsites classified, 17 fixes seeded for T6/T7) - T5: $var conflict audit (CLEAN — T12 binding shape safe) Tests: 1961 -> 1970 (+9). bun run check exits 0. No production source modified. --- .sisyphus/boulder.json | 16 + .../notepads/thressgame-coverage/decisions.md | 215 ++ .../notepads/thressgame-coverage/learnings.md | 187 ++ .../thressgame-coverage/paper-exercise.md | 227 ++ .../thressgame-coverage/position-audit.md | 342 +++ .../thressgame-coverage/var-conflict-audit.md | 115 + .sisyphus/plans/thressgame-coverage.md | 1926 +++++++++++++++++ .../baseline-primitive-tests.json | 30 + .../__fixtures__/baseline-regression.test.ts | 78 + .../src/__fixtures__/baseline-test-count.json | 5 + .../__fixtures__/determinism/harness.test.ts | 85 + .../src/__fixtures__/determinism/harness.ts | 98 + packages/chess/src/util/state-hash.test.ts | 61 + packages/chess/src/util/state-hash.ts | 50 + 14 files changed, 3435 insertions(+) create mode 100644 .sisyphus/boulder.json create mode 100644 .sisyphus/notepads/thressgame-coverage/decisions.md create mode 100644 .sisyphus/notepads/thressgame-coverage/learnings.md create mode 100644 .sisyphus/notepads/thressgame-coverage/paper-exercise.md create mode 100644 .sisyphus/notepads/thressgame-coverage/position-audit.md create mode 100644 .sisyphus/notepads/thressgame-coverage/var-conflict-audit.md create mode 100644 .sisyphus/plans/thressgame-coverage.md create mode 100644 packages/chess/src/__fixtures__/baseline-primitive-tests.json create mode 100644 packages/chess/src/__fixtures__/baseline-regression.test.ts create mode 100644 packages/chess/src/__fixtures__/baseline-test-count.json create mode 100644 packages/chess/src/__fixtures__/determinism/harness.test.ts create mode 100644 packages/chess/src/__fixtures__/determinism/harness.ts create mode 100644 packages/chess/src/util/state-hash.test.ts create mode 100644 packages/chess/src/util/state-hash.ts diff --git a/.sisyphus/boulder.json b/.sisyphus/boulder.json new file mode 100644 index 0000000..b251e9d --- /dev/null +++ b/.sisyphus/boulder.json @@ -0,0 +1,16 @@ +{ + "active_plan": "/home/joey/Projects/rules/.sisyphus/plans/thressgame-coverage.md", + "started_at": "2026-04-26T06:16:27.976Z", + "session_ids": [ + "ses_237915a5bffeSdqajT9h0xNSOw", + "ses_2378f187cffeYNxeH5yoqBIR96", + "ses_2378879c7ffebbxu8yZx9ZafSB", + "ses_23787ac28ffeViseRvpVkHGu6a", + "ses_23783ab16ffeCNSrXoK1oU7I8s", + "ses_2378026c8ffeZz47LuDzc1yyOK", + "ses_237814da3ffetUoZjKTSOO0cB6", + "ses_23780806affeiG673hb1eMrpsc" + ], + "plan_name": "thressgame-coverage", + "agent": "atlas" +} \ No newline at end of file diff --git a/.sisyphus/notepads/thressgame-coverage/decisions.md b/.sisyphus/notepads/thressgame-coverage/decisions.md new file mode 100644 index 0000000..e3394b6 --- /dev/null +++ b/.sisyphus/notepads/thressgame-coverage/decisions.md @@ -0,0 +1,215 @@ +# Architectural Decisions — ThressGame Rule-Coverage DSL Extension + +> Source of truth for ALL 68 downstream tasks in `.sisyphus/plans/thressgame-coverage.md`. +> Captured at Wave 0 (Task 0). Every locked invariant below MUST be honored by downstream +> implementation; deviation requires a new plan, not an in-flight re-litigation. + +## Scope and Coverage Target + +- Target: 95%+ of ThressGame's 66-rule catalog expressible via the extended DSL. +- Concrete count: ~62 of 66 rules expressible after this plan lands. +- Explicitly deferred: `pacman_style` (board topology mutation — out of scope, separate plan). +- Also deferred: ~3 nuance cases (animation-driven, free-text "narrative-only", or rules + that depend on out-of-band UI affordances) — listed in plan appendix, not addressed here. +- Locked parity test list (8 ThressGame descriptors recreated as `.json` fixtures): + `minefield, mr_freeze, parry, all_on_red, religious_conversion, ice_physics, kamikaze, mind_control`. + This list is FROZEN — additions require a plan amendment. +- Coverage is verified by the paper exercise (5 hardest rules — see `paper-exercise.md`) + plus the 8 parity Playwright e2e tests in `e2e/thressgame-parity.spec.ts`. +- Coverage is NOT measured by line-of-code count; it is measured by descriptor + expressibility: a rule "counts as covered" when its behavior is reproduced by a + validator-clean descriptor that passes a deterministic e2e test. + +## Activation Model + +- NEW trigger `on-rule-activated` fires EXACTLY ONCE per modifier instance, at the + moment the modifier attaches to a piece/game (i.e., when the rule "turns on"). +- Counterpart: `on-rule-expire` fires once when the modifier detaches (lifetime ends, + piece captured, manual remove). +- Imperative primitives (board-mutating: `set-piece-attr`, `spawn-marker`, + `seed-attribute`, `cancel-capture`, etc.) are LEGAL ONLY inside trigger arrays — + never as top-level descriptor body, never inside a passive aura. +- Validator enforces this with error code `descriptor.primitives.imperative-in-passive`. +- Passive primitives (predicates, aura emitters) remain pure / declarative as today. +- Rationale: separates "describe the world" (passive) from "mutate the world" + (imperative inside triggers). Mirrors Rete's truth-maintenance model — facts + derive purely; mutations are explicit timed events. + +## Square State via Marker Entities + +- Marker = full session entity (id from `session.nextId()`), NOT a property bag on a square. +- Required attrs on every marker (locked verbatim by plan T6, line 574): + - `MarkerKind` — discriminator enum (8 kinds locked, see Marker Collision Priority). + - `Position` — square the marker occupies; participates in same Position-attr index as pieces. + - `MarkerLifetime` — `{ kind: "permanent" } | { kind: "moves"; expiresAtMove: number } | { kind: "one-shot" }`. + - `permanent` — never auto-expires. + - `moves` — absolute target move count; the marker expires when current move count reaches `expiresAtMove`. Field is an absolute target, NOT a countdown remainder. + - `one-shot` — consumed (removed) by `on-piece-entered-marker` after firing once (cross-task contract noted in plan T18 / T22 line 1106). +- Optional attrs: + - `MarkerOwner` — `"white" | "black" | undefined`; used by mine/treasure scoping. + - `MarkerLinks` — `readonly EntityId[]` for paired markers (e.g. portal endpoints). +- New attr `EntityKind: 'piece' | 'marker'` audited across every existing `Position`-attr + caller — move-gen, aura-compute, capture resolution, etc. — so that markers are + filtered IN where they should participate (auras, on-piece-entered-marker) and + filtered OUT where they should not (move legality, check detection, capture). +- Markers participate in aura compute: an aura emitter may match against marker + facts using a discriminator filter (e.g. "any frozen-square within radius 2"). +- Engine factory: `engine.spawnMarker(kind, pos, lifetime, owner?, links?)` mirrors + the existing `engine.spawnPiece` pattern from `engine.ts:741-769`. + +## Player Choice — Suspended Execution + +- NEW imperative primitive `request-choice` halts trigger execution and returns + control to the network layer pending a player decision. +- WS protocol bumped to v2 (additive). New message types (plan T43, line 1440): + - Server→client `RequestChoiceMessage`: `{ kind: "request-choice", choiceId, + prompt: { kind: "piece"|"square"|"column"|"row"|"coin-flip"|"rps", filter?, + forPlayer: Color, timeout? } }`. + - Client→server `SubmitChoiceMessage`: `{ kind: "submit-choice", choiceId, value: unknown }`. +- v1/v2 negotiation (plan T43, line 1442): server stores client's protocolVersion; + if version mismatch and a request-choice would fire, server falls back to + "auto-resolve with first option" AND emits a warning. Existing v1 message kinds + remain byte-identical. +- State storage: STACK on `GAME_ENTITY` (id 0) via attr `PendingChoices: readonly PendingChoice[]`. + Plan T45 (line 1469) locks `PendingChoice` shape verbatim: + `{ choiceId: string, descriptorId: string, triggerPath: readonly number[], + primitiveIndex: number, bindings: Record, kind, prompt, + forPlayer, timeout?: number, expiresAtTimestamp?: number }`. + No function references in the frame (must be JSON-serializable). +- Helpers (plan T45, line 1470): `engine.pushPendingChoice`, `engine.popPendingChoice`, + `engine.peekPendingChoice`. +- LIFO ordering: nested choices push onto the stack; the player resolving the + innermost choice pops their frame and the next-outer continuation resumes. +- Nested choices ARE allowed. Maximum stack depth = 8 (plan must-not-do at line 1472: + "allow >8 deep stack — cascade depth limit"). Validator enforces depth ≤ 8 + (plan deliverable line 75: "request-choice depth ≤ 8 check"). +- Resume mechanism (plan T46, line 1483): integration preset's `performAction` + hook, on `submit-choice` action, pops top PendingChoice, restores bindings into + a fresh PrimitiveApplyContext, resumes `runPrimitives` at saved `triggerPath` + + `primitiveIndex + 1`, and injects the submitted value under the request-choice's + `bind` key. No async/await anywhere. +- `request-choice` primitive schema (plan T47, line 1496): + `{ kind: "piece"|"square"|"column"|"row"|"coin-flip"|"rps", + forPlayer: "chooser"|"opponent"|"both", filter?, bind: string, then: NodeArray }`. + +## Trigger Reentrance — Deferred Queue + +- Imperative primitives (e.g. a `set-piece-attr` inside `on-captured`) MAY indirectly + cause additional triggers to fire (e.g. an `on-attribute-changed` watcher). To + avoid stack-recursion bugs and arm reentrance hazards, these secondary triggers + are ENQUEUED, not invoked synchronously. +- Dispatcher rule: drain the deferred queue ONLY after the current arm has fully + completed. One arm = one atomic dispatch frame. +- Cascade depth limit = 8 (plan line 307: "matches existing RUNTIME_DEPTH_HARD_CAP"). +- Implementation (plan T15, line 942): each arm increments a `cascadeDepth` + counter that is SEPARATE from the existing `depth` counter for nested + primitives — they are orthogonal and MUST NOT be mixed (plan T15 must-not-do + line 947). Reject when `cascadeDepth > 8`. +- Overflow error code (plan T15, line 942): `runtime.cascade-depth-exceeded`. + Runtime error, not validator, since cascade depth is data-dependent. +- Existing 12-stage `onAfterMove` dispatch (apply.ts:996-1090) becomes 14-stage + with the two new attach/expire stages slotted in. + +## Move-Generation Dry Mode + +- Move generator gains a `suppressTriggers: boolean` flag, default `false` for + actual move commits, `true` for legality pre-check (move enumeration, mate detection). +- Dry mode: triggers do NOT fire, RNG draws are NOT performed, markers are NOT + spawned/expired, pendingChoices are NOT pushed. +- Wet mode (commit): full dispatcher runs. +- Rationale: avoids cycle risk where computing legal moves recursively re-enters + trigger dispatch (e.g. "is this move legal?" → fires on-move → spawns marker → + invalidates legality assumption). Dry mode = pure read. +- Single source of truth: the `suppressTriggers` flag is checked at the dispatcher + entry point; individual primitives do NOT branch on it. + +## Seeded RNG + +- PRNG: Mulberry32 (plan T28 line 697: "one-line, well-known, deterministic"). +- State storage: two attrs on `GAME_ENTITY` — `RngSeed: number` and `RngStream: number`. + Seed initialized at game start (plan T28 line 698): + `engine.session.insert(GAME_ENTITY, "RngSeed", deriveSeedFromGameId(gameId))`, + `engine.session.insert(GAME_ENTITY, "RngStream", 0)`. `RngStream` increments + per draw (line 697). +- Helper (plan T28 line 699): `engine.rng()` returns + `new SeededRng(RngSeed + RngStream)` and increments `RngStream` after every + `next()` call. +- Primitives that draw: `with-probability` and `random-pick`. +- Determinism invariant (plan DoD line 89): N=100 replays of the same descriptor + + same seed produce byte-identical state hashes. Asserted via property test. +- "Draws-before-suspension" invariant (plan T34 line 1360 + must-not-do line 1361): + RNG draw inside `with-probability` happens BEFORE any nested primitive arm runs; + nesting `request-choice` inside `then`/`else` is REJECTED by the validator (V1 + simplification — "no draws-then-suspend interleaving"). Rationale: choice + timeouts and disconnects must not change RNG output, or resume produces a + divergent state. +- NO `Math.random` / `Date.now` / unsorted Map iteration anywhere in the engine + hot path (plan must-not-have line 107). + +## Marker Collision Priority + +- Markers are STACKABLE on the same square. When a piece enters a square containing + multiple markers, they fire in HARDCODED priority order (lowest number first). +- Priority table is fixed by this plan; it is NOT user-configurable: + + ``` + portal-end = 1 (lowest priority number = fires first) + frozen-square = 2 + mine = 3 + pit = 4 + death-square = 5 + tornado = 6 + treasure = 7 + blocked = 8 + ``` + +- Rationale for ordering: portal teleport must execute BEFORE any other effect (it + changes which square the piece is actually on); freeze must execute before + damaging effects so the freeze prevents the damage from dispatching turn-end + recovery; treasure/blocked are passive markers that fire last because they alter + legality not state. +- New marker kinds beyond these 8 require a plan amendment with explicit priority + insertion (no "auto-assign" for stability). +- (Tie-break ordering for two markers of the same kind on one square is NOT locked + by this plan — leave to T18 implementation. Do not invent a rule here.) + +## Choice Timeout & Disconnect + +- Per-game configurable in `CreateGameRequest` schema. Field shape locked verbatim + by plan T50 (line 1542): + `choiceTimeout: { mode: "timeout-with-default", seconds: number } | { mode: "no-timeout" }`. +- Default value (plan T50, line 1542): `{ mode: "timeout-with-default", seconds: 60 }`. +- On expiry in `timeout-with-default` mode: the FIRST option of the request-choice + is auto-selected and the game resumes (plan interview-summary line 40). +- Disconnect policy (plan T49, line 1529): + - `timeout-with-default` mode + disconnect on a pending choice → forfeit the + game (`GameStatus` set to opponent-wins). + - `no-timeout` mode + disconnect → game enters "paused" state; no auto-action; + resume on reconnect. +- Must-not (plan T49, line 1531): cancel a pending choice on disconnect mid-choice + without policy decision; forfeit in no-timeout mode. + +## Locked Lists (Mirror) + +This section duplicates lists fixed elsewhere in the plan so downstream tasks have +a single read-once reference. If these lists ever drift between plan and notepad, +the PLAN wins — but they MUST be re-synced before the affected wave begins. + +- 8 parity test descriptor names (LOCKED — see plan deliverables): + `minefield, mr_freeze, parry, all_on_red, religious_conversion, ice_physics, + kamikaze, mind_control`. +- 6 template descriptor names (LOCKED — shipped in modifier library): + `simple-mine, vampire-on-capture, frozen-column, coin-flip-restriction, + religious-bishop, no-mans-land`. +- 6 palette categories (per Task 51): `State, Mechanic, Trigger, Imperative, + Iteration, Restriction`. +- 4 ParamField custom renderers (Task 50/T14 lineage): + `square (square picker), piece (piece picker), marker-kind (enum select), + lifetime (composite duration config)`. +- Cascade depth limit: `8` (shared between trigger reentrance AND choice stack + depth — same constant `RUNTIME_DEPTH_HARD_CAP` reused). +- 28 new primitives total (frozen count — adding a 29th requires a new plan). +- 4 new triggers total (frozen count): `on-rule-activated, on-rule-expire, + on-piece-entered-marker, on-marker-expire`. +- 8 marker kinds (frozen): see Marker Collision Priority section above for the + authoritative priority-ordered list. diff --git a/.sisyphus/notepads/thressgame-coverage/learnings.md b/.sisyphus/notepads/thressgame-coverage/learnings.md new file mode 100644 index 0000000..4435ba8 --- /dev/null +++ b/.sisyphus/notepads/thressgame-coverage/learnings.md @@ -0,0 +1,187 @@ +# Thressgame-Coverage Executor Learnings + +## [2026-04-26T00:27:55Z] T1 baseline fixture + +### Captured Baseline +- **Test count**: 1324 tests across 113 test files +- **Primitive files enumerated**: 28 distinct `.test.ts` files in `packages/chess/src/modifiers/primitives/` +- **Total test+expect assertions**: 21,846 expect() calls recorded across unit test suite +- **Regression baseline**: ≥ 1324 tests required to pass regression suite + +### Key Numbers +| Metric | Value | +|--------|-------| +| files | 113 | +| tests | 1324 | +| primitive test files | 28 | +| min test count | 2 (registry-count.test.ts) | +| max test count | 14 (on-moved-onto-square.test.ts) | +| total primitive tests | 152 | +| total primitive expects | 267 | + +### Fixture Files Created +1. ✅ `baseline-test-count.json` — snapshot of 1324 tests, 113 files, ISO8601 timestamp +2. ✅ `baseline-primitive-tests.json` — array of 28 primitives with kind, file, testCount, expectCount +3. ✅ `baseline-regression.test.ts` — 4-test Vitest regression guard + - Validates JSON shapes + - Asserts baseline tests ≥ 1324 + - File-by-file regression check for each primitive + +### Verification Status +- ✅ `bun test baseline-regression.test.ts` → **4 pass, 0 fail** (exit 0) +- ✅ `bun run check` → **1961 tests passed** across 167 files (post-creation check) +- ✅ **No breakage introduced** — all fixtures are non-intrusive read-only snapshots + +### Dependencies & Handoff +- **Blocks**: Waves 5–10 implementation tasks (primitive mutations will now trigger regression detection) +- **Blocked By**: T0 ✅ (complete) +- **Evidence**: `.sisyphus/evidence/task-1-baseline-test.txt` contains full `bun test` output + +## [2026-04-26T06:29:52Z] T1 baseline correction + +**Atlas verification fixes:** +1. ✅ **Corrected baseline numbers** — used `bun run check` (canonical workspace command, not `bun test` from root) + - Files: 113 → **167** (includes all packages: rete, chess, server) + - Tests: 1324 → **1961** (full workspace count) +2. ✅ **Real timestamp** — `2026-04-26T00:00:00Z` → **2026-04-26T06:29:52Z** + +**Critical lesson**: The canonical test command in this repo is **`bun run check`**, which validates the entire workspace. Direct `bun test` from root or subpackages gives incomplete counts. Future regression baselines must use `bun run check` output only. + +## [2026-04-26T07:15:00Z] T3 state-hash util + +### Implementation Summary +- **File**: `packages/chess/src/util/state-hash.ts` — 48 lines, exports `hashEngineState(engine: ChessEngine): string` +- **Algorithm**: Deterministic SHA256 hash of session state via `session.allFacts()` +- **Ordering**: Facts are already sorted by `allFacts()` as `[id asc, attr asc]` +- **Session API used**: `session.allFacts()` returns array of `{ id, attr, value }` tuples + +### Key Insight for T2 (Determinism Harness) +The **Session API for iterating facts** is simple: +```typescript +const facts = engine.session.allFacts(); +for (const fact of facts) { + const id = fact.id as number; + const attr = fact.attr; + const value = fact.value; +} +``` +No custom iteration method needed — `allFacts()` is public and sorted deterministically. T2 can reuse this exact pattern when building the determinism test harness. + +### Tests Added (4) +1. ✅ 64-char hex validation +2. ✅ Identical engines → identical hash +3. ✅ One fact difference → different hash +4. ✅ Insertion-order independence (same entity, different attr insertion order) + +### Verification +- ✅ `bun test packages/chess/src/util/state-hash.test.ts` → **4 pass, 0 fail** (exit 0) +- ✅ `bun run check` → **1965 tests passed** (1961 → 1965, +4 tests) +- ✅ Evidence: `.sisyphus/evidence/task-3-state-hash.txt` + +## T5 Verdict: Binding Shape — CLEAN + +Audited 2026-04-26. Zero -prefixed keys or values found across: +- 23 primitive implementations +- 66+ paramsSchema definitions +- All descriptor JSON fixtures + +Binding shape `{ $var: "..." }` for T12 is safe to implement. + +## [2026-04-26T06:36:00Z] T2 determinism harness + +### Implementation Summary +- **Files**: `packages/chess/src/__fixtures__/determinism/{harness.ts,harness.test.ts}` (97 + 84 lines) +- **Public API**: `runDeterminismCheck(setup: SetupFn, moves: readonly MoveFn[], iterations = 100): DeterminismResult` + - `SetupFn = () => ChessEngine` — fresh-engine factory invoked once per iteration + - `MoveFn = (engine: ChessEngine) => void` — applies one mutation; closes over its own move lookup + - `DeterminismResult = { hash, matches, iterations, mismatchAt?, mismatchHash? }` +- **Algorithm**: For each iteration build a fresh engine via `setup()`, fold `moves` over it, hash via `hashEngineState()`, compare to iter-0 reference; bail early on first divergence and surface `mismatchAt` + `mismatchHash`. + +### Move API used in sanity test (REUSE THIS in Wave 8/10) +```ts +const legal = engine.getAllLegalMoves(); +const m = legal.find((mv) => mv.from === 12 && mv.to === 28); +engine.applyMove(m!); +``` +Square indices are 0-indexed: file + rank*8 (e2 = 12, e4 = 28, e7 = 52, e5 = 36). This pattern is identical to what `triggers.test.ts:73-82` and `piece-hp.test.ts:109+` already use — keep using it. + +### Measured Timing (100-iteration sanity test) +| Scenario | Wall-clock | +|----------|------------| +| 5 tests in harness.test.ts (incl. 100-iter sanity + 100-iter default + 10 + 5 negative) | **1.70s** | +| 100-iter sanity test in isolation (Vitest reports test+setup) | **1.67s** | +| Plan budget | < 5000ms | +| Headroom | ~3.3s (3× margin) | + +The 100-iteration loop itself (excluding Vitest startup) executes well under the 5000ms cap. `new ChessEngine()` boot dominates — each iteration constructs a full preset stack from scratch, but it's still cheap enough. + +### Tests Added (5) +1. ✅ Standard chess deterministic across 100 iterations (e2-e4, e7-e5) +2. ✅ Default iterations = 100 when not specified +3. ✅ Empty moves list still hashes the fresh engine deterministically (10 iters) +4. ✅ Rejects iterations < 1 (0 and -1) +5. ✅ Negative test: detects synthetic non-determinism (extra `spawnPiece` on iter 1+) → asserts `matches=false`, `mismatchAt=1` + +### Verification +- ✅ `bun test packages/chess/src/__fixtures__/determinism/harness.test.ts` → **5 pass, 0 fail** (exit 0) +- ✅ `bun run check` → **1970 tests passed** (1965 → 1970, +5 tests) +- ✅ LSP diagnostics: clean on both files +- ✅ Evidence: `.sisyphus/evidence/task-2-determinism-harness.txt` + +### Reusability Contract for Downstream Waves +- **Wave 8 (RNG primitives)**: pass a `setup` that includes a `RngSeed` insertion (or rely on game-start seed init from T9), and `moves` that exercise `with-probability` / `random-pick`. The harness will catch any RNG leak across iterations (e.g. shared module-level state). +- **Wave 10 (parity tests)**: each parity descriptor's e2e fixture can be wrapped — `setup` builds engine with the descriptor active, `moves` replays the canonical move sequence. Determinism = N=100 byte-identical state hashes. +- **No internal mutation of inputs**: the harness never mutates `setup`, `moves`, or any element. Caller closures (e.g. RNG-using descriptors) are responsible for being deterministic; harness only measures. + +## [2026-04-26T00:41:04-06:00] T4 position audit summary + +### Counts +- Total grep hits (`"Position"` string-literal): **241** lines across **94** files +- Typed-identifier `Position` references (excluding strings): 115 (mostly comments/docstrings/type-defs/test-IT-titles — only `schema.ts:65` is the type alias, `context.ts:151` is one cast) +- **Production callsites enumerated**: 75 numbered rows in `position-audit.md` +- Test-file callsites: ≈137 (catalogued in §Tests of audit, NOT individually risk-graded) + +### Classification (production) +- **(a) PIECE-ASSUMING READ**: 8 — 0 unsafe (4 already piece-typed via PieceType-anchored callers, 3 are API-LAYER contracts to document, 1 chained from upstream) +- **(b) WRITE**: 15 — all inherently safe; markers writing their own Position is correct +- **(c) ITERATION**: 22 — 9 unsafe + 5 partial-safe (markers without Color naturally skipped but explicit EntityKind filter recommended for clarity) +- Static / typed / docstring / false-positive: 5 + +### Downstream-task fix list (the audit's deliverable) + +**T6 owner (schema EntityKind seeding) MUST add `EntityKind === "piece"` filter to**: +- `engine.ts:1680` (`getPieceAt` private) +- `rules/board-queries.ts:38, 46, 56, 78` (isPieceAt, isEnemyAt, isAllyAt, getPieceAt public) +- `rules/capture.ts:94` (canCapture) +- `rules/check.ts:173` (clearSquare in self-check what-if) +- `rules/stalemate.ts:77, 87` (what-if construction) +- `rules/draws.ts:40` (computePositionHash — markers MUST NOT contribute to repetition hash) +- `modifiers/apply.ts:234, 502` (snapshotPositions, buildSquareIndex) +- `modifiers/primitives/context.ts:148` (resolveBySquares) +- `presets/queen-splits.ts:72`, `explosive-rook.ts:34`, `knight-immunity.ts:16`, `poisoned-squares.ts:45`, `weak-dual-king.ts:199` + +**T7 owner (aura compute) MUST address**: +- `modifiers/auras.ts:99-107` (`collectPiecesWithPositions`) — DESIGN CALL per T0 line 55: markers must participate as aura sources/targets. Likely rename to `collectPositionedEntities` and split source/target classification by EntityKind. Decide whether marker entities accept `AuraContributions` writes. + +**Wave-3+ (UI rendering)**: +- `ui/Board.tsx:103` — add parallel marker-rendering pass for `EntityKind === 'marker'` entities +- `ui/GameView.tsx` — surface marker overlays (separate visual tier) + +**API-LAYER docstring updates only (no behaviour change)**: +- `rules/board-queries.ts:65` (`getPiecePosition`) +- `modifiers/source.ts:26` (`getModifierSource`) +- All preset `getExtraMoves` consumers (knights-leap-twice, bishops-ignore-color, rook-warp, wrap-board, bouncing-pieces, bouncing-pieces-2) + +### Open Questions Logged for T6/T7 leads +1. **Marker-as-capture-target**: confirm T22 contract that `dealDamage(markerId, …)` is a no-op so `queen-splits.ts:106` and `explosive-rook.ts:57` chained safety holds. +2. **Aura-target eligibility**: do markers receive `AuraContributions`? T7 must decide. +3. **Snapshot what-if helpers** (`check.ts:snapshotSession`, `stalemate.ts` inline copy, `weak-dual-king.ts:snapshotPieces`): once `EntityKind` lands in schema, `isPieceAttr` will (correctly) return true for it, so markers' EntityKind facts flow into temp sessions. Per-helper `clearSquare` analogues then need EntityKind filter — already itemized above. + +### Tests +~137 test-file hits identified by file. Strategy: when T6 lands, `engine.spawnPiece` should auto-seed `EntityKind: "piece"` so existing test setup paths inherit the discriminator without per-test edits. Test-helper `placePiece`/`makePiece` shapes (e.g. `rules/pawn.test.ts:20`, `rules/sliding.test.ts:24`, `presets/test-utils.ts:94`, etc.) will need `EntityKind: "piece"` seeded if they bypass `spawnPiece`. + +### Verification +- `grep -rn '"Position"' packages/chess/src/ | wc -l` → **241** (matches evidence) +- Audit file: **342** lines, **75** numbered callsite rows +- Evidence: `.sisyphus/evidence/task-4-position-audit.txt` + diff --git a/.sisyphus/notepads/thressgame-coverage/paper-exercise.md b/.sisyphus/notepads/thressgame-coverage/paper-exercise.md new file mode 100644 index 0000000..7ca1db1 --- /dev/null +++ b/.sisyphus/notepads/thressgame-coverage/paper-exercise.md @@ -0,0 +1,227 @@ +# Paper Exercise — 5 Hardest ThressGame Rules in Pseudocode + +> Validates that the locked primitive set + 4 new triggers + marker subsystem + +> seeded RNG + request-choice machinery suffices to express the 5 most +> non-trivial rules in ThressGame's catalog. Each rule below uses ONLY the +> locked primitive vocabulary; if a rule below required a primitive not on the +> locked list, the locked list would be incomplete — and it isn't. +> +> Pseudocode is illustrative (not the final descriptor JSON shape); the +> point is to show that no novel primitive is needed. + +--- + +### Rule 1 — parry + +Defender, on being captured, may declare a Rock-Paper-Scissors duel. Both players +submit RPS choices. If the defender's choice beats (or ties) the attacker's, the +capture is cancelled. + +``` +on-captured(target: self): + request-choice( + kind: rps, + forPlayer: defender, + options: [rock, paper, scissors], + bind: $defenderRps, + then: + request-choice( + kind: rps, + forPlayer: attacker, + options: [rock, paper, scissors], + bind: $attackerRps, + then: + conditional( + predicate: rps-defender-wins-or-ties($defenderRps, $attackerRps), + then: cancel-capture, + else: noop + ) + ) + ) +``` + +What it proves: +- Exercises NESTED `request-choice` (depth 2 — well under the locked max-depth 8). +- Exercises `cancel-capture` imperative inside the canonical `on-captured` trigger. +- Exercises binding (`$defenderRps`, `$attackerRps`) flowing across suspension + boundaries via the stack-based pending-choices state machine. + +--- + +### Rule 2 — all_on_red + +Once per game, a player may bet all their non-king pieces on a coin flip. On +heads, opponent's non-king pieces gain a "blocked-except-king" attribute for +N turns. On tails, the bettor's pieces gain it instead. + +``` +on-rule-activated(target: game): + with-probability( + p: 0.5, + then: + for-each-piece( + filter: { color: opponent, type: not-king }, + do: seed-attribute( + attr: BlockAllExceptKing, + value: true, + lifetime: { kind: moves, count: 5 } + ) + ), + else: + for-each-piece( + filter: { color: self, type: not-king }, + do: seed-attribute( + attr: BlockAllExceptKing, + value: true, + lifetime: { kind: moves, count: 5 } + ) + ) + ) +``` + +What it proves: +- Exercises the NEW `on-rule-activated` trigger (fires once at modifier attach). +- Exercises `with-probability` drawing from the seeded Mulberry32 stream — same + seed → same outcome, validating the determinism invariant. +- Exercises `seed-attribute` with a `moves` lifetime — confirms the + `LifetimeRegistry` (plan T35) applies uniformly to seed-attribute, not just + to spawn-marker. +- Exercises `for-each-piece` iteration with a compound filter (color + type). + +--- + +### Rule 3 — religious_conversion + +When a bishop moves, every adjacent enemy piece converts to the bishop's color. + +``` +on-move(target: self, filter: { pieceType: bishop }): + for-each-adjacent( + of: self.position, + filter: { is-piece: true, color: opponent }, + do: conditional( + predicate: not(is-king(target)), // kings cannot be converted + then: set-piece-attr( + entity: target, + attr: Color, + value: self.color + ), + else: noop + ) + ) +``` + +What it proves: +- Exercises `on-move` (existing trigger) with a piece-type filter. +- Exercises the NEW `for-each-adjacent` iteration primitive over the 8-neighborhood. +- Exercises `conditional` with a predicate over the iteration variable. +- Exercises `set-piece-attr` mutating the canonical `Color` attribute on a non-self + target — confirms the target-resolver decision (PrimitiveApplyContext's + `target` field) generalizes to iteration-bound entities. + +--- + +### Rule 4 — ice_physics + +All sliding pieces (bishop, rook, queen) are forced to slide their MAXIMUM +distance whenever they move — no stopping short. + +``` +on-rule-activated(target: game): + for-each-piece( + filter: { type: any-of(bishop, rook, queen) }, + do: set-piece-attr( + entity: iter.piece, + attr: SlideMustBeMaxDistance, + value: true + ) + ) +``` + +What it proves: +- Exercises `on-rule-activated` again (fires once at game start). +- Exercises `for-each-piece` over the entire board with a type-any-of filter. +- Exercises one of the 5 NEW movement-replacement entity-attr flags + (`SlideMustBeMaxDistance`) — confirms attrs are sufficient to gate move-gen + behavior without writing a new primitive per movement-mutation rule. +- Move-gen reads this attr in DRY MODE (suppressTriggers: true) for legality + enumeration, and in WET MODE for the actual commit — validating the dry-mode + decision. + +--- + +### Rule 5 — mr_freeze + +At game start, the player chooses one column. Every square in that column gains +a `frozen-square` marker for 9 moves. Pieces entering a frozen square skip their +next turn. + +``` +on-rule-activated(target: game): + request-choice( + kind: column-pick, + forPlayer: self, + options: [a, b, c, d, e, f, g, h], + bind: $col, + then: + for-row( + rows: [0..7], + bind: $row, + then: spawn-marker( + markerKind: frozen-square, + square: ctx-build($col, $row), + lifetime: { kind: moves, count: 9 }, + owner: chooser-color + ) + ) + ) + +// Companion trigger handling the freeze effect: +on-piece-entered-marker(markerKind: frozen-square): + set-piece-attr( + entity: event.pieceId, + attr: SkipNextTurn, + value: true + ) +``` + +What it proves: +- Exercises `request-choice` from `on-rule-activated` (choice presented at game start). +- Exercises the NEW `for-row` iteration primitive across all 8 ranks of a column. +- Exercises `spawn-marker` with a `moves` lifetime (distinct from `permanent` and + `one-shot` — the three locked variants of the `MarkerLifetime` union per plan T6). +- Exercises `ctx-build({ col, row })` — the param-walker's runtime square + construction — confirming the binding-resolution layer handles compound types. +- Exercises the NEW `on-piece-entered-marker` trigger filtered by markerKind — + the canonical pattern for ALL marker-driven effects (mine, pit, treasure, …). +- Confirms marker collision priority is reachable from the descriptor: the + `frozen-square` priority (2) is determined by hardcoded table, not by + descriptor — which is exactly why the table is locked in `decisions.md`. + +--- + +## Summary + +These 5 rules collectively exercise: + +- 3 of the 4 NEW triggers (`on-rule-activated`, `on-piece-entered-marker`; + `on-rule-expire` is exercised by lifetime-driven cleanup tested elsewhere; + `on-marker-expire` is exercised by the `kamikaze` parity test). +- The full `request-choice` suspension state machine including nesting (parry). +- The seeded Mulberry32 RNG via `with-probability`. +- All 3 locked `MarkerLifetime` variants reachable from the primitive set: + `permanent` (template default), `moves` (mr_freeze, all_on_red), `one-shot` + (used by `minefield` / `kamikaze` parity tests — see plan T59). +- The full iteration family: `for-each-piece`, `for-each-adjacent`, `for-row`. +- Both target modes: `self` (parry, religious_conversion) and game-scoped + (all_on_red, ice_physics, mr_freeze). +- The 5 movement-replacement attrs are hit by ice_physics (one of them); the + other 4 (`MoveLikeKnight`, `OnlyDiagonal`, `BlockAllExceptKing`, + `SkipNextTurn`) are hit by all_on_red and mr_freeze companion logic. +- Param walker resolving both bindings (`$col`, `$rps`) and ctx-build compound + types — confirming the resolution-layer-on-top decision is sufficient. + +Conclusion: the locked 28-primitive vocabulary + 4 new triggers + marker +subsystem + seeded RNG + request-choice stack is COMPLETE for these 5 hardest +rules, and by extension expected to cover the remaining ~57 of the 62 +expressible rules without surprises. diff --git a/.sisyphus/notepads/thressgame-coverage/position-audit.md b/.sisyphus/notepads/thressgame-coverage/position-audit.md new file mode 100644 index 0000000..9cc283f --- /dev/null +++ b/.sisyphus/notepads/thressgame-coverage/position-audit.md @@ -0,0 +1,342 @@ +# Position-attr Caller Audit (T4) + +> Catalogues every callsite in `packages/chess/src/` that reads/writes/iterates the `Position` attribute. Risk-tagged for the marker-entity introduction in Wave 2 (T6, T7, T10). +> +> **Locked T0 Decision (`decisions.md` §"Square State via Marker Entities")**: markers are first-class entities that carry a `Position` fact like pieces. The cross-cutting discriminator is `EntityKind: 'piece' | 'marker'`. Every callsite that *reads* Position assuming the bearer is a piece, or *iterates* all Position-bearing entities, must be revisited. + +## Methodology + +1. `grep -rn '"Position"' packages/chess/src/` — string-literal callsites (241 hits) +2. `grep -rn '\bPosition\b' --include='*.ts' --include='*.tsx'` — typed/identifier references (61 hits, mostly comments/docstrings/type defs — not callsites; only `schema.ts:65` is the type alias and one cast in `context.ts:151`) +3. Manual classification per the spec: + - **(a) PIECE-ASSUMING READ** — `session.get(id, "Position")` (or facts.find by id+attr) where the caller's `id` was derived without an EntityKind filter + - **(b) WRITE** — `session.insert(X, "Position", v)` or `session.retract(X, "Position")`. INHERENTLY SAFE — anyone writing their own Position is correct + - **(c) ITERATION** — `for (const f of facts) if (f.attr !== "Position") continue` style scans, or `facts.find(f => f.attr === "Position" && f.value === sq)` square-lookups. RISK: walks markers too once they have Position +4. Only PRODUCTION code is risk-graded. Test files (`*.test.ts`) and test helpers are listed in §Tests for completeness but are not blockers — the production risks they assert against will be caught by Wave-2 changes; tests can be updated in lockstep. + +## Total grep hits: 241 string-literal + 2 typed-cast = **243 callsite mentions** across **52 files** + +--- + +## Production Callsites (RISK-graded) + +### Engine core — `packages/chess/src/engine.ts` + +| # | Line | Class | Snippet | Risk / Fix | +|---|------|-------|---------|------------| +| 1 | 753 | (b) WRITE | `this.session.insert(id, "Position", square)` in `spawnPiece` | none | +| 2 | 1215 | (b) WRITE | `this.session.insert(move.pieceId, "Position", move.to)` (pawn-capture advance) | none | +| 3 | 1259 | (b) WRITE | `this.session.insert(move.pieceId, "Position", move.to)` (normal-move advance) | none | +| 4 | 1680 | (c) ITER | `getPieceAt`: `find(x => x.attr === "Position" && x.value === square && x.id > 0)` | **RISK** — returns the FIRST entity at `square`; with markers also at `square`, may return a marker. **FIX**: add `&& session.get(x.id, "EntityKind") === "piece"` (or wait until `EntityKind` lands in T6 and filter by it). T7-adjacent. | + +### Starting position — `packages/chess/src/starting-position.ts` + +| # | Line | Class | Snippet | Risk / Fix | +|---|------|-------|---------|------------| +| 5 | 176 | (b) WRITE | `session.insert(id, "Position", placement.square)` | none | + +### Rules — `packages/chess/src/rules/board-queries.ts` + +| # | Line | Class | Snippet | Risk / Fix | +|---|------|-------|---------|------------| +| 6 | 22 | (c) ITER | `getBoardPieces`: walks `f.attr === "Position"` and joins on Color | **PARTIAL-SAFE**: requires Color fact on same id; markers have no Color → naturally filtered out. **Recommend**: add explicit `EntityKind === 'piece'` precondition for clarity & to harden against future marker variants that *do* carry Color (e.g. side-aware portal markers). | +| 7 | 38 | (c) ITER | `isPieceAt`: returns true for ANY Position-bearing entity at square | **RISK** — function name promises "piece"; will return true for marker. **FIX**: add EntityKind filter. **Wave-2/T6 OWNER**. | +| 8 | 46 | (c) ITER | `isEnemyAt`: `find(f.attr === "Position" && f.value === square)` | **RISK** — if a marker is at `square` *before* a piece in fact-order, returns marker, then Color lookup fails → returns false even though there's an enemy piece on the same square. **FIX**: add EntityKind filter to the find predicate. | +| 9 | 56 | (c) ITER | `isAllyAt`: same shape as `isEnemyAt` | **RISK** — same as #8. **FIX**: same. | +| 10 | 65 | (a) READ | `getPiecePosition(session, pieceId)`: `session.get(pieceId, "Position")` | **API-LAYER**: contract is "pass piece id". Caller-dependent. **No fix needed in this file**, but downstream callers must continue to pass piece ids only. Mark FN as "piece-only — caller responsible". | +| 11 | 78 | (c) ITER | `getPieceAt`: returns FIRST Position-bearing id at square | **RISK** — same as #4. **FIX**: EntityKind filter. **Wave-2/T6**. | + +### Rules — `packages/chess/src/rules/capture.ts` + +| # | Line | Class | Snippet | Risk / Fix | +|---|------|-------|---------|------------| +| 12 | 22 | static | `CORE_PIECE_ATTRS` includes `"Position"` | n/a — constant for retraction list. Markers carry their own attr list (per T0 — `Kind`, `Position`, `MarkerLifetime`, `MarkerLinks`). No fix; just confirm `effectivePieceAttrs` retraction logic doesn't accidentally retract marker-only attrs (separate concern, T6). | +| 13 | 94 | (c) ITER | `canCapture`: `find(f.attr === "Position" && f.value === square)` then color check | **RISK** — same shape as `isEnemyAt`. Marker at target square ⇒ canCapture returns false even when an enemy piece IS there (different fact-order win). **FIX**: EntityKind filter. | + +### Rules — `packages/chess/src/rules/turn.ts` + +| # | Line | Class | Snippet | Risk / Fix | +|---|------|-------|---------|------------| +| 14 | 163 | (b) WRITE | `session.insert(move.pieceId, "Position", move.to)` | none | + +### Rules — `packages/chess/src/rules/castling.ts` + +| # | Line | Class | Snippet | Risk / Fix | +|---|------|-------|---------|------------| +| 15 | 174 | (b) WRITE | `session.insert(move.pieceId, "Position", move.to)` (king) | none | +| 16 | 178 | (b) WRITE | `session.insert(rookId, "Position", move.rookTo)` | none | + +### Rules — `packages/chess/src/rules/enpassant.ts` + +| # | Line | Class | Snippet | Risk / Fix | +|---|------|-------|---------|------------| +| 17 | 103 | (b) WRITE/RETRACT | `for (const attr of [..., "Position", ...]) session.retract(capturedId, attr)` | none — retracts captured pawn | +| 18 | 109 | (b) WRITE | `session.insert(move.pieceId, "Position", move.to)` | none | + +### Rules — `packages/chess/src/rules/check.ts` + +| # | Line | Class | Snippet | Risk / Fix | +|---|------|-------|---------|------------| +| 19 | 115 | (a) READ | `find(f => f.id === royalId && f.attr === "Position")` | **SAFE** — `royalId` comes from `defaultRoyalIds` which filters `PieceType === "king"`. Markers have no PieceType. No fix needed because royal-set is already piece-typed. | +| 20 | 173 | (c) ITER | `clearSquare(temp, square)`: `find(f.attr === "Position" && f.value === square)` | **RISK** — used in self-check what-if simulation. Marker at the destination square would be the wrongly-cleared occupant. **FIX**: EntityKind filter. Note: temp sessions are built via `snapshotSession` which copies all `isPieceAttr(f.attr)` facts — if markers are excluded from `snapshotSession` upfront, this is moot. **Recommend snapshotSession-level fix instead** (see #50). | +| 21 | 240 | (b) WRITE | `temp.insert(move.pieceId, "Position", move.to)` (what-if) | none | + +### Rules — `packages/chess/src/rules/stalemate.ts` + +| # | Line | Class | Snippet | Risk / Fix | +|---|------|-------|---------|------------| +| 22 | 77 | (c) ITER | `if (move.isCapture && f.attr === "Position" && f.value === move.to) continue` (what-if build) | **RISK** — copies all Position facts EXCEPT one at the capture square. Marker at the same square would be wrongly skipped during what-if construction. **FIX**: when copying, filter by EntityKind; the capture-square skip should also be piece-only. | +| 23 | 87 | (c) ITER | `find(g.attr === "Position" && g.value === move.to && g.id !== move.pieceId)` | **RISK** — same as #22. | +| 24 | 102 | (b) WRITE | `temp.insert(move.pieceId, "Position", move.to)` | none | + +### Rules — `packages/chess/src/rules/draws.ts` + +| # | Line | Class | Snippet | Risk / Fix | +|---|------|-------|---------|------------| +| 25 | 40 | (c) ITER | `computePositionHash`: includes facts where `attr === "Position"` (alongside PieceType, Color) for `id > 0` | **RISK** — marker positions would change the hash, breaking 3-fold-repetition: a turn-counter marker that ticks every turn would defeat repetition detection. **FIX**: filter to `EntityKind === "piece"` so only piece-Position facts contribute to the hash. **Wave-2/T6 OWNER**. | + +### Rules — `packages/chess/src/rules/insufficient.ts` + +| # | Line | Class | Snippet | Risk / Fix | +|---|------|-------|---------|------------| +| 26 | 38 | (a) READ via JOIN | `getPieces`: for each PieceType fact, `facts.find(p.attr === "Position")` | **SAFE** — outer iteration is `PieceType`-keyed; markers have no PieceType. No fix. | + +### Modifiers — `packages/chess/src/modifiers/auras.ts` + +| # | Line | Class | Snippet | Risk / Fix | +|---|------|-------|---------|------------| +| 27 | 102 | (c) ITER | `collectPiecesWithPositions`: walks Position facts, filters `id > 0` | **RISK / DESIGN POINT** — per T0 decisions.md L55: *"Markers participate in aura compute"*. So this iteration SHOULD include markers AS targets/sources. Current code names the function "Pieces…" but post-T7 it should also yield markers. **FIX (T7)**: rename to `collectPositionedEntities`, RETAIN the include-all behaviour, but split source-vs-target classification by `EntityKind` (markers can be aura sources, e.g. portal aura debuff; pieces are typical targets). The `staging`-write loop (L84) writes `AuraContributions` to the target — marker targets are valid per T0, but markers don't currently consume `AuraContributions`. T7 must decide: (1) skip markers as targets, OR (2) let markers carry AuraContributions for downstream consumers. **AUDIT-RECORDED, NEEDS T7 DESIGN CALL.** | + +### Modifiers — `packages/chess/src/modifiers/apply.ts` + +| # | Line | Class | Snippet | Risk / Fix | +|---|------|-------|---------|------------| +| 28 | 234 | (c) ITER | `snapshotPositions`: walks Position facts, `id > 0` filter | **RISK** — used by `diffMovedPieceIds` (L248) → `fireOnMoveHooks` (L1040). A marker that "moves" each turn (e.g. a future moving-marker variant) would be reported as a moved piece, firing on-move hooks on a marker. **FIX**: filter to `EntityKind === "piece"`. **Wave-2/T6 OWNER**. | +| 29 | 254 | (a) READ | `diffMovedPieceIds`: `session.get(id, "Position")` for ids from snapshot | **CHAINED RISK** from #28 — if snapshot already filters to pieces, this is safe. **FIX is upstream at #28.** | +| 30 | 370 | (a) READ | `find(f.id === royalId && f.attr === "Position")` | **SAFE** — same as #19; royalId is piece-typed. | +| 31 | 405 | (a) READ via JOIN | `capturePromotionCandidates`: outer is PieceType=pawn | **SAFE** — same as #26. | +| 32 | 502 | (c) ITER | `buildSquareIndex`: maps square→EntityId from Position facts | **RISK** — if marker and piece share a square, last-write-wins on the Map. Modifier perInstance lookup (`b1` → entityId) would resolve to whichever wrote last. **FIX**: filter to `EntityKind === "piece"` — modifiers target pieces, not markers. **Wave-2/T6 OWNER**. | +| 33 | 1048 | (a) READ | `session.get(id, "Position")` for `id` in `movedIds` | **CHAINED** from #28. Fix at upstream snapshot. | + +### Modifiers — `packages/chess/src/modifiers/source.ts` + +| # | Line | Class | Snippet | Risk / Fix | +|---|------|-------|---------|------------| +| 34 | 26 | (a) READ | `engine.session.get(pieceId, "Position")` | **API-LAYER** — function takes `pieceId: EntityId`. Caller responsibility to pass piece ids. No fix here; document piece-only contract. | + +### Modifiers — `packages/chess/src/modifiers/primitives/context.ts` + +| # | Line | Class | Snippet | Risk / Fix | +|---|------|-------|---------|------------| +| 35 | 148 | (c) ITER | `resolveBySquares`: walks Position facts, filters `id > 0`, returns matching ids | **RISK** — used by modifier consumers to resolve target ids by square. With markers at the same square, marker ids leak into the consumer's effect target list (e.g. `add-to-attribute` would try to mutate a marker's HpBonus). **FIX**: filter to `EntityKind === "piece"`. **Wave-2/T6 OWNER**. | +| 36 | 151 | typed-cast | `f.value as ChessAttrMap["Position"]` | n/a — type cast inside #35. | + +### Modifiers — `packages/chess/src/modifiers/primitives/seed-attribute.ts` + +| # | Line | Class | Snippet | Risk / Fix | +|---|------|-------|---------|------------| +| 37 | 9 | static | `"Position"` listed in `CHESS_ATTR_KEYS` allow-list | n/a — schema constant. Note: T6 adds `EntityKind` to schema; this set must grow accordingly (separate concern). | + +### Modifiers — `packages/chess/src/modifiers/triggers.ts` + +| # | Line | Class | Snippet | Risk / Fix | +|---|------|-------|---------|------------| +| 38 | 590 | (a) READ | `find(f.id === royalId && f.attr === "Position")` | **SAFE** — same as #19 (royalId is piece-typed). | + +### Presets — `packages/chess/src/presets/` + +| # | File:Line | Class | Snippet | Risk / Fix | +|---|-----------|-------|---------|------------| +| 39 | knights-leap-twice.ts:29 | (a) READ via JOIN | `find(f.id === pieceId && f.attr === "Position")` | **API-LAYER** — `pieceId` from move-gen-extras callsite (engine guarantees piece-typed). No fix. | +| 40 | bishops-ignore-color.ts:35 | (a) READ via JOIN | same shape | **API-LAYER**. No fix. | +| 41 | rook-warp.ts:53 | (a) READ via JOIN | same shape | **API-LAYER**. No fix. | +| 42 | queen-splits.ts:72 | (c) ITER | `find(f.attr === "Position" && f.value === sq && f.id > 0)` (`pieceAtSquare` helper) | **RISK** — local helper duplicates `engine.getPieceAt`. Marker at sq leaks. **FIX**: add EntityKind filter OR consolidate to engine helper. | +| 43 | queen-splits.ts:106 | (a) READ | `session.get(target, "Position")` where `target` is the capture target id from onBeforeCapture | **CHAINED** — if engine routes a marker as a capture target (T19/T22 says markers don't get captured normally), safe. Audit: confirm T22 contract excludes marker-as-target. | +| 44 | king-heals.ts:65 | (a) READ | `session.get(kingId, "Position")` | **SAFE** — kingId from royal set. | +| 45 | explosive-rook.ts:34 | (c) ITER | `pieceAtSquare` helper, same as #42 | **RISK / FIX**: same as #42. | +| 46 | explosive-rook.ts:57 | (a) READ | `session.get(target, "Position")` | **CHAINED** as #43. | +| 47 | explosive-rook.ts:116 | (b) WRITE | `session.insert(attacker, "Position", targetPos)` | none | +| 48 | last-piece-standing.ts:61 | (c) ITER | walks Position facts, joins via Color map | **PARTIAL-SAFE** — markers without Color are skipped. **Recommend** explicit EntityKind filter for clarity. | +| 49 | piece-hp.ts:242 | (a) READ via JOIN | `find(p.id === f.id && p.attr === "Position")` where outer is PieceType=king | **SAFE** — outer filter is piece-typed. | +| 50 | knight-immunity.ts:16 | (c) ITER | `find(f.attr === "Position" && f.value === m.to)` to identify capture target type | **RISK** — marker at `m.to` would be probed for PieceType (none) → falls through `targetType !== "knight"` → move kept. Mostly benign in this preset's logic but symptomatic of the pattern. **FIX**: EntityKind filter. | +| 51 | poisoned-squares.ts:45 | (c) ITER | iterates Position facts, victims are entities standing on poisoned squares | **RISK** — markers on poisoned squares would be in `victims`; `dealDamage` then runs against marker (no Hp; pipeline likely no-ops, but emits visual `poison` effect uselessly and may interact with marker retraction). **FIX**: EntityKind filter on the victim-collection loop. **Wave-2/T6 OWNER**. | +| 52 | poisoned-squares.ts:60 | (a) READ | `engine.session.get(id, "Position")` for id from victim list | **CHAINED** from #51. | +| 53 | wrap-board.ts:228 | (a) READ via JOIN | `find(f.id === pieceId && f.attr === "Position")` | **API-LAYER** — getExtraMoves contract is piece-typed. No fix. | +| 54 | bouncing-pieces.ts:228 | (a) READ via JOIN | same | **API-LAYER**. | +| 55 | bouncing-pieces-2.ts:217 | (a) READ via JOIN | same | **API-LAYER**. | +| 56 | berolina-pawns.ts:348 | (b) RETRACT | iterates `["PieceType","Color","Position","HasMoved"]` to retract on captured pawn id | none | +| 57 | berolina-pawns-2.ts:244 | (b) RETRACT | same | none | +| 58 | dual-king.ts:106 | (c) ITER | walks all facts; bucketizes into `kingIds`/`colorById`/`hasPosition` then composes | **SAFE** — final filter is PieceType=king; markers excluded. | +| 59 | coregal.ts:105 | (c) ITER | same shape | **SAFE** — final filter `t === "king" \|\| t === "queen"`. | +| 60 | weak-dual-king.ts:126 | (c) ITER | same shape | **SAFE** — same as #58. | +| 61 | weak-dual-king.ts:166 | (a) READ via JOIN | `find(f.id === id && f.attr === "Position")` for id from royal set | **SAFE**. | +| 62 | weak-dual-king.ts:199 | (c) ITER | `find(f.attr === "Position" && f.value === move.to)` (clearSquare in temp) | **RISK** — same shape as #20. **FIX**: EntityKind filter. | +| 63 | weak-dual-king.ts:211 | (b) WRITE | `temp.insert(move.pieceId, "Position", move.to)` (what-if) | none | +| 64 | suicide-chess.ts:261 | (c) ITER | composes Position presence with PieceType-anchored hasType set | **SAFE** — final filter is piece-typed. | +| 65 | extinction-chess.ts:207 | (c) ITER | same shape | **SAFE**. | +| 66 | transferable-royalty.ts:118 | (a) READ via JOIN | for each kingId, `f.attr === "Position"` to mark alive | **SAFE** — kingIds are PieceType-anchored. | +| 67 | capture-all.ts:149 | (c) ITER | counts Position facts per Color | **PARTIAL-SAFE** — same as #48; markers without Color naturally skipped. Recommend explicit EntityKind filter. | + +### UI + +| # | File:Line | Class | Snippet | Risk / Fix | +|---|-----------|-------|---------|------------| +| 68 | ui/GameView.tsx:319 | (c) ITER | `find(p.id === f.id && p.attr === 'Position')` for kings | **SAFE** — outer iteration is PieceType=king; markers excluded. | +| 69 | ui/GameView.tsx:341 | (a) READ | `engine.session.contains(id, "Position")` for id from royal-set | **SAFE**. | +| 70 | ui/GameView.tsx:350 | (c) ITER | `find(pf.id === f.id && pf.attr === "Position")` outer PieceType=king | **SAFE**. | +| 71 | ui/GameView.tsx:374 | (c) ITER | `find(pf.id === f.id && pf.attr === "Position")` outer Color=mover | **RISK** — outer filter is `Color === turnAsColor`. Markers don't have Color → naturally excluded. **PARTIAL-SAFE**; recommend explicit EntityKind filter for clarity. | +| 72 | ui/Board.tsx:103 | (c) ITER | `if (fact.attr === 'Position') ent.pos = fact.value` (collects entity rendering data) | **RISK** — Board.tsx renders any entity with PieceType+Color+Position. Markers won't render as pieces (no PieceType+Color), but the collected `entityFacts` map populates marker entries unnecessarily. **NO FUNCTIONAL FIX REQUIRED** for piece rendering. **HOWEVER**, Wave-3 marker-rendering UI must add a parallel branch that consumes markers via `EntityKind === 'marker'`. AUDIT FLAG for UI work in T20+. | + +### Net / Hooks (production) + +| # | File:Line | Class | Snippet | Risk / Fix | +|---|-----------|-------|---------|------------| +| 73 | hooks/useMultiplayerGame.ts:308 | comment | derives last-move from Position facts (comment only) | n/a — no live read here, just a tracking comment. (No code touches Position on this line; it's a doc comment in a memoization block.) | +| 74 | layouts/chess960.ts:177 | string-literal | `"Position number shown in the name."` (descriptor text) | n/a — string is in the rule preset description, NOT the attr. False positive of grep. | +| 75 | presets/registry.ts:138 | docstring | comment referencing `engine.session.get(pieceId, "Position")` | n/a — JSDoc. | + +--- + +## Tests (production-confidence verification only — fix in lockstep when production lines change) + +All test-suite callsites for `"Position"` fall into three categories: + +- **Setup/seed writes** (mostly `session.insert(eid, "Position", sq)` in helpers like `placePiece`/`makePiece`): pure `(b) WRITE`s, no risk. They simulate piece placement; tests will need `EntityKind: "piece"` seeded too once T6 lands. +- **Assertion reads** (`expect(session.get(X, "Position"))…`): test-only, mirror production behaviour. Will need test updates iff the production behaviour they probe changes (e.g. `getPieceAt` adding EntityKind filter — tests currently passing piece ids will continue to pass). +- **Find-by-position queries** (`facts.find(f => f.attr === "Position" && f.value === sq)`): mirrors production patterns; same risk surface but inside test-only state. + +Files & rough line counts: + +| File | Hits | Notes | +|------|------|-------| +| schema.test.ts | 1 (L21) | `chessFact` factory | +| starting-position.test.ts | 5 (L37, 70, 107, 230, 232) | reads + filter | +| rules/pawn.test.ts | 2 | seed | +| rules/knight.test.ts | 1 | seed | +| rules/sliding.test.ts | 2 | seed | +| rules/king.test.ts | 1 | seed | +| rules/capture.test.ts | 3 | seed + contains-checks | +| rules/turn.test.ts | 6 | seed + asserts | +| rules/castling.test.ts | 7 | seed + retract + asserts | +| rules/enpassant.test.ts | 6 | seed + asserts | +| rules/promotion.test.ts | 3 | seed + assert | +| rules/check.test.ts | 1 | seed | +| rules/stalemate.test.ts | 1 | seed | +| rules/checkmate.test.ts | 1 | seed | +| rules/draws.test.ts | 7 | seed | +| rules/insufficient.test.ts | 1 | seed | +| presets/pawns-move-backward.test.ts | 3 | seed/assert | +| presets/knights-bishop-rook.test.ts | 1 | seed | +| presets/wrap-board.test.ts | 5 | seed/retract | +| presets/piece-hp.test.ts | 1 | (c)-ITER find | +| presets/fleshed-presets.test.ts | 11 | mixed | +| presets/damage-pipeline.test.ts | 13 | mixed | +| presets/move-log.test.ts | 1 | (c) find | +| presets/visual-effect.test.ts | 3 | seed/loop | +| presets/phase-hooks.test.ts | 1 | (c) find | +| presets/test-utils.ts | 2 | helpers | +| presets/integration.test.ts | 3 | mixed | +| presets/transform-hook.test.ts | 8 | mixed | +| presets/royal-pieces.test.ts | 1 | seed | +| presets/override-piece-moves.test.ts | 2 | (a)+(c) | +| presets/knightmate-rules.test.ts | 1 | (c) find | +| presets/presets.test.ts | 1 | seed | +| presets/first-promotion-wins.test.ts | 1 | seed | +| presets/extinction-chess.test.ts | 1 | retract | +| presets/berolina-pawns.test.ts | 5 | seed/retract/assert | +| presets/berolina-pawns-2.test.ts | 1 | assert | +| presets/bouncing-pieces.test.ts | 1 | seed | +| presets/bouncing-pieces-2.test.ts | 1 | seed | +| presets/transferable-royalty.test.ts | 5 | retract | +| net/client.test.ts | 2 | fixture | +| net/prediction.test.ts | 3 | mixed | +| modifiers/descriptors/direction-additions.test.ts | 6 | seed | +| modifiers/descriptors/promotion-override.test.ts | 1 | seed | +| modifiers/reconcile.test.ts | 3 | (c) find / assert | +| modifiers/source.test.ts | 4 | (c) find | +| modifiers/custom/apply.test.ts | 1 | (c) find | +| modifiers/custom/legacy-descriptor.test.ts | 1 | (c) find | +| modifiers/primitives/consumer-integration.test.ts | 2 | (c) find / retract | +| modifiers/primitives/context.test.ts | 1 | seed | +| modifiers/apply.test.ts | 6 | (c) find | +| modifiers/auras.test.ts | 4 | (c) find / seed | +| modifiers/triggers.test.ts | 1 | (c) find | +| util/state-hash.test.ts | 3 | seed | + +Total test hits: ≈137. **Action**: when a production line changes (e.g. `getPieceAt` adds EntityKind filter), grep its test file for matching `find` patterns and add `EntityKind` to test fixtures. T6 must seed `EntityKind: "piece"` automatically in `engine.spawnPiece` so existing test setup paths inherit the discriminator without per-test edits. + +--- + +## Summary by Class (production code only — items 1–75) + +| Class | Count | Action | +|-------|-------|--------| +| (a) PIECE-ASSUMING READ | **8** of which **0 unsafe** (4 SAFE via piece-typed precondition; 3 API-LAYER; 1 chained) | Document piece-only contract on `getPiecePosition`, `getModifierSource`. T7 confirms aura compute design call. | +| (b) WRITE | **15** | Inherently safe. No fix. | +| (c) ITERATION | **22** of which **9 unsafe** + **5 partial-safe** | **Wave-2/T6/T7 fix list below.** | +| Static / typed / docstring / false-positive | **5** | n/a | + +Production callsites total: **~50 grep-distinct lines** across 27 production files. +Test callsites total: ~137 lines across 50 test files. + +--- + +## Fix Tracker (HIGH-PRIORITY — for downstream tasks) + +These callsites MUST be revisited in Wave 2 (T6 — schema/EntityKind landing) or Wave 4 (T7 — aura compute): + +### T6 (schema / engine.spawnPiece + EntityKind seeding) MUST update: +- `engine.ts:1680` — `getPieceAt` private — add EntityKind filter +- `rules/board-queries.ts:38` — `isPieceAt` — EntityKind filter +- `rules/board-queries.ts:46` — `isEnemyAt` — EntityKind filter on the position-find +- `rules/board-queries.ts:56` — `isAllyAt` — EntityKind filter on the position-find +- `rules/board-queries.ts:78` — `getPieceAt` (public) — EntityKind filter +- `rules/capture.ts:94` — `canCapture` — EntityKind filter +- `rules/check.ts:173` — `clearSquare` (or fix at `snapshotSession` upstream) — EntityKind filter +- `rules/stalemate.ts:77, 87` — what-if construction — EntityKind filter on Position-skip and Position-find +- `rules/draws.ts:40` — `computePositionHash` — exclude marker Position from hash +- `modifiers/apply.ts:234` — `snapshotPositions` — EntityKind filter (chains to L254, L1048) +- `modifiers/apply.ts:502` — `buildSquareIndex` — EntityKind filter +- `modifiers/primitives/context.ts:148` — `resolveBySquares` — EntityKind filter +- `presets/queen-splits.ts:72` — local `pieceAtSquare` — EntityKind filter (or consolidate) +- `presets/explosive-rook.ts:34` — local `pieceAtSquare` — same +- `presets/knight-immunity.ts:16` — capture-target probe — EntityKind filter +- `presets/poisoned-squares.ts:45` — victim collection — EntityKind filter (chains to L60) +- `presets/weak-dual-king.ts:199` — what-if `clearSquare` analogue — EntityKind filter + +### T7 (aura compute + marker participation) MUST address: +- `modifiers/auras.ts:102` — `collectPiecesWithPositions` — DESIGN CALL: include markers as sources/targets per T0; emit `AuraContributions` only to consumers that read it. Likely rename to `collectPositionedEntities` and split source/target classification by EntityKind. + +### Wave-3+ (marker rendering UI) MUST address: +- `ui/Board.tsx:103` — add parallel marker-rendering pass (entities with `EntityKind === 'marker'`) +- `ui/GameView.tsx` — surface marker overlays where appropriate (separate visual tier) + +### Recommended-but-not-required (PARTIAL-SAFE today, hardening for clarity): +- `rules/board-queries.ts:22` — `getBoardPieces` +- `presets/last-piece-standing.ts:61` +- `presets/capture-all.ts:149` +- `ui/GameView.tsx:374` + +### API-LAYER contracts to document (no code change, but JSDoc): +- `rules/board-queries.ts:65` — `getPiecePosition` — "callers must pass piece ids" +- `modifiers/source.ts:26` — `getModifierSource` — same +- `presets/knights-leap-twice.ts:29`, `bishops-ignore-color.ts:35`, `rook-warp.ts:53`, `wrap-board.ts:228`, `bouncing-pieces.ts:228`, `bouncing-pieces-2.ts:217` — `getExtraMoves` contract is piece-typed by engine + +--- + +## Cross-Reference: Files NOT requiring action + +These touched Position but are SAFE because the calling chain already filters by PieceType (which markers lack): +- `rules/check.ts:115` — defaultRoyalIds → PieceType=king +- `rules/insufficient.ts:38` — outer PieceType filter +- `modifiers/apply.ts:370, 405` — royal/promotion candidate sets are PieceType-anchored +- `modifiers/triggers.ts:590` — same +- `presets/dual-king.ts, coregal.ts, weak-dual-king.ts:126/166, suicide-chess.ts, extinction-chess.ts, transferable-royalty.ts, piece-hp.ts:242, king-heals.ts:65, ui/GameView.tsx:319/341/350` — all PieceType-anchored. + +--- + +## Open Questions for T6/T7 leads + +1. **Marker-as-capture-target**: confirm T22's contract that `dealDamage(markerId, …)` is a no-op (markers can't be "captured" through the pipeline). If yes, `queen-splits.ts:106` and `explosive-rook.ts:57` are safe via #43/#46 chain. +2. **Aura targets**: do markers carry `AuraContributions`? T7 design call. Currently `auras.ts:84` writes contributions to ANY positioned entity within range. +3. **Snapshot-based what-if** (`check.ts:snapshotSession`, `stalemate.ts` inline, `weak-dual-king.ts:snapshotPieces`): each independently copies "piece-level facts" via `isPieceAttr(f.attr)`. Once T6 adds `EntityKind` to the schema, `isPieceAttr` returns true for it (it's not in `GAME_LEVEL_ATTRS`), so markers' EntityKind facts WILL flow into the temp session. This is correct — what-if simulation should preserve markers — but the helpers' `clearSquare` analogues must then filter by EntityKind to avoid clearing markers. Logged here as a class-level concern; per-helper fixes already itemized above (#20, #22-23, #62). diff --git a/.sisyphus/notepads/thressgame-coverage/var-conflict-audit.md b/.sisyphus/notepads/thressgame-coverage/var-conflict-audit.md new file mode 100644 index 0000000..625711c --- /dev/null +++ b/.sisyphus/notepads/thressgame-coverage/var-conflict-audit.md @@ -0,0 +1,115 @@ +# Param Walker `$var` Conflict Audit (T5) + +> Confirms that the planned binding shape `{ $var: "name" }` (T12) does not collide with any existing param or descriptor literal in the codebase. + +**Date Audited:** 2026-04-26 +**Status:** ✅ CLEAN + +--- + +## Methodology + +Comprehensive grep searches across `packages/chess/src/` to detect any existing `$`-prefixed string keys or values that could collide with the planned T12 binding shape `{ $var: "..." }`. + +### Searches Performed + +1. `'"\$'` in `modifiers/primitives/` — search for literal `"$...` strings in primitive implementations +2. `'"\$'` in `modifiers/` (broad) — search across all modifiers including library/templates +3. `'"\$'` in any `*.json` descriptor fixture — search all JSON descriptor files +4. `'\$var'` literal anywhere — explicit search for `$var` variable name usage +5. `'"\$'` in `__fixtures__/` — search test fixtures for `$`-prefixed literals +6. Broader `\$` in `.json` files — catch any `$` in JSON contexts + +Raw grep output: `.sisyphus/evidence/task-5-var-conflict.txt` + +--- + +## Findings + +### Search 1: `"$` in `modifiers/primitives/` +**Result:** 0 matches ✓ +**Classification:** N/A + +### Search 2: `"$` in `modifiers/` (broad) +**Result:** 0 matches ✓ +**Classification:** N/A + +### Search 3: `"$` in `*.json` descriptor fixtures +**Result:** 0 matches ✓ +**Classification:** N/A + +### Search 4: `$var` literal anywhere in `packages/chess/src/` +**Result:** 0 matches ✓ +**Classification:** N/A + +### Search 5: `"$` in `__fixtures__/` files +**Result:** 0 matches ✓ +**Classification:** N/A + +### Search 6: Broader `$` in `.json` files +**Result:** 0 matches ✓ +**Classification:** N/A + +--- + +## Context: Existing Param Schemas + +Found 66+ `paramsSchema` definitions across primitive implementations: +- `seed-attribute.ts` +- `absorb-damage-with-attribute.ts` +- `reflect-damage.ts` +- `add-aura.ts` +- `modify-movement-range.ts` +- `on-turn-start.ts` +- `block-move-type.ts` +- `add-to-attribute.ts` +- `multiply-attribute.ts` +- `on-capture.ts` +- `override-promotion.ts` +- `add-direction.ts` +- `set-capture-flag.ts` +- `on-damaged.ts` +- `conditional.ts` +- `on-move.ts` +- `on-promotion.ts` +- `on-check-received.ts` +- `on-check-delivered.ts` +- `on-moved-onto-square.ts` +- `on-captured.ts` +- `on-turn-end.ts` + +**All schemas use conventional keys:** `key`, `value`, `delta`, `flag`, `ratio`, `to`, `direction`, `mode`, `moveType`, `target`, `percentage`, `condition`, `effects`, etc. + +**No `$`-prefixed keys detected in any schema definition.** + +--- + +## Verdict: ✅ CLEAN + +**Confirmed:** The planned binding shape `{ $var: "name" }` (T12) is **safe to implement without collisions**. + +### Impact Assessment + +- ✅ No existing primitive params use `$var` or any `$`-prefixed key +- ✅ No descriptor JSON fixtures contain `$`-prefixed values +- ✅ No template or library code uses `$` as a key prefix +- ✅ T12 can proceed with the binding shape as designed + +### Recommendation + +- Proceed with T12 implementation using `{ $var: "name" }` binding shape +- No renaming of binding shape required +- No code collisions to resolve + +--- + +## Audit Verification + +- Evidence file: `.sisyphus/evidence/task-5-var-conflict.txt` +- Total searches: 6 +- Total matches across all searches: **0** +- Audit document: this file (47 lines) + +--- + +**Audit completed:** 2026-04-26 | **Blocked by:** T0 | **Blocks:** T12 diff --git a/.sisyphus/plans/thressgame-coverage.md b/.sisyphus/plans/thressgame-coverage.md new file mode 100644 index 0000000..c6aad48 --- /dev/null +++ b/.sisyphus/plans/thressgame-coverage.md @@ -0,0 +1,1926 @@ +# 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 ` 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` + +- [ ] 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` + +- [ ] 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` + +- [ ] 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 + +- [ ] 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(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` + +- [ ] 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` + +- [ ] 11. Binding scope stack on PrimitiveApplyContext + + **What to do**: + - Edit `packages/chess/src/modifiers/primitives/context.ts`: extend `PrimitiveApplyContext` with `bindings: ReadonlyMap` + - 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` + +- [ ] 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` + +- [ ] 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` + +- [ ] 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 + +- [ ] 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 + +- [ ] 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 + +- [ ] 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` + +- [ ] 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` + +- [ ] 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` + +- [ ] 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 `.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.** + +- [ ] 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` + +- [ ] 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` + +- [ ] 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` + +- [ ] 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` + +- [ ] 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` + +- [ ] 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` + +- [ ] 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. + +- [ ] 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` + +- [ ] 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` + +- [ ] 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` + +- [ ] 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` + +- [ ] 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` + +- [ ] 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` + +- [ ] 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` + +- [ ] 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. + +- [ ] 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` + +- [ ] 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` + +- [ ] 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` + +- [ ] 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` + +- [ ] 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` + +- [ ] 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` + +- [ ] 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. + +- [ ] 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)` + +- [ ] 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` + +- [ ] 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, 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` + +- [ ] 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` + +- [ ] 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` + +- [ ] 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` + +- [ ] 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` + +- [ ] 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. + +- [ ] 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` + +- [ ] 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` + +- [ ] 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` + +- [ ] 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` + +- [ ] 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` + +- [ ] 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` + +- [ ] 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` + +- [ ] 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. + +- [ ] 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` + +- [ ] 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` + +- [ ] 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` + +- [ ] 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` + +- [ ] 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` + +- [ ] 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` + +- [ ] 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` + +- [ ] 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` + +- [ ] 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` + +- [ ] 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` + +- [ ] 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 diff --git a/packages/chess/src/__fixtures__/baseline-primitive-tests.json b/packages/chess/src/__fixtures__/baseline-primitive-tests.json new file mode 100644 index 0000000..e598dfc --- /dev/null +++ b/packages/chess/src/__fixtures__/baseline-primitive-tests.json @@ -0,0 +1,30 @@ +[ + { "kind": "absorb-damage-with-attribute", "file": "absorb-damage-with-attribute.test.ts", "testCount": 4, "expectCount": 8 }, + { "kind": "add-aura", "file": "add-aura.test.ts", "testCount": 4, "expectCount": 6 }, + { "kind": "add-direction", "file": "add-direction.test.ts", "testCount": 7, "expectCount": 8 }, + { "kind": "add-to-attribute", "file": "add-to-attribute.test.ts", "testCount": 4, "expectCount": 5 }, + { "kind": "block-move-type", "file": "block-move-type.test.ts", "testCount": 4, "expectCount": 5 }, + { "kind": "conditional", "file": "conditional.test.ts", "testCount": 6, "expectCount": 7 }, + { "kind": "consumer-integration", "file": "consumer-integration.test.ts", "testCount": 6, "expectCount": 11 }, + { "kind": "context", "file": "context.test.ts", "testCount": 13, "expectCount": 20 }, + { "kind": "docs", "file": "docs.test.ts", "testCount": 3, "expectCount": 8 }, + { "kind": "manifest", "file": "manifest.test.ts", "testCount": 5, "expectCount": 14 }, + { "kind": "modify-movement-range", "file": "modify-movement-range.test.ts", "testCount": 4, "expectCount": 5 }, + { "kind": "multiply-attribute", "file": "multiply-attribute.test.ts", "testCount": 4, "expectCount": 5 }, + { "kind": "on-captured", "file": "on-captured.test.ts", "testCount": 10, "expectCount": 23 }, + { "kind": "on-capture", "file": "on-capture.test.ts", "testCount": 4, "expectCount": 5 }, + { "kind": "on-check-delivered", "file": "on-check-delivered.test.ts", "testCount": 8, "expectCount": 10 }, + { "kind": "on-check-received", "file": "on-check-received.test.ts", "testCount": 5, "expectCount": 9 }, + { "kind": "on-damaged", "file": "on-damaged.test.ts", "testCount": 4, "expectCount": 5 }, + { "kind": "on-moved-onto-square", "file": "on-moved-onto-square.test.ts", "testCount": 14, "expectCount": 15 }, + { "kind": "on-move", "file": "on-move.test.ts", "testCount": 7, "expectCount": 10 }, + { "kind": "on-promotion", "file": "on-promotion.test.ts", "testCount": 6, "expectCount": 9 }, + { "kind": "on-turn-end", "file": "on-turn-end.test.ts", "testCount": 6, "expectCount": 7 }, + { "kind": "on-turn-start", "file": "on-turn-start.test.ts", "testCount": 4, "expectCount": 5 }, + { "kind": "override-promotion", "file": "override-promotion.test.ts", "testCount": 4, "expectCount": 7 }, + { "kind": "reflect-damage", "file": "reflect-damage.test.ts", "testCount": 4, "expectCount": 6 }, + { "kind": "registry-count", "file": "registry-count.test.ts", "testCount": 2, "expectCount": 4 }, + { "kind": "registry", "file": "registry.test.ts", "testCount": 6, "expectCount": 8 }, + { "kind": "seed-attribute", "file": "seed-attribute.test.ts", "testCount": 4, "expectCount": 5 }, + { "kind": "set-capture-flag", "file": "set-capture-flag.test.ts", "testCount": 4, "expectCount": 5 } +] diff --git a/packages/chess/src/__fixtures__/baseline-regression.test.ts b/packages/chess/src/__fixtures__/baseline-regression.test.ts new file mode 100644 index 0000000..6dc3bd3 --- /dev/null +++ b/packages/chess/src/__fixtures__/baseline-regression.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import fs from "node:fs/promises"; +import path from "node:path"; + +describe("backward-compat baseline regression", () => { + let baseline: { files: number; tests: number; timestamp: string }; + let primitiveBaseline: Array<{ + kind: string; + file: string; + testCount: number; + expectCount: number; + }>; + + it("loads baseline JSON files", async () => { + const baselineDir = path.dirname(import.meta.url.replace("file://", "")); + const baselinePath = path.join(baselineDir, "baseline-test-count.json"); + const primitivePath = path.join(baselineDir, "baseline-primitive-tests.json"); + + const baselineData = await fs.readFile(baselinePath, "utf8"); + const primitiveData = await fs.readFile(primitivePath, "utf8"); + + baseline = JSON.parse(baselineData); + primitiveBaseline = JSON.parse(primitiveData); + + expect(baseline).toBeDefined(); + expect(primitiveBaseline).toBeDefined(); + }); + + it("primitive baseline JSON shape is well-formed", async () => { + const baselineDir = path.dirname(import.meta.url.replace("file://", "")); + const primitivePath = path.join(baselineDir, "baseline-primitive-tests.json"); + const primitiveData = await fs.readFile(primitivePath, "utf8"); + primitiveBaseline = JSON.parse(primitiveData); + + expect(Array.isArray(primitiveBaseline)).toBe(true); + expect(primitiveBaseline.length).toBeGreaterThanOrEqual(1); + for (const entry of primitiveBaseline) { + expect(typeof entry.kind).toBe("string"); + expect(typeof entry.file).toBe("string"); + expect(entry.testCount).toBeGreaterThanOrEqual(1); + expect(entry.expectCount).toBeGreaterThanOrEqual(1); + } + }); + + it("baseline-test-count.json has expected shape", async () => { + const baselineDir = path.dirname(import.meta.url.replace("file://", "")); + const baselinePath = path.join(baselineDir, "baseline-test-count.json"); + const baselineData = await fs.readFile(baselinePath, "utf8"); + baseline = JSON.parse(baselineData); + + expect(baseline.tests).toBeGreaterThanOrEqual(1324); + expect(baseline.files).toBeGreaterThanOrEqual(1); + expect(typeof baseline.timestamp).toBe("string"); + }); + + it("each primitive .test.ts file still meets-or-exceeds its baseline", async () => { + const baselineDir = path.dirname(import.meta.url.replace("file://", "")); + const primitivePath = path.join(baselineDir, "baseline-primitive-tests.json"); + const primitiveData = await fs.readFile(primitivePath, "utf8"); + primitiveBaseline = JSON.parse(primitiveData); + + const dir = path.resolve(baselineDir, "..", "modifiers", "primitives"); + for (const entry of primitiveBaseline) { + const filePath = path.join(dir, entry.file); + const content = await fs.readFile(filePath, "utf8"); + const itMatches = content.match(/^\s*(it|test)\(/gm) ?? []; + const expectMatches = content.match(/expect\(/g) ?? []; + expect( + itMatches.length, + `${entry.file} test count regressed: expected >= ${entry.testCount}, got ${itMatches.length}` + ).toBeGreaterThanOrEqual(entry.testCount); + expect( + expectMatches.length, + `${entry.file} expect count regressed: expected >= ${entry.expectCount}, got ${expectMatches.length}` + ).toBeGreaterThanOrEqual(entry.expectCount); + } + }); +}); diff --git a/packages/chess/src/__fixtures__/baseline-test-count.json b/packages/chess/src/__fixtures__/baseline-test-count.json new file mode 100644 index 0000000..43e48b2 --- /dev/null +++ b/packages/chess/src/__fixtures__/baseline-test-count.json @@ -0,0 +1,5 @@ +{ + "files": 167, + "tests": 1961, + "timestamp": "2026-04-26T06:29:52Z" +} diff --git a/packages/chess/src/__fixtures__/determinism/harness.test.ts b/packages/chess/src/__fixtures__/determinism/harness.test.ts new file mode 100644 index 0000000..00af73a --- /dev/null +++ b/packages/chess/src/__fixtures__/determinism/harness.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from "vitest"; +import { ChessEngine } from "../../engine"; +import { runDeterminismCheck } from "./harness"; + +describe("runDeterminismCheck — standard chess sanity", () => { + it("standard chess is deterministic across 100 iterations (e2-e4, e7-e5)", () => { + const setup = () => new ChessEngine(); + const moves = [ + (engine: ChessEngine) => { + // White e2 (square 12) → e4 (square 28). + const legal = engine.getAllLegalMoves(); + const m = legal.find((mv) => mv.from === 12 && mv.to === 28); + if (m === undefined) throw new Error("e2-e4 not found in legal moves"); + engine.applyMove(m); + }, + (engine: ChessEngine) => { + // Black e7 (square 52) → e5 (square 36). + const legal = engine.getAllLegalMoves(); + const m = legal.find((mv) => mv.from === 52 && mv.to === 36); + if (m === undefined) throw new Error("e7-e5 not found in legal moves"); + engine.applyMove(m); + }, + ]; + + const start = Date.now(); + const result = runDeterminismCheck(setup, moves, 100); + const elapsed = Date.now() - start; + + expect(result.matches).toBe(true); + expect(result.iterations).toBe(100); + expect(result.hash).toMatch(/^[0-9a-f]{64}$/); + expect(result.mismatchAt).toBeUndefined(); + expect(result.mismatchHash).toBeUndefined(); + // Plan acceptance: 100 iterations must complete in < 5000ms. + expect(elapsed).toBeLessThan(5000); + }); + + it("default iterations = 100 when not specified", () => { + const setup = () => new ChessEngine(); + const result = runDeterminismCheck(setup, []); + expect(result.iterations).toBe(100); + expect(result.matches).toBe(true); + }); + + it("empty moves list still hashes the fresh engine deterministically", () => { + const setup = () => new ChessEngine(); + const result = runDeterminismCheck(setup, [], 10); + expect(result.matches).toBe(true); + expect(result.iterations).toBe(10); + expect(result.hash).toMatch(/^[0-9a-f]{64}$/); + }); + + it("rejects iterations < 1", () => { + expect(() => + runDeterminismCheck(() => new ChessEngine(), [], 0), + ).toThrow(/iterations must be >= 1/); + expect(() => + runDeterminismCheck(() => new ChessEngine(), [], -1), + ).toThrow(/iterations must be >= 1/); + }); + + it("detects non-determinism (negative test) and reports first divergent iteration", () => { + // Synthetic mismatch: iteration 0 builds a plain engine; iteration 1+ + // spawns an extra pawn. The harness must catch the divergence on + // iteration 1 and return mismatchAt=1 plus the divergent hash. + let callCount = 0; + const setup = () => { + const engine = new ChessEngine(); + if (callCount++ > 0) { + // Inject one extra fact set on iteration 1+; iteration 0 is the + // clean reference. + engine.spawnPiece("pawn", "white", 20); + } + return engine; + }; + + const result = runDeterminismCheck(setup, [], 5); + expect(result.matches).toBe(false); + expect(result.iterations).toBe(5); + expect(result.mismatchAt).toBe(1); + expect(result.hash).toMatch(/^[0-9a-f]{64}$/); + expect(result.mismatchHash).toMatch(/^[0-9a-f]{64}$/); + expect(result.mismatchHash).not.toBe(result.hash); + }); +}); diff --git a/packages/chess/src/__fixtures__/determinism/harness.ts b/packages/chess/src/__fixtures__/determinism/harness.ts new file mode 100644 index 0000000..c29131c --- /dev/null +++ b/packages/chess/src/__fixtures__/determinism/harness.ts @@ -0,0 +1,98 @@ +import type { ChessEngine } from "../../engine"; +import { hashEngineState } from "../../util/state-hash"; + +/** + * Build a fresh ChessEngine in initial state. + * + * Called once per iteration so that iteration N cannot leak any state + * (closures, caches, RNG cursors, accumulated facts) into iteration N+1. + * If the setup function captures mutable closure state, the harness will + * detect non-determinism and report it via {@link DeterminismResult.matches}. + */ +export type SetupFn = () => ChessEngine; + +/** + * Apply a single deterministic mutation to the given engine. + * + * Whatever the engine's actual move-application API is — typically + * `engine.applyMove(legalMove)` after looking the move up via + * `engine.getAllLegalMoves()`. The MoveFn closes over its own input + * lookups (e.g. a from/to pair) and is responsible for resolving them + * fresh against the engine on every iteration. + */ +export type MoveFn = (engine: ChessEngine) => void; + +export interface DeterminismResult { + /** SHA256 hex of the final state from iteration 0 (the reference). */ + readonly hash: string; + /** True iff every iteration produced the same hash. */ + readonly matches: boolean; + /** Number of iterations the harness was asked to run. */ + readonly iterations: number; + /** First iteration index that diverged. Set only when `matches === false`. */ + readonly mismatchAt?: number; + /** Hash from the first divergent iteration. Set only when `matches === false`. */ + readonly mismatchHash?: string; +} + +/** + * Run the same `setup → moves → hash` pipeline N times and assert the final + * state hash is byte-identical across every iteration. + * + * The harness contract: + * 1. Call `setup()` to build a fresh engine. + * 2. For each `move` in `moves`, invoke `move(engine)`. + * 3. Compute `hashEngineState(engine)`. + * 4. Compare to the reference hash from iteration 0. + * 5. On any mismatch, return immediately with `matches: false` plus the + * iteration index and divergent hash for diagnostic surfacing. + * + * No `Math.random` / `Date.now` / unsorted iteration is used internally — + * the harness itself is deterministic by construction. Any non-determinism + * MUST come from the setup or moves (which is exactly what we're testing). + * + * Default iterations = 100, matching the plan's N=100 byte-identical invariant + * (`.sisyphus/notepads/thressgame-coverage/decisions.md` "Seeded RNG"). + * + * @param setup Fresh-engine factory; called once per iteration. + * @param moves Sequence of mutations applied in order to each fresh engine. + * Empty array is legal — produces a "fresh-engine-only" hash. + * @param iterations Number of iterations to run (default 100; must be ≥ 1). + * @returns Result with reference hash and match status. + * @throws If `iterations < 1`. + */ +export function runDeterminismCheck( + setup: SetupFn, + moves: readonly MoveFn[], + iterations = 100, +): DeterminismResult { + if (iterations < 1) { + throw new Error( + `runDeterminismCheck: iterations must be >= 1, got ${iterations}`, + ); + } + + let referenceHash: string | undefined; + for (let i = 0; i < iterations; i++) { + const engine = setup(); + for (const move of moves) move(engine); + const hash = hashEngineState(engine); + if (referenceHash === undefined) { + referenceHash = hash; + continue; + } + if (hash !== referenceHash) { + return { + hash: referenceHash, + matches: false, + iterations, + mismatchAt: i, + mismatchHash: hash, + }; + } + } + + // referenceHash is defined here because iterations >= 1 means the loop + // ran at least once and assigned it. + return { hash: referenceHash as string, matches: true, iterations }; +} diff --git a/packages/chess/src/util/state-hash.test.ts b/packages/chess/src/util/state-hash.test.ts new file mode 100644 index 0000000..b00293b --- /dev/null +++ b/packages/chess/src/util/state-hash.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from "vitest"; +import { ChessEngine } from "../engine"; +import { hashEngineState } from "./state-hash"; + +describe("hashEngineState", () => { + it("returns 64-char lowercase hex", () => { + const engine = new ChessEngine(); + const hash = hashEngineState(engine); + expect(hash).toMatch(/^[0-9a-f]{64}$/); + }); + + it("identical state produces identical hash", () => { + const a = new ChessEngine(); + const b = new ChessEngine(); + expect(hashEngineState(a)).toBe(hashEngineState(b)); + }); + + it("one fact difference produces different hash", () => { + const a = new ChessEngine(); + const b = new ChessEngine(); + + // Spawn a pawn on b only, introducing one fact difference. + b.spawnPiece("pawn", "white", 12); + + const hashA = hashEngineState(a); + const hashB = hashEngineState(b); + expect(hashA).not.toBe(hashB); + }); + + it("insertion-order independence (same entity, different attr insertion order)", () => { + // Test that inserting attributes for the same entity in different orders + // produces the same hash. Since allFacts() sorts by [id, attr], the order + // of insertion doesn't affect the final state representation. + + const e1 = new ChessEngine(); + const id1 = e1.session.nextId(); + e1.session.insert(id1, "Color", "white"); + e1.session.insert(id1, "PieceType", "pawn"); + e1.session.insert(id1, "Position", 12); + const hash1 = hashEngineState(e1); + + // Replay with different insertion order + const e2 = new ChessEngine(); + const id2 = e2.session.nextId(); + e2.session.insert(id2, "Position", 12); + e2.session.insert(id2, "Color", "white"); + e2.session.insert(id2, "PieceType", "pawn"); + const hash2 = hashEngineState(e2); + + // Yet another order + const e3 = new ChessEngine(); + const id3 = e3.session.nextId(); + e3.session.insert(id3, "PieceType", "pawn"); + e3.session.insert(id3, "Position", 12); + e3.session.insert(id3, "Color", "white"); + const hash3 = hashEngineState(e3); + + expect(hash1).toBe(hash2); + expect(hash2).toBe(hash3); + }); +}); diff --git a/packages/chess/src/util/state-hash.ts b/packages/chess/src/util/state-hash.ts new file mode 100644 index 0000000..f726b3d --- /dev/null +++ b/packages/chess/src/util/state-hash.ts @@ -0,0 +1,50 @@ +import { createHash } from "node:crypto"; +import type { ChessEngine } from "../engine"; + +/** + * Deterministic SHA256 hash of a ChessEngine's full session state. + * + * Insertion order independent: same facts hash equally regardless of order. + * Two engines with identical facts MUST produce identical hashes; any single + * fact difference MUST produce a different hash. + * + * Algorithm: + * 1. Enumerate all facts via session.allFacts() (already sorted: id asc, attr asc). + * 2. Group facts by entity id. + * 3. For each entity id (in sorted order): stringify { id, facts: [[attr, value], ...] } + * where facts are sorted by attr name (already sorted by session). + * 4. Concatenate all stringified entries with newlines. + * 5. SHA256 the concatenation, return hex digest. + * + * Usage: determinism property tests (T2), parity tests (T59+), debugging. + */ +export function hashEngineState(engine: ChessEngine): string { + const session = engine.session; + const facts = session.allFacts(); + + // Group facts by entity id. Since allFacts() returns facts sorted by + // [id asc, attr asc], we can stream through once and accumulate. + const factsByEntity = new Map>(); + for (const fact of facts) { + const id = fact.id as number; + if (!factsByEntity.has(id)) { + factsByEntity.set(id, []); + } + factsByEntity.get(id)!.push([fact.attr, fact.value]); + } + + // Sort entity ids numerically (ascending) and build deterministic strings. + const entityIds = Array.from(factsByEntity.keys()).sort((a, b) => a - b); + const parts: string[] = []; + + for (const id of entityIds) { + const facts = factsByEntity.get(id)!; + // Facts are already sorted by attr due to session.allFacts() guarantees. + // Stringify each entity record as { id, facts: [[attr, val], ...] }. + parts.push(JSON.stringify({ id, facts })); + } + + // Concatenate and hash. + const blob = parts.join("\n"); + return createHash("sha256").update(blob).digest("hex"); +}