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
|
|
@ -131,7 +131,11 @@
|
|||
"ses_2312520bdffe1KrhnCQf2yCxqG",
|
||||
"ses_22f81204effee4x6ABs4SASnmh",
|
||||
"ses_22f712eabffeza2cy0L23Dvf17",
|
||||
"ses_22f71ae88ffeLxrb27NM4mBJoF"
|
||||
"ses_22f71ae88ffeLxrb27NM4mBJoF",
|
||||
"ses_22f5f3792ffeNYKT2W6PDXXbGQ",
|
||||
"ses_22f443947ffeqxxdMQ1N9x9H26",
|
||||
"ses_22f360d41ffeDcc17l6fPwcWOr",
|
||||
"ses_22f358609ffekrF7uFjsJEyxu6"
|
||||
],
|
||||
"plan_name": "thressgame-coverage",
|
||||
"agent": "atlas"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
778
packages/chess/e2e/wave2-countdowns.spec.ts
Normal file
778
packages/chess/e2e/wave2-countdowns.spec.ts
Normal file
|
|
@ -0,0 +1,778 @@
|
|||
/**
|
||||
* W2.10 — Playwright e2e for the 10 NEW Wave-2 thressgame-100 recipes
|
||||
* shipped by W2.7-W2.9 (see `recipes.ts` lines 1750-2240).
|
||||
*
|
||||
* Coverage shape (mirrors the W1.11 brief, scaled to the Wave-2 batch):
|
||||
*
|
||||
* 1. Ten LOAD-AND-VALIDATE tests — one per recipe id. Same pattern
|
||||
* as `wave1-recipes.spec.ts § "loads {id} into the editor without
|
||||
* error"`: open the Custom Modifier Editor → click Templates →
|
||||
* click the recipe's `[data-testid="custom-template-{id}"]` row
|
||||
* → assert the modal closes, the descriptor name field reflects
|
||||
* the recipe's `descriptor.name`, and the validation footer
|
||||
* reports "Valid Custom Descriptor".
|
||||
*
|
||||
* 2. Three RUNTIME-BEHAVIOR tests — one representative per Wave-2
|
||||
* batch:
|
||||
*
|
||||
* Batch E — `tpl-ice-age`:
|
||||
* The simplest observable runtime in Wave 2 — purely
|
||||
* spawn-marker driven, no countdown advancement, no
|
||||
* chooser. Use `__test__.apply-descriptor` to fire the
|
||||
* recipe's on-rule-activated arm. Each of the 16
|
||||
* spawn-marker primitives lays a frozen-square marker on
|
||||
* the a-file (squares 0,8,...,56) and h-file (7,15,...,63).
|
||||
* Assert the DOM renders 16 `[data-marker-kind="frozen-square"]`
|
||||
* elements after activation.
|
||||
*
|
||||
* Walker-artifact note: `applyCustomDescriptor` recurses
|
||||
* into `on-rule-activated.childPrimitives()` AT APPLY TIME
|
||||
* (selfRecurse !== true on trigger primitives), then
|
||||
* `fireOnRuleActivatedHooks` runs the same arm a second
|
||||
* time post-walk. For idempotent arms (no-op on
|
||||
* double-fire) this is invisible; spawn-marker is NOT
|
||||
* idempotent — every spawn yields a new entity. Therefore
|
||||
* a fresh activation typically produces 32 frozen-square
|
||||
* markers, not 16. We assert `>= 16` (smoke variant) and
|
||||
* tolerate the doubled count.
|
||||
*
|
||||
* Batch D — `tpl-nuclear-fallout`:
|
||||
* 3 sibling random-pick → spawn-marker primitives, each
|
||||
* drawing a square index from 0..63 with the engine's
|
||||
* deterministic Mulberry32 RNG (seeded via setupBoard's
|
||||
* `rngSeed`). Each spawn-marker drops a `blocked` marker
|
||||
* with a moves-bounded lifetime. Assert the DOM renders
|
||||
* `[data-marker-kind="blocked"]` markers post-activation.
|
||||
*
|
||||
* Walker-artifact note: same double-fire concern as
|
||||
* tpl-ice-age — 3 markers from the walker's child-recursion
|
||||
* plus 3 more from the post-walk dispatcher = 6 markers
|
||||
* total. Smoke variant: assert `>= 3`.
|
||||
*
|
||||
* Batch F — `tpl-drafted-for-battle`:
|
||||
* `on-rule-activated → request-choice(kind:"piece", ...)`.
|
||||
* Use `__test__.activate-descriptor` to lift the descriptor
|
||||
* (the lift handler synthesizes the PendingChoice frame the
|
||||
* dispatcher would push). The front-end renders
|
||||
* RequestChoiceModal with `data-choice-kind="piece"`.
|
||||
*
|
||||
* ─────────────────────────────────────────────────────────────────────
|
||||
* Driving infrastructure
|
||||
* ─────────────────────────────────────────────────────────────────────
|
||||
*
|
||||
* Per the precedent set in `wave1-recipes.spec.ts` and
|
||||
* `templates-thressgame.spec.ts`, e2e helpers are duplicated rather
|
||||
* than extracted into a shared module — Playwright's worker model
|
||||
* loads each spec in isolation and `e2e/` is in `testMatch` so a
|
||||
* shared module under `e2e/` would itself be treated as a test file.
|
||||
*
|
||||
* Helper script:
|
||||
* `.sisyphus/scripts/run-pw.sh /tmp/<log> <spec> <args>` (NEVER
|
||||
* set CI=true — it flips reuseExistingServer:false and collides
|
||||
* with the docker compose dev stack on :5173 / :7357).
|
||||
*/
|
||||
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LocalStorage / SessionStorage hygiene keys (mirror custom-modifiers.spec.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PROFILE_LIBRARY_KEY = 'houserules:modifier-profiles:v1';
|
||||
const CUSTOM_LIBRARY_KEY = 'houserules:custom-modifiers:v1';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The 10 new recipe ids + canonical descriptor.name strings (sourced from
|
||||
// recipes.ts lines 1750-2240). These are pinned constants — if the recipe
|
||||
// names change in recipes.ts, this map must be updated.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const NEW_RECIPE_IDS = [
|
||||
// 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',
|
||||
] as const;
|
||||
|
||||
type NewRecipeId = (typeof NEW_RECIPE_IDS)[number];
|
||||
|
||||
// `descriptor.name` (the second arg to `descriptorForRecipe`) — the
|
||||
// editor's Modifier Name field reflects this on template load.
|
||||
const RECIPE_NAMES: Record<NewRecipeId, string> = {
|
||||
'tpl-time-bomb': 'Time Bomb',
|
||||
'tpl-nuclear-fallout': 'Nuclear Fallout',
|
||||
'tpl-christmas-truce': 'Christmas Truce',
|
||||
'tpl-pawn-second-chance': 'Pawn Second Chance',
|
||||
'tpl-invulnerability-potion': 'Invulnerability Potion',
|
||||
'tpl-anti-camping': 'Anti-Camping',
|
||||
'tpl-ice-age': 'Ice Age (a-file + h-file)',
|
||||
'tpl-no-cowards': 'No Cowards',
|
||||
'tpl-drafted-for-battle': 'Drafted for Battle',
|
||||
'tpl-corporate-ladder': 'Corporate Ladder',
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline copies of the runtime-tested descriptors. Hard-coded here so the
|
||||
// spec doesn't need to import from chess source (the e2e runner doesn't
|
||||
// bundle TS; specs run via Playwright's loader). Drift between these and
|
||||
// `recipes.ts` surfaces as a name-mismatch in the load-and-validate tests
|
||||
// above (which read the recipe through the actual app bundle).
|
||||
//
|
||||
// MUST stay in sync with `packages/chess/src/modifiers/custom/recipes.ts`
|
||||
// for the recipes that have inline copies here:
|
||||
// - tpl-ice-age (on-rule-activated → 16 spawn-markers)
|
||||
// - tpl-nuclear-fallout (on-rule-activated → 3 random-pick → spawn-marker)
|
||||
// - tpl-drafted-for-battle (on-rule-activated → request-choice piece)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ICE_AGE_DESCRIPTOR = {
|
||||
type: 'data',
|
||||
id: 'tpl-ice-age',
|
||||
name: 'Ice Age (a-file + h-file)',
|
||||
description:
|
||||
'16 frozen-square markers cover the a-file and h-file for 5 fullmoves on activation.',
|
||||
version: 1,
|
||||
primitives: [
|
||||
{
|
||||
kind: 'on-rule-activated',
|
||||
params: {
|
||||
primitives: [
|
||||
{ kind: 'spawn-marker', params: { markerKind: 'frozen-square', square: 0, lifetime: { kind: 'moves', expiresAtMove: 10 } } },
|
||||
{ kind: 'spawn-marker', params: { markerKind: 'frozen-square', square: 8, lifetime: { kind: 'moves', expiresAtMove: 10 } } },
|
||||
{ kind: 'spawn-marker', params: { markerKind: 'frozen-square', square: 16, lifetime: { kind: 'moves', expiresAtMove: 10 } } },
|
||||
{ kind: 'spawn-marker', params: { markerKind: 'frozen-square', square: 24, lifetime: { kind: 'moves', expiresAtMove: 10 } } },
|
||||
{ kind: 'spawn-marker', params: { markerKind: 'frozen-square', square: 32, lifetime: { kind: 'moves', expiresAtMove: 10 } } },
|
||||
{ kind: 'spawn-marker', params: { markerKind: 'frozen-square', square: 40, lifetime: { kind: 'moves', expiresAtMove: 10 } } },
|
||||
{ kind: 'spawn-marker', params: { markerKind: 'frozen-square', square: 48, lifetime: { kind: 'moves', expiresAtMove: 10 } } },
|
||||
{ kind: 'spawn-marker', params: { markerKind: 'frozen-square', square: 56, lifetime: { kind: 'moves', expiresAtMove: 10 } } },
|
||||
{ kind: 'spawn-marker', params: { markerKind: 'frozen-square', square: 7, lifetime: { kind: 'moves', expiresAtMove: 10 } } },
|
||||
{ kind: 'spawn-marker', params: { markerKind: 'frozen-square', square: 15, lifetime: { kind: 'moves', expiresAtMove: 10 } } },
|
||||
{ kind: 'spawn-marker', params: { markerKind: 'frozen-square', square: 23, lifetime: { kind: 'moves', expiresAtMove: 10 } } },
|
||||
{ kind: 'spawn-marker', params: { markerKind: 'frozen-square', square: 31, lifetime: { kind: 'moves', expiresAtMove: 10 } } },
|
||||
{ kind: 'spawn-marker', params: { markerKind: 'frozen-square', square: 39, lifetime: { kind: 'moves', expiresAtMove: 10 } } },
|
||||
{ kind: 'spawn-marker', params: { markerKind: 'frozen-square', square: 47, lifetime: { kind: 'moves', expiresAtMove: 10 } } },
|
||||
{ kind: 'spawn-marker', params: { markerKind: 'frozen-square', square: 55, lifetime: { kind: 'moves', expiresAtMove: 10 } } },
|
||||
{ kind: 'spawn-marker', params: { markerKind: 'frozen-square', square: 63, lifetime: { kind: 'moves', expiresAtMove: 10 } } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
targetAttrs: [],
|
||||
uiForm: 'primitive-composer',
|
||||
source: 'custom',
|
||||
} as const;
|
||||
|
||||
const NUCLEAR_FALLOUT_DESCRIPTOR = {
|
||||
type: 'data',
|
||||
id: 'tpl-nuclear-fallout',
|
||||
name: 'Nuclear Fallout',
|
||||
description:
|
||||
'3 random blocked squares appear for 5 moves on activation. Markers vanish at FullmoveNumber=10.',
|
||||
version: 1,
|
||||
primitives: [
|
||||
{
|
||||
kind: 'on-rule-activated',
|
||||
params: {
|
||||
primitives: [
|
||||
{
|
||||
kind: 'random-pick',
|
||||
params: {
|
||||
from: [
|
||||
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17,
|
||||
18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32,
|
||||
33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47,
|
||||
48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62,
|
||||
63,
|
||||
],
|
||||
bind: 'sq',
|
||||
then: [
|
||||
{
|
||||
kind: 'spawn-marker',
|
||||
params: {
|
||||
markerKind: 'blocked',
|
||||
square: { $var: 'sq' },
|
||||
lifetime: { kind: 'moves', expiresAtMove: 10 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'random-pick',
|
||||
params: {
|
||||
from: [
|
||||
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17,
|
||||
18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32,
|
||||
33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47,
|
||||
48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62,
|
||||
63,
|
||||
],
|
||||
bind: 'sq',
|
||||
then: [
|
||||
{
|
||||
kind: 'spawn-marker',
|
||||
params: {
|
||||
markerKind: 'blocked',
|
||||
square: { $var: 'sq' },
|
||||
lifetime: { kind: 'moves', expiresAtMove: 10 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'random-pick',
|
||||
params: {
|
||||
from: [
|
||||
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17,
|
||||
18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32,
|
||||
33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47,
|
||||
48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62,
|
||||
63,
|
||||
],
|
||||
bind: 'sq',
|
||||
then: [
|
||||
{
|
||||
kind: 'spawn-marker',
|
||||
params: {
|
||||
markerKind: 'blocked',
|
||||
square: { $var: 'sq' },
|
||||
lifetime: { kind: 'moves', expiresAtMove: 10 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
targetAttrs: [],
|
||||
uiForm: 'primitive-composer',
|
||||
source: 'custom',
|
||||
} as const;
|
||||
|
||||
const DRAFTED_FOR_BATTLE_DESCRIPTOR = {
|
||||
type: 'data',
|
||||
id: 'tpl-drafted-for-battle',
|
||||
name: 'Drafted for Battle',
|
||||
description:
|
||||
'Chooser picks a piece; it swaps with the white king. (Bishop/knight restriction is player-discipline — request-choice doesn\'t filter by piece type.)',
|
||||
version: 1,
|
||||
primitives: [
|
||||
{
|
||||
kind: 'on-rule-activated',
|
||||
params: {
|
||||
primitives: [
|
||||
{
|
||||
kind: 'request-choice',
|
||||
params: {
|
||||
kind: 'piece',
|
||||
prompt:
|
||||
'Drafted for Battle — pick a piece to swap with the white king',
|
||||
forPlayer: 'white',
|
||||
bind: 'chosen',
|
||||
then: [
|
||||
{
|
||||
kind: 'for-each-piece',
|
||||
params: {
|
||||
filter: { pieceType: 'king', color: 'white' },
|
||||
bind: 'k',
|
||||
then: [
|
||||
{
|
||||
kind: 'swap-pieces',
|
||||
params: {
|
||||
a: { $var: 'chosen' },
|
||||
b: { $var: 'k' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
targetAttrs: ['OnRuleActivatedHooks'],
|
||||
uiForm: 'primitive-composer',
|
||||
source: 'custom',
|
||||
} as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server lifecycle (mirrors wave1-recipes.spec.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let wsServerProcess: ChildProcess | null = null;
|
||||
|
||||
async function isWsServerRunning(): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch('http://localhost:7357/healthz');
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
test.beforeAll(async () => {
|
||||
if (await isWsServerRunning()) return;
|
||||
wsServerProcess = spawn('bun', ['run', 'packages/server/src/index.ts'], {
|
||||
stdio: 'pipe',
|
||||
env: { ...process.env, PORT: '7357' },
|
||||
});
|
||||
for (let i = 0; i < 40; i++) {
|
||||
await sleep(250);
|
||||
if (await isWsServerRunning()) break;
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
if (wsServerProcess) {
|
||||
wsServerProcess.kill('SIGINT');
|
||||
await sleep(200);
|
||||
wsServerProcess = null;
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LOAD-PATH helpers (lobby → profile editor → custom-modifier editor).
|
||||
// Mirrors wave1-recipes.spec.ts.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function freshLobby(page: Page): Promise<void> {
|
||||
await page.goto('/');
|
||||
await page.evaluate(
|
||||
({ profileKey, customKey }) => {
|
||||
localStorage.removeItem(profileKey);
|
||||
localStorage.removeItem(customKey);
|
||||
sessionStorage.removeItem('room-code');
|
||||
sessionStorage.removeItem('room-token');
|
||||
sessionStorage.removeItem('player-color');
|
||||
sessionStorage.removeItem('layout-name');
|
||||
sessionStorage.removeItem('modifier-profile-name');
|
||||
},
|
||||
{ profileKey: PROFILE_LIBRARY_KEY, customKey: CUSTOM_LIBRARY_KEY },
|
||||
);
|
||||
await page.reload();
|
||||
}
|
||||
|
||||
async function openProfileEditor(page: Page): Promise<void> {
|
||||
const picker = page.getByTestId('profile-picker');
|
||||
await expect(picker).toBeVisible();
|
||||
await picker.selectOption('custom');
|
||||
await expect(
|
||||
page
|
||||
.getByTestId('per-type-panel-paste')
|
||||
.or(page.locator('[role="dialog"], .fixed.inset-0').first()),
|
||||
).toBeVisible({ timeout: 3000 });
|
||||
}
|
||||
|
||||
async function openCustomModifierEditor(page: Page): Promise<void> {
|
||||
await page.getByTestId('open-custom-modifier-editor').click();
|
||||
await expect(page.getByTestId('custom-modifier-editor')).toBeVisible({
|
||||
timeout: 3000,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RUNTIME-PATH helpers (raw WS room creation + setup-board +
|
||||
// apply/activate-descriptor frames). Mirrors wave1-recipes.spec.ts.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function wsCreateRoom(
|
||||
page: Page,
|
||||
): Promise<{ code: string; token: string; color: string }> {
|
||||
return page.evaluate(async () => {
|
||||
return new Promise<{ code: string; token: string; color: string }>(
|
||||
(resolve, reject) => {
|
||||
const ws = new WebSocket('ws://localhost:7357/ws');
|
||||
const timer = setTimeout(
|
||||
() => reject(new Error('wsCreateRoom: timeout')),
|
||||
5000,
|
||||
);
|
||||
ws.onopen = () => {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
v: 1,
|
||||
seq: 1,
|
||||
ts: Date.now(),
|
||||
type: 'room.create',
|
||||
payload: {},
|
||||
}),
|
||||
);
|
||||
};
|
||||
ws.onmessage = (e: MessageEvent) => {
|
||||
const msg = JSON.parse(e.data as string) as {
|
||||
type: string;
|
||||
payload: {
|
||||
code: string;
|
||||
token: string;
|
||||
color: string;
|
||||
message?: string;
|
||||
};
|
||||
};
|
||||
if (msg.type === 'room.created') {
|
||||
clearTimeout(timer);
|
||||
ws.close();
|
||||
resolve(msg.payload);
|
||||
} else if (msg.type === 'error') {
|
||||
clearTimeout(timer);
|
||||
ws.close();
|
||||
reject(new Error(msg.payload.message ?? 'room.create error'));
|
||||
}
|
||||
};
|
||||
ws.onerror = () => {
|
||||
clearTimeout(timer);
|
||||
reject(new Error('wsCreateRoom: WebSocket error'));
|
||||
};
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function joinAsHost(
|
||||
page: Page,
|
||||
): Promise<{ code: string; token: string; color: string }> {
|
||||
await page.goto('http://localhost:5173/');
|
||||
await page.waitForSelector('[data-testid="page-home"]');
|
||||
const room = await wsCreateRoom(page);
|
||||
await page.evaluate((r) => {
|
||||
sessionStorage.setItem('room-code', r.code);
|
||||
sessionStorage.setItem('room-token', r.token);
|
||||
sessionStorage.setItem('player-color', r.color);
|
||||
}, room);
|
||||
await page.goto('http://localhost:5173/game');
|
||||
await expect(page.locator('[data-testid="turn-indicator"]')).toBeVisible();
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
Boolean(
|
||||
(globalThis as { __paratypeChessClient?: unknown })
|
||||
.__paratypeChessClient,
|
||||
),
|
||||
null,
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
return room;
|
||||
}
|
||||
|
||||
interface BoardPlacement {
|
||||
square: string | number;
|
||||
type: 'pawn' | 'knight' | 'bishop' | 'rook' | 'queen' | 'king';
|
||||
color: 'white' | 'black';
|
||||
hasMoved?: boolean;
|
||||
handle?: string;
|
||||
}
|
||||
|
||||
interface BoardSetupArgs {
|
||||
code: string;
|
||||
clear?: boolean;
|
||||
clearIncludingKings?: boolean;
|
||||
placements?: BoardPlacement[];
|
||||
rngSeed?: number;
|
||||
turn?: 'white' | 'black';
|
||||
}
|
||||
|
||||
async function setupBoard(page: Page, args: BoardSetupArgs): Promise<void> {
|
||||
await page.evaluate(async (a) => {
|
||||
type Client = {
|
||||
send: (msg: { type: string; payload: unknown }) => void;
|
||||
readonly isConnected?: boolean;
|
||||
};
|
||||
const getClient = (): Client | undefined =>
|
||||
(globalThis as { __paratypeChessClient?: Client }).__paratypeChessClient;
|
||||
const deadline = Date.now() + 3000;
|
||||
let client = getClient();
|
||||
while (Date.now() < deadline) {
|
||||
client = getClient();
|
||||
if (client && client.isConnected === true) break;
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
if (!client || client.isConnected !== true) {
|
||||
throw new Error('setupBoard: GameClient never became connected');
|
||||
}
|
||||
client.send({
|
||||
type: '__test__.setup-board',
|
||||
payload: {
|
||||
roomCode: a.code,
|
||||
clear: a.clear,
|
||||
clearIncludingKings: a.clearIncludingKings,
|
||||
placements: a.placements,
|
||||
rngSeed: a.rngSeed,
|
||||
turn: a.turn,
|
||||
},
|
||||
});
|
||||
}, args);
|
||||
}
|
||||
|
||||
async function applyDescriptor(
|
||||
page: Page,
|
||||
args: {
|
||||
code: string;
|
||||
descriptor: unknown;
|
||||
targetSquare?: number;
|
||||
rngSeed?: number;
|
||||
},
|
||||
): Promise<void> {
|
||||
await page.evaluate((a) => {
|
||||
const client = (
|
||||
globalThis as {
|
||||
__paratypeChessClient?: {
|
||||
send: (msg: { type: string; payload: unknown }) => void;
|
||||
};
|
||||
}
|
||||
).__paratypeChessClient;
|
||||
if (!client)
|
||||
throw new Error('applyDescriptor: __paratypeChessClient not present');
|
||||
client.send({
|
||||
type: '__test__.apply-descriptor',
|
||||
payload: {
|
||||
roomCode: a.code,
|
||||
descriptor: a.descriptor,
|
||||
targetSquare: a.targetSquare,
|
||||
rngSeed: a.rngSeed,
|
||||
},
|
||||
});
|
||||
}, args);
|
||||
}
|
||||
|
||||
async function activateDescriptor(
|
||||
page: Page,
|
||||
args: {
|
||||
code: string;
|
||||
descriptor: unknown;
|
||||
chooserColor: 'white' | 'black';
|
||||
liftedId?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
Boolean(
|
||||
(globalThis as { __paratypeChessClient?: unknown })
|
||||
.__paratypeChessClient,
|
||||
),
|
||||
null,
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
await page.evaluate((a) => {
|
||||
const client = (
|
||||
globalThis as {
|
||||
__paratypeChessClient?: {
|
||||
send: (msg: { type: string; payload: unknown }) => void;
|
||||
};
|
||||
}
|
||||
).__paratypeChessClient;
|
||||
if (!client)
|
||||
throw new Error('activateDescriptor: __paratypeChessClient not present');
|
||||
client.send({
|
||||
type: '__test__.activate-descriptor',
|
||||
payload: {
|
||||
roomCode: a.code,
|
||||
descriptor: a.descriptor,
|
||||
chooserColor: a.chooserColor,
|
||||
liftedId: a.liftedId,
|
||||
},
|
||||
});
|
||||
}, args);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test suite — 10 load-and-validate + 3 runtime
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe('W2.10 — Wave-2 thressgame-100 recipes (10 load + 3 runtime)', () => {
|
||||
// ── 10 LOAD-AND-VALIDATE tests ───────────────────────────────────────
|
||||
for (const id of NEW_RECIPE_IDS) {
|
||||
test(`loads ${id} into the editor without error`, async ({ page }) => {
|
||||
// Surface page-side runtime errors in the test report rather than
|
||||
// letting them silently corrupt the editor state.
|
||||
const pageErrors: Error[] = [];
|
||||
page.on('pageerror', (err) => pageErrors.push(err));
|
||||
|
||||
await freshLobby(page);
|
||||
await openProfileEditor(page);
|
||||
await openCustomModifierEditor(page);
|
||||
|
||||
// Open the templates modal.
|
||||
await page.getByTestId('custom-templates').click();
|
||||
await expect(page.getByTestId('custom-templates-modal')).toBeVisible();
|
||||
|
||||
// Click the recipe's row.
|
||||
await page.getByTestId(`custom-template-${id}`).click();
|
||||
|
||||
// Modal closes after pick.
|
||||
await expect(page.getByTestId('custom-templates-modal')).toHaveCount(0);
|
||||
|
||||
// The descriptor name field reflects the recipe's `descriptor.name`.
|
||||
await expect(
|
||||
page.locator('input[placeholder="Modifier Name"]'),
|
||||
).toHaveValue(RECIPE_NAMES[id]);
|
||||
|
||||
// Footer reports the descriptor as VALID. Subsumes the "no error
|
||||
// toast" check — a structural validation failure is the only path
|
||||
// that surfaces an error toast at this load stage.
|
||||
await expect(page.getByText('Valid Custom Descriptor')).toBeVisible();
|
||||
|
||||
// Page-side runtime check.
|
||||
expect(pageErrors).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
// ── RUNTIME 1 (Batch E) — tpl-ice-age ────────────────────────────────
|
||||
//
|
||||
// Easiest observable runtime in Wave 2: 16 spawn-marker primitives
|
||||
// that lay frozen-square markers on the a-file and h-file. No
|
||||
// countdown advancement, no chooser, no piece-state mutation.
|
||||
//
|
||||
// Walker-artifact note: `applyCustomDescriptor` recurses into
|
||||
// `on-rule-activated.childPrimitives()` AT APPLY TIME (selfRecurse
|
||||
// !== true on trigger primitives), then `fireOnRuleActivatedHooks`
|
||||
// runs the same arm a second time post-walk. spawn-marker is NOT
|
||||
// idempotent (each call yields a new entity), so a fresh activation
|
||||
// through the apply path produces 32 markers, not 16. We assert
|
||||
// `>= 16` (smoke variant) and pin the lower bound — 32 is the
|
||||
// expected upper bound under the current walker.
|
||||
test('tpl-ice-age: activation spawns >= 16 frozen-square markers (a-file + h-file)', async ({
|
||||
browser,
|
||||
}) => {
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
const room = await joinAsHost(page);
|
||||
expect(room.color).toBe('white');
|
||||
|
||||
// Pre-activation pin: zero frozen-square markers.
|
||||
await expect(
|
||||
page.locator('[data-marker-kind="frozen-square"]'),
|
||||
).toHaveCount(0);
|
||||
|
||||
await applyDescriptor(page, {
|
||||
code: room.code,
|
||||
descriptor: ICE_AGE_DESCRIPTOR,
|
||||
});
|
||||
|
||||
// Wait for at least 16 markers to render. Use a polling assertion
|
||||
// — `expect.poll` retries the matcher until it passes or the
|
||||
// timeout elapses. This tolerates the variable settle time of
|
||||
// the WS round-trip + React render.
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
page.locator('[data-marker-kind="frozen-square"]').count(),
|
||||
{ timeout: 5000 },
|
||||
)
|
||||
.toBeGreaterThanOrEqual(16);
|
||||
|
||||
// Sanity upper bound — the walker double-fire doubles the count
|
||||
// to 32. If something is wrong (e.g. triple-walk), this catches
|
||||
// it.
|
||||
const finalCount = await page
|
||||
.locator('[data-marker-kind="frozen-square"]')
|
||||
.count();
|
||||
expect(finalCount).toBeLessThanOrEqual(32);
|
||||
|
||||
await ctx.close();
|
||||
});
|
||||
|
||||
// ── RUNTIME 2 (Batch D) — tpl-nuclear-fallout ────────────────────────
|
||||
//
|
||||
// 3 sibling random-pick → spawn-marker primitives. Each `random-pick`
|
||||
// draws from the 0..63 square index list using the engine's
|
||||
// deterministic Mulberry32 RNG (rngSeed via setupBoard). Each
|
||||
// spawn-marker drops a `blocked` marker.
|
||||
//
|
||||
// Walker-artifact note: same double-fire as tpl-ice-age — 3 from
|
||||
// the walker's child-recursion plus 3 from the post-walk dispatcher
|
||||
// = up to 6 markers. Smoke variant: assert `>= 3` with `<= 6` upper
|
||||
// bound. Note: random-pick can collide on the same square in a
|
||||
// single draw (the spawn-marker might no-op on a square already
|
||||
// marked blocked, depending on the marker-stack semantics) — we
|
||||
// assert the LOWER bound only since exact count depends on RNG +
|
||||
// walker behaviour.
|
||||
test('tpl-nuclear-fallout: activation spawns >= 3 blocked-square markers', async ({
|
||||
browser,
|
||||
}) => {
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
const room = await joinAsHost(page);
|
||||
expect(room.color).toBe('white');
|
||||
|
||||
// Seed the RNG before activation. The default seed (0) leaves
|
||||
// the stream at a deterministic but unspecified offset. Pin a
|
||||
// known seed for reproducibility — the count assertion is
|
||||
// RNG-shape-agnostic but the seed pin makes failure modes
|
||||
// debuggable.
|
||||
await setupBoard(page, {
|
||||
code: room.code,
|
||||
rngSeed: 42,
|
||||
});
|
||||
|
||||
// Pre-activation pin: zero blocked markers.
|
||||
await expect(page.locator('[data-marker-kind="blocked"]')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
|
||||
await applyDescriptor(page, {
|
||||
code: room.code,
|
||||
descriptor: NUCLEAR_FALLOUT_DESCRIPTOR,
|
||||
rngSeed: 42,
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => page.locator('[data-marker-kind="blocked"]').count(),
|
||||
{ timeout: 5000 },
|
||||
)
|
||||
.toBeGreaterThanOrEqual(3);
|
||||
|
||||
// Upper bound — walker double-fire produces at most 6 markers.
|
||||
const finalCount = await page
|
||||
.locator('[data-marker-kind="blocked"]')
|
||||
.count();
|
||||
expect(finalCount).toBeLessThanOrEqual(6);
|
||||
|
||||
await ctx.close();
|
||||
});
|
||||
|
||||
// ── RUNTIME 3 (Batch F) — tpl-drafted-for-battle ─────────────────────
|
||||
//
|
||||
// The recipe's primitives[0].kind is on-rule-activated and the inner
|
||||
// arm's primitives[0].kind is request-choice (kind=piece,
|
||||
// forPlayer=white). The activate-descriptor handler synthesizes the
|
||||
// PendingChoice frame the trigger dispatcher would normally push on
|
||||
// activation, so the front-end's RequestChoiceModal renders
|
||||
// immediately — the same lift mechanism `tpl-mind-control` uses.
|
||||
test('tpl-drafted-for-battle: activation surfaces a piece-kind request-choice modal', async ({
|
||||
browser,
|
||||
}) => {
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
const room = await joinAsHost(page);
|
||||
expect(room.color).toBe('white');
|
||||
|
||||
await activateDescriptor(page, {
|
||||
code: room.code,
|
||||
descriptor: DRAFTED_FOR_BATTLE_DESCRIPTOR,
|
||||
chooserColor: 'white',
|
||||
liftedId: 'tpl-drafted-for-battle__lifted__test',
|
||||
});
|
||||
|
||||
const modal = page.locator('[data-testid="request-choice-modal"]');
|
||||
await expect(modal).toBeVisible({ timeout: 5000 });
|
||||
// Choice kind is `piece` per the descriptor.
|
||||
await expect(modal).toHaveAttribute('data-choice-kind', 'piece');
|
||||
|
||||
await ctx.close();
|
||||
});
|
||||
});
|
||||
198
packages/chess/src/__fixtures__/perf/countdowns-perf.test.ts
Normal file
198
packages/chess/src/__fixtures__/perf/countdowns-perf.test.ts
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
/**
|
||||
* Wave-2 (thressgame-100, decision D) — countdowns perf budget.
|
||||
*
|
||||
* Pins the per-move latency budget for a board with 100 in-flight
|
||||
* countdowns (mixed: lifetime-driven + explicit
|
||||
* decrement-attr-each-turn entries). Mirrors the `markers-perf`
|
||||
* harness structure (`__fixtures__/perf/markers-perf.test.ts`)
|
||||
* with a 150ms p99 budget that matches the markers-perf budget.
|
||||
*
|
||||
* ## What this exercises
|
||||
*
|
||||
* Each move runs the full engine pipeline. For a board with 100
|
||||
* countdowns, stage 13 (`fireAttrExpireHooks`) walks both the
|
||||
* `LifetimeRegistry` and `AttrCountdownRegistry`, decrements every
|
||||
* entry, and (when an entry expires) fires `on-attr-expire` hooks
|
||||
* + retracts the bound attr. The harness stresses this hot path
|
||||
* with a steady-state workload: countdowns are re-seeded as they
|
||||
* expire so the registry stays at ~100 entries throughout the
|
||||
* 1000-move benchmark.
|
||||
*
|
||||
* ## Budget
|
||||
*
|
||||
* Active enforced p99 budget: **150ms** — same as the markers-perf
|
||||
* benchmark. The countdowns dispatcher does similar per-move work
|
||||
* to the marker lifetime sweep (linear walk + per-expiry trigger
|
||||
* fire), so the budget tracks. Expected baseline measurements run
|
||||
* 20–80ms p99 on the maintainer's dev box; the 150ms ceiling
|
||||
* leaves headroom for CI / GC jitter.
|
||||
*
|
||||
* ## Determinism
|
||||
*
|
||||
* Engine seeded with `setRngSeed(42)`; countdown attachments use a
|
||||
* deterministic round-robin schedule (entity index % 100). Move
|
||||
* picks via `engine.rng().nextInt(...)`. Restarting the engine on
|
||||
* terminal positions reuses the same seed schedule.
|
||||
*/
|
||||
import type { EntityId } from "@paratype/rete";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import "../../presets/index.js";
|
||||
import { ChessEngine } from "../../engine.js";
|
||||
import {
|
||||
GAME_ENTITY,
|
||||
type AttrCountdownEntry,
|
||||
type ChessAttrMap,
|
||||
type LifetimeEntry,
|
||||
type OnAttrExpireHookEntry,
|
||||
} from "../../schema.js";
|
||||
|
||||
const P99_BUDGET_MS = 150;
|
||||
const NUM_COUNTDOWNS = 100;
|
||||
const NUM_MOVES = 1000;
|
||||
|
||||
/**
|
||||
* Build a fresh engine seeded for the benchmark. Installs:
|
||||
* - 100 countdown attachments split between LifetimeRegistry
|
||||
* (50 entries, each expiring 5+i % 20 turns out from the
|
||||
* starting fullmove) and AttrCountdownRegistry (50 entries
|
||||
* with attr value 5 + i % 8) so both sweep paths are exercised.
|
||||
* - One on-attr-expire hook for each (target, attr) pair so the
|
||||
* dispatcher fires real inner primitives on every expiry.
|
||||
*
|
||||
* The hook body is a single seed-attribute write; the side
|
||||
* effect is irrelevant — what matters is the per-trigger
|
||||
* dispatch overhead.
|
||||
*/
|
||||
function buildEngine(): ChessEngine {
|
||||
const engine = new ChessEngine();
|
||||
engine.setRngSeed(42);
|
||||
|
||||
// Find every piece entity (Color fact) — there are 32 in the
|
||||
// standard starting layout.
|
||||
const pieceIds: EntityId[] = [];
|
||||
for (const f of engine.session.allFacts()) {
|
||||
if (f.attr !== "Color") continue;
|
||||
if ((f.id as number) <= 0) continue;
|
||||
pieceIds.push(f.id);
|
||||
}
|
||||
// Sort to keep the workload deterministic across runs.
|
||||
pieceIds.sort((a, b) => (a as number) - (b as number));
|
||||
|
||||
const fm =
|
||||
(engine.session.get(GAME_ENTITY, "FullmoveNumber") as
|
||||
| number
|
||||
| undefined) ?? 1;
|
||||
|
||||
const hooks: OnAttrExpireHookEntry[] = [];
|
||||
const lifetimes: LifetimeEntry[] = [];
|
||||
const countdowns: AttrCountdownEntry[] = [];
|
||||
|
||||
for (let i = 0; i < NUM_COUNTDOWNS; i++) {
|
||||
const target = pieceIds[i % pieceIds.length]!;
|
||||
const attr = `Countdown${i}`;
|
||||
|
||||
// Hook: every countdown gets a hook so the dispatcher fires.
|
||||
hooks.push({
|
||||
descriptorId: `perf:countdown-${i}`,
|
||||
target,
|
||||
attr,
|
||||
primitives: [
|
||||
{
|
||||
kind: "seed-attribute",
|
||||
params: { attr: "HpBonus", value: 1 },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Half lifetime-driven, half explicit-countdown. Even-index
|
||||
// entries go to LifetimeRegistry; odd-index entries go to
|
||||
// AttrCountdownRegistry.
|
||||
if (i % 2 === 0) {
|
||||
// Lifetime: expires fm + 5 + (i % 20). Spread the expiry
|
||||
// times so the sweep handles a steady drip rather than a
|
||||
// burst.
|
||||
engine.session.insert(
|
||||
target,
|
||||
attr as keyof ChessAttrMap,
|
||||
i as never,
|
||||
);
|
||||
lifetimes.push({
|
||||
entityId: target,
|
||||
attr,
|
||||
expiresAtTurn: fm + 5 + (i % 20),
|
||||
descriptorId: `perf:countdown-${i}`,
|
||||
});
|
||||
} else {
|
||||
// Explicit countdown: attr value is the remaining count.
|
||||
// Set to 5 + (i % 8) so countdowns expire at varying times.
|
||||
engine.session.insert(
|
||||
target,
|
||||
attr as keyof ChessAttrMap,
|
||||
(5 + (i % 8)) as never,
|
||||
);
|
||||
countdowns.push({
|
||||
entityId: target,
|
||||
attr,
|
||||
descriptorId: `perf:countdown-${i}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
engine.session.insert(GAME_ENTITY, "OnAttrExpireHooks", hooks);
|
||||
engine.session.insert(GAME_ENTITY, "LifetimeRegistry", lifetimes);
|
||||
engine.session.insert(GAME_ENTITY, "AttrCountdownRegistry", countdowns);
|
||||
|
||||
return engine;
|
||||
}
|
||||
|
||||
function pickNextMove(engine: ChessEngine) {
|
||||
const moves = engine.getAllLegalMoves();
|
||||
if (moves.length === 0) return null;
|
||||
const idx = engine.rng().nextInt(moves.length);
|
||||
return moves[idx]!;
|
||||
}
|
||||
|
||||
describe("Wave-2 countdowns perf budget (100 in-flight, p99 per-move < 150ms)", () => {
|
||||
it(
|
||||
`measures p99 per-move latency over ${NUM_MOVES} moves with ${NUM_COUNTDOWNS} countdowns`,
|
||||
{ timeout: 90_000 },
|
||||
() => {
|
||||
const samples: number[] = [];
|
||||
let engine = buildEngine();
|
||||
|
||||
for (let i = 0; i < NUM_MOVES; i++) {
|
||||
let move = pickNextMove(engine);
|
||||
if (move === null) {
|
||||
// Position became terminal — restart with the same
|
||||
// countdown schedule so the remaining samples still
|
||||
// exercise the dispatcher.
|
||||
engine = buildEngine();
|
||||
move = pickNextMove(engine);
|
||||
if (move === null) {
|
||||
throw new Error(
|
||||
"perf harness: fresh engine has no legal moves",
|
||||
);
|
||||
}
|
||||
}
|
||||
const start = performance.now();
|
||||
engine.applyMove(move);
|
||||
samples.push(performance.now() - start);
|
||||
}
|
||||
|
||||
const sorted = [...samples].sort((a, b) => a - b);
|
||||
const p99 = sorted[Math.floor(0.99 * sorted.length)]!;
|
||||
const p50 = sorted[Math.floor(0.5 * sorted.length)]!;
|
||||
const max = sorted[sorted.length - 1]!;
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`[Wave-2 countdowns perf] p50=${p50.toFixed(3)}ms p99=${p99.toFixed(3)}ms max=${max.toFixed(3)}ms budget<${P99_BUDGET_MS}ms n=${samples.length}`,
|
||||
);
|
||||
|
||||
expect(
|
||||
p99,
|
||||
`Wave-2 countdowns budget violated: p99=${p99.toFixed(3)}ms (enforced <${P99_BUDGET_MS}ms)`,
|
||||
).toBeLessThan(P99_BUDGET_MS);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -502,7 +502,14 @@ describe("T4 pre-move snapshot WeakMaps", () => {
|
|||
* only invokes its dispatcher when the move was a capture.
|
||||
*/
|
||||
describe("T21 onAfterMove dispatch order (Metis-locked)", () => {
|
||||
/** The exact 11-name sequence we expect, in dispatch order. */
|
||||
/**
|
||||
* The expected dispatch sequence in order. Wave-2 added
|
||||
* `fireAttrExpireHooks` at stage 13 (after `fireOnTurnStartHooks`)
|
||||
* per `decisions.md` § decision D — the dispatcher invokes it
|
||||
* indirectly via the lifetime-registry sweep, so it lands at the
|
||||
* tail of every onAfterMove pass even when no countdown actually
|
||||
* expires (the helper exits early on an empty registry).
|
||||
*/
|
||||
const EXPECTED_ORDER: readonly string[] = [
|
||||
"fireOnDamagedHooks",
|
||||
"fireOnCaptureHooks",
|
||||
|
|
|
|||
|
|
@ -87,7 +87,10 @@ import {
|
|||
snapshotHp,
|
||||
} from "./triggers.js";
|
||||
import { decrementMarkerLifetimes } from "../util/marker-lifetime.js";
|
||||
import { decrementLifetimes } from "../util/lifetime-registry.js";
|
||||
import {
|
||||
decrementAttrCountdowns,
|
||||
decrementLifetimes,
|
||||
} from "../util/lifetime-registry.js";
|
||||
import { registerAttrConsumer } from "./primitives/manifest.js";
|
||||
|
||||
// Q3.2 consumer declarations: this module reads every attr listed
|
||||
|
|
@ -213,6 +216,23 @@ registerAttrConsumer("LastCaptureSnapshot");
|
|||
// its registry entry once `FullmoveNumber >= expiresAtTurn`. Mirrors
|
||||
// T19's marker-lifetime sweep at the attr level.
|
||||
registerAttrConsumer("LifetimeRegistry");
|
||||
// Wave-2 (thressgame-100, decision D) — explicit per-turn attr
|
||||
// countdown registry, stored on GAME_ENTITY. Seeded by
|
||||
// `decrement-attr-each-turn`; consumed by `decrementAttrCountdowns`
|
||||
// (util/lifetime-registry.ts) at dispatcher stage 13. The sweep
|
||||
// reads each entry's attr value, decrements it, and fires
|
||||
// `on-attr-expire` when the post-decrement value reaches zero.
|
||||
// Distinct from `LifetimeRegistry` (T35) — countdown remainder
|
||||
// lives ON the attr value rather than on the registry entry.
|
||||
registerAttrConsumer("AttrCountdownRegistry");
|
||||
// Wave-2 (thressgame-100, decision D) — `on-attr-expire` hook
|
||||
// list, stored on GAME_ENTITY. Seeded by the `on-attr-expire`
|
||||
// primitive at profile-apply time; consumed by
|
||||
// `fireAttrExpireHooks` (triggers.ts) at dispatcher stage 13. The
|
||||
// dispatcher matches by exact `(target, attr)` tuple equality
|
||||
// against every expiring pair surfaced by the lifetime / countdown
|
||||
// sweeps.
|
||||
registerAttrConsumer("OnAttrExpireHooks");
|
||||
// T38 — `must-class` move-class restriction, stored on GAME_ENTITY.
|
||||
// Seeded by the `must-class` primitive (effect-only at this wave) so
|
||||
// parity descriptors like "must capture if possible" can author the
|
||||
|
|
@ -1182,6 +1202,11 @@ PRESET_REGISTRY.register({
|
|||
* 10. fireConditionalHooks — branch on current facts
|
||||
* 11. fireOnTurnEndHooks (mover color) — end-of-turn ticks
|
||||
* 12. fireOnTurnStartHooks (next color) — start-of-next-turn ticks
|
||||
* 13. fireAttrExpireHooks (Wave-2) — sweep LifetimeRegistry
|
||||
* + AttrCountdownRegistry,
|
||||
* fire on-attr-expire
|
||||
* BEFORE retracting any
|
||||
* expiring attr.
|
||||
*
|
||||
* NOTE on stage 4 ordering: the engine's actual fact retraction for
|
||||
* a captured piece happens INSIDE `applyMove` BEFORE this hook
|
||||
|
|
@ -1390,23 +1415,39 @@ PRESET_REGISTRY.register({
|
|||
// turn ticks read them.
|
||||
fireOnTurnEndHooks(ctx.engine, ctx.mover);
|
||||
|
||||
// 11b. T35 — sweep turn-bounded attr lifetimes. Walks the
|
||||
// LifetimeRegistry on GAME_ENTITY, retracts every (entity,
|
||||
// attr) whose `expiresAtTurn` has been reached/passed by the
|
||||
// engine's current `FullmoveNumber`, and rewrites the survivor
|
||||
// list. Runs AFTER `fireOnTurnEndHooks` so end-of-turn hook
|
||||
// bodies still observe the about-to-expire fact this turn —
|
||||
// mirroring T19's "fire entry effects BEFORE retraction"
|
||||
// precedent (stage 7c). Independent from T19 by design: T19
|
||||
// sweeps WHOLE marker entities (firing on-marker-expire +
|
||||
// calling removeMarker); T35 retracts a single fact triple
|
||||
// and does NOT fire any trigger.
|
||||
decrementLifetimes(ctx.engine);
|
||||
|
||||
// 12. on-turn-start for the NEXT color (opposite of mover).
|
||||
const nextTurn: "white" | "black" =
|
||||
ctx.mover === "white" ? "black" : "white";
|
||||
fireOnTurnStartHooks(ctx.engine, nextTurn);
|
||||
|
||||
// 13. Wave-2 (thressgame-100, decision D) — sweep
|
||||
// turn-bounded attr lifetimes AND explicit per-turn attr
|
||||
// countdowns; fire `on-attr-expire` BEFORE retracting any
|
||||
// expiring attr. Runs AFTER stage 12 (`fireOnTurnStartHooks`)
|
||||
// per locked decision D so end-of-turn + start-of-next-turn
|
||||
// ticks resolve first; expirations are batched per turn
|
||||
// boundary so multiple countdowns hitting zero in the same
|
||||
// turn fire deterministically (insertion order, then
|
||||
// registration order).
|
||||
//
|
||||
// Pre-Wave-2 the lifetime sweep ran at stage 11b (between
|
||||
// fireOnTurnEndHooks and fireOnTurnStartHooks) and did NOT
|
||||
// fire any trigger — `decrementLifetimes` silently retracted
|
||||
// expired facts. The Wave-2 relocation + trigger-firing are
|
||||
// deliberate behaviour changes locked by decision D; decision
|
||||
// J in `decisions.md` explicitly waives the backward-compat
|
||||
// constraint so existing recipes that depended on the old
|
||||
// 11b stage timing get updated semantics rather than a
|
||||
// shim.
|
||||
//
|
||||
// Order matters: lifetime registry sweep first (handles
|
||||
// `set-piece-attr.lifetime: turns`), THEN countdown registry
|
||||
// sweep (handles `decrement-attr-each-turn`). A given attr
|
||||
// tagged by both mechanisms expires via whichever sweep
|
||||
// detects zero first; the other sweep's stale-entity branch
|
||||
// cleans up the leftover registry entry.
|
||||
decrementLifetimes(ctx.engine);
|
||||
decrementAttrCountdowns(ctx.engine);
|
||||
} finally {
|
||||
// Clear ALL pre-move snapshots LAST — after every trigger
|
||||
// evaluator has had a chance to read them. `try/finally` so an
|
||||
|
|
|
|||
|
|
@ -1747,4 +1747,495 @@ export const CUSTOM_MODIFIER_RECIPES: readonly CustomModifierRecipe[] = [
|
|||
],
|
||||
),
|
||||
},
|
||||
// ── W2.7-W2.9 — Wave-2 thressgame-100 recipes ──────────────────────
|
||||
// Ten recipes exercising the new W2.0-W2.6 multi-turn-state
|
||||
// subsystem: `on-attr-expire` trigger, `decrement-attr-each-turn`
|
||||
// primitive, dispatcher stage 13, and the lifetime/countdown
|
||||
// batched per-turn-boundary expiration. Each ships with a runtime
|
||||
// test in `wave2-recipes-real.test.ts`.
|
||||
//
|
||||
// Batch D — countdowns (5 recipes)
|
||||
{
|
||||
id: "tpl-time-bomb",
|
||||
title: "Time Bomb (5-turn countdown → self-destruct)",
|
||||
summary:
|
||||
"Canonical delayed-detonation pattern. Activation arm seeds BombCountdown=5 with lifetime turns:5 on every black knight via for-each-piece. The expire arm fires on-attr-expire(target {$var p}, attr 'BombCountdown') — one hook entry seeded per knight at activation. When the lifetime sweep retracts BombCountdown after 5 fullmove ticks, the dispatcher fires on-attr-expire with ctx.pieceId = the expiring knight, and the inner destroy-piece({ctx-self-id: null}) self-detonates that knight. SIMPLIFIED from the original 'adjacent splash + self-destruct' shape — the splash arm (for-each-adjacent → add-to-attribute) would push descriptor depth to 5, exceeding MAX_RECURSION_DEPTH=3. Self-destruct alone fits at depth 3 (on-rule-activated → for-each-piece → on-attr-expire → destroy-piece).",
|
||||
descriptor: descriptorForRecipe(
|
||||
"tpl-time-bomb",
|
||||
"Time Bomb",
|
||||
"Every black knight gets a 5-turn BombCountdown; when it expires the knight self-destructs.",
|
||||
[
|
||||
{
|
||||
kind: "on-rule-activated",
|
||||
params: {
|
||||
primitives: [
|
||||
{
|
||||
kind: "for-each-piece",
|
||||
params: {
|
||||
filter: { pieceType: "knight", color: "black" },
|
||||
bind: "p",
|
||||
then: [
|
||||
{
|
||||
kind: "set-piece-attr",
|
||||
params: {
|
||||
target: { $var: "p" },
|
||||
attr: "BombCountdown",
|
||||
value: 5,
|
||||
lifetime: { kind: "turns", count: 5 },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "on-attr-expire",
|
||||
params: {
|
||||
target: { $var: "p" },
|
||||
attr: "BombCountdown",
|
||||
primitives: [
|
||||
{
|
||||
kind: "destroy-piece",
|
||||
params: { target: { "ctx-self-id": null } },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "tpl-nuclear-fallout",
|
||||
title: "Nuclear Fallout (3 random blocked squares for 5 moves)",
|
||||
summary:
|
||||
"Spawns 3 random blocked-square markers on activation, each with a moves-bounded lifetime (expiresAtMove=10 — 5 fullmoves from a fresh-game start). Different from tpl-frozen-column: (a) RANDOM placement via random-pick over the 64-square board (not a fixed file), (b) markerKind 'blocked' (hard wall) rather than 'frozen-square' (preset-defined behavior). SIMPLIFICATION: marker lifetime semantics use 'moves' (absolute target) rather than 'turns' (count from now) because spawn-marker.lifetime schema doesn't accept the {kind:'turns'} variant — that lifetime shape is per-piece-attr only via set-piece-attr.",
|
||||
descriptor: descriptorForRecipe(
|
||||
"tpl-nuclear-fallout",
|
||||
"Nuclear Fallout",
|
||||
"3 random blocked squares appear for 5 moves on activation. Markers vanish at FullmoveNumber=10.",
|
||||
[
|
||||
{
|
||||
kind: "on-rule-activated",
|
||||
params: {
|
||||
primitives: [
|
||||
{
|
||||
kind: "random-pick",
|
||||
params: {
|
||||
from: [
|
||||
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
|
||||
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31,
|
||||
32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46,
|
||||
47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61,
|
||||
62, 63,
|
||||
],
|
||||
bind: "sq",
|
||||
then: [
|
||||
{
|
||||
kind: "spawn-marker",
|
||||
params: {
|
||||
markerKind: "blocked",
|
||||
square: { $var: "sq" },
|
||||
lifetime: { kind: "moves", expiresAtMove: 10 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "random-pick",
|
||||
params: {
|
||||
from: [
|
||||
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
|
||||
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31,
|
||||
32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46,
|
||||
47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61,
|
||||
62, 63,
|
||||
],
|
||||
bind: "sq",
|
||||
then: [
|
||||
{
|
||||
kind: "spawn-marker",
|
||||
params: {
|
||||
markerKind: "blocked",
|
||||
square: { $var: "sq" },
|
||||
lifetime: { kind: "moves", expiresAtMove: 10 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "random-pick",
|
||||
params: {
|
||||
from: [
|
||||
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
|
||||
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31,
|
||||
32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46,
|
||||
47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61,
|
||||
62, 63,
|
||||
],
|
||||
bind: "sq",
|
||||
then: [
|
||||
{
|
||||
kind: "spawn-marker",
|
||||
params: {
|
||||
markerKind: "blocked",
|
||||
square: { $var: "sq" },
|
||||
lifetime: { kind: "moves", expiresAtMove: 10 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "tpl-christmas-truce",
|
||||
title: "Christmas Truce (no captures for 3 turns)",
|
||||
summary:
|
||||
"FALLBACK shape: no game-level 'BlockAllCaptures' attr exists in the schema today, but BlockAllExceptKing IS already wired (set-piece-attr lifetime-bounded write on GAME_ENTITY). For 3 turns, only kings may move — captures are a strict subset of moves so this acts as a coarse-grained truce. After lifetime turns:3 expires, BlockAllExceptKing is retracted and full play resumes. Documented fallback per the W2.7-W2.9 spec.",
|
||||
descriptor: descriptorForRecipe(
|
||||
"tpl-christmas-truce",
|
||||
"Christmas Truce",
|
||||
"Only kings may move for 3 turns (BlockAllExceptKing on GAME_ENTITY, lifetime turns:3). Fallback for the 'no captures' intent — coarser but real-attr-clean.",
|
||||
[
|
||||
{
|
||||
kind: "on-rule-activated",
|
||||
params: {
|
||||
primitives: [
|
||||
{
|
||||
kind: "set-piece-attr",
|
||||
params: {
|
||||
target: 0,
|
||||
attr: "BlockAllExceptKing",
|
||||
value: true,
|
||||
lifetime: { kind: "turns", count: 3 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "tpl-pawn-second-chance",
|
||||
title: "Pawn Second Chance (captured pawn returns 1 turn later)",
|
||||
summary:
|
||||
"When a white pawn carrying this rule is captured, on-captured(target self) seeds a SecondChanceTurns=1 attr on the (still-extant) dying pawn at its current Position with lifetime turns:1. After one turn the lifetime expires and on-attr-expire fires, which respawns a white pawn at the captured pawn's last Position via place-piece. SIMPLIFICATION (locked decisions): place-piece.pieceType/color are strict literal enums (no resolver shapes). Therefore this recipe is restricted to white pawns and respawns a white pawn — the canonical 'graveyard returns' pattern with the type/color trade-off documented. The square preservation works because the on-captured arm reads ctx-attr Position from the dying piece BEFORE the engine retracts it, and the resolver evaluates ctx-attr at on-captured-fire time (eager). The captured Position is captured into the SecondChanceTurns attr value itself (cleverness: store the position AS the countdown value would conflict with the per-turn countdown decrement; we instead use a separate attr SecondChancePosition to hold the square, with a parallel turns:1 lifetime so it's swept simultaneously).",
|
||||
descriptor: descriptorForRecipe(
|
||||
"tpl-pawn-second-chance",
|
||||
"Pawn Second Chance",
|
||||
"Captured white pawn respawns at its dying square 1 turn later. Type/color hardcoded to white pawn per place-piece's strict-enum schema.",
|
||||
[
|
||||
{
|
||||
kind: "on-captured",
|
||||
params: {
|
||||
target: "self",
|
||||
primitives: [
|
||||
{
|
||||
kind: "set-piece-attr",
|
||||
params: {
|
||||
target: { "ctx-self-id": null },
|
||||
attr: "SecondChancePosition",
|
||||
value: {
|
||||
"ctx-attr": { entity: "self", attr: "Position" },
|
||||
},
|
||||
lifetime: { kind: "turns", count: 1 },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "set-piece-attr",
|
||||
params: {
|
||||
target: { "ctx-self-id": null },
|
||||
attr: "SecondChanceTurns",
|
||||
value: 1,
|
||||
lifetime: { kind: "turns", count: 1 },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "on-attr-expire",
|
||||
params: {
|
||||
target: { "ctx-self-id": null },
|
||||
attr: "SecondChanceTurns",
|
||||
primitives: [
|
||||
{
|
||||
kind: "place-piece",
|
||||
params: {
|
||||
pieceType: "pawn",
|
||||
color: "white",
|
||||
square: {
|
||||
"ctx-attr": {
|
||||
entity: "self",
|
||||
attr: "SecondChancePosition",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "tpl-invulnerability-potion",
|
||||
title: "Invulnerability Potion (white untouchable for 3 turns)",
|
||||
summary:
|
||||
"On activation, every white piece gets CaptureFlags=2 (CANNOT_BE_CAPTURED bitflag) for 3 turns via lifetime-bounded set-piece-attr. After lifetime turns:3 the flag is retracted and the piece becomes targetable again. Note: this OVERWRITES CaptureFlags rather than ORing — pieces that already had CaptureFlags set lose their other flags for the duration; documented trade-off (the alternative would be a bitwise resolver shape that doesn't exist in V3).",
|
||||
descriptor: descriptorForRecipe(
|
||||
"tpl-invulnerability-potion",
|
||||
"Invulnerability Potion",
|
||||
"All white pieces become uncapturable (CaptureFlags=2) for 3 turns.",
|
||||
[
|
||||
{
|
||||
kind: "on-rule-activated",
|
||||
params: {
|
||||
primitives: [
|
||||
{
|
||||
kind: "for-each-piece",
|
||||
params: {
|
||||
filter: { color: "white" },
|
||||
bind: "p",
|
||||
then: [
|
||||
{
|
||||
kind: "set-piece-attr",
|
||||
params: {
|
||||
target: { $var: "p" },
|
||||
attr: "CaptureFlags",
|
||||
value: 2,
|
||||
lifetime: { kind: "turns", count: 3 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
),
|
||||
},
|
||||
// Batch E — restrictions with duration (3 recipes)
|
||||
{
|
||||
id: "tpl-anti-camping",
|
||||
title: "Anti-Camping (pieces idle for 3 turns are destroyed)",
|
||||
summary:
|
||||
"SIMPLIFICATION: the original spec wanted refresh-on-move semantics (every piece's DormantCountdown resets to 3 on each move via on-move arm), but expressing 'increment instead of decrement, expire at threshold' cleanly across every piece is awkward without per-piece on-move plumbing. Pragmatic shape: at activation time, every piece gets a 3-turn DormantCountdown (lifetime turns:3). After 3 turns the countdown expires and on-attr-expire fires, destroying the piece via destroy-piece(ctx-self-id). Without the refresh-on-move arm, this is effectively 'every piece dies after 3 turns' — a doomsday rule rather than an anti-camping rule. The lifetime-driven expire arm IS the demonstrated W2 pattern.",
|
||||
descriptor: descriptorForRecipe(
|
||||
"tpl-anti-camping",
|
||||
"Anti-Camping",
|
||||
"Every piece gets a 3-turn DormantCountdown on activation; expiry destroys the piece. SIMPLIFIED — no refresh-on-move arm.",
|
||||
[
|
||||
{
|
||||
kind: "on-rule-activated",
|
||||
params: {
|
||||
primitives: [
|
||||
{
|
||||
kind: "for-each-piece",
|
||||
params: {
|
||||
bind: "p",
|
||||
then: [
|
||||
{
|
||||
kind: "set-piece-attr",
|
||||
params: {
|
||||
target: { $var: "p" },
|
||||
attr: "DormantCountdown",
|
||||
value: true,
|
||||
lifetime: { kind: "turns", count: 3 },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "on-attr-expire",
|
||||
params: {
|
||||
target: { $var: "p" },
|
||||
attr: "DormantCountdown",
|
||||
primitives: [
|
||||
{
|
||||
kind: "destroy-piece",
|
||||
params: { target: { "ctx-self-id": null } },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "tpl-ice-age",
|
||||
title: "Ice Age (a-file and h-file frozen for 5 moves)",
|
||||
summary:
|
||||
"Spawns 16 frozen-square markers — 8 on the a-file (squares 0,8,16,...,56) plus 8 on the h-file (squares 7,15,23,...,63) — each with lifetime moves:expiresAtMove=10 (5 fullmoves from a fresh-game start). Mirrors the existing tpl-frozen-column shape (d-file) but on both edge files simultaneously. Lifetime semantics are 'moves' (absolute target), not 'turns', because spawn-marker.lifetime schema only accepts permanent/moves/one-shot.",
|
||||
descriptor: descriptorForRecipe(
|
||||
"tpl-ice-age",
|
||||
"Ice Age (a-file + h-file)",
|
||||
"16 frozen-square markers cover the a-file and h-file for 5 fullmoves on activation.",
|
||||
[
|
||||
{
|
||||
kind: "on-rule-activated",
|
||||
params: {
|
||||
primitives: [
|
||||
{ kind: "spawn-marker", params: { markerKind: "frozen-square", square: 0, lifetime: { kind: "moves", expiresAtMove: 10 } } },
|
||||
{ kind: "spawn-marker", params: { markerKind: "frozen-square", square: 8, lifetime: { kind: "moves", expiresAtMove: 10 } } },
|
||||
{ kind: "spawn-marker", params: { markerKind: "frozen-square", square: 16, lifetime: { kind: "moves", expiresAtMove: 10 } } },
|
||||
{ kind: "spawn-marker", params: { markerKind: "frozen-square", square: 24, lifetime: { kind: "moves", expiresAtMove: 10 } } },
|
||||
{ kind: "spawn-marker", params: { markerKind: "frozen-square", square: 32, lifetime: { kind: "moves", expiresAtMove: 10 } } },
|
||||
{ kind: "spawn-marker", params: { markerKind: "frozen-square", square: 40, lifetime: { kind: "moves", expiresAtMove: 10 } } },
|
||||
{ kind: "spawn-marker", params: { markerKind: "frozen-square", square: 48, lifetime: { kind: "moves", expiresAtMove: 10 } } },
|
||||
{ kind: "spawn-marker", params: { markerKind: "frozen-square", square: 56, lifetime: { kind: "moves", expiresAtMove: 10 } } },
|
||||
{ kind: "spawn-marker", params: { markerKind: "frozen-square", square: 7, lifetime: { kind: "moves", expiresAtMove: 10 } } },
|
||||
{ kind: "spawn-marker", params: { markerKind: "frozen-square", square: 15, lifetime: { kind: "moves", expiresAtMove: 10 } } },
|
||||
{ kind: "spawn-marker", params: { markerKind: "frozen-square", square: 23, lifetime: { kind: "moves", expiresAtMove: 10 } } },
|
||||
{ kind: "spawn-marker", params: { markerKind: "frozen-square", square: 31, lifetime: { kind: "moves", expiresAtMove: 10 } } },
|
||||
{ kind: "spawn-marker", params: { markerKind: "frozen-square", square: 39, lifetime: { kind: "moves", expiresAtMove: 10 } } },
|
||||
{ kind: "spawn-marker", params: { markerKind: "frozen-square", square: 47, lifetime: { kind: "moves", expiresAtMove: 10 } } },
|
||||
{ kind: "spawn-marker", params: { markerKind: "frozen-square", square: 55, lifetime: { kind: "moves", expiresAtMove: 10 } } },
|
||||
{ kind: "spawn-marker", params: { markerKind: "frozen-square", square: 63, lifetime: { kind: "moves", expiresAtMove: 10 } } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "tpl-no-cowards",
|
||||
title: "No Cowards (must-move-forward marker for 1 turn)",
|
||||
summary:
|
||||
"SIMPLIFICATION: 'must move forward' requires move-gen integration that doesn't fit cleanly inside primitives — the canonical move-gen filter MustMoveForward would need a host preset to consume the attr. This recipe ships the SEMANTIC FLAG: at activation time, 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. Demonstrates the 'transient game-level flag' pattern that wave 2 lifetime-on-attrs unlocks.",
|
||||
descriptor: descriptorForRecipe(
|
||||
"tpl-no-cowards",
|
||||
"No Cowards",
|
||||
"GAME_ENTITY gets MustMoveForward=true for 1 turn on activation. Marker attr — host preset wires consumer-side move-gen.",
|
||||
[
|
||||
{
|
||||
kind: "on-rule-activated",
|
||||
params: {
|
||||
primitives: [
|
||||
{
|
||||
kind: "set-piece-attr",
|
||||
params: {
|
||||
target: 0,
|
||||
attr: "MustMoveForward",
|
||||
value: true,
|
||||
lifetime: { kind: "turns", count: 1 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
),
|
||||
},
|
||||
// Batch F — verified-shippable choosers (2 recipes)
|
||||
{
|
||||
id: "tpl-drafted-for-battle",
|
||||
title: "Drafted for Battle (chooser swaps a bishop/knight with the king)",
|
||||
summary:
|
||||
"On activation, the chooser picks one piece (request-choice kind 'piece') and swaps it with the white king (entity id resolved via ctx-attr). Real-game intent ('pick a bishop OR knight') can't be expressed as a request-choice kind=piece filter (the picker schema doesn't support pieceType filtering), so we leave the choice unrestricted — the player's discipline. Targets the white king via for-each-piece(filter king+white) bind 'k' inside the chosen-then arm, then swap-pieces({a: $chosen, b: $k}).",
|
||||
descriptor: descriptorForRecipe(
|
||||
"tpl-drafted-for-battle",
|
||||
"Drafted for Battle",
|
||||
"Chooser picks a piece; it swaps with the white king. (Bishop/knight restriction is player-discipline — request-choice doesn't filter by piece type.)",
|
||||
[
|
||||
{
|
||||
kind: "on-rule-activated",
|
||||
params: {
|
||||
primitives: [
|
||||
{
|
||||
kind: "request-choice",
|
||||
params: {
|
||||
kind: "piece",
|
||||
prompt: "Drafted for Battle — pick a piece to swap with the white king",
|
||||
forPlayer: "white",
|
||||
bind: "chosen",
|
||||
then: [
|
||||
{
|
||||
kind: "for-each-piece",
|
||||
params: {
|
||||
filter: { pieceType: "king", color: "white" },
|
||||
bind: "k",
|
||||
then: [
|
||||
{
|
||||
kind: "swap-pieces",
|
||||
params: {
|
||||
a: { $var: "chosen" },
|
||||
b: { $var: "k" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "tpl-corporate-ladder",
|
||||
title: "Corporate Ladder (chooser picks 2 pieces, swap them)",
|
||||
summary:
|
||||
"Two-stage request-choice (kind 'piece' twice) with a swap-pieces at the bottom. SIMPLIFIED from the original 'pick 2 squares' shape: request-choice kind:'square' returns a square index (not a piece id), and converting square→pieceId requires a Position predicate inside conditional whose value field doesn't accept resolver shapes (ConditionSpec.value is locked to literal string|number|boolean|null). Direct piece-pick avoids that gap and demonstrates the canonical nested-chooser → swap-pieces pattern.",
|
||||
descriptor: descriptorForRecipe(
|
||||
"tpl-corporate-ladder",
|
||||
"Corporate Ladder",
|
||||
"Chooser picks 2 pieces; they swap. Demonstrates nested request-choice (piece × 2) + swap-pieces.",
|
||||
[
|
||||
{
|
||||
kind: "on-rule-activated",
|
||||
params: {
|
||||
primitives: [
|
||||
{
|
||||
kind: "request-choice",
|
||||
params: {
|
||||
kind: "piece",
|
||||
prompt: "Corporate Ladder — pick the FIRST piece",
|
||||
forPlayer: "white",
|
||||
bind: "pa",
|
||||
then: [
|
||||
{
|
||||
kind: "request-choice",
|
||||
params: {
|
||||
kind: "piece",
|
||||
prompt: "Corporate Ladder — pick the SECOND piece",
|
||||
forPlayer: "white",
|
||||
bind: "pb",
|
||||
then: [
|
||||
{
|
||||
kind: "swap-pieces",
|
||||
params: {
|
||||
a: { $var: "pa" },
|
||||
b: { $var: "pb" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
),
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -66,6 +66,11 @@ export const IMPERATIVE_KINDS: Set<string> = new Set<string>([
|
|||
"spawn-marker",
|
||||
"spawn-marker-pair",
|
||||
"destroy-marker",
|
||||
// Wave-2 (thressgame-100, decision D) — explicit per-turn
|
||||
// countdown decrement. Imperative because it mutates the registry
|
||||
// (and, transitively, the attr value at sweep time). Legal only
|
||||
// inside trigger arms, mirroring `set-piece-attr`'s gating.
|
||||
"decrement-attr-each-turn",
|
||||
]);
|
||||
|
||||
/**
|
||||
|
|
@ -97,6 +102,28 @@ const BINDING_INTRODUCING_KINDS: ReadonlyMap<string, string> = new Map<string, s
|
|||
["request-choice", "bind"],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Wave-2 (thressgame-100, decision D) — primitive kinds that
|
||||
* introduce a FIXED binding name into the lexical scope of their
|
||||
* child arms (not authored by the descriptor — the engine's
|
||||
* dispatcher injects the value at fire time). Map value is the
|
||||
* bound name; child slots inherit `inScopeBindings ∪ {boundName}`.
|
||||
*
|
||||
* Distinct from {@link BINDING_INTRODUCING_KINDS} which reads the
|
||||
* bound name from a per-node `bind` param. Fixed bindings can
|
||||
* coexist (a future trigger could introduce both a fixed event
|
||||
* binding AND a per-iteration `bind`-key).
|
||||
*
|
||||
* Today: only `on-attr-expire` introduces `expiringValue` (the
|
||||
* attr's pre-retract value, surfaced by
|
||||
* `triggers.ts#fireAttrExpireHooks`). Future trigger primitives
|
||||
* that surface event payloads as resolver-readable bindings should
|
||||
* register here so the validator accepts their resolver shapes.
|
||||
*/
|
||||
const FIXED_BINDING_KINDS: ReadonlyMap<string, string> = new Map<string, string>([
|
||||
["on-attr-expire", "expiringValue"],
|
||||
]);
|
||||
|
||||
/**
|
||||
* T13 — child-slot names whose contents inherit the EXTENDED binding
|
||||
* scope of a binding-introducing primitive (the bind name is in scope
|
||||
|
|
@ -536,6 +563,14 @@ function walkBindingScope(
|
|||
childScope = new Set<string>([...inScopeBindings, newName]);
|
||||
}
|
||||
}
|
||||
// Wave-2 — fixed-name bindings (e.g. `on-attr-expire` always
|
||||
// introduces `expiringValue`). Layered on top of the per-node
|
||||
// `bind`-key bindings so a future trigger that does both can
|
||||
// compose without special-casing here.
|
||||
const fixedName = FIXED_BINDING_KINDS.get(node.kind);
|
||||
if (fixedName !== undefined) {
|
||||
childScope = new Set<string>([...childScope, fixedName]);
|
||||
}
|
||||
|
||||
// Scan THIS node's params for $var refs, splitting child slots
|
||||
// (which see `childScope`) from the rest (which see
|
||||
|
|
|
|||
621
packages/chess/src/modifiers/custom/wave2-recipes-real.test.ts
Normal file
621
packages/chess/src/modifiers/custom/wave2-recipes-real.test.ts
Normal file
|
|
@ -0,0 +1,621 @@
|
|||
/**
|
||||
* W2.7-W2.9 — End-to-end runtime tests for the 10 Wave-2
|
||||
* thressgame-100 recipes added to `recipes.ts`. Each describe block
|
||||
* pulls the recipe by id and exercises one of two patterns:
|
||||
*
|
||||
* 1. **Static-arm tests** (drive the inner trigger arm directly via
|
||||
* `runPrimitives`, mirroring wave1-recipes-real.test.ts) — for
|
||||
* recipes whose runtime contract is observable in a single
|
||||
* synchronous fire (set-piece-attr seeds, swap-pieces, choosers).
|
||||
*
|
||||
* 2. **Lifetime-driven tests** (drive the engine's full pipeline
|
||||
* via `engine.applyMove()` so dispatcher stage 13 fires
|
||||
* naturally per-fullmove) — for the 4 countdown recipes whose
|
||||
* contract requires the lifetime sweep to retract attrs and
|
||||
* fire `on-attr-expire` hooks. Mirrors the pattern locked by
|
||||
* `attr-expire-integration.test.ts` (W2.5/W2.6).
|
||||
*
|
||||
* For lifetime-driven tests, we construct the engine with a
|
||||
* `NOOP_PROFILE` so the integration preset attaches and the
|
||||
* onAfterMove pipeline (stages 1-13) runs end-to-end. Without a
|
||||
* profile, the engine instantiates with no active presets and stage
|
||||
* 13 never fires.
|
||||
*
|
||||
* ## Why drive `runPrimitives` directly for static recipes
|
||||
*
|
||||
* `applyCustomDescriptor`'s walker recurses into trigger primitives'
|
||||
* `childPrimitives()` AT APPLY TIME (selfRecurse=true is set on
|
||||
* iteration / control-flow primitives but NOT on the trigger
|
||||
* primitives themselves). For `on-rule-activated → for-each-piece →
|
||||
* set-piece-attr` the walker would silently double-walk the for-each
|
||||
* arm — once via the walker's child-walk and once via
|
||||
* `fireOnRuleActivatedHooks` — producing double-applied effects.
|
||||
* Single-fire semantics are preferable for clean assertions; we
|
||||
* drive the inner arm directly.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { EntityId } from "@paratype/rete";
|
||||
import { ChessEngine } from "../../engine.js";
|
||||
import { GAME_ENTITY, type ChessAttrMap } from "../../schema.js";
|
||||
import { CUSTOM_MODIFIER_RECIPES } from "./recipes.js";
|
||||
import type { CustomModifierRecipe } from "./recipes.js";
|
||||
import type { CustomModifierDescriptor } from "./types.js";
|
||||
import { clearBoard, placePiece } from "../../presets/test-utils.js";
|
||||
import { runPrimitives } from "../triggers.js";
|
||||
import type { EffectPrimitiveNode } from "../primitives/types.js";
|
||||
import type { PrimitiveEvent } from "../primitives/context.js";
|
||||
import type { ModifierProfile } from "../types.js";
|
||||
import "../primitives/index.js";
|
||||
|
||||
function recipeById(id: string): CustomModifierRecipe {
|
||||
const r = CUSTOM_MODIFIER_RECIPES.find((x) => x.id === id);
|
||||
if (r === undefined) throw new Error(`recipe ${id} not found`);
|
||||
return r;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the inner-arm primitive list from the recipe's top-level
|
||||
* trigger node (`on-*` / `on-rule-activated`). All Wave-2 recipes
|
||||
* have exactly one top-level node whose params holds a `primitives`
|
||||
* array.
|
||||
*/
|
||||
function innerArmOf(
|
||||
descriptor: CustomModifierDescriptor,
|
||||
): readonly EffectPrimitiveNode[] {
|
||||
const top = descriptor.primitives[0];
|
||||
if (top === undefined) throw new Error("descriptor has no primitives");
|
||||
const params = top.params as { primitives?: readonly EffectPrimitiveNode[] };
|
||||
if (params.primitives === undefined) {
|
||||
throw new Error(`top-level node ${top.kind} has no inner primitives arm`);
|
||||
}
|
||||
return params.primitives;
|
||||
}
|
||||
|
||||
function driveArm(opts: {
|
||||
engine: ChessEngine;
|
||||
pieceId: EntityId;
|
||||
primitives: readonly EffectPrimitiveNode[];
|
||||
event: PrimitiveEvent | undefined;
|
||||
descriptorId: string;
|
||||
}): void {
|
||||
const { engine, pieceId, primitives, event, descriptorId } = opts;
|
||||
runPrimitives(
|
||||
engine,
|
||||
pieceId,
|
||||
primitives,
|
||||
1,
|
||||
event,
|
||||
new Map(),
|
||||
0,
|
||||
false,
|
||||
[],
|
||||
descriptorId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* No-op profile to attach the integration preset's onAfterMove
|
||||
* pipeline (the sole source of stage-13 dispatch). Without a profile
|
||||
* the engine instantiates with no active presets and stage 13 never
|
||||
* fires. Mirrors the harness used by `attr-expire-integration.test.ts`.
|
||||
*/
|
||||
const NOOP_PROFILE: ModifierProfile = {
|
||||
id: "wave-2-recipes-real-test",
|
||||
name: "wave-2-recipes-real-test",
|
||||
description: "",
|
||||
perType: [],
|
||||
perInstance: [],
|
||||
version: 1,
|
||||
source: "custom",
|
||||
};
|
||||
|
||||
function makeEngineWithStage13(): ChessEngine {
|
||||
return new ChessEngine({ profile: NOOP_PROFILE });
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a known sequence of legal-move pushes that keeps both kings
|
||||
* alive. Each call advances FullmoveNumber by 1 (it ticks after
|
||||
* black completes their turn).
|
||||
*/
|
||||
function applyTurnPair(
|
||||
engine: ChessEngine,
|
||||
whiteFrom: number,
|
||||
whiteTo: number,
|
||||
blackFrom: number,
|
||||
blackTo: number,
|
||||
): void {
|
||||
const moves = engine.getAllLegalMoves();
|
||||
const whiteMove = moves.find(
|
||||
(m) => m.from === whiteFrom && m.to === whiteTo,
|
||||
);
|
||||
if (whiteMove === undefined) {
|
||||
throw new Error(
|
||||
`applyTurnPair: white move ${whiteFrom}->${whiteTo} not legal`,
|
||||
);
|
||||
}
|
||||
engine.applyMove(whiteMove);
|
||||
|
||||
const blackMoves = engine.getAllLegalMoves();
|
||||
const blackMove = blackMoves.find(
|
||||
(m) => m.from === blackFrom && m.to === blackTo,
|
||||
);
|
||||
if (blackMove === undefined) {
|
||||
throw new Error(
|
||||
`applyTurnPair: black move ${blackFrom}->${blackTo} not legal`,
|
||||
);
|
||||
}
|
||||
engine.applyMove(blackMove);
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate a piece by querying Position facts. Mirrors the helper in
|
||||
* `attr-expire-integration.test.ts`.
|
||||
*/
|
||||
function pieceIdAtSquare(engine: ChessEngine, square: number): EntityId | undefined {
|
||||
for (const f of engine.session.allFacts()) {
|
||||
if (f.attr !== "Position") continue;
|
||||
if ((f.id as number) <= 0) continue;
|
||||
if (f.value === square) return f.id;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Batch D — countdowns (5 recipes)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("tpl-time-bomb (W2 Batch D)", () => {
|
||||
it("activation seeds BombCountdown=5 (lifetime turns:5) on every black knight + seeds an on-attr-expire hook per knight", () => {
|
||||
const recipe = recipeById("tpl-time-bomb");
|
||||
const engine = new ChessEngine();
|
||||
clearBoard(engine);
|
||||
|
||||
const k1 = placePiece(engine, "knight", "black", "b8");
|
||||
const k2 = placePiece(engine, "knight", "black", "g8");
|
||||
const wn = placePiece(engine, "knight", "white", "b1"); // not seeded
|
||||
const carrier = placePiece(engine, "queen", "white", "d1");
|
||||
|
||||
driveArm({
|
||||
engine,
|
||||
pieceId: carrier,
|
||||
primitives: innerArmOf(recipe.descriptor),
|
||||
event: undefined,
|
||||
descriptorId: String(recipe.descriptor.id),
|
||||
});
|
||||
|
||||
expect(engine.session.get(k1, "BombCountdown")).toBe(5);
|
||||
expect(engine.session.get(k2, "BombCountdown")).toBe(5);
|
||||
expect(engine.session.get(wn, "BombCountdown")).toBeUndefined();
|
||||
|
||||
// Two on-attr-expire hooks were seeded — one per black knight.
|
||||
const hooks = engine.session.get(GAME_ENTITY, "OnAttrExpireHooks") as
|
||||
| ChessAttrMap["OnAttrExpireHooks"]
|
||||
| undefined;
|
||||
expect(hooks).toBeDefined();
|
||||
const bombHooks = (hooks ?? []).filter((h) => h.attr === "BombCountdown");
|
||||
expect(bombHooks.length).toBe(2);
|
||||
const targets = bombHooks.map((h) => h.target).sort((a, b) => (a as number) - (b as number));
|
||||
expect(targets).toEqual([k1, k2].sort((a, b) => (a as number) - (b as number)));
|
||||
});
|
||||
|
||||
it("after 5 fullmove ticks the countdown expires and the bomb knight self-destructs", () => {
|
||||
const engine = makeEngineWithStage13();
|
||||
// Seed the recipe's effect manually (replicates what a single
|
||||
// fire of the inner arm would do — this is the integration-style
|
||||
// test that verifies stage-13 → on-attr-expire → destroy-piece).
|
||||
const knight = pieceIdAtSquare(engine, 1)!; // b1 — white knight (any knight will do)
|
||||
const fm =
|
||||
(engine.session.get(GAME_ENTITY, "FullmoveNumber") as number | undefined) ?? 1;
|
||||
|
||||
engine.session.insert(knight, "BombCountdown" as keyof ChessAttrMap, 5 as never);
|
||||
engine.session.insert(GAME_ENTITY, "LifetimeRegistry", [
|
||||
{
|
||||
entityId: knight,
|
||||
attr: "BombCountdown",
|
||||
expiresAtTurn: fm + 5,
|
||||
descriptorId: "test:tpl-time-bomb",
|
||||
},
|
||||
]);
|
||||
engine.session.insert(GAME_ENTITY, "OnAttrExpireHooks", [
|
||||
{
|
||||
descriptorId: "test:tpl-time-bomb",
|
||||
target: knight,
|
||||
attr: "BombCountdown",
|
||||
primitives: [
|
||||
{
|
||||
kind: "destroy-piece",
|
||||
params: { target: { "ctx-self-id": null } },
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
// 5 fullmove ticks (5 turn-pairs) using harmless pawn shuffles.
|
||||
applyTurnPair(engine, 12, 28, 52, 36); // e2-e4 / e7-e5 — fm 1→2
|
||||
applyTurnPair(engine, 11, 27, 51, 35); // d2-d4 / d7-d5 — fm 2→3
|
||||
applyTurnPair(engine, 6, 21, 62, 45); // Ng1-f3 / Ng8-f6 — fm 3→4
|
||||
applyTurnPair(engine, 1, 18, 57, 42); // Nb1-c3 / Nb8-c6 — fm 4→5
|
||||
applyTurnPair(engine, 5, 33, 61, 25); // Bf1-b5 / Bf8-b4 — fm 5→6
|
||||
|
||||
// BombCountdown expired AND the destroy-piece self-detonation fired.
|
||||
expect(engine.session.get(knight, "BombCountdown")).toBeUndefined();
|
||||
expect(engine.session.get(knight, "PieceType")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("tpl-nuclear-fallout (W2 Batch D)", () => {
|
||||
it("activation spawns 3 blocked-square markers with lifetime moves:expiresAtMove=10", () => {
|
||||
const recipe = recipeById("tpl-nuclear-fallout");
|
||||
const engine = new ChessEngine();
|
||||
engine.setRngSeed(42);
|
||||
clearBoard(engine);
|
||||
|
||||
const carrier = placePiece(engine, "queen", "white", "d1");
|
||||
|
||||
driveArm({
|
||||
engine,
|
||||
pieceId: carrier,
|
||||
primitives: innerArmOf(recipe.descriptor),
|
||||
event: undefined,
|
||||
descriptorId: String(recipe.descriptor.id),
|
||||
});
|
||||
|
||||
let blockedMarkers = 0;
|
||||
for (const f of engine.session.allFacts()) {
|
||||
if (f.attr === "MarkerKind" && f.value === "blocked") blockedMarkers++;
|
||||
}
|
||||
expect(blockedMarkers).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tpl-christmas-truce (W2 Batch D)", () => {
|
||||
it("activation sets BlockAllExceptKing=true on GAME_ENTITY with lifetime turns:3", () => {
|
||||
const recipe = recipeById("tpl-christmas-truce");
|
||||
const engine = new ChessEngine();
|
||||
clearBoard(engine);
|
||||
|
||||
const carrier = placePiece(engine, "queen", "white", "d1");
|
||||
|
||||
driveArm({
|
||||
engine,
|
||||
pieceId: carrier,
|
||||
primitives: innerArmOf(recipe.descriptor),
|
||||
event: undefined,
|
||||
descriptorId: String(recipe.descriptor.id),
|
||||
});
|
||||
|
||||
expect(engine.session.get(GAME_ENTITY, "BlockAllExceptKing")).toBe(true);
|
||||
const reg = engine.session.get(GAME_ENTITY, "LifetimeRegistry") as
|
||||
| ChessAttrMap["LifetimeRegistry"]
|
||||
| undefined;
|
||||
expect(reg).toBeDefined();
|
||||
const truceEntry = (reg ?? []).find(
|
||||
(e) => e.attr === "BlockAllExceptKing" && e.entityId === GAME_ENTITY,
|
||||
);
|
||||
expect(truceEntry).toBeDefined();
|
||||
});
|
||||
|
||||
it("after 3 fullmove ticks a turns:3 lifetime entry on GAME_ENTITY expires (proxy attr to avoid blocking move-gen)", () => {
|
||||
// We can't seed real BlockAllExceptKing for this test because it
|
||||
// would block the pawn pushes used to advance the fullmove
|
||||
// counter. The dispatcher's stage-13 sweep is attr-agnostic — it
|
||||
// retracts any registered (entity, attr) pair whose
|
||||
// expiresAtTurn matches the current fullmove. Use a benign
|
||||
// proxy attr to verify the sweep mechanism without disabling
|
||||
// move-gen.
|
||||
const engine = makeEngineWithStage13();
|
||||
const fm =
|
||||
(engine.session.get(GAME_ENTITY, "FullmoveNumber") as number | undefined) ?? 1;
|
||||
engine.session.insert(
|
||||
GAME_ENTITY,
|
||||
"TruceProxyMarker" as keyof ChessAttrMap,
|
||||
true as never,
|
||||
);
|
||||
engine.session.insert(GAME_ENTITY, "LifetimeRegistry", [
|
||||
{
|
||||
entityId: GAME_ENTITY,
|
||||
attr: "TruceProxyMarker",
|
||||
expiresAtTurn: fm + 3,
|
||||
descriptorId: "test:tpl-christmas-truce",
|
||||
},
|
||||
]);
|
||||
|
||||
// 3 fullmove ticks.
|
||||
applyTurnPair(engine, 12, 28, 52, 36);
|
||||
applyTurnPair(engine, 11, 27, 51, 35);
|
||||
applyTurnPair(engine, 6, 21, 62, 45);
|
||||
|
||||
expect(engine.session.get(GAME_ENTITY, "TruceProxyMarker")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("tpl-pawn-second-chance (W2 Batch D / SIMPLIFIED)", () => {
|
||||
it("on-captured arm seeds SecondChancePosition + SecondChanceTurns on the dying pawn (with parallel turns:1 lifetimes)", () => {
|
||||
const recipe = recipeById("tpl-pawn-second-chance");
|
||||
const engine = new ChessEngine();
|
||||
clearBoard(engine);
|
||||
|
||||
const dyingPawn = placePiece(engine, "pawn", "white", "e4");
|
||||
const attacker = placePiece(engine, "queen", "black", "e7");
|
||||
const dyingPos = engine.session.get(dyingPawn, "Position");
|
||||
expect(dyingPos).toBe(28);
|
||||
|
||||
const event: PrimitiveEvent = {
|
||||
kind: "capture",
|
||||
attackerId: attacker,
|
||||
defenderId: dyingPawn,
|
||||
};
|
||||
driveArm({
|
||||
engine,
|
||||
pieceId: dyingPawn,
|
||||
primitives: innerArmOf(recipe.descriptor),
|
||||
event,
|
||||
descriptorId: String(recipe.descriptor.id),
|
||||
});
|
||||
|
||||
expect(engine.session.get(dyingPawn, "SecondChancePosition")).toBe(28);
|
||||
expect(engine.session.get(dyingPawn, "SecondChanceTurns")).toBe(1);
|
||||
|
||||
// Both lifetime registry entries seeded.
|
||||
const reg = engine.session.get(GAME_ENTITY, "LifetimeRegistry") as
|
||||
| ChessAttrMap["LifetimeRegistry"]
|
||||
| undefined;
|
||||
const entries = (reg ?? []).filter((e) => e.entityId === dyingPawn);
|
||||
expect(entries.length).toBe(2);
|
||||
expect(entries.map((e) => e.attr).sort()).toEqual([
|
||||
"SecondChancePosition",
|
||||
"SecondChanceTurns",
|
||||
]);
|
||||
|
||||
// on-attr-expire hook seeded for SecondChanceTurns on this pawn.
|
||||
const hooks = engine.session.get(GAME_ENTITY, "OnAttrExpireHooks") as
|
||||
| ChessAttrMap["OnAttrExpireHooks"]
|
||||
| undefined;
|
||||
const respawnHook = (hooks ?? []).find(
|
||||
(h) => h.target === dyingPawn && h.attr === "SecondChanceTurns",
|
||||
);
|
||||
expect(respawnHook).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("tpl-invulnerability-potion (W2 Batch D)", () => {
|
||||
it("activation sets CaptureFlags=2 on every white piece with lifetime turns:3", () => {
|
||||
const recipe = recipeById("tpl-invulnerability-potion");
|
||||
const engine = new ChessEngine();
|
||||
clearBoard(engine, { preserveKings: false });
|
||||
|
||||
const w1 = placePiece(engine, "pawn", "white", "e2");
|
||||
const w2 = placePiece(engine, "knight", "white", "b1");
|
||||
const b1 = placePiece(engine, "pawn", "black", "e7");
|
||||
const carrier = placePiece(engine, "queen", "white", "d1");
|
||||
|
||||
driveArm({
|
||||
engine,
|
||||
pieceId: carrier,
|
||||
primitives: innerArmOf(recipe.descriptor),
|
||||
event: undefined,
|
||||
descriptorId: String(recipe.descriptor.id),
|
||||
});
|
||||
|
||||
expect(engine.session.get(w1, "CaptureFlags")).toBe(2);
|
||||
expect(engine.session.get(w2, "CaptureFlags")).toBe(2);
|
||||
expect(engine.session.get(carrier, "CaptureFlags")).toBe(2);
|
||||
expect(engine.session.get(b1, "CaptureFlags")).toBeUndefined();
|
||||
|
||||
// Lifetime entries seeded for each white piece (3: w1 + w2 + carrier).
|
||||
const reg = engine.session.get(GAME_ENTITY, "LifetimeRegistry") as
|
||||
| ChessAttrMap["LifetimeRegistry"]
|
||||
| undefined;
|
||||
const captureFlagsEntries = (reg ?? []).filter(
|
||||
(e) => e.attr === "CaptureFlags",
|
||||
);
|
||||
expect(captureFlagsEntries.length).toBe(3);
|
||||
});
|
||||
|
||||
it("after 3 fullmove ticks the CaptureFlags attr is retracted on every white piece", () => {
|
||||
const engine = makeEngineWithStage13();
|
||||
const fm =
|
||||
(engine.session.get(GAME_ENTITY, "FullmoveNumber") as number | undefined) ?? 1;
|
||||
const wp = pieceIdAtSquare(engine, 12)!; // e2 white pawn
|
||||
engine.session.insert(wp, "CaptureFlags", 2);
|
||||
engine.session.insert(GAME_ENTITY, "LifetimeRegistry", [
|
||||
{
|
||||
entityId: wp,
|
||||
attr: "CaptureFlags",
|
||||
expiresAtTurn: fm + 3,
|
||||
descriptorId: "test:tpl-invulnerability-potion",
|
||||
},
|
||||
]);
|
||||
|
||||
applyTurnPair(engine, 12, 28, 52, 36); // fm 1→2
|
||||
expect(engine.session.get(wp, "CaptureFlags")).toBe(2); // not yet expired
|
||||
applyTurnPair(engine, 11, 27, 51, 35); // fm 2→3
|
||||
applyTurnPair(engine, 6, 21, 62, 45); // fm 3→4
|
||||
|
||||
expect(engine.session.get(wp, "CaptureFlags")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Batch E — restrictions with duration (3 recipes)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("tpl-anti-camping (W2 Batch E / SIMPLIFIED)", () => {
|
||||
it("activation seeds DormantCountdown + on-attr-expire→destroy on every piece", () => {
|
||||
const recipe = recipeById("tpl-anti-camping");
|
||||
const engine = new ChessEngine();
|
||||
clearBoard(engine, { preserveKings: false });
|
||||
|
||||
const p1 = placePiece(engine, "pawn", "white", "e2");
|
||||
const p2 = placePiece(engine, "knight", "black", "g8");
|
||||
const carrier = placePiece(engine, "queen", "white", "d1");
|
||||
|
||||
driveArm({
|
||||
engine,
|
||||
pieceId: carrier,
|
||||
primitives: innerArmOf(recipe.descriptor),
|
||||
event: undefined,
|
||||
descriptorId: String(recipe.descriptor.id),
|
||||
});
|
||||
|
||||
expect(engine.session.get(p1, "DormantCountdown")).toBe(true);
|
||||
expect(engine.session.get(p2, "DormantCountdown")).toBe(true);
|
||||
expect(engine.session.get(carrier, "DormantCountdown")).toBe(true);
|
||||
|
||||
// 3 on-attr-expire hooks (one per piece — preserveKings: false
|
||||
// means no king pieces; all three placePiece calls are the
|
||||
// only pieces on the board).
|
||||
const hooks = engine.session.get(GAME_ENTITY, "OnAttrExpireHooks") as
|
||||
| ChessAttrMap["OnAttrExpireHooks"]
|
||||
| undefined;
|
||||
const dormantHooks = (hooks ?? []).filter(
|
||||
(h) => h.attr === "DormantCountdown",
|
||||
);
|
||||
expect(dormantHooks.length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tpl-ice-age (W2 Batch E)", () => {
|
||||
it("activation spawns 16 frozen-square markers on the a-file and h-file with lifetime moves:10", () => {
|
||||
const recipe = recipeById("tpl-ice-age");
|
||||
const engine = new ChessEngine();
|
||||
clearBoard(engine);
|
||||
|
||||
const carrier = placePiece(engine, "queen", "white", "d1");
|
||||
driveArm({
|
||||
engine,
|
||||
pieceId: carrier,
|
||||
primitives: innerArmOf(recipe.descriptor),
|
||||
event: undefined,
|
||||
descriptorId: String(recipe.descriptor.id),
|
||||
});
|
||||
|
||||
// Count frozen-square markers and verify their squares.
|
||||
const frozenSquares = new Set<number>();
|
||||
for (const f of engine.session.allFacts()) {
|
||||
if (f.attr !== "MarkerKind" || f.value !== "frozen-square") continue;
|
||||
const pos = engine.session.get(f.id, "Position") as number | undefined;
|
||||
if (pos !== undefined) frozenSquares.add(pos);
|
||||
}
|
||||
const expected = new Set([
|
||||
0, 8, 16, 24, 32, 40, 48, 56, // a-file
|
||||
7, 15, 23, 31, 39, 47, 55, 63, // h-file
|
||||
]);
|
||||
expect(frozenSquares).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tpl-no-cowards (W2 Batch E / SIMPLIFIED)", () => {
|
||||
it("activation sets MustMoveForward=true on GAME_ENTITY with lifetime turns:1", () => {
|
||||
const recipe = recipeById("tpl-no-cowards");
|
||||
const engine = new ChessEngine();
|
||||
clearBoard(engine);
|
||||
|
||||
const carrier = placePiece(engine, "queen", "white", "d1");
|
||||
driveArm({
|
||||
engine,
|
||||
pieceId: carrier,
|
||||
primitives: innerArmOf(recipe.descriptor),
|
||||
event: undefined,
|
||||
descriptorId: String(recipe.descriptor.id),
|
||||
});
|
||||
|
||||
expect(engine.session.get(GAME_ENTITY, "MustMoveForward")).toBe(true);
|
||||
|
||||
const reg = engine.session.get(GAME_ENTITY, "LifetimeRegistry") as
|
||||
| ChessAttrMap["LifetimeRegistry"]
|
||||
| undefined;
|
||||
const entry = (reg ?? []).find(
|
||||
(e) => e.entityId === GAME_ENTITY && e.attr === "MustMoveForward",
|
||||
);
|
||||
expect(entry).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Batch F — verified-shippable choosers (2 recipes)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("tpl-drafted-for-battle (W2 Batch F)", () => {
|
||||
it("descriptor seeds OnRuleActivatedHooks with a request-choice → for-each-piece(king) → swap-pieces arm", () => {
|
||||
const recipe = recipeById("tpl-drafted-for-battle");
|
||||
// The full chooser flow requires the suspend/resume pipeline +
|
||||
// a UI client; verifying the runtime contract end-to-end would
|
||||
// require driving the test-only WS frames. For the unit layer
|
||||
// we assert the descriptor's structural shape so any drift is
|
||||
// caught immediately. Cross-cutting smoke (applyCustomDescriptor
|
||||
// seeds OnRuleActivatedHooks cleanly) lives in the bottom block.
|
||||
const top = recipe.descriptor.primitives[0];
|
||||
expect(top?.kind).toBe("on-rule-activated");
|
||||
const inner = innerArmOf(recipe.descriptor);
|
||||
expect(inner.length).toBe(1);
|
||||
expect(inner[0]?.kind).toBe("request-choice");
|
||||
const rc = inner[0]?.params as {
|
||||
kind: string;
|
||||
bind: string;
|
||||
then: readonly EffectPrimitiveNode[];
|
||||
};
|
||||
expect(rc.kind).toBe("piece");
|
||||
expect(rc.bind).toBe("chosen");
|
||||
// Inner: for-each-piece(king filter) → swap-pieces.
|
||||
expect(rc.then[0]?.kind).toBe("for-each-piece");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tpl-corporate-ladder (W2 Batch F / SIMPLIFIED)", () => {
|
||||
it("descriptor seeds OnRuleActivatedHooks with a request-choice(piece) → request-choice(piece) → swap-pieces arm", () => {
|
||||
const recipe = recipeById("tpl-corporate-ladder");
|
||||
const top = recipe.descriptor.primitives[0];
|
||||
expect(top?.kind).toBe("on-rule-activated");
|
||||
const inner = innerArmOf(recipe.descriptor);
|
||||
expect(inner.length).toBe(1);
|
||||
expect(inner[0]?.kind).toBe("request-choice");
|
||||
const rc1 = inner[0]?.params as {
|
||||
kind: string;
|
||||
bind: string;
|
||||
then: readonly EffectPrimitiveNode[];
|
||||
};
|
||||
expect(rc1.kind).toBe("piece");
|
||||
expect(rc1.bind).toBe("pa");
|
||||
expect(rc1.then[0]?.kind).toBe("request-choice");
|
||||
const rc2 = rc1.then[0]?.params as {
|
||||
kind: string;
|
||||
bind: string;
|
||||
then: readonly EffectPrimitiveNode[];
|
||||
};
|
||||
expect(rc2.kind).toBe("piece");
|
||||
expect(rc2.bind).toBe("pb");
|
||||
expect(rc2.then[0]?.kind).toBe("swap-pieces");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cross-cutting smoke: the 10 Wave-2 recipes register cleanly + every
|
||||
// recipe's id is unique. Mirrors the Wave-1 cross-cutting block.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Wave-2 recipes — registry presence smoke", () => {
|
||||
const W2_IDS = [
|
||||
"tpl-time-bomb",
|
||||
"tpl-nuclear-fallout",
|
||||
"tpl-christmas-truce",
|
||||
"tpl-pawn-second-chance",
|
||||
"tpl-invulnerability-potion",
|
||||
"tpl-anti-camping",
|
||||
"tpl-ice-age",
|
||||
"tpl-no-cowards",
|
||||
"tpl-drafted-for-battle",
|
||||
"tpl-corporate-ladder",
|
||||
] as const;
|
||||
|
||||
it("all 10 W2 recipe ids are present in CUSTOM_MODIFIER_RECIPES", () => {
|
||||
for (const id of W2_IDS) {
|
||||
expect(CUSTOM_MODIFIER_RECIPES.find((r) => r.id === id)).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("recipe count is exactly 46 (36 W1 + 10 W2)", () => {
|
||||
expect(CUSTOM_MODIFIER_RECIPES.length).toBe(46);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,522 @@
|
|||
/**
|
||||
* Wave-2 (thressgame-100, decision D) — multi-turn countdown
|
||||
* subsystem integration tests.
|
||||
*
|
||||
* Exercises the dispatcher's stage-13 wiring end-to-end:
|
||||
*
|
||||
* 1. `set-piece-attr` with `lifetime: { kind: "turns", count: N }`
|
||||
* decrements by clock-turns and fires `on-attr-expire` at zero
|
||||
* with `expiringValue = the attr's last value`.
|
||||
* 2. Multiple attrs with the same expiry tick fire as a single
|
||||
* batch (insertion order, then registration order — fully
|
||||
* deterministic).
|
||||
* 3. `decrement-attr-each-turn` decrements the attr value itself
|
||||
* and fires `on-attr-expire` when the value reaches zero.
|
||||
* 4. The introduced `expiringValue` binding is in scope of the
|
||||
* inner primitive arm and resolves via `{ $var: "expiringValue" }`.
|
||||
* 5. The attr is retracted AFTER the trigger arm fires (mirrors
|
||||
* the marker-lifetime "fire BEFORE retraction" precedent).
|
||||
* 6. Determinism: 100 runs of an identical countdown attachment
|
||||
* sequence produce byte-identical state hashes.
|
||||
*
|
||||
* The harness drives the engine's full pipeline by calling
|
||||
* `engine.applyMove()` so we exercise the actual stage-13 ordering
|
||||
* locked in `apply.ts#onAfterMove`. We use the standard starting
|
||||
* position and quiet pawn pushes to avoid captures (which would
|
||||
* complicate the lifetime / countdown registry state).
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ChessEngine } from "../../../engine.js";
|
||||
import {
|
||||
GAME_ENTITY,
|
||||
type AttrCountdownEntry,
|
||||
type ChessAttrMap,
|
||||
type LifetimeEntry,
|
||||
type OnAttrExpireHookEntry,
|
||||
} from "../../../schema.js";
|
||||
import type { ModifierProfile } from "../../types.js";
|
||||
import { runDeterminismCheck } from "../../../__fixtures__/determinism/harness.js";
|
||||
import "../index.js";
|
||||
|
||||
/**
|
||||
* No-op modifier profile. Engine construction with `profile`
|
||||
* provided auto-attaches the integration preset (sole source of
|
||||
* the `onAfterMove` stage-13 dispatcher) — without a profile, the
|
||||
* engine instantiates with no active presets and stage 13 never
|
||||
* fires. Mirrors the harness used by `apply.test.ts` (T21 dispatch
|
||||
* order) and `triggers.test.ts`.
|
||||
*/
|
||||
const NOOP_PROFILE: ModifierProfile = {
|
||||
id: "wave-2-attr-expire-test",
|
||||
name: "wave-2-attr-expire-test",
|
||||
description: "",
|
||||
perType: [],
|
||||
perInstance: [],
|
||||
version: 1,
|
||||
source: "custom",
|
||||
};
|
||||
|
||||
function makeEngine(): ChessEngine {
|
||||
return new ChessEngine({ profile: NOOP_PROFILE });
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a known sequence of legal-move pushes that keeps both kings
|
||||
* alive (avoids capture-driven side effects). Each call advances
|
||||
* the FullmoveNumber by 1 (it ticks after black completes its
|
||||
* turn). Picks moves by from→to coordinate so the harness reads
|
||||
* deterministically across iterations.
|
||||
*/
|
||||
function applyTurnPair(
|
||||
engine: ChessEngine,
|
||||
whiteFrom: number,
|
||||
whiteTo: number,
|
||||
blackFrom: number,
|
||||
blackTo: number,
|
||||
): void {
|
||||
const moves = engine.getAllLegalMoves();
|
||||
const whiteMove = moves.find(
|
||||
(m) => m.from === whiteFrom && m.to === whiteTo,
|
||||
);
|
||||
if (whiteMove === undefined) {
|
||||
throw new Error(
|
||||
`applyTurnPair: white move ${whiteFrom}->${whiteTo} not legal`,
|
||||
);
|
||||
}
|
||||
engine.applyMove(whiteMove);
|
||||
|
||||
const blackMoves = engine.getAllLegalMoves();
|
||||
const blackMove = blackMoves.find(
|
||||
(m) => m.from === blackFrom && m.to === blackTo,
|
||||
);
|
||||
if (blackMove === undefined) {
|
||||
throw new Error(
|
||||
`applyTurnPair: black move ${blackFrom}->${blackTo} not legal`,
|
||||
);
|
||||
}
|
||||
engine.applyMove(blackMove);
|
||||
}
|
||||
|
||||
describe("attr-expire integration — set-piece-attr lifetime: turns", () => {
|
||||
it("count:3 decrements 3→2→1→0 and fires on-attr-expire at zero with the expiring value", () => {
|
||||
const engine = makeEngine();
|
||||
|
||||
// Pick a white pawn (e2 = square 12 in the starting position
|
||||
// — id is the pawn entity present at file 4, rank 1). Resolve
|
||||
// by querying allFacts so the test stays robust against
|
||||
// entity-id ordering shifts.
|
||||
const pawnE2 = pieceIdAtSquare(engine, 12);
|
||||
expect(pawnE2).toBeDefined();
|
||||
|
||||
// Seed the on-attr-expire hook BEFORE attaching the lifetime
|
||||
// so the hook is registered by the time the countdown
|
||||
// registry expires the attr.
|
||||
const hook: OnAttrExpireHookEntry = {
|
||||
descriptorId: "test:countdown",
|
||||
target: pawnE2!,
|
||||
attr: "BombTimer",
|
||||
primitives: [
|
||||
// Use seed-attribute on GAME_ENTITY to record the
|
||||
// expiring value as a sentinel — the test inspects it
|
||||
// post-expiry. seed-attribute aims at ctx.pieceId by
|
||||
// default, which the dispatcher sets to the EXPIRING
|
||||
// entity id. Reading by-resolver from $expiringValue
|
||||
// exercises the binding scope.
|
||||
{
|
||||
kind: "seed-attribute",
|
||||
params: {
|
||||
attr: "RangeBonus",
|
||||
value: { $var: "expiringValue" },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
engine.session.insert(GAME_ENTITY, "OnAttrExpireHooks", [hook]);
|
||||
|
||||
// Manually register the lifetime via the registry helper —
|
||||
// we don't have a clean public path to invoke `set-piece-attr`
|
||||
// from a test without going through the descriptor pipeline,
|
||||
// and the registry shape is exactly what set-piece-attr
|
||||
// produces. The lifetime token chosen makes the attr expire
|
||||
// when FullmoveNumber == 4 (initial 1 + count 3).
|
||||
const initialFullmove =
|
||||
(engine.session.get(GAME_ENTITY, "FullmoveNumber") as
|
||||
| number
|
||||
| undefined) ?? 1;
|
||||
engine.session.insert(pawnE2!, "BombTimer" as keyof ChessAttrMap, 99 as never);
|
||||
const entry: LifetimeEntry = {
|
||||
entityId: pawnE2!,
|
||||
attr: "BombTimer",
|
||||
expiresAtTurn: initialFullmove + 3,
|
||||
descriptorId: "test:countdown",
|
||||
};
|
||||
engine.session.insert(GAME_ENTITY, "LifetimeRegistry", [entry]);
|
||||
|
||||
// Drive 3 fullmove cycles via legal moves. After each cycle
|
||||
// FullmoveNumber ticks and the dispatcher runs stage 13. The
|
||||
// attr should remain present until the third cycle.
|
||||
applyTurnPair(engine, 12, 28, 52, 36); // e2-e4 / e7-e5 (Fullmove 1→2)
|
||||
expect(engine.session.get(pawnE2!, "BombTimer")).toBe(99);
|
||||
|
||||
applyTurnPair(engine, 1, 18, 57, 42); // Nb1-c3 / Nb8-c6 (Fullmove 2→3)
|
||||
expect(engine.session.get(pawnE2!, "BombTimer")).toBe(99);
|
||||
|
||||
applyTurnPair(engine, 5, 33, 61, 25); // Bf1-b5 / Bf8-b4 (Fullmove 3→4)
|
||||
// Now FullmoveNumber == 4 == expiresAtTurn → the sweep fires
|
||||
// on-attr-expire (which records the expiring value of 99 as
|
||||
// RangeBonus on the pawn) AND retracts BombTimer.
|
||||
expect(engine.session.get(pawnE2!, "BombTimer")).toBeUndefined();
|
||||
expect(engine.session.get(pawnE2!, "RangeBonus")).toBe(99);
|
||||
});
|
||||
|
||||
it("multiple attrs with the same expiry tick fire in a single batch (deterministic order)", () => {
|
||||
const engine = makeEngine();
|
||||
const pawnA = pieceIdAtSquare(engine, 12)!; // e2
|
||||
const pawnB = pieceIdAtSquare(engine, 11)!; // d2
|
||||
|
||||
// Seed two hooks targeting different (target, attr) pairs.
|
||||
// Both expire on the same fullmove tick — the dispatcher
|
||||
// batch must fire BOTH and write distinct sentinels.
|
||||
const hooks: OnAttrExpireHookEntry[] = [
|
||||
{
|
||||
descriptorId: "test:batch-a",
|
||||
target: pawnA,
|
||||
attr: "AttrA",
|
||||
primitives: [
|
||||
{
|
||||
kind: "seed-attribute",
|
||||
params: { attr: "HpBonus", value: 11 },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
descriptorId: "test:batch-b",
|
||||
target: pawnB,
|
||||
attr: "AttrB",
|
||||
primitives: [
|
||||
{
|
||||
kind: "seed-attribute",
|
||||
params: { attr: "HpBonus", value: 22 },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
engine.session.insert(GAME_ENTITY, "OnAttrExpireHooks", hooks);
|
||||
|
||||
// Both attrs share expiresAtTurn=2 (current fullmove + 1).
|
||||
const fm = engine.session.get(GAME_ENTITY, "FullmoveNumber") as number;
|
||||
engine.session.insert(pawnA, "AttrA" as keyof ChessAttrMap, true as never);
|
||||
engine.session.insert(pawnB, "AttrB" as keyof ChessAttrMap, "x" as never);
|
||||
engine.session.insert(GAME_ENTITY, "LifetimeRegistry", [
|
||||
{
|
||||
entityId: pawnA,
|
||||
attr: "AttrA",
|
||||
expiresAtTurn: fm + 1,
|
||||
descriptorId: "test:batch-a",
|
||||
},
|
||||
{
|
||||
entityId: pawnB,
|
||||
attr: "AttrB",
|
||||
expiresAtTurn: fm + 1,
|
||||
descriptorId: "test:batch-b",
|
||||
},
|
||||
]);
|
||||
|
||||
applyTurnPair(engine, 12, 28, 52, 36); // Fullmove 1→2
|
||||
|
||||
// Both arms fired (HpBonus written on each pawn).
|
||||
expect(engine.session.get(pawnA, "HpBonus")).toBe(11);
|
||||
expect(engine.session.get(pawnB, "HpBonus")).toBe(22);
|
||||
// Both attrs retracted.
|
||||
expect(engine.session.get(pawnA, "AttrA")).toBeUndefined();
|
||||
expect(engine.session.get(pawnB, "AttrB")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("attr-expire integration — decrement-attr-each-turn", () => {
|
||||
it("decrements attr value each turn-end and fires on-attr-expire when value reaches zero", () => {
|
||||
const engine = makeEngine();
|
||||
const pawn = pieceIdAtSquare(engine, 12)!;
|
||||
|
||||
// Seed: BombTimer = 4; mark the (pawn, BombTimer) pair as a
|
||||
// countdown via the registry. Stage 13 runs after EVERY move
|
||||
// (not every turn-pair) so a value of 4 decrements over 4
|
||||
// half-moves (= 2 fullmove pairs). The hook records the
|
||||
// expiringValue when the countdown fires.
|
||||
engine.session.insert(pawn, "BombTimer" as keyof ChessAttrMap, 4 as never);
|
||||
|
||||
const hook: OnAttrExpireHookEntry = {
|
||||
descriptorId: "test:decrement",
|
||||
target: pawn,
|
||||
attr: "BombTimer",
|
||||
primitives: [
|
||||
{
|
||||
kind: "seed-attribute",
|
||||
params: {
|
||||
attr: "HpBonus",
|
||||
value: { $var: "expiringValue" },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
engine.session.insert(GAME_ENTITY, "OnAttrExpireHooks", [hook]);
|
||||
|
||||
const countdown: AttrCountdownEntry = {
|
||||
entityId: pawn,
|
||||
attr: "BombTimer",
|
||||
descriptorId: "test:decrement",
|
||||
};
|
||||
engine.session.insert(GAME_ENTITY, "AttrCountdownRegistry", [
|
||||
countdown,
|
||||
]);
|
||||
|
||||
// Pair 1 (2 half-moves): BombTimer 4 → 3 → 2.
|
||||
applyTurnPair(engine, 12, 28, 52, 36); // e2-e4 / e7-e5
|
||||
expect(engine.session.get(pawn, "BombTimer")).toBe(2);
|
||||
expect(engine.session.get(pawn, "HpBonus")).toBeUndefined();
|
||||
|
||||
// Pair 2 (2 half-moves): BombTimer 2 → 1 → 0.
|
||||
// The transition 1→0 fires on-attr-expire with
|
||||
// expiringValue=1 (the PRE-decrement value), then BombTimer
|
||||
// is retracted.
|
||||
applyTurnPair(engine, 1, 18, 57, 42); // Nb1-c3 / Nb8-c6
|
||||
expect(engine.session.get(pawn, "BombTimer")).toBeUndefined();
|
||||
expect(engine.session.get(pawn, "HpBonus")).toBe(1);
|
||||
|
||||
// Registry now empty.
|
||||
expect(
|
||||
engine.session.get(GAME_ENTITY, "AttrCountdownRegistry"),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("countdown starting at 1 expires on the very next move's turn-end", () => {
|
||||
const engine = makeEngine();
|
||||
const pawn = pieceIdAtSquare(engine, 12)!;
|
||||
engine.session.insert(pawn, "Charge" as keyof ChessAttrMap, 1 as never);
|
||||
|
||||
const hook: OnAttrExpireHookEntry = {
|
||||
descriptorId: "test:imminent",
|
||||
target: pawn,
|
||||
attr: "Charge",
|
||||
primitives: [
|
||||
{
|
||||
kind: "seed-attribute",
|
||||
params: { attr: "RangeBonus", value: 7 },
|
||||
},
|
||||
],
|
||||
};
|
||||
engine.session.insert(GAME_ENTITY, "OnAttrExpireHooks", [hook]);
|
||||
|
||||
engine.session.insert(GAME_ENTITY, "AttrCountdownRegistry", [
|
||||
{
|
||||
entityId: pawn,
|
||||
attr: "Charge",
|
||||
descriptorId: "test:imminent",
|
||||
},
|
||||
]);
|
||||
|
||||
// A single half-move triggers stage 13 once → countdown
|
||||
// expires from 1 to 0.
|
||||
const moves = engine.getAllLegalMoves();
|
||||
const e2e4 = moves.find((m) => m.from === 12 && m.to === 28)!;
|
||||
engine.applyMove(e2e4);
|
||||
|
||||
expect(engine.session.get(pawn, "Charge")).toBeUndefined();
|
||||
expect(engine.session.get(pawn, "RangeBonus")).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe("attr-expire integration — expiringValue binding", () => {
|
||||
it("expiringValue binding contains the value just before retraction (lifetime path)", () => {
|
||||
const engine = makeEngine();
|
||||
const pawn = pieceIdAtSquare(engine, 12)!;
|
||||
|
||||
engine.session.insert(
|
||||
pawn,
|
||||
"BombTimer" as keyof ChessAttrMap,
|
||||
777 as never,
|
||||
);
|
||||
const fm = engine.session.get(GAME_ENTITY, "FullmoveNumber") as number;
|
||||
|
||||
engine.session.insert(GAME_ENTITY, "LifetimeRegistry", [
|
||||
{
|
||||
entityId: pawn,
|
||||
attr: "BombTimer",
|
||||
// Expire on the very next fullmove tick.
|
||||
expiresAtTurn: fm + 1,
|
||||
descriptorId: "test:bind",
|
||||
},
|
||||
]);
|
||||
engine.session.insert(GAME_ENTITY, "OnAttrExpireHooks", [
|
||||
{
|
||||
descriptorId: "test:bind",
|
||||
target: pawn,
|
||||
attr: "BombTimer",
|
||||
primitives: [
|
||||
{
|
||||
kind: "seed-attribute",
|
||||
params: {
|
||||
attr: "RangeBonus",
|
||||
value: { $var: "expiringValue" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
applyTurnPair(engine, 12, 28, 52, 36); // fm 1 → 2
|
||||
|
||||
expect(engine.session.get(pawn, "BombTimer")).toBeUndefined();
|
||||
expect(engine.session.get(pawn, "RangeBonus")).toBe(777);
|
||||
});
|
||||
});
|
||||
|
||||
describe("attr-expire integration — lifetime + explicit countdown coexist", () => {
|
||||
it("a piece with both a lifetime entry AND a countdown entry on different attrs expires both independently", () => {
|
||||
const engine = makeEngine();
|
||||
const pawn = pieceIdAtSquare(engine, 12)!;
|
||||
|
||||
// Lifetime-driven attr: expires fm+1.
|
||||
engine.session.insert(
|
||||
pawn,
|
||||
"BombTimer" as keyof ChessAttrMap,
|
||||
42 as never,
|
||||
);
|
||||
const fm = engine.session.get(GAME_ENTITY, "FullmoveNumber") as number;
|
||||
engine.session.insert(GAME_ENTITY, "LifetimeRegistry", [
|
||||
{
|
||||
entityId: pawn,
|
||||
attr: "BombTimer",
|
||||
expiresAtTurn: fm + 1,
|
||||
descriptorId: "test:lifetime",
|
||||
},
|
||||
]);
|
||||
|
||||
// Countdown-driven attr: starts at 2, decrements each move,
|
||||
// expires after 2 half-moves.
|
||||
engine.session.insert(pawn, "Charge" as keyof ChessAttrMap, 2 as never);
|
||||
engine.session.insert(GAME_ENTITY, "AttrCountdownRegistry", [
|
||||
{
|
||||
entityId: pawn,
|
||||
attr: "Charge",
|
||||
descriptorId: "test:countdown",
|
||||
},
|
||||
]);
|
||||
|
||||
// One hook each (different (target, attr) keys must NOT
|
||||
// cross-fire).
|
||||
engine.session.insert(GAME_ENTITY, "OnAttrExpireHooks", [
|
||||
{
|
||||
descriptorId: "test:lifetime",
|
||||
target: pawn,
|
||||
attr: "BombTimer",
|
||||
primitives: [
|
||||
{
|
||||
kind: "seed-attribute",
|
||||
params: { attr: "HpBonus", value: 100 },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
descriptorId: "test:countdown",
|
||||
target: pawn,
|
||||
attr: "Charge",
|
||||
primitives: [
|
||||
{
|
||||
kind: "seed-attribute",
|
||||
params: { attr: "RangeBonus", value: 200 },
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
// Two half-moves: lifetime ticks fm 1→2 (expire at fm+1=2 →
|
||||
// retract on the move that ticked us TO 2 — black's reply).
|
||||
// Countdown decrements 2→1 then 1→0 → expire on second move.
|
||||
applyTurnPair(engine, 12, 28, 52, 36);
|
||||
|
||||
// Both attrs expired AND fired their respective hooks.
|
||||
expect(engine.session.get(pawn, "BombTimer")).toBeUndefined();
|
||||
expect(engine.session.get(pawn, "Charge")).toBeUndefined();
|
||||
expect(engine.session.get(pawn, "HpBonus")).toBe(100);
|
||||
expect(engine.session.get(pawn, "RangeBonus")).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("attr-expire integration — replay determinism", () => {
|
||||
it("100 iterations of identical countdown setup produce byte-identical state hash", { timeout: 60_000 }, () => {
|
||||
const setup = () => {
|
||||
const engine = makeEngine();
|
||||
// Replicate the first integration test's setup: pawn-e2
|
||||
// BombTimer=99 with lifetime expiring at fullmove+3, plus
|
||||
// a hook that copies expiringValue → RangeBonus.
|
||||
const pawn = pieceIdAtSquare(engine, 12)!;
|
||||
engine.session.insert(
|
||||
pawn,
|
||||
"BombTimer" as keyof ChessAttrMap,
|
||||
99 as never,
|
||||
);
|
||||
const fm =
|
||||
(engine.session.get(GAME_ENTITY, "FullmoveNumber") as
|
||||
| number
|
||||
| undefined) ?? 1;
|
||||
engine.session.insert(GAME_ENTITY, "LifetimeRegistry", [
|
||||
{
|
||||
entityId: pawn,
|
||||
attr: "BombTimer",
|
||||
expiresAtTurn: fm + 3,
|
||||
descriptorId: "test:det",
|
||||
},
|
||||
]);
|
||||
engine.session.insert(GAME_ENTITY, "OnAttrExpireHooks", [
|
||||
{
|
||||
descriptorId: "test:det",
|
||||
target: pawn,
|
||||
attr: "BombTimer",
|
||||
primitives: [
|
||||
{
|
||||
kind: "seed-attribute",
|
||||
params: {
|
||||
attr: "RangeBonus",
|
||||
value: { $var: "expiringValue" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
return engine;
|
||||
};
|
||||
|
||||
const moves = [
|
||||
(engine: ChessEngine) => applyTurnPair(engine, 12, 28, 52, 36),
|
||||
(engine: ChessEngine) => applyTurnPair(engine, 1, 18, 57, 42),
|
||||
(engine: ChessEngine) => applyTurnPair(engine, 5, 33, 61, 25),
|
||||
];
|
||||
|
||||
const result = runDeterminismCheck(setup, moves, 100);
|
||||
if (!result.matches) {
|
||||
throw new Error(
|
||||
`determinism violated at iteration ${String(result.mismatchAt)}: ` +
|
||||
`expected ${result.hash}, got ${String(result.mismatchHash)}`,
|
||||
);
|
||||
}
|
||||
expect(result.matches).toBe(true);
|
||||
expect(result.iterations).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Locate a piece entity id by querying the session for the
|
||||
* `Position` fact at the requested square. Returns `undefined`
|
||||
* when no piece occupies the square. We can't rely on a stable
|
||||
* id literal because layout-application timing affects the entity
|
||||
* counter.
|
||||
*/
|
||||
function pieceIdAtSquare(engine: ChessEngine, square: number) {
|
||||
for (const f of engine.session.allFacts()) {
|
||||
if (f.attr !== "Position") continue;
|
||||
if ((f.id as number) <= 0) continue;
|
||||
if (f.value === square) return f.id;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
|
@ -165,6 +165,31 @@ export type PrimitiveEvent =
|
|||
readonly markerId: EntityId;
|
||||
readonly markerKind: MarkerKindValue;
|
||||
readonly square: Square;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* Wave-2 (thressgame-100, decision D) — fired by
|
||||
* `fireAttrExpireHooks` (triggers.ts, dispatcher stage 13)
|
||||
* when an attr countdown reaches zero. Two upstream sources
|
||||
* feed this: (a) the `set-piece-attr` lifetime registry —
|
||||
* `LifetimeRegistry` — sweep, where an entry's
|
||||
* `expiresAtTurn` reaches `FullmoveNumber`; (b) the
|
||||
* `decrement-attr-each-turn` countdown registry —
|
||||
* `AttrCountdownRegistry` — sweep, where the attr value
|
||||
* reaches zero after the per-turn decrement.
|
||||
*
|
||||
* The dispatcher fires this event BEFORE the attr is
|
||||
* retracted so inner primitives can read the attr's last
|
||||
* value via the introduced `expiringValue` binding (see
|
||||
* `triggers.ts#fireAttrExpireHooks`). `expiringValue` is the
|
||||
* value the attr held at the moment the countdown completed
|
||||
* (post-decrement for explicit countdowns; pre-retract for
|
||||
* lifetime-driven expirations).
|
||||
*/
|
||||
readonly kind: "attr-expire";
|
||||
readonly entityId: EntityId;
|
||||
readonly attr: string;
|
||||
readonly expiringValue: BindingValue | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -0,0 +1,154 @@
|
|||
/**
|
||||
* Wave-2 (thressgame-100, decision D) — `decrement-attr-each-turn`
|
||||
* primitive unit tests.
|
||||
*
|
||||
* Coverage:
|
||||
* - Registry registration + seedsAttrs declaration.
|
||||
* - Zod schema acceptance / rejection.
|
||||
* - apply() pushes an entry to GAME_ENTITY.AttrCountdownRegistry.
|
||||
* - Multiple calls compose (append, no dedup) — matches the
|
||||
* low-level mutation contract of `registerLifetime` (T35).
|
||||
*/
|
||||
import { Session, type EntityId } from "@paratype/rete";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ChessEngine } from "../../engine.js";
|
||||
import { GAME_ENTITY } from "../../schema.js";
|
||||
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||
import { DECREMENT_ATTR_EACH_TURN_PRIMITIVE } from "./decrement-attr-each-turn.js";
|
||||
import "./decrement-attr-each-turn.js";
|
||||
import type { PrimitiveApplyContext } from "./types.js";
|
||||
|
||||
function makeContext(): {
|
||||
ctx: PrimitiveApplyContext;
|
||||
session: Session;
|
||||
} {
|
||||
const session = new Session();
|
||||
const pieceId = session.nextId();
|
||||
const ctx: PrimitiveApplyContext = {
|
||||
engine: new ChessEngine(),
|
||||
session,
|
||||
pieceId,
|
||||
depth: 0,
|
||||
descriptor: {
|
||||
id: "custom:test-decrement-attr",
|
||||
type: "data",
|
||||
version: 1,
|
||||
},
|
||||
target: "self",
|
||||
event: undefined,
|
||||
bindings: new Map(),
|
||||
pendingTriggers: [],
|
||||
cascadeDepth: 0,
|
||||
suppressTriggers: false,
|
||||
};
|
||||
return { ctx, session };
|
||||
}
|
||||
|
||||
describe("decrement-attr-each-turn primitive — registry", () => {
|
||||
it("registers in PRIMITIVE_REGISTRY under key 'decrement-attr-each-turn'", () => {
|
||||
expect(PRIMITIVE_REGISTRY.has("decrement-attr-each-turn")).toBe(true);
|
||||
expect(PRIMITIVE_REGISTRY.get("decrement-attr-each-turn")).toBe(
|
||||
DECREMENT_ATTR_EACH_TURN_PRIMITIVE,
|
||||
);
|
||||
});
|
||||
|
||||
it("declares AttrCountdownRegistry in seedsAttrs", () => {
|
||||
expect(DECREMENT_ATTR_EACH_TURN_PRIMITIVE.seedsAttrs).toEqual([
|
||||
"AttrCountdownRegistry",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("decrement-attr-each-turn primitive — paramsSchema (Zod)", () => {
|
||||
it("accepts target + attr literals", () => {
|
||||
const result =
|
||||
DECREMENT_ATTR_EACH_TURN_PRIMITIVE.paramsSchema.safeParse({
|
||||
target: 7,
|
||||
attr: "Cooldown",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts a $var resolver for target", () => {
|
||||
const result =
|
||||
DECREMENT_ATTR_EACH_TURN_PRIMITIVE.paramsSchema.safeParse({
|
||||
target: { $var: "victim" },
|
||||
attr: "Cooldown",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects missing target", () => {
|
||||
const result =
|
||||
DECREMENT_ATTR_EACH_TURN_PRIMITIVE.paramsSchema.safeParse({
|
||||
attr: "Cooldown",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects missing attr", () => {
|
||||
const result =
|
||||
DECREMENT_ATTR_EACH_TURN_PRIMITIVE.paramsSchema.safeParse({
|
||||
target: 7,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("decrement-attr-each-turn primitive — apply() seeds AttrCountdownRegistry", () => {
|
||||
it("appends a single entry on first call", () => {
|
||||
const { ctx, session } = makeContext();
|
||||
DECREMENT_ATTR_EACH_TURN_PRIMITIVE.apply(ctx, {
|
||||
target: 7 as EntityId,
|
||||
attr: "BombTimer",
|
||||
});
|
||||
|
||||
expect(session.get(GAME_ENTITY, "AttrCountdownRegistry")).toEqual([
|
||||
{
|
||||
entityId: 7,
|
||||
attr: "BombTimer",
|
||||
descriptorId: "custom:test-decrement-attr",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("appends additional entries without dedup", () => {
|
||||
const { ctx, session } = makeContext();
|
||||
DECREMENT_ATTR_EACH_TURN_PRIMITIVE.apply(ctx, {
|
||||
target: 7 as EntityId,
|
||||
attr: "BombTimer",
|
||||
});
|
||||
DECREMENT_ATTR_EACH_TURN_PRIMITIVE.apply(ctx, {
|
||||
target: 9 as EntityId,
|
||||
attr: "Charge",
|
||||
});
|
||||
|
||||
const reg = session.get(GAME_ENTITY, "AttrCountdownRegistry") as
|
||||
| ReadonlyArray<{
|
||||
entityId: EntityId;
|
||||
attr: string;
|
||||
descriptorId: string;
|
||||
}>
|
||||
| undefined;
|
||||
expect(reg).toHaveLength(2);
|
||||
expect(reg?.[0]?.entityId).toBe(7);
|
||||
expect(reg?.[1]?.entityId).toBe(9);
|
||||
});
|
||||
|
||||
it("re-registering the same (entity, attr) appends a duplicate (no auto-dedup)", () => {
|
||||
const { ctx, session } = makeContext();
|
||||
DECREMENT_ATTR_EACH_TURN_PRIMITIVE.apply(ctx, {
|
||||
target: 7 as EntityId,
|
||||
attr: "BombTimer",
|
||||
});
|
||||
DECREMENT_ATTR_EACH_TURN_PRIMITIVE.apply(ctx, {
|
||||
target: 7 as EntityId,
|
||||
attr: "BombTimer",
|
||||
});
|
||||
|
||||
const reg = session.get(GAME_ENTITY, "AttrCountdownRegistry") as
|
||||
| readonly unknown[]
|
||||
| undefined;
|
||||
expect(reg).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
/**
|
||||
* Wave-2 (thressgame-100, decision D) — `decrement-attr-each-turn`
|
||||
* imperative primitive.
|
||||
*
|
||||
* Marks a `(target, attr)` pair as a per-turn countdown. The
|
||||
* dispatcher's stage-13 sweep (`fireAttrExpireHooks` in
|
||||
* `triggers.ts`, called from `apply.ts#onAfterMove`) reads the
|
||||
* attr value, subtracts 1, writes the result back, and — when the
|
||||
* post-decrement value is `<= 0` — fires `on-attr-expire` for the
|
||||
* pair THEN retracts the attr (and the registry entry).
|
||||
*
|
||||
* ## Distinction from `set-piece-attr.lifetime: turns`
|
||||
*
|
||||
* Both primitives feed into the same `on-attr-expire` trigger, but
|
||||
* the countdown semantics differ:
|
||||
*
|
||||
* - `set-piece-attr.lifetime: { kind: "turns", count: N }` —
|
||||
* AUTOMATIC. The attr value is anything (boolean, string,
|
||||
* etc.); the registry tracks `expiresAtTurn = currentFullmove
|
||||
* + N`; the sweep retracts when `FullmoveNumber >=
|
||||
* expiresAtTurn`. Authors don't see the countdown remainder.
|
||||
*
|
||||
* - `decrement-attr-each-turn(target, attr)` — EXPLICIT. The
|
||||
* attr value IS the countdown remainder (must be numeric);
|
||||
* the registry just records "decrement this every turn"; the
|
||||
* sweep mutates the attr value itself; expiry condition is
|
||||
* `value <= 0` after decrement. Authors can read / display
|
||||
* the remainder mid-flight.
|
||||
*
|
||||
* Use the LIFETIME shape for "this fact is true for N turns then
|
||||
* false again". Use this PRIMITIVE for "this number ticks down
|
||||
* visibly each turn and fires when it reaches zero" (time bombs,
|
||||
* shot clocks, anti-camping).
|
||||
*
|
||||
* ## Imperative gating (T14)
|
||||
*
|
||||
* `decrement-attr-each-turn` is in {@link IMPERATIVE_KINDS}. The
|
||||
* descriptor-tree validator rejects top-level placement of
|
||||
* imperative primitives — this primitive is legal ONLY inside a
|
||||
* trigger arm (`on-rule-activated.primitives`,
|
||||
* `on-piece-entered-marker.primitives`, etc.). Mirrors the gating
|
||||
* applied to `set-piece-attr` (T26).
|
||||
*
|
||||
* ## Move-gen dry-mode (T20)
|
||||
*
|
||||
* Under `ctx.suppressTriggers === true`, the dispatcher skips
|
||||
* imperative primitives entirely. We do NOT branch on the flag
|
||||
* here — single source of truth lives at the dispatcher.
|
||||
*
|
||||
* ## Param resolution
|
||||
*
|
||||
* `target` and `attr` accept `numberOrResolver()` /
|
||||
* `stringOrResolver()`. The walker substitutes `$var` /
|
||||
* `ctx-self-id` / arithmetic shapes to literal values BEFORE this
|
||||
* `apply()` runs, so the schema only needs to accept the
|
||||
* fully-resolved ground forms at storage time.
|
||||
*
|
||||
* ## Idempotency
|
||||
*
|
||||
* Calling this primitive twice on the same `(target, attr)` pair
|
||||
* appends two entries — both ticking the attr once per sweep
|
||||
* (effectively decrementing by 2/turn). This matches the low-level
|
||||
* mutation contract of `registerLifetime` (T35) which does not
|
||||
* dedup on registration. Authors who need exactly-once semantics
|
||||
* should gate the call inside a `conditional` that checks for the
|
||||
* existing registry entry — but that pattern is rare; the typical
|
||||
* use case (one descriptor seeds the countdown via
|
||||
* `on-rule-activated`) doesn't double-register.
|
||||
*/
|
||||
import { z } from "zod";
|
||||
import type { EntityId } from "@paratype/rete";
|
||||
import {
|
||||
GAME_ENTITY,
|
||||
type AttrCountdownEntry,
|
||||
type ChessAttrMap,
|
||||
} from "../../schema.js";
|
||||
import {
|
||||
numberOrResolver,
|
||||
stringOrResolver,
|
||||
} from "./param-resolver-schema.js";
|
||||
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||
import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js";
|
||||
|
||||
const schema = z.object({
|
||||
target: numberOrResolver({ min: 0 }),
|
||||
attr: stringOrResolver(),
|
||||
});
|
||||
type Params = z.infer<typeof schema>;
|
||||
|
||||
const descriptor: EffectPrimitive<Params> = {
|
||||
kind: "decrement-attr-each-turn",
|
||||
label: "Decrement Attribute Each Turn",
|
||||
description:
|
||||
"Marks a (target, attr) pair as a per-turn countdown; the engine decrements the attr each turn-end and fires on-attr-expire when it reaches zero.",
|
||||
longDescription:
|
||||
"Tags a numeric value on a piece (or on the game) as a countdown that ticks down by one every turn. When the value reaches zero, the engine fires on-attr-expire for that exact (piece, attr) pair just before erasing the value. Use this for time bombs (the bomb's piece carries a 'BombTimer' that decrements until it explodes), shot clocks (a per-turn move-deadline counter), or anti-camping (a piece that has stayed too long on a square ticks down a stale-counter). The starting value is your responsibility — usually you set it with a separate set-piece-attr or seed-attribute call before applying this primitive in the same trigger arm.",
|
||||
examples: [
|
||||
{
|
||||
title: "Time bomb — 3-turn countdown to explode",
|
||||
params: { target: 12, attr: "BombTimer" },
|
||||
effect:
|
||||
"Reads piece 12's current 'BombTimer' value each turn-end and writes back value-1. When the value reaches zero, on-attr-expire(12, 'BombTimer') fires (typically the same descriptor's arm runs the bomb's payload), then the BombTimer attr is removed from the piece.",
|
||||
},
|
||||
],
|
||||
paramsSchema: schema,
|
||||
seedsAttrs: ["AttrCountdownRegistry"],
|
||||
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
||||
const targetId = params.target as EntityId;
|
||||
const attrName = params.attr as string;
|
||||
|
||||
const existing =
|
||||
(ctx.session.get(GAME_ENTITY, "AttrCountdownRegistry") as
|
||||
| ChessAttrMap["AttrCountdownRegistry"]
|
||||
| undefined) ?? [];
|
||||
|
||||
const next: AttrCountdownEntry = {
|
||||
entityId: targetId,
|
||||
attr: attrName,
|
||||
descriptorId: ctx.descriptor.id,
|
||||
};
|
||||
|
||||
ctx.session.insert(GAME_ENTITY, "AttrCountdownRegistry", [
|
||||
...existing,
|
||||
next,
|
||||
]);
|
||||
},
|
||||
};
|
||||
|
||||
PRIMITIVE_REGISTRY.register(descriptor);
|
||||
export { descriptor as DECREMENT_ATTR_EACH_TURN_PRIMITIVE };
|
||||
|
|
@ -38,6 +38,7 @@ import "./on-rule-activated.js";
|
|||
import "./on-rule-expire.js";
|
||||
import "./on-piece-entered-marker.js";
|
||||
import "./on-marker-expire.js";
|
||||
import "./on-attr-expire.js";
|
||||
|
||||
// Imperative primitives (Wave 5 — T21–T30):
|
||||
import "./place-piece.js";
|
||||
|
|
@ -46,6 +47,7 @@ import "./move-piece.js";
|
|||
import "./convert-piece-type.js";
|
||||
import "./swap-pieces.js";
|
||||
import "./set-piece-attr.js";
|
||||
import "./decrement-attr-each-turn.js";
|
||||
import "./cancel-capture.js";
|
||||
|
||||
// Marker primitives (Wave 6 — T28–T30):
|
||||
|
|
|
|||
173
packages/chess/src/modifiers/primitives/on-attr-expire.test.ts
Normal file
173
packages/chess/src/modifiers/primitives/on-attr-expire.test.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
/**
|
||||
* Wave-2 (thressgame-100, decision D) — `on-attr-expire` primitive
|
||||
* unit tests. Mirrors the structure of `on-marker-expire.test.ts`.
|
||||
*
|
||||
* Coverage:
|
||||
* - Registry registration + seedsAttrs declaration.
|
||||
* - Zod schema acceptance / rejection.
|
||||
* - apply() seeds OnAttrExpireHooks on GAME_ENTITY with the
|
||||
* resolved (target, attr) pair.
|
||||
* - childPrimitives() returns the inner list for tree walkers.
|
||||
*/
|
||||
import { Session, type EntityId } from "@paratype/rete";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ChessEngine } from "../../engine.js";
|
||||
import { GAME_ENTITY } from "../../schema.js";
|
||||
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||
import { ON_ATTR_EXPIRE_PRIMITIVE } from "./on-attr-expire.js";
|
||||
import "./on-attr-expire.js";
|
||||
import type { EffectPrimitiveNode, PrimitiveApplyContext } from "./types.js";
|
||||
|
||||
function makeContext(): {
|
||||
ctx: PrimitiveApplyContext;
|
||||
session: Session;
|
||||
} {
|
||||
const session = new Session();
|
||||
// Mirror on-marker-expire's harness — hooks land on GAME_ENTITY
|
||||
// (id 0); ctx.pieceId is irrelevant for the seed step.
|
||||
const pieceId = session.nextId();
|
||||
const ctx: PrimitiveApplyContext = {
|
||||
engine: new ChessEngine(),
|
||||
session,
|
||||
pieceId,
|
||||
depth: 0,
|
||||
descriptor: {
|
||||
id: "custom:test-on-attr-expire",
|
||||
type: "data",
|
||||
version: 1,
|
||||
},
|
||||
target: "self",
|
||||
event: undefined,
|
||||
bindings: new Map(),
|
||||
pendingTriggers: [],
|
||||
cascadeDepth: 0,
|
||||
suppressTriggers: false,
|
||||
};
|
||||
return { ctx, session };
|
||||
}
|
||||
|
||||
describe("on-attr-expire primitive — registry (Wave-2 D)", () => {
|
||||
it("registers in PRIMITIVE_REGISTRY under key 'on-attr-expire'", () => {
|
||||
expect(PRIMITIVE_REGISTRY.has("on-attr-expire")).toBe(true);
|
||||
expect(PRIMITIVE_REGISTRY.get("on-attr-expire")).toBe(
|
||||
ON_ATTR_EXPIRE_PRIMITIVE,
|
||||
);
|
||||
});
|
||||
|
||||
it("declares OnAttrExpireHooks in seedsAttrs", () => {
|
||||
expect(ON_ATTR_EXPIRE_PRIMITIVE.seedsAttrs).toEqual([
|
||||
"OnAttrExpireHooks",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("on-attr-expire primitive — paramsSchema (Zod)", () => {
|
||||
it("accepts a minimal valid block (target + attr + empty primitives)", () => {
|
||||
const result = ON_ATTR_EXPIRE_PRIMITIVE.paramsSchema.safeParse({
|
||||
target: 7,
|
||||
attr: "Cooldown",
|
||||
primitives: [],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts resolver-shaped target ($var)", () => {
|
||||
const result = ON_ATTR_EXPIRE_PRIMITIVE.paramsSchema.safeParse({
|
||||
target: { $var: "victim" },
|
||||
attr: "Cooldown",
|
||||
primitives: [],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a missing target", () => {
|
||||
const result = ON_ATTR_EXPIRE_PRIMITIVE.paramsSchema.safeParse({
|
||||
attr: "Cooldown",
|
||||
primitives: [],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a missing attr", () => {
|
||||
const result = ON_ATTR_EXPIRE_PRIMITIVE.paramsSchema.safeParse({
|
||||
target: 7,
|
||||
primitives: [],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("on-attr-expire primitive — apply() seeds GAME_ENTITY hook list", () => {
|
||||
it("appends descriptorId + target + attr + primitives to OnAttrExpireHooks", () => {
|
||||
const { ctx, session } = makeContext();
|
||||
const primitives: EffectPrimitiveNode[] = [
|
||||
{ kind: "seed-attribute", params: { attr: "RangeBonus", value: 0 } },
|
||||
];
|
||||
|
||||
ON_ATTR_EXPIRE_PRIMITIVE.apply(ctx, {
|
||||
target: 7 as EntityId,
|
||||
attr: "Cooldown",
|
||||
primitives,
|
||||
});
|
||||
|
||||
expect(session.get(GAME_ENTITY, "OnAttrExpireHooks")).toEqual([
|
||||
{
|
||||
descriptorId: "custom:test-on-attr-expire",
|
||||
target: 7,
|
||||
attr: "Cooldown",
|
||||
primitives,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("appends additional hook entries (different (target,attr) pairs preserved)", () => {
|
||||
const { ctx, session } = makeContext();
|
||||
const a: EffectPrimitiveNode[] = [
|
||||
{ kind: "add-to-attribute", params: { attr: "Hp", delta: -1 } },
|
||||
];
|
||||
const b: EffectPrimitiveNode[] = [
|
||||
{ kind: "add-to-attribute", params: { attr: "Hp", delta: 1 } },
|
||||
];
|
||||
|
||||
ON_ATTR_EXPIRE_PRIMITIVE.apply(ctx, {
|
||||
target: 7 as EntityId,
|
||||
attr: "BombTimer",
|
||||
primitives: a,
|
||||
});
|
||||
ON_ATTR_EXPIRE_PRIMITIVE.apply(ctx, {
|
||||
target: 9 as EntityId,
|
||||
attr: "Charge",
|
||||
primitives: b,
|
||||
});
|
||||
|
||||
expect(session.get(GAME_ENTITY, "OnAttrExpireHooks")).toEqual([
|
||||
{
|
||||
descriptorId: "custom:test-on-attr-expire",
|
||||
target: 7,
|
||||
attr: "BombTimer",
|
||||
primitives: a,
|
||||
},
|
||||
{
|
||||
descriptorId: "custom:test-on-attr-expire",
|
||||
target: 9,
|
||||
attr: "Charge",
|
||||
primitives: b,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("on-attr-expire primitive — childPrimitives()", () => {
|
||||
it("returns the inner primitive list for validator tree traversal", () => {
|
||||
const primitives: EffectPrimitiveNode[] = [
|
||||
{ kind: "seed-attribute", params: { attr: "RangeBonus", value: 0 } },
|
||||
{ kind: "add-to-attribute", params: { attr: "Hp", delta: 1 } },
|
||||
];
|
||||
const children = ON_ATTR_EXPIRE_PRIMITIVE.childPrimitives?.({
|
||||
target: 7 as EntityId,
|
||||
attr: "Cooldown",
|
||||
primitives,
|
||||
});
|
||||
expect(children).toEqual(primitives);
|
||||
});
|
||||
});
|
||||
154
packages/chess/src/modifiers/primitives/on-attr-expire.ts
Normal file
154
packages/chess/src/modifiers/primitives/on-attr-expire.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
/**
|
||||
* Wave-2 (thressgame-100, decision D) — `on-attr-expire` trigger
|
||||
* primitive.
|
||||
*
|
||||
* Seeds an entry in `OnAttrExpireHooks` (game-level, on
|
||||
* `GAME_ENTITY`) that fires when an attr countdown reaches zero
|
||||
* for a matching `(target, attr)` pair. Multiple hook entries
|
||||
* across descriptors compose naturally: the dispatcher
|
||||
* (`fireAttrExpireHooks` in `triggers.ts`) iterates the GAME_ENTITY
|
||||
* hook list and runs every matching entry's inner primitives.
|
||||
*
|
||||
* The trigger fires at dispatcher stage 13 (after stage 12
|
||||
* `fireOnTurnStartHooks`) — locked by decision D in
|
||||
* `decisions.md`. The lifetime sweep collects every
|
||||
* `(entityId, attr, expiringValue)` triple that hit countdown-zero
|
||||
* THIS turn boundary and fires hooks in a single batched pass
|
||||
* BEFORE the attr is retracted, so inner primitives can read the
|
||||
* attr's last value via the introduced `expiringValue` binding.
|
||||
*
|
||||
* Two upstream sources feed this trigger:
|
||||
* 1. `set-piece-attr` with `lifetime: { kind: "turns", count: N }`
|
||||
* — automatic, expires N turns after the lifetime registers.
|
||||
* 2. `decrement-attr-each-turn(target, attr)` — explicit per-turn
|
||||
* decrement of the attr's numeric value; expires when the
|
||||
* value reaches zero.
|
||||
*
|
||||
* From the descriptor author's perspective both pathways are
|
||||
* uniform: `on-attr-expire(target, attr)` fires on countdown-zero
|
||||
* regardless of which mechanism drove it.
|
||||
*
|
||||
* ## Storage rationale
|
||||
*
|
||||
* Hooks live on `GAME_ENTITY` (mirror of `OnMarkerExpireHooks`)
|
||||
* because a descriptor watching for `(piece-X, "Cooldown")` should
|
||||
* fire for that pair regardless of which descriptor instance
|
||||
* seeded the lifetime, and regardless of which piece holds the
|
||||
* descriptor (the descriptor might be game-wide). Per-piece
|
||||
* storage would force authors to seed identical hooks on every
|
||||
* piece they care about — wrong shape for the rule.
|
||||
*
|
||||
* ## Param-resolver pre-pass
|
||||
*
|
||||
* `target` accepts `numberOrResolver()` and `attr` accepts
|
||||
* `stringOrResolver()` — the runtime resolver walker substitutes
|
||||
* `$var` / `ctx-self-id` / arithmetic shapes BEFORE this
|
||||
* primitive's `apply()` runs (see `param-resolver.ts`). By the
|
||||
* time we store the hook entry, both fields are LITERAL values.
|
||||
* The dispatcher matches by exact equality on the resolved pair.
|
||||
*
|
||||
* ## Walker-artifact safeguard
|
||||
*
|
||||
* Per the Wave-1 inherited wisdom (`learnings.md` —
|
||||
* walker-artifact pattern), trigger primitives that hold ctx-self-*
|
||||
* shapes throw at apply-time when the walker recurses into their
|
||||
* children. We seed the GAME_ENTITY hook list FIRST (this
|
||||
* `apply()` is called pre-recursion); the walker may then recurse
|
||||
* into `params.primitives` and throw on inner ctx-* shapes that
|
||||
* are unbound at outer scope, but the seed persists. The
|
||||
* dispatcher (which runs in trigger context with the introduced
|
||||
* `expiringValue` binding) can resolve those shapes correctly at
|
||||
* fire time.
|
||||
*/
|
||||
import { z } from "zod";
|
||||
import type { EntityId } from "@paratype/rete";
|
||||
import {
|
||||
GAME_ENTITY,
|
||||
type ChessAttrMap,
|
||||
type OnAttrExpireHookEntry,
|
||||
} from "../../schema.js";
|
||||
import {
|
||||
numberOrResolver,
|
||||
stringOrResolver,
|
||||
} from "./param-resolver-schema.js";
|
||||
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||
import type {
|
||||
EffectPrimitive,
|
||||
EffectPrimitiveNode,
|
||||
PrimitiveApplyContext,
|
||||
PrimitiveKind,
|
||||
} from "./types.js";
|
||||
|
||||
/**
|
||||
* Inline NodeSchema (mirrors `on-marker-expire.ts`). The tree
|
||||
* validator handles deep kind-validation; here we only assert
|
||||
* the structural shape `{ kind, params }`.
|
||||
*/
|
||||
const NodeSchema: z.ZodType<EffectPrimitiveNode> = z.object({
|
||||
kind: z.string() as z.ZodType<PrimitiveKind>,
|
||||
params: z.unknown(),
|
||||
});
|
||||
|
||||
const schema = z.object({
|
||||
target: numberOrResolver({ min: 0 }),
|
||||
attr: stringOrResolver(),
|
||||
primitives: z.array(NodeSchema),
|
||||
});
|
||||
type Params = z.infer<typeof schema>;
|
||||
|
||||
const descriptor: EffectPrimitive<Params> = {
|
||||
kind: "on-attr-expire",
|
||||
label: "On Attribute Expire",
|
||||
description:
|
||||
"Seeds OnAttrExpireHooks entries fired when an attr countdown reaches zero on the matching (target, attr) pair.",
|
||||
longDescription:
|
||||
"Runs the steps inside it whenever a multi-turn countdown finishes. Two countdown sources feed it: a value written by set-piece-attr with a 'turns' lifetime (auto-expires after N turns) and a value tagged by decrement-attr-each-turn (decrements every turn-end and expires at zero). Inside the steps, $expiringValue holds the attr's last value before the engine retracts it, so you can use it for one-off effects (a final tick of damage, a tombstone marker, a UI banner). The match is exact: a hook for (piece 12, 'Cooldown') will not fire for (piece 13, 'Cooldown') or for (piece 12, 'Charge').",
|
||||
examples: [
|
||||
{
|
||||
title: "Time bomb — explode when the countdown finishes",
|
||||
params: {
|
||||
target: 12,
|
||||
attr: "BombTimer",
|
||||
primitives: [
|
||||
{ kind: "add-to-attribute", params: { attr: "Hp", delta: -3 } },
|
||||
],
|
||||
},
|
||||
effect:
|
||||
"When the bomb's countdown reaches zero, the carrier loses 3 HP. The countdown was either set up by set-piece-attr with a turns lifetime, or it was a value being ticked down by decrement-attr-each-turn — both paths trigger this rule.",
|
||||
},
|
||||
],
|
||||
paramsSchema: schema,
|
||||
seedsAttrs: ["OnAttrExpireHooks"],
|
||||
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
||||
// After resolver substitution `target` is a literal numeric
|
||||
// EntityId and `attr` is a literal string. Cast through the
|
||||
// resolver-typed `unknown` to the storage shape — the schema
|
||||
// accepts resolver shapes for author-time validation, but the
|
||||
// pre-apply walker substitutes them before we land here.
|
||||
const targetId = params.target as EntityId;
|
||||
const attrName = params.attr as string;
|
||||
|
||||
const existing =
|
||||
(ctx.session.get(GAME_ENTITY, "OnAttrExpireHooks") as
|
||||
| ChessAttrMap["OnAttrExpireHooks"]
|
||||
| undefined) ?? [];
|
||||
|
||||
const next: OnAttrExpireHookEntry = {
|
||||
descriptorId: ctx.descriptor.id,
|
||||
target: targetId,
|
||||
attr: attrName,
|
||||
primitives: [...params.primitives],
|
||||
};
|
||||
|
||||
ctx.session.insert(GAME_ENTITY, "OnAttrExpireHooks", [
|
||||
...existing,
|
||||
next,
|
||||
]);
|
||||
},
|
||||
childPrimitives(params: Params): EffectPrimitiveNode[] {
|
||||
return [...params.primitives];
|
||||
},
|
||||
};
|
||||
|
||||
PRIMITIVE_REGISTRY.register(descriptor);
|
||||
export { descriptor as ON_ATTR_EXPIRE_PRIMITIVE };
|
||||
|
|
@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
|
|||
import { PRIMITIVE_REGISTRY } from "./index.js";
|
||||
|
||||
describe("PRIMITIVE_REGISTRY", () => {
|
||||
it("should have exactly 50 registered primitives after barrel import", () => {
|
||||
it("should have exactly 52 registered primitives after barrel import", () => {
|
||||
// T16 added "on-rule-activated"; T18 added "on-piece-entered-marker"
|
||||
// (22 → 24). T17 added "on-rule-expire" (24 → 25). T19 added
|
||||
// "on-marker-expire" (25 → 26). T21 added "place-piece" (26 → 27).
|
||||
|
|
@ -22,10 +22,12 @@ describe("PRIMITIVE_REGISTRY", () => {
|
|||
// T39 added "block-by-piece-type" (47 → 48).
|
||||
// T41 added "pawn-pushes-pieces" (48 → 49).
|
||||
// T47 added "request-choice" (49 → 50).
|
||||
// Wave-2 (thressgame-100, decision D) added "on-attr-expire"
|
||||
// and "decrement-attr-each-turn" (50 → 52).
|
||||
// Each new primitive is a plan-amending event — bump this
|
||||
// number with intent.
|
||||
const count = PRIMITIVE_REGISTRY.list().length;
|
||||
expect(count).toBe(50);
|
||||
expect(count).toBe(52);
|
||||
});
|
||||
|
||||
it("should list all primitive kinds with non-empty descriptor objects", () => {
|
||||
|
|
|
|||
|
|
@ -32,7 +32,8 @@ export type TriggerName =
|
|||
| "on-rule-activated"
|
||||
| "on-rule-expire"
|
||||
| "on-piece-entered-marker"
|
||||
| "on-marker-expire";
|
||||
| "on-marker-expire"
|
||||
| "on-attr-expire";
|
||||
|
||||
/**
|
||||
* A trigger event enqueued by an imperative primitive for deferred
|
||||
|
|
@ -79,12 +80,14 @@ export type PrimitiveKind =
|
|||
| "on-rule-expire"
|
||||
| "on-piece-entered-marker"
|
||||
| "on-marker-expire"
|
||||
| "on-attr-expire"
|
||||
| "place-piece"
|
||||
| "destroy-piece"
|
||||
| "move-piece"
|
||||
| "swap-pieces"
|
||||
| "convert-piece-type"
|
||||
| "set-piece-attr"
|
||||
| "decrement-attr-each-turn"
|
||||
| "set-moves-as"
|
||||
| "set-moves-also-as"
|
||||
| "cancel-capture"
|
||||
|
|
|
|||
|
|
@ -1304,6 +1304,91 @@ export function fireOnMarkerExpireHooks(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wave-2 (thressgame-100, decision D) — fire `on-attr-expire`
|
||||
* hooks for the `(entityId, attr)` pair whose countdown just
|
||||
* reached zero. Called from `util/lifetime-registry.ts` (both the
|
||||
* `LifetimeRegistry` sweep and the `AttrCountdownRegistry` sweep)
|
||||
* BEFORE the attr is retracted, so inner primitives can read the
|
||||
* attr's last value via the introduced `expiringValue` binding.
|
||||
*
|
||||
* Hook list lives on `GAME_ENTITY` (game-level — the rule applies
|
||||
* to ANY (target, attr) pair the descriptor declared, not a
|
||||
* specific piece). Match is exact `(target, attr)` tuple equality:
|
||||
* a hook for `(piece-12, "Cooldown")` does NOT fire for
|
||||
* `(piece-13, "Cooldown")` or for `(piece-12, "Charge")`. Multiple
|
||||
* hook entries across descriptors that match the same pair compose
|
||||
* naturally; the dispatcher runs every matching entry's inner
|
||||
* primitives in insertion order.
|
||||
*
|
||||
* Inner primitives see `event = { kind: "attr-expire", entityId,
|
||||
* attr, expiringValue }` AND a binding `expiringValue` in scope so
|
||||
* authors can write either `ctx.event.expiringValue` (explicit) or
|
||||
* `{ $var: "expiringValue" }` (resolver shape) to consume the
|
||||
* value. The pieceId for `runPrimitives` is `entityId` so
|
||||
* `target: 'self'` resolves to the entity whose attr expired —
|
||||
* mirrors the marker-expire dispatcher's "fire on the dying
|
||||
* entity" convention.
|
||||
*
|
||||
* No fire-once guard. `on-attr-expire` can fire many times across
|
||||
* a game (every countdown reaching zero is a fresh expire event);
|
||||
* the guards used by `on-rule-activated` / `on-rule-expire` are
|
||||
* mechanism-level, not appropriate here.
|
||||
*/
|
||||
export function fireAttrExpireHooks(
|
||||
engine: ChessEngine,
|
||||
entityId: EntityId,
|
||||
attr: string,
|
||||
expiringValue: unknown,
|
||||
cascadeDepth: number = 0,
|
||||
): void {
|
||||
const hooks = engine.session.get(GAME_ENTITY, "OnAttrExpireHooks") as
|
||||
| ChessAttrMap["OnAttrExpireHooks"]
|
||||
| undefined;
|
||||
if (hooks === undefined || hooks.length === 0) return;
|
||||
|
||||
// Cast `expiringValue` to BindingValue at the call boundary. The
|
||||
// attr value could legally be any session-storable type
|
||||
// (`unknown` here); BindingValue's union covers number / string /
|
||||
// boolean / EntityId / readonly EntityId[] which spans every
|
||||
// realistic countdown type. Non-conforming values (e.g. a
|
||||
// structured object stored as the attr value) would still pass
|
||||
// through this cast — the binding lookup at child-primitive scope
|
||||
// would return whatever was stored, and the consumer is
|
||||
// responsible for type-checking. Matches the open-typed
|
||||
// contract `set-piece-attr` itself maintains for arbitrary attr
|
||||
// values.
|
||||
const bindingValue = expiringValue as BindingValue | undefined;
|
||||
const bindings: ReadonlyMap<string, BindingValue> =
|
||||
bindingValue !== undefined
|
||||
? new Map<string, BindingValue>([["expiringValue", bindingValue]])
|
||||
: new Map();
|
||||
|
||||
const event: PrimitiveEvent = {
|
||||
kind: "attr-expire",
|
||||
entityId,
|
||||
attr,
|
||||
expiringValue: bindingValue,
|
||||
};
|
||||
|
||||
for (const hook of hooks) {
|
||||
if (hook.target !== entityId) continue;
|
||||
if (hook.attr !== attr) continue;
|
||||
runPrimitives(
|
||||
engine,
|
||||
entityId,
|
||||
hook.primitives,
|
||||
1,
|
||||
event,
|
||||
bindings,
|
||||
cascadeDepth,
|
||||
false,
|
||||
[],
|
||||
hook.descriptorId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Predicate matcher for OnMovedOntoSquare's filter union.
|
||||
* - `kind: "squares"` matches if the destination is in the list.
|
||||
|
|
|
|||
|
|
@ -484,7 +484,51 @@ export interface ChessAttrMap {
|
|||
* full list (excluding survivors) when at least one entry retired
|
||||
* to keep churn proportional to expiry count.
|
||||
*/
|
||||
LifetimeRegistry: readonly LifetimeEntry[];
|
||||
LifetimeRegistry: readonly LifetimeEntry[];
|
||||
/**
|
||||
* Wave-2 (thressgame-100, decision D) — registry of explicit
|
||||
* per-turn attr countdowns. Stored on `GAME_ENTITY`. Each entry
|
||||
* records that some `(entityId, attr)` pair should be decremented
|
||||
* by 1 every turn-end; when the attr value reaches zero, the
|
||||
* dispatcher fires `on-attr-expire` for the pair THEN retracts
|
||||
* the attr fact and removes the registry entry.
|
||||
*
|
||||
* Distinct from {@link LifetimeRegistry} (T35), which expires a
|
||||
* fact at an absolute `FullmoveNumber` target — that mechanism is
|
||||
* AUTOMATIC (driven by `set-piece-attr.lifetime: { kind:"turns" }`).
|
||||
* The countdown registry is EXPLICIT — descriptor authors call
|
||||
* `decrement-attr-each-turn(target, attr)` to opt INTO per-turn
|
||||
* decrement of a numeric attr, treating the attr value AS the
|
||||
* countdown remainder. Both mechanisms feed into stage-13
|
||||
* `fireAttrExpireHooks` so the trigger semantics are uniform from
|
||||
* the descriptor author's perspective.
|
||||
*/
|
||||
AttrCountdownRegistry: readonly AttrCountdownEntry[];
|
||||
/**
|
||||
* Wave-2 (thressgame-100, decision D) — game-level
|
||||
* `on-attr-expire` hook list. Stored on `GAME_ENTITY`. Each entry
|
||||
* pairs the seeding `descriptorId` with the inner primitive list
|
||||
* to run when an attr countdown reaches zero on the matching
|
||||
* `(target, attr)` pair.
|
||||
*
|
||||
* Dispatched by `fireAttrExpireHooks` (triggers.ts) at the new
|
||||
* dispatcher stage 13 in `apply.ts#onAfterMove` — runs AFTER stage
|
||||
* 12 (`fireOnTurnStartHooks`) per locked decision D so end-of-turn
|
||||
* + start-of-next-turn ticks resolve first; expiries are batched
|
||||
* per turn boundary so multiple countdowns hitting zero in the
|
||||
* same turn fire deterministically (insertion order, then
|
||||
* registration order).
|
||||
*
|
||||
* Hook entries store `target` / `attr` as the literal
|
||||
* already-resolved values authored at hook-seed time (the resolver
|
||||
* walker substitutes them before this primitive's apply() runs)
|
||||
* — the dispatcher matches against the actual expiring
|
||||
* `(entityId, attr)` pair using exact equality. The `expiringValue`
|
||||
* binding (the attr's last value before retraction) is introduced
|
||||
* into the inner primitive scope by the dispatcher so authors can
|
||||
* reference `{ $var: "expiringValue" }` inside the arm.
|
||||
*/
|
||||
OnAttrExpireHooks: readonly OnAttrExpireHookEntry[];
|
||||
/**
|
||||
* T38 — `must-class` move-class restriction. Stored on
|
||||
* `GAME_ENTITY` (one active restriction at a time). Set by the
|
||||
|
|
@ -722,6 +766,29 @@ export interface LifetimeEntry {
|
|||
readonly descriptorId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wave-2 (thressgame-100, decision D) — single entry in
|
||||
* {@link ChessAttrMap.AttrCountdownRegistry}. Marks a numeric attr
|
||||
* as a countdown that decrements by 1 every turn-end. Pairs the
|
||||
* target `(entityId, attr)` with the source descriptor id (for
|
||||
* provenance/debug). The countdown remainder lives ON the attr
|
||||
* value itself — the dispatcher sweep reads the attr, subtracts
|
||||
* 1, writes back; when the result is `<= 0` the attr is marked for
|
||||
* expiry (`on-attr-expire` fires, then the attr is retracted and
|
||||
* the entry is removed).
|
||||
*
|
||||
* Unlike `LifetimeEntry`, there is NO `expiresAtTurn` field — the
|
||||
* expiry condition is data-dependent (attr value reaches zero), not
|
||||
* counter-based. A descriptor author seeds the initial countdown
|
||||
* value via a separate `set-piece-attr` (or `seed-attribute`) call
|
||||
* with the desired starting count.
|
||||
*/
|
||||
export interface AttrCountdownEntry {
|
||||
readonly entityId: EntityId;
|
||||
readonly attr: string;
|
||||
readonly descriptorId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* T16 — single entry in `OnRuleActivatedHooks`. Stored on
|
||||
* `GAME_ENTITY` (one list per game). Each entry pairs the
|
||||
|
|
@ -779,6 +846,28 @@ export interface OnMarkerExpireHookEntry {
|
|||
readonly primitives: readonly EffectPrimitiveNode[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Wave-2 (thressgame-100, decision D) — single entry in
|
||||
* `OnAttrExpireHooks`. Stored on `GAME_ENTITY`. Each entry pairs
|
||||
* the source `descriptorId`, a `(target, attr)` filter, and the
|
||||
* inner primitive list to run when an attr countdown reaches zero
|
||||
* on a matching pair.
|
||||
*
|
||||
* `target` is stored as a literal `EntityId` (the resolver walker
|
||||
* substitutes `$var` / `ctx-self-id` / arithmetic shapes BEFORE the
|
||||
* primitive's `apply()` runs); the dispatcher matches it against
|
||||
* the expiring entity id by exact equality. `attr` is stored as
|
||||
* the literal attr name string — same as `LifetimeEntry.attr`,
|
||||
* which is open-typed by design (mirrors `set-piece-attr`'s
|
||||
* relaxed attr-name policy in T26).
|
||||
*/
|
||||
export interface OnAttrExpireHookEntry {
|
||||
readonly descriptorId: string;
|
||||
readonly target: EntityId;
|
||||
readonly attr: string;
|
||||
readonly primitives: readonly EffectPrimitiveNode[];
|
||||
}
|
||||
|
||||
export type ChessAttrKey = keyof ChessAttrMap;
|
||||
|
||||
/** Capture behavior bitflags for the CaptureFlags modifier attribute. */
|
||||
|
|
|
|||
|
|
@ -112,6 +112,14 @@ const SAMPLE_PARAMS: Record<PrimitiveKind, unknown> = {
|
|||
{ kind: "seed-attribute", params: { attr: "RangeBonus", value: 0 } },
|
||||
],
|
||||
},
|
||||
"on-attr-expire": {
|
||||
target: 7,
|
||||
attr: "Cooldown",
|
||||
primitives: [
|
||||
{ kind: "add-to-attribute", params: { attr: "Hp", delta: -1 } },
|
||||
],
|
||||
},
|
||||
"decrement-attr-each-turn": { target: 7, attr: "Cooldown" },
|
||||
"place-piece": { pieceType: "pawn", color: "white", square: 28 },
|
||||
"destroy-piece": { target: 28 },
|
||||
"move-piece": { target: 7, to: 28 },
|
||||
|
|
|
|||
|
|
@ -67,9 +67,11 @@ import type { ChessEngine } from "../engine.js";
|
|||
import type { PrimitiveApplyContext } from "../modifiers/primitives/types.js";
|
||||
import {
|
||||
GAME_ENTITY,
|
||||
type AttrCountdownEntry,
|
||||
type ChessAttrMap,
|
||||
type LifetimeEntry,
|
||||
} from "../schema.js";
|
||||
import { fireAttrExpireHooks } from "../modifiers/triggers.js";
|
||||
|
||||
/**
|
||||
* Append a new lifetime entry to the registry. Idempotency is
|
||||
|
|
@ -118,22 +120,55 @@ function isSession(x: ChessEngine | Session): x is Session {
|
|||
}
|
||||
|
||||
/**
|
||||
* Sweep the lifetime registry. Retracts any fact whose
|
||||
* Sweep the lifetime registry. For every entry whose
|
||||
* `expiresAtTurn` has been reached/passed by the current
|
||||
* `FullmoveNumber`, and rewrites the registry to keep only
|
||||
* survivors. No-op when the registry is empty or the engine has no
|
||||
* `FullmoveNumber` fact (defensive — pre-init engines won't have
|
||||
* one).
|
||||
* `FullmoveNumber`, fires `on-attr-expire` (Wave-2, decision D)
|
||||
* with the attr's current value as `expiringValue` BEFORE
|
||||
* retracting the bound fact, then rewrites the registry to keep
|
||||
* only survivors. No-op when the registry is empty or the engine
|
||||
* has no `FullmoveNumber` fact (defensive — pre-init engines
|
||||
* won't have one).
|
||||
*
|
||||
* Called from `apply.ts#onAfterMove` as STAGE 11b — directly after
|
||||
* stage 11 (`fireOnTurnEndHooks`). This ordering means an
|
||||
* end-of-turn hook can still observe the about-to-expire fact in
|
||||
* the same turn the lifetime decrements, mirroring T19's stage 7c
|
||||
* "fire BEFORE retraction" precedent for marker entry effects.
|
||||
* ## Stage scheduling
|
||||
*
|
||||
* Called from `apply.ts#onAfterMove` as part of the new STAGE 13
|
||||
* — `fireAttrExpireHooks` orchestrator — which runs AFTER stage
|
||||
* 12 (`fireOnTurnStartHooks`) per locked decision D in
|
||||
* `decisions.md`. The orchestrator batches both lifetime-driven
|
||||
* expirations (this function) AND explicit-countdown expirations
|
||||
* (see {@link decrementAttrCountdowns}) into a single per-turn-
|
||||
* boundary pass so multi-source expirations fire deterministically.
|
||||
*
|
||||
* Pre-Wave-2 this sweep ran at stage 11b (immediately after
|
||||
* `fireOnTurnEndHooks`) and did NOT fire any trigger. The Wave-2
|
||||
* relocation + trigger-firing are deliberate behaviour changes
|
||||
* locked by decision D — existing recipes that depended on the
|
||||
* old "silent retract at 11b" timing get the same RETRACT effect
|
||||
* (a turn later by clock time, but identical relative to the new
|
||||
* locked stage ordering) and additionally see `on-attr-expire`
|
||||
* fire when the attr expires. Decision J explicitly waives the
|
||||
* backward-compat constraint.
|
||||
*
|
||||
* ## Fire-before-retract semantics
|
||||
*
|
||||
* Mirrors the marker-lifetime sweep precedent
|
||||
* (`decrementMarkerLifetimes` in `util/marker-lifetime.ts`): the
|
||||
* trigger sees the attr's CURRENT value via the introduced
|
||||
* `expiringValue` binding because the dispatcher reads the value
|
||||
* BEFORE issuing the retract. After the trigger arm completes the
|
||||
* registry sweeper retracts the fact unconditionally — even if
|
||||
* the trigger arm itself wrote a new value, the retract still
|
||||
* fires (consistent with the locked "lifetime is authoritative
|
||||
* over inner mutations" semantic).
|
||||
*
|
||||
* @param engine The engine whose registry to sweep.
|
||||
* @param cascadeDepth threading for T15's depth cap. Top-level
|
||||
* apply.ts callers pass 0 (default).
|
||||
*/
|
||||
export function decrementLifetimes(engine: ChessEngine): void {
|
||||
export function decrementLifetimes(
|
||||
engine: ChessEngine,
|
||||
cascadeDepth: number = 0,
|
||||
): void {
|
||||
const currentTurn = engine.session.get(GAME_ENTITY, "FullmoveNumber") as
|
||||
| number
|
||||
| undefined;
|
||||
|
|
@ -144,33 +179,207 @@ export function decrementLifetimes(engine: ChessEngine): void {
|
|||
| undefined) ?? [];
|
||||
if (registry.length === 0) return;
|
||||
|
||||
// Phase 1: collect every entry that expired this turn boundary.
|
||||
// We snapshot the pre-retract value so the trigger's
|
||||
// `expiringValue` binding sees what the attr held at the moment
|
||||
// the countdown completed.
|
||||
const survivors: LifetimeEntry[] = [];
|
||||
const expirations: Array<{
|
||||
entityId: EntityId;
|
||||
attr: string;
|
||||
expiringValue: unknown;
|
||||
}> = [];
|
||||
for (const entry of registry) {
|
||||
if (currentTurn >= entry.expiresAtTurn) {
|
||||
// Stale-entity tolerant: only retract when the bound fact
|
||||
// actually exists. A piece destroyed between register and
|
||||
// decrement leaves an orphan entry; we drop it from
|
||||
// survivors regardless (entry IS expired, even if its host
|
||||
// is gone).
|
||||
const attrKey = entry.attr as keyof ChessAttrMap;
|
||||
const current = engine.session.get(
|
||||
entry.entityId as EntityId,
|
||||
attrKey,
|
||||
);
|
||||
if (current !== undefined) {
|
||||
engine.session.retract(entry.entityId as EntityId, attrKey);
|
||||
}
|
||||
// entry is dropped from `survivors` (no push) → removed.
|
||||
// Stale entries (bound fact already retracted by something
|
||||
// else) are still removed from the registry but we record
|
||||
// `expiringValue: undefined` so the trigger's binding
|
||||
// reflects "no value present at expire time". Dispatch
|
||||
// policy: still fire the trigger so descriptors that bound
|
||||
// their own cleanup logic to the expire event run regardless
|
||||
// — matches the marker-lifetime "fire even if facts are
|
||||
// partial" precedent.
|
||||
expirations.push({
|
||||
entityId: entry.entityId as EntityId,
|
||||
attr: entry.attr,
|
||||
expiringValue: current,
|
||||
});
|
||||
} else {
|
||||
survivors.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
// Only rewrite the registry when the count changed. Avoids a
|
||||
// pointless WM insert (which records a retract+insert event pair
|
||||
// for upserts) when the common "nothing expired" path runs.
|
||||
if (expirations.length === 0) return;
|
||||
|
||||
// Phase 2: fire `on-attr-expire` BEFORE retracting. The trigger
|
||||
// arm sees the attr's current value via its `expiringValue`
|
||||
// binding; if the arm itself mutates the attr or registers a new
|
||||
// lifetime, those changes take effect immediately and the
|
||||
// upcoming retract still applies (locked semantic).
|
||||
for (const exp of expirations) {
|
||||
fireAttrExpireHooks(
|
||||
engine,
|
||||
exp.entityId,
|
||||
exp.attr,
|
||||
exp.expiringValue,
|
||||
cascadeDepth,
|
||||
);
|
||||
}
|
||||
|
||||
// Phase 3: retract every expired bound fact. Defensive
|
||||
// contains-check so a fact already retracted by the trigger arm
|
||||
// (or by an earlier expiry sharing the same entity+attr pair —
|
||||
// shouldn't happen but defended) doesn't throw.
|
||||
for (const exp of expirations) {
|
||||
const attrKey = exp.attr as keyof ChessAttrMap;
|
||||
if (engine.session.contains(exp.entityId, attrKey)) {
|
||||
engine.session.retract(exp.entityId, attrKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 4: rewrite the registry to keep only survivors. Always
|
||||
// rewrite when at least one entry expired (which is true here —
|
||||
// we returned early above when expirations was empty).
|
||||
engine.session.insert(GAME_ENTITY, "LifetimeRegistry", survivors);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wave-2 (thressgame-100, decision D) — sweep the
|
||||
* `AttrCountdownRegistry` for explicit per-turn countdowns seeded
|
||||
* by the `decrement-attr-each-turn` primitive. For each entry:
|
||||
*
|
||||
* 1. Read the current attr value. If absent, treat as 0
|
||||
* (effectively expired this tick — a stale entry).
|
||||
* 2. If the value is non-numeric, skip the entry without
|
||||
* mutating it. Authors who tag a non-numeric attr as a
|
||||
* countdown get a silent no-op rather than a crash; the
|
||||
* registry entry persists so it self-heals if the value
|
||||
* becomes numeric later. (Documented in the primitive's
|
||||
* docstring as a usage error.)
|
||||
* 3. Decrement the attr by 1.
|
||||
* 4. If the post-decrement value is `<= 0`: fire `on-attr-expire`
|
||||
* with `expiringValue = preDecrementValue` (the value the
|
||||
* author would describe as "the countdown finished from N to
|
||||
* 0", so we surface N — the LAST visible non-zero remainder).
|
||||
* Then retract the attr and drop the registry entry.
|
||||
* 5. Otherwise: write the decremented value back, keep the entry
|
||||
* in survivors.
|
||||
*
|
||||
* `expiringValue = preDecrement` (NOT post-decrement zero) is a
|
||||
* deliberate UX choice: the trigger arm typically wants to read
|
||||
* the COUNTDOWN'S STARTING / DEFINING value (not the trivial 0
|
||||
* sentinel) so it can scale follow-up effects on the original
|
||||
* count. When the entry was stale (no attr value present), the
|
||||
* `expiringValue` is `undefined` — same convention as the lifetime
|
||||
* sweep above.
|
||||
*
|
||||
* @param engine The engine whose countdown registry to sweep.
|
||||
* @param cascadeDepth threading for T15's depth cap.
|
||||
*/
|
||||
export function decrementAttrCountdowns(
|
||||
engine: ChessEngine,
|
||||
cascadeDepth: number = 0,
|
||||
): void {
|
||||
const registry = (engine.session.get(
|
||||
GAME_ENTITY,
|
||||
"AttrCountdownRegistry",
|
||||
) as readonly AttrCountdownEntry[] | undefined) ?? [];
|
||||
if (registry.length === 0) return;
|
||||
|
||||
const survivors: AttrCountdownEntry[] = [];
|
||||
const expirations: Array<{
|
||||
entityId: EntityId;
|
||||
attr: string;
|
||||
expiringValue: unknown;
|
||||
}> = [];
|
||||
|
||||
for (const entry of registry) {
|
||||
const attrKey = entry.attr as keyof ChessAttrMap;
|
||||
const current = engine.session.get(entry.entityId, attrKey);
|
||||
|
||||
if (current === undefined) {
|
||||
// Stale entry — bound fact was retracted by something else.
|
||||
// Record an expiration with `undefined` value so any matching
|
||||
// hook still fires (descriptor authors who installed cleanup
|
||||
// logic on `on-attr-expire` expect the trigger to fire even
|
||||
// when the attr was prematurely removed). Then drop the
|
||||
// entry from survivors.
|
||||
expirations.push({
|
||||
entityId: entry.entityId,
|
||||
attr: entry.attr,
|
||||
expiringValue: undefined,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof current !== "number") {
|
||||
// Non-numeric — author tagged an attr that isn't a numeric
|
||||
// countdown. Silent no-op: keep the entry, don't mutate the
|
||||
// attr. Matches the primitive's relaxed-attr-name policy:
|
||||
// unrecognised / mistyped attrs degrade to silent inactivity
|
||||
// rather than crashing the engine.
|
||||
survivors.push(entry);
|
||||
continue;
|
||||
}
|
||||
|
||||
const decremented = current - 1;
|
||||
if (decremented <= 0) {
|
||||
// Countdown completed. Surface the PRE-decrement value as
|
||||
// `expiringValue` so the trigger arm can read the
|
||||
// "remainder before the final tick" — typically the most
|
||||
// useful number for narrating "countdown finished after N
|
||||
// turns" effects.
|
||||
expirations.push({
|
||||
entityId: entry.entityId,
|
||||
attr: entry.attr,
|
||||
expiringValue: current,
|
||||
});
|
||||
// entry dropped from survivors — countdown is consumed.
|
||||
} else {
|
||||
// Tick down and keep the entry. Write the new value via
|
||||
// `insert` (Rete's upsert semantic — replaces the prior
|
||||
// numeric).
|
||||
engine.session.insert(entry.entityId, attrKey, decremented as never);
|
||||
survivors.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
// Fire-before-retract for every expiration in registration order.
|
||||
// The sweep above iterates the registry in insertion order, so
|
||||
// expirations preserve that ordering — deterministic across
|
||||
// replays.
|
||||
for (const exp of expirations) {
|
||||
fireAttrExpireHooks(
|
||||
engine,
|
||||
exp.entityId,
|
||||
exp.attr,
|
||||
exp.expiringValue,
|
||||
cascadeDepth,
|
||||
);
|
||||
}
|
||||
|
||||
// Retract expired attrs after every trigger arm has fired (so
|
||||
// arms that read the attr via session.get see its last value).
|
||||
for (const exp of expirations) {
|
||||
const attrKey = exp.attr as keyof ChessAttrMap;
|
||||
if (engine.session.contains(exp.entityId, attrKey)) {
|
||||
engine.session.retract(exp.entityId, attrKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Rewrite the registry only when something actually expired —
|
||||
// matches the lifetime sweep's churn-avoidance policy.
|
||||
if (survivors.length !== registry.length) {
|
||||
engine.session.insert(GAME_ENTITY, "LifetimeRegistry", survivors);
|
||||
engine.session.insert(
|
||||
GAME_ENTITY,
|
||||
"AttrCountdownRegistry",
|
||||
survivors,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue