feat(thressgame-100): Wave 2 \u2014 multi-turn state + 10 countdown recipes + e2e
Wave 2 of thressgame-100 epic complete. Coverage 27/51 \u2192 37/51 = 72 % (on plan target).
ENGINE WORK (W2.0\u2013W2.6):
- New trigger primitive on-attr-expire(target, attr, primitives) \u2014 fires when an
attr countdown hits zero. Introduces 'expiringValue' binding to inner primitives.
- New imperative primitive decrement-attr-each-turn(target, attr) \u2014 explicit
countdown control (alternate to set-piece-attr.lifetime: turns).
- New dispatcher stage 13 fireAttrExpireHooks \u2014 batched per-turn-boundary, runs
AFTER stage 12 fireOnTurnStartHooks.
- Lifetime registry extension (lifetime-registry.ts) \u2014 decrementAttrCountdowns
function mirrors the marker-lifetime pattern; fires on-attr-expire BEFORE
retraction so triggers can still read the expiring value.
- Schema additions (schema.ts) \u2014 OnAttrExpireHooks (game-level on GAME_ENTITY),
AttrCountdownRegistry (game-level), OnAttrExpireHookEntry, AttrCountdownEntry.
- Validator (validate.ts) \u2014 decrement-attr-each-turn added to IMPERATIVE_KINDS;
on-attr-expire registered as binding-introducer with FIXED_BINDING_KINDS map.
- IMPERATIVE_KINDS list and trigger-scope walker updated.
10 NEW RECIPES (W2.7\u2013W2.9):
- tpl-time-bomb \u2014 black knights self-destruct after 5 turns
- tpl-nuclear-fallout \u2014 3 random blocked-square markers (deterministic seed)
- tpl-christmas-truce \u2014 BlockAllExceptKing on GAME_ENTITY for 3 turns
(proxy for missing BlockAllCaptures attr)
- tpl-pawn-second-chance \u2014 captured white pawn returns 1 turn later
- tpl-invulnerability-potion \u2014 white pieces uncapturable for 3 turns
- tpl-anti-camping \u2014 every piece dies after 3 turns (refresh-on-move
arm dropped per depth limit)
- tpl-ice-age \u2014 16 frozen-square markers covering files a + h
- tpl-no-cowards \u2014 MustMoveForward semantic flag for 1 turn
- tpl-drafted-for-battle \u2014 chooser picks bishop/knight, swap with king
- tpl-corporate-ladder \u2014 chooser picks 2 pieces to swap
NINE DOCUMENTED SIMPLIFICATIONS (full rationale in notepad and evidence):
- tpl-time-bomb dropped adjacent-splash arm (depth 5 > MAX_RECURSION_DEPTH=3)
- tpl-christmas-truce uses BlockAllExceptKing (no BlockAllCaptures attr)
- tpl-pawn-second-chance white-pawn-only (place-piece pieceType/color strict)
- tpl-invulnerability-potion uses set-piece-attr (set-capture-flag has no target)
- tpl-anti-camping dropped refresh arm (depth limit)
- tpl-no-cowards ships flag only (move-gen consumer wiring deferred to host preset)
- tpl-corporate-ladder uses request-choice(piece) \u00d7 2 (square \u2192 pieceId conversion
isn't expressible in ConditionSpec.value)
- tpl-drafted-for-battle bishop/knight restriction is player discipline
- tpl-nuclear-fallout / tpl-ice-age use lifetime: moves (spawn-marker.lifetime
schema doesn't accept turns)
TEST SURFACE:
- on-attr-expire.test.ts: 12 unit tests
- decrement-attr-each-turn.test.ts: 10 unit tests
- attr-expire-integration.test.ts: 7 integration tests
(incl. N=100 determinism)
- countdowns-perf.test.ts: 1 perf test
(p50=13.6ms p99=24.6ms; budget <150ms)
- wave2-recipes-real.test.ts: 15 runtime tests (NEW)
- recipes.test.ts: 5 \u00d7 46 = 378 expect calls
- wave2-countdowns.spec.ts (Playwright e2e): 13 tests
(10 load + 3 runtime: ice-age,
nuclear-fallout, drafted-for-battle)
bun run check: 3055 tests pass (was 3010, +45). 0 regressions.
e2e: 13/13 green via .sisyphus/scripts/run-pw.sh against docker compose dev stack.
BACKWARD-INCOMPAT TESTS UPDATED (per locked decision J):
- registry-count.test.ts: 50 \u2192 52 (decrement + on-attr-expire)
- ParamField.snapshot.test.tsx: SAMPLE_PARAMS exhaustiveness for two new kinds
- apply.test.ts: stage-list comment updated for new stage 13
Plan: .sisyphus/plans/thressgame-100.md
Notepads: .sisyphus/notepads/thressgame-100/
Evidence: .sisyphus/evidence/thressgame-100-wave2.txt (gitignored, 1037 lines)
This commit is contained in:
parent
9c47dc60ac
commit
01a77f043a
22 changed files with 3863 additions and 44 deletions
|
|
@ -93,3 +93,91 @@
|
|||
- **Imperative-in-passive validator gate is bypassed on apply-descriptor**. `parseCustomModifierDescriptor` (called by broadcast.ts § handleTestApplyDescriptor) ONLY runs the Zod schema, not the validator. So a descriptor with `spawn-marker` at top level passes parse and the walker happily applies it. The `descriptor.primitives.imperative-in-passive` validator is only enforced at library-save / UI paths.
|
||||
- **DOM doesn't surface arbitrary attrs**. The wire's `effectivePieceAttrs` set is preset-driven; without `piece-hp` active, `Hp` writes to a piece-id are NOT serialized into the client's prediction snapshot. The PredictionManager probe returns `undefined` even when the server-side write succeeded. Use the unit test layer for Hp pinning; e2e Hp probes work only when piece-hp (or another preset that whitelists Hp) is active.
|
||||
- **Test count delta (e2e only)**: wave1-recipes.spec.ts ships 17 tests (13 load + 4 runtime). All green in 22s on a single worker against the docker compose dev stack. Helper log: `/tmp/pw-w1-11.log`.
|
||||
|
||||
## [2026-04-27] W2.0-W2.6 — multi-turn countdown subsystem
|
||||
|
||||
### Existing infrastructure inherited (not built)
|
||||
|
||||
- **`set-piece-attr.lifetime: { kind: "turns", count: N }`** was already accepted at schema level AND wired through `applyLifetime` (T35 / T42) — `LifetimeRegistry` on GAME_ENTITY tracks `expiresAtTurn = currentFullmove + count`. The decrementer (`util/lifetime-registry.ts#decrementLifetimes`) was running at stage 11b (between `fireOnTurnEndHooks` and `fireOnTurnStartHooks`) and silently retracting expired facts WITHOUT firing any trigger.
|
||||
- **What changed**: extended the existing sweep to fire `on-attr-expire` BEFORE retracting (mirror of T19's `decrementMarkerLifetimes` "fire BEFORE remove" precedent), and RELOCATED the call to a new stage 13 (after `fireOnTurnStartHooks`) per locked decision D in `decisions.md`. Added a SECOND sweep (`decrementAttrCountdowns`) for the new explicit `decrement-attr-each-turn` registry (`AttrCountdownRegistry` on GAME_ENTITY).
|
||||
|
||||
### What this task added
|
||||
|
||||
- **2 new primitives** registered in `PRIMITIVE_REGISTRY` (50 → 52): `on-attr-expire` (trigger; seeds `OnAttrExpireHooks` on GAME_ENTITY filtered by exact `(target, attr)` match), `decrement-attr-each-turn` (imperative; appends `AttrCountdownEntry` to `AttrCountdownRegistry`). Both registered in `index.ts` barrel; `decrement-attr-each-turn` added to `IMPERATIVE_KINDS`.
|
||||
- **2 new schema attrs**: `OnAttrExpireHooks` (hook list, `OnAttrExpireHookEntry[]`), `AttrCountdownRegistry` (registry list, `AttrCountdownEntry[]`). Both consumer-registered in `apply.ts` for the load-time integrity check.
|
||||
- **1 new dispatcher stage**: stage 13 invokes both lifetime-registry sweeps — `decrementLifetimes` (existing, now firing trigger) + `decrementAttrCountdowns` (new). Order locked: lifetime first, countdown second.
|
||||
- **1 new event variant**: `PrimitiveEvent.kind === "attr-expire"` carries `{entityId, attr, expiringValue}`. `expiringValue` is the attr's last value before retraction — for explicit countdowns that's the PRE-decrement value (so authors see "1" rather than the trivial "0" sentinel).
|
||||
- **1 new fixed-binding mechanism in validator** (`FIXED_BINDING_KINDS` map in `validate.ts`): `on-attr-expire` always introduces `expiringValue` into the lexical scope of its `primitives` child slot. Distinct from `BINDING_INTRODUCING_KINDS` (which reads the bind-name from a per-node param) — fixed bindings are dispatcher-injected.
|
||||
|
||||
### Backward-incompat tests updated (per decision J)
|
||||
|
||||
- **`registry-count.test.ts`**: 50 → 52 (2 new primitives).
|
||||
- **`ParamField.snapshot.test.tsx`**: `SAMPLE_PARAMS` map updated to include the two new primitive kinds (TS coverage requirement — `Record<PrimitiveKind, unknown>` exhaustiveness).
|
||||
- **`apply.test.ts`**: comment-only update on `EXPECTED_ORDER` to document stage 13. The spy list is unchanged; the new dispatcher invocation runs through `decrementLifetimes` / `decrementAttrCountdowns` indirectly via `fireAttrExpireHooks`, which the existing `firstOccurrences` reducer ignores.
|
||||
|
||||
### Walker-artifact safeguard re-applied
|
||||
|
||||
- `on-attr-expire.apply()` seeds the GAME_ENTITY hook list FIRST, before the walker recurses into `params.primitives`. Inner primitives that contain ctx-* shapes will throw if walker-recursed at outer scope (no `expiringValue` binding visible), but the hook seed persists so the dispatcher fires correctly at runtime. Mirrors the W1.11 walker-artifact pattern locked by `on-piece-entered-marker`.
|
||||
|
||||
### Perf budget result (countdowns-perf.test.ts)
|
||||
|
||||
- 100 in-flight countdowns (50 lifetime + 50 explicit) × 1000 moves: p50=13.6ms, p99=24.6ms, max=36.9ms. Active enforced budget: 150ms p99 — comfortably within. Aspirational-budget gap (~25ms vs 150ms) leaves headroom for cascade-heavy descriptors that haven't been benchmarked yet.
|
||||
|
||||
### Test count delta
|
||||
|
||||
- `bun run check`: 3010 → 3040 tests (+30). 0 regressions.
|
||||
- New files: `on-attr-expire.test.ts` (12 tests), `decrement-attr-each-turn.test.ts` (10 tests), `__tests__/attr-expire-integration.test.ts` (7 integration tests including determinism @ N=100), `__fixtures__/perf/countdowns-perf.test.ts` (1 perf test).
|
||||
|
||||
## [2026-04-26] W2.7-W2.9 — 10 Wave-2 recipes + runtime tests
|
||||
|
||||
### Recipes shipped (36 → 46)
|
||||
|
||||
- Batch D — countdowns (5): `tpl-time-bomb`, `tpl-nuclear-fallout`, `tpl-christmas-truce`, `tpl-pawn-second-chance`, `tpl-invulnerability-potion`.
|
||||
- Batch E — restrictions with duration (3): `tpl-anti-camping`, `tpl-ice-age`, `tpl-no-cowards`.
|
||||
- Batch F — verified-shippable choosers (2): `tpl-drafted-for-battle`, `tpl-corporate-ladder`.
|
||||
|
||||
Final recipe count: **46**. None of the originally-planned recipes was skipped.
|
||||
|
||||
### Simplifications shipped vs. plan
|
||||
|
||||
- **`tpl-time-bomb`** — original intent was 5-turn countdown → adjacent splash AOE (-99 Hp on neighbours via for-each-adjacent + add-to-attribute) THEN self-destruct. The composed shape `on-rule-activated → for-each-piece → on-attr-expire → for-each-adjacent → add-to-attribute` is **5 levels deep**, exceeding `MAX_RECURSION_DEPTH = 3` (validate.ts § walkPrimitiveNodes). Pragmatic fallback: drop the splash arm; on-attr-expire's inner is just `destroy-piece({ctx-self-id: null})`. The countdown→expire→self-destruct demonstration of the new W2.0-W2.6 lifetime subsystem is preserved cleanly at depth 3 (on-rule-activated → for-each-piece → on-attr-expire → destroy-piece). Splash AOE on countdown-expiry will need a flatter authoring shape (probably hoisting on-attr-expire to top-level with literal targets) — left as future work.
|
||||
- **`tpl-nuclear-fallout`** — the original spec accepted a documented simplification re: `random-pick → spawn-marker.square` resolver chain. Shipped as 3 sibling random-picks (mirrors `tpl-minefield-full`'s 5-mine pattern). Lifetime is `{kind: "moves", expiresAtMove: 10}` (5 fullmoves from a fresh-game start), NOT `{kind: "turns"}` — `spawn-marker.lifetime` schema accepts only `permanent | moves | one-shot` (decrement-attr-each-turn semantics are per-piece-attr, NOT per-marker-entity).
|
||||
- **`tpl-christmas-truce`** — no game-level `BlockAllCaptures` attr exists in the schema. Documented fallback: write `BlockAllExceptKing=true` on `GAME_ENTITY` with `lifetime: {kind: "turns", count: 3}`. Captures are a strict subset of moves; this acts as a coarse-grained truce.
|
||||
- **`tpl-pawn-second-chance`** — locked decision is that `place-piece.pieceType/color` are STRICT literal enums (no resolver shapes — see `place-piece.ts:110-111`). Cannot copy a captured piece's class from a $var-bound id. Documented fallback: ship as `tpl-pawn-second-chance` (white pawns only); respawn a white pawn at the dying piece's last Position. Used a clever 2-attr split (`SecondChancePosition` carries the square as a piece-attr value, `SecondChanceTurns` carries the countdown — both with parallel `turns:1` lifetimes so they're swept simultaneously). The on-attr-expire arm reads `SecondChancePosition` via `ctx-attr` to get the respawn square.
|
||||
- **`tpl-invulnerability-potion`** — `set-capture-flag` writes to `ctx.pieceId` only (no target redirect), and CaptureFlags is a bitfield (the OR semantic in set-capture-flag.apply is per-piece-mutation). For the "every white piece for 3 turns" pattern we use `set-piece-attr({attr: "CaptureFlags", value: 2, lifetime: turns:3})` which OVERWRITES (not OR) — pieces with prior flags lose them for the duration. Documented in summary; locked trade-off.
|
||||
- **`tpl-anti-camping`** — original spec wanted a refresh-on-move arm (`on-move → set-piece-attr` resets DormantCountdown to 3 on every move). Refresh logic across every piece would need separate per-piece on-move plumbing; pragmatic shape: the activation arm seeds `DormantCountdown=true` with `lifetime: turns:3` on EVERY piece, plus an `on-attr-expire` per piece that destroys it. No refresh; effectively "every piece dies after 3 turns" — a doomsday rule. The lifetime-driven expire pattern IS the demonstrated W2 subsystem; the refresh arm is future work.
|
||||
- **`tpl-no-cowards`** — full implementation requires move-gen integration consumers don't have. Shipped as a SEMANTIC FLAG: GAME_ENTITY gets `MustMoveForward=true` with `lifetime: turns:1`. Host presets that consume MustMoveForward see the flag for one turn. Without consumer wiring it's a marker attr only — but it demonstrates the "transient game-level flag" pattern.
|
||||
- **`tpl-corporate-ladder`** — original spec wanted `request-choice(square) × 2 → swap-pieces` (2 squares, swap their resident pieces). Problem: `request-choice kind:"square"` returns a square integer, not a piece id; `swap-pieces.a/b` accepts numeric resolvers, but converting square→pieceId requires a `Position`-comparison predicate inside `conditional`, and `ConditionSpec.value` is locked to literal `string|number|boolean|null` (no resolver shapes — see `schema.ts:111`). Pragmatic fallback: `request-choice kind:"piece"` × 2 + swap-pieces. The chooser binding directly returns piece ids — clean.
|
||||
- **`tpl-drafted-for-battle`** — `request-choice kind:"piece"` doesn't filter by piece type, so the "pick a bishop OR knight" intent isn't expressible at the schema level. Player discipline replaces engine-level filtering; documented in summary.
|
||||
|
||||
### New runtime patterns discovered
|
||||
|
||||
- **`on-attr-expire` dispatcher matches by EXACT `(target, attr)` tuple equality** (`triggers.ts:1374-1376`: `if (hook.target !== entityId) continue; if (hook.attr !== attr) continue`). A single hook entry only fires for ONE specific piece. To seed "any-piece" semantics at activation time, the recipe MUST iterate matching pieces inside the activation arm and seed one hook entry PER piece — the on-attr-expire primitive lives inside the for-each-piece loop, with `target: { $var: "p" }` resolving to each iteration's bound id (the param-resolver substitutes resolver shapes BEFORE on-attr-expire.apply() runs, per `param-resolver.ts § resolveParams`).
|
||||
- **MAX_RECURSION_DEPTH=3 forces composition flattening for countdown recipes.** Composed shape `on-rule-activated → for-each-piece → on-attr-expire → <inner>` is at the depth 3 limit; `<inner>` MUST be a single flat primitive list. For richer reactions (splash damage, complex effects), authors must hoist on-attr-expire to top-level with a literal target (which only covers known-id pieces) OR redesign with a separate descriptor.
|
||||
- **`spawn-marker.lifetime` accepts permanent/moves/one-shot, NOT turns** (per `spawn-marker.ts:90-97` LIFETIME_SCHEMA). The `turns` lifetime variant is per-piece-attr only via `set-piece-attr`. Recipes wanting turn-bounded markers use `{kind: "moves", expiresAtMove: <absolute>}` — the absolute target is `currentFullmove + N`. For activation-time seeding from a fresh game (FullmoveNumber=1), this is `1 + N - 1 = N` fullmoves of life... actually decrementMarkerLifetimes retracts when `FullmoveNumber > expiresAtMove`, so a marker spawned at fm=1 with expiresAtMove=10 lives 9 fullmove ticks. Empirically verified via tpl-ice-age test — markers ARE present after activation and would be retracted at fm=11.
|
||||
|
||||
### Test-harness conventions
|
||||
|
||||
- **Two test patterns coexist** in wave2-recipes-real.test.ts:
|
||||
1. **Static-arm tests** drive the inner trigger arm directly via `runPrimitives` — single-fire semantics, no double-walker artifact, asserts seeded state immediately. Used for 8 of 10 recipes.
|
||||
2. **Lifetime-driven integration tests** use `new ChessEngine({profile: NOOP_PROFILE})` so the integration preset's onAfterMove dispatcher attaches and stage-13 fires at every move. We seed the lifetime/hook state manually (mirroring `attr-expire-integration.test.ts`'s harness) then drive `applyTurnPair` to advance fullmove ticks and assert post-expiry state. Used for 2 recipes (`tpl-time-bomb`, `tpl-christmas-truce`, `tpl-invulnerability-potion`).
|
||||
- **`clearBoard(engine, {preserveKings: false})`** is required for "exact piece count" assertions — the default `preserveKings: true` leaves white + black king on the board (2 extra pieces) which corrupts `for-each-piece` → seed-counting assertions (anti-camping seeds a hook per piece; if kings remain, the count is +2).
|
||||
- **GAME_ENTITY-level lifetime sweep is attr-agnostic.** The dispatcher's stage-13 sweep retracts ANY `(entity, attr)` pair whose `expiresAtTurn` matches the current fullmove. For tests that need to advance fullmove ticks WITHOUT triggering a real game-state-changing attr (like `BlockAllExceptKing` which would block move-gen), use a benign proxy attr name (`TruceProxyMarker`) for the same lifetime registry entry — the sweep mechanism is the test target, not the attr's downstream effect.
|
||||
- **`PrimitiveEvent.kind` for on-captured arms is `"capture"`, NOT `"captured"`.** Both on-capture and on-captured triggers consume the same event kind; the `(attackerId, defenderId)` pair disambiguates which side the trigger fires on (see `context.ts:101-115`). TS error TS2820 surfaces immediately if you try `kind: "captured"`.
|
||||
|
||||
### Test count delta
|
||||
|
||||
- `bun run check`: 3040 → 3055 tests (+15). 0 regressions.
|
||||
- New file: `wave2-recipes-real.test.ts` (15 tests covering all 10 recipes + 1 cross-cutting smoke for the registry).
|
||||
- recipes.test.ts: 5 tests, 378 expect calls (5 invariants now apply to 46 recipes vs 36).
|
||||
|
||||
## [2026-04-26] W2.10 — Playwright e2e for the 10 Wave-2 recipes
|
||||
|
||||
- **All 13 tests green on first run** in 16.2s on the docker compose dev stack via `.sisyphus/scripts/run-pw.sh` (10 load-and-validate + 3 runtime). Spec at `packages/chess/e2e/wave2-countdowns.spec.ts`. Helper log: `/tmp/pw-w2-10.log`.
|
||||
- **Runtime test depth decisions**:
|
||||
- `tpl-ice-age` — full runtime (smoke variant). `applyDescriptor` fires the on-rule-activated arm; the walker double-fires on trigger primitives (selfRecurse !== true), so 16 spawn-marker primitives produce up to 32 frozen-square markers post-activation. Asserted `>= 16` lower bound + `<= 32` upper bound. Pin works because spawn-marker is non-idempotent and the double-fire is a documented walker artifact (see W1.11 § march-of-the-pawnguins).
|
||||
- `tpl-nuclear-fallout` — full runtime (smoke variant). Same walker double-fire concern — 3 random-pick → spawn-marker primitives produce up to 6 blocked markers. Asserted `>= 3` lower bound + `<= 6` upper bound. The lower-bound smoke is sufficient because `wave2-recipes-real.test.ts` pins the exact 3-marker contract at the unit level by driving `runPrimitives` directly on the inner arm.
|
||||
- `tpl-drafted-for-battle` — full runtime via `__test__.activate-descriptor` lift (NOT apply). The lift handler synthesizes the PendingChoice frame the dispatcher would push on activation, so `RequestChoiceModal` renders synchronously with `data-choice-kind="piece"`. Same mechanism `tpl-mind-control` uses in `templates-thressgame.spec.ts`.
|
||||
- **Countdown-advance tests SKIPPED** (per task brief): countdowns `tpl-time-bomb`, `tpl-pawn-second-chance`, `tpl-invulnerability-potion`, `tpl-christmas-truce`, `tpl-anti-camping` ship with load-and-validate only at the e2e layer. The countdown contract (lifetime sweep at stage 13, on-attr-expire firing) is pinned at the unit/integration level by `wave2-recipes-real.test.ts` and `attr-expire-integration.test.ts`. Driving N fullmove ticks deterministically through the WS protocol is unreliable enough that adding it would more likely surface false-flake than recipe regression.
|
||||
- **`data-marker-kind` for non-special markers**: Board.tsx line 388 — the default switch case renders `<div data-marker-kind={marker.kind} ...>` so `blocked` / `pit` / `tornado` / `treasure` / `death-square` all surface their marker-kind through the same selector. Frozen-square / mine / portal-end have explicit DOM nodes (lines 369/376/385).
|
||||
- **Test count delta (e2e only)**: wave2-countdowns.spec.ts ships 13 tests. All green on first invocation.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue