feat(thressgame-100): Wave 1 partial — resolver V3 + add-to-attribute.target
Wave 1 (W1.0–W1.6) of the thressgame-100 epic — push ThressGame coverage from
27 % toward 85 %+ via resolver expressiveness. Foundation for Layer-1 rules
(self-targeting destroys, mass-mover, adjacent splash). Recipes (W1.7–W1.12)
land in subsequent commits.
Resolver V3 (param-resolver.ts) — 6 new shapes:
- {ctx-self-id: null} → ctx.pieceId
- {ctx-self-marker-id: null} → ctx.markerId (throws when undefined)
- {add: [<resolver>, <int>]} → recursive arithmetic, MAX_SAFE_INTEGER overflow throws
- {sub: [...]}, {mul: [...]}, {mod: [...]} — same pattern; mod uses positive-modulo
formula ((l % r) + r) % r so column-wrap recipes work for any sign of l
V3 union order locked (param-resolver-schema.ts):
[literal, $var, ctx-attr, ctx-build, ctx-self-id, ctx-self-marker-id, add, sub, mul, mod]
PrimitiveApplyContext (types.ts): added optional readonly markerId? field.
runPrimitives (triggers.ts): populates markerId in ctx for piece-entered-marker
and marker-expire events from event.markerId (single source of truth).
W1.6 — add-to-attribute.target:
- Schema gains optional target?: numberOrResolver({ min: 0 }) field
- apply() resolves target then defaults to ctx.pieceId when undefined
- Closes the long-documented adjacent-splash sharp edge — splash damage now
expressible via target redirection instead of forcing set-piece-attr
Test surface:
- param-resolver.test.ts: +22 tests (39 total) — overflow boundary, recursive
nesting, mixed shapes, replay determinism, ctx.markerId failure modes
- param-resolver-schema.test.ts: +19 tests (38 total) — V3 union for each helper,
arithmetic shape parsing, ctx-self-id payload validation
- add-to-attribute.test.ts: +6 tests (10 total) — target literal, target $var,
target omitted (backward-compat), reject string target, apply with/without target
- ParamField snapshot regenerated for add-to-attribute.target rendering
bun run check: 2983 tests pass (was 2941, +42).
Plan: .sisyphus/plans/thressgame-100.md (5 waves + cross-ref + final verification,
~73 atomic tasks locked end-to-end).
Notepads: .sisyphus/notepads/thressgame-100/
Locked architectural decisions (irrevocable across all 6 waves):
A. Arithmetic resolver shapes — arity 2, no comparisons, no booleans
B. Self-targeting via ctx-self-id / ctx-self-marker-id (NOT 'self' literal)
C. add-to-attribute.target optional, defaults to ctx.pieceId
D. Multi-turn countdowns via on-attr-expire trigger (Wave 2)
E. Piece-pair lifecycle via PieceLink + on-piece-pair-link-broken (Wave 4)
F. Resource accumulation on GAME_ENTITY (Wave 5)
G. Board topology via BoardTopology attr (Wave 4)
H. Validator V3 — superset of V2, all V2 fixtures auto-validate
J. User-explicit overrides — no backward-compat constraint, no time/cost limit
This commit is contained in:
parent
34655ddadd
commit
6a38be6fc6
13 changed files with 1812 additions and 10 deletions
59
.sisyphus/notepads/thressgame-100/decisions.md
Normal file
59
.sisyphus/notepads/thressgame-100/decisions.md
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
# thressgame-100 — Locked Architectural Decisions
|
||||
|
||||
## Locked at plan-write time (irrevocable across all 6 waves)
|
||||
|
||||
### A. Arithmetic resolver shapes (Wave 1)
|
||||
- Add `add`, `sub`, `mul`, `mod` shapes. Arity = 2. Operands = `(resolver | integer)`.
|
||||
- Range-check at `apply()`-time only (validator just checks shape).
|
||||
- NO comparison ops, NO boolean logic, NO conditionals inside resolver shapes.
|
||||
- Union order in V3: `[literal, $var, ctx-attr, ctx-build, ctx-self-id, ctx-self-marker-id, add, sub, mul, mod]`
|
||||
|
||||
### B. Self-targeting (Wave 1)
|
||||
- New shape `{ "ctx-self-id": null }` returns `ctx.pieceId` directly.
|
||||
- New shape `{ "ctx-self-marker-id": null }` returns `ctx.markerId` (only valid inside marker triggers).
|
||||
- NOT adding `"self"` literal branch to numeric schemas.
|
||||
|
||||
### C. `add-to-attribute` gains `target` field (Wave 1)
|
||||
- Optional. Defaults to `ctx.pieceId` when omitted (backward-compatible default).
|
||||
- When present, accepts `numberOrResolver()` (V3 union).
|
||||
|
||||
### D. Multi-turn countdowns (Wave 2)
|
||||
- Generalize `MarkerLifetime` to per-piece attrs. `set-piece-attr.lifetime` already accepted.
|
||||
- New trigger `on-attr-expire(target, attr)` fires on countdown-zero.
|
||||
- New primitive `decrement-attr-each-turn(target, attr)` for explicit countdown control.
|
||||
- New dispatcher stage 13 (after stage 12 `fireOnTurnStartHooks`), batched per turn boundary.
|
||||
|
||||
### E. Piece-pair lifecycle (Wave 4)
|
||||
- New attr `PieceLink` (per-piece, list of EntityIds).
|
||||
- New trigger `on-piece-pair-link-broken`.
|
||||
- New primitives `link-pieces(a, b)`, `unlink-pieces(a, b)`.
|
||||
|
||||
### F. Resource accumulation (Wave 5)
|
||||
- New attrs `WhiteScore`, `BlackScore` on GAME_ENTITY. Default 0.
|
||||
- New primitives `add-resource(player, amount)`, `spend-resource(player, amount, then, else)`.
|
||||
- New trigger `on-resource-changed(player, threshold, direction)`.
|
||||
|
||||
### G. Board topology (Wave 4)
|
||||
- New attr `BoardTopology: "standard" | "wrap-files" | "wrap-all"` on GAME_ENTITY. Default `"standard"`.
|
||||
- New primitive `set-board-topology(value)`.
|
||||
- Standard topology = no regression (move-gen check is opt-in).
|
||||
|
||||
### H. Validator V3
|
||||
- All `numberOrResolver` / `enumOrResolverFor` callsites widen to V3 union (mechanical).
|
||||
- V3 is a superset of V2 (existing fixtures auto-validate clean).
|
||||
|
||||
### I. Test gates per wave
|
||||
- `bun run check` exit 0
|
||||
- Every new recipe ships with `*-real.test.ts` proving end-to-end runtime
|
||||
- Every wave ships ≥1 new e2e Playwright spec
|
||||
- Determinism property tests for every new primitive (N=100, byte-identical state hash)
|
||||
|
||||
### J. User-explicit overrides (locked)
|
||||
- "No backward-compat constraint" — schemas can break. Update existing tests rather than preserving.
|
||||
- "No time/cost limit" — execution may take as long as needed; reviewer cycles can iterate.
|
||||
- "Just fucking get it all done" — no premature optimization, no scope reduction, ship complete waves.
|
||||
|
||||
### K. Coverage accounting (locked numbers)
|
||||
- Effective denominator = 51 ThressGame rules (after removing 6 stubs + 8 preset-shaped from raw 65)
|
||||
- Wave-end coverage: W1 27/51 (53%) → W2 37/51 (72%) → W3 45/51 (88%) → W4 50/51 (98%) → W5 51/51 (100%)
|
||||
- 85% target hit at end of W3; W4-W5 are completeness-driven not target-driven
|
||||
10
.sisyphus/notepads/thressgame-100/issues.md
Normal file
10
.sisyphus/notepads/thressgame-100/issues.md
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# thressgame-100 — Issues / Gotchas
|
||||
|
||||
(Empty at plan-write time. Append findings as work proceeds.)
|
||||
|
||||
## Format
|
||||
|
||||
```
|
||||
## [TIMESTAMP] Wave/Task: W<N>.<M>
|
||||
{description of issue, workaround, or open question}
|
||||
```
|
||||
52
.sisyphus/notepads/thressgame-100/learnings.md
Normal file
52
.sisyphus/notepads/thressgame-100/learnings.md
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# thressgame-100 — Inherited Wisdom
|
||||
|
||||
## From thressgame-coverage epic (foundation, Atlas waves 0-19)
|
||||
|
||||
- **Test command**: `bun run check` — NOT `bun test` (hits stale dist/)
|
||||
- **TS project references**: `bunx tsc -b --force packages/chess` regenerates `dist/index.d.ts`
|
||||
- **Playwright helper**: ALWAYS `.sisyphus/scripts/run-pw.sh <log> <args>`. NEVER `bunx playwright test` directly (times out agent runtime)
|
||||
- **NEVER set `CI=true`** in helper — flips `reuseExistingServer: false`, collides with docker compose dev stack
|
||||
- **Docker stack**: `docker-compose.dev.yml` runs server :7357 + web :5173. Verify with `docker compose -f docker-compose.dev.yml ps`
|
||||
- **Test-only WS frames** in `broadcast.ts`: `__test__.activate-descriptor`, `__test__.apply-descriptor` (gated `NODE_ENV !== "production"`)
|
||||
- **Dev-only debug hook**: `globalThis.__paratypeChessClient` (gated `import.meta.env.DEV`) — use from Playwright for engine introspection
|
||||
- **DOM selectors**: `[data-square="e4"]`, `[data-piece="white-pawn"]`, `[data-piece-id]`, `[data-marker-kind="frozen-square"]`, `[data-testid="request-choice-modal"]`, `[data-choice-kind="..."]`
|
||||
- **Marker priorities (locked)**: portal-end=1 < frozen-square=2 < mine=3 < pit=4 < death-square=5 < tornado=6 < treasure=7 < blocked=8
|
||||
- **Cascade depth limit = 8** (RUNTIME_DEPTH_HARD_CAP); error code `runtime.cascade-depth-exceeded`
|
||||
- **Choice stack max depth = 8**; error code `runtime.choice-depth-exceeded`
|
||||
- **Mulberry32 PRNG**: deterministic; seeded via `engine.rng()` advancing `RngStream` on `GAME_ENTITY`
|
||||
|
||||
## From thressgame-templates epic (V2 era, prior wave)
|
||||
|
||||
- **`__resolverEnumValues` discriminator**: `enumOrResolverFor` attaches this directly via `Object.assign`. Survives Zod 4.x parse + `.optional()` wrapping. Read by ParamField at `_def.innerType.__resolverEnumValues`
|
||||
- **Validator iteration-trigger-scope fix** at `validate.ts:325-349`: extended trigger-scope detection to recognize `for-each-*` and `random-pick`
|
||||
- **JSON import in vitest**: `assert { type: "json" }` doesn't work reliably; inline descriptors as TS literals or use `fs.readFileSync(new URL(..., import.meta.url))` — the URL form fails too in vitest. Best: inline.
|
||||
- **Snapshot tests regenerate** with `bunx vitest run path -u` after rendered text changes
|
||||
|
||||
## From the oracle's full-corpus analysis (current epic)
|
||||
|
||||
- **Effective denominator = 51** (65 raw - 6 stubs - 8 preset-shaped)
|
||||
- **Wave 4 = highest regression risk** (topology change touches every move-gen test)
|
||||
- **Wave 5 = paradigm break** (game-level mutable state) — but `GAME_ENTITY` already has mutable state (RngStream, choice timeouts), so it's a NEW pattern only in scope, not architecture
|
||||
- **6 empty-stub rules in ruleHooks.js**: `pawns_with_viagra`, `estrogen`, `knee_surgery`, `pawns_learned_strength`, plus `parry`/`pacman_style` bodies that defer elsewhere
|
||||
- **8 preset-shaped rules**: `dual_king`, `coregal`, `god_kings`, `early_promotion`, `proletariat`, `short_stop`, `trains_rights`, `pacman_style` — already in `RULES.md` as v2 presets
|
||||
|
||||
## Don'ts (carried from prior waves)
|
||||
|
||||
- Do NOT edit any file in `__fixtures__/parity/` (canonical descriptors)
|
||||
- Do NOT use `Date.now()` (breaks replay determinism)
|
||||
- Do NOT `background_cancel(all=true)` (kills tasks whose results haven't been collected)
|
||||
- Do NOT add `Math.random()` (use `engine.rng()` only)
|
||||
- Do NOT skip the `*-real.test.ts` for new recipes (recipe validation alone is not proof of correctness)
|
||||
|
||||
## [2026-04-26] W1.1-W1.5 — resolver V3
|
||||
|
||||
- **`PrimitiveApplyContext.markerId` did NOT exist pre-task.** Added as `readonly markerId?: EntityId | undefined` (the `| undefined` is REQUIRED with `exactOptionalPropertyTypes: true` even when the field is `?:`). Construction sites that omit it inherit `undefined` cleanly — verified against 60+ test ctx builders without a single edit needed.
|
||||
- **Populated markerId in `runPrimitives` (triggers.ts) from `event.markerId`** for the two marker trigger event kinds (`piece-entered-marker`, `marker-expire`). This is the single source of truth — no other ctx-construction site needs the field today, since profile-time applies and non-marker triggers legitimately have `markerId === undefined`. The `ctx-self-marker-id` resolver throws BindingError-style at that boundary.
|
||||
- **Zod 4.x recursive types via `z.lazy()`**: ArithmeticShape recursively references NumericResolverInput which references ArithmeticShape. Working pattern: declare BOTH as `z.ZodType<unknown>` typed via `z.lazy(() => z.union([...]))`. The order matters — `NumericResolverInput` is declared FIRST (referencing `ArithmeticShape` which is forward-declared via TDZ-safe `z.lazy`), then `ArithmeticShape` is defined. TypeScript's variable-not-yet-initialized warning is silenced by `z.lazy`'s deferred evaluation.
|
||||
- **Helper return type widening**: `numberOrResolver()` now returns `z.ZodType<unknown>` instead of the V2 `z.ZodUnion<readonly [...]>`. The narrow union type is no longer expressible because `ArithmeticShape` is `z.ZodType<unknown>` (recursion). Consumers that care about narrow types downcast at use site — same pattern V2 used internally.
|
||||
- **Locked V3 union order in helpers** (matches `decisions.md` § A): `[literal, $var, ctx-attr, ctx-build, ctx-self-id, ctx-self-marker-id, add, sub, mul, mod]`. `add/sub/mul/mod` collapse to a single `ArithmeticShape` lazy union — but ordering inside is `add, sub, mul, mod`, so introspection order at `_def.options` matches the locked tuple.
|
||||
- **Right operand of arithmetic locked to `z.number().int()`**, NOT another resolver. Keeps overflow risk bounded (multi-resolver chains can still build via nested-on-the-left). Validator catches non-integer right operands at parse time; runtime overflow check at `MAX_SAFE_INTEGER` is the second-line defense.
|
||||
- **Positive modulo formula `((l % r) + r) % r`** is essential for column-wrap recipes (`mod(add($col, 1), 8)`) — JS `%` returns negative for negative left. Tested via `mod(-1, 8) === 7`.
|
||||
- **`ctx-self-id` / `ctx-self-marker-id` payload locked to `null`** (not `true`, `0`, `""`). Forces explicit shape in JSON descriptors. Tested at both validation (Zod `z.null()`) and runtime (walker `inner !== null` throw).
|
||||
- **Test count delta**: param-resolver.test.ts 17 → 39 tests (+22). param-resolver-schema.test.ts 19 → 38 tests (+19). All 2983 tests in `bun run check` pass.
|
||||
- **18 obsolete ParamField snapshots** were observed in the test output — pre-existing, NOT caused by this task. Confirmed by checking obsolete count is identical to fresh-write count from prior run.
|
||||
10
.sisyphus/notepads/thressgame-100/problems.md
Normal file
10
.sisyphus/notepads/thressgame-100/problems.md
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# thressgame-100 — Unresolved Blockers
|
||||
|
||||
(Empty at plan-write time. Promote items here from issues.md when blocked beyond 3 retries.)
|
||||
|
||||
## Format
|
||||
|
||||
```
|
||||
## [TIMESTAMP] Wave/Task: W<N>.<M> — BLOCKED
|
||||
{description, blocking dependency, what's needed to unblock}
|
||||
```
|
||||
460
.sisyphus/plans/thressgame-100.md
Normal file
460
.sisyphus/plans/thressgame-100.md
Normal file
|
|
@ -0,0 +1,460 @@
|
|||
# ThressGame 100 — Full-Coverage Epic (Path B)
|
||||
|
||||
## TL;DR
|
||||
|
||||
> **Quick Summary**: Push ThressGame coverage from 27% (14/51) to ≥85% across 5 sequential waves. Add arithmetic resolver shapes, multi-turn countdowns, choice-into-target plumbing, wraparound/topology hooks, and game-level economy. Ship recipes for ALL 65 ThressGame rules except 6 documented empty-stubs (`pawns_with_viagra`, `estrogen`, `knee_surgery`, `pawns_learned_strength`, plus 2 routed elsewhere). 8 preset-shaped rules (`dual_king`, `coregal`, `god_kings`, `early_promotion`, `proletariat`, `short_stop`, `trains_rights`, `ice_physics` already shipped, `pacman_style`) get FORK_INTO_PRESET treatment — they ship as preset rule variants AND get cross-referenced from the Templates modal.
|
||||
>
|
||||
> **No backward-compat constraint** — the user explicitly authorized breaking changes. Determinism MUST hold (replay-byte-identical), but API surface changes are fine.
|
||||
>
|
||||
> **Deliverables**:
|
||||
> - **Validator V3**: arithmetic resolver shapes (`add`, `sub`, `mul`, `mod`), `ctx-self-id`, `ctx-self-marker-id`
|
||||
> - **`add-to-attribute` gains `target` field** — closes adjacent-splash class
|
||||
> - **Generalized lifetime semantics**: `set-piece-attr` lifetime fires `on-attr-expire` trigger on countdown-zero
|
||||
> - **New triggers**: `on-attr-expire`, `on-piece-pair-link-broken`, `on-resource-changed`
|
||||
> - **New primitives**: `decrement-attr-each-turn`, `link-pieces`, `unlink-pieces`, `add-resource`, `spend-resource`, `condition-attr-cmp` (rich comparison conditional)
|
||||
> - **Engine extensions**: wraparound topology flag, multi-turn deferred dispatch, score/inventory entities on `GAME_ENTITY`
|
||||
> - **51 ThressGame rules covered as recipes** (51/51 of the non-stub, non-preset corpus = 100% of denominator; 51/65 raw = 78% of source corpus; +8 cross-referenced presets = effective 90% surfaceable behavior)
|
||||
> - **8 preset-shaped rules** documented in `RULES.md` with cross-references in Templates modal
|
||||
> - **6 WONT_FIX rules** documented with reason per rule
|
||||
> - **Test surface**: ~225 new unit tests, ~25 new parity fixtures, ~12 new e2e specs, ~50 new snapshots
|
||||
>
|
||||
> **Estimated Effort**: XL — 5 waves, ~6-8 dev weeks compressed via aggressive parallelism. NO TIME LIMIT per user directive.
|
||||
> **Parallel Execution**: HEAVY within waves; strict serial across waves.
|
||||
> **Critical Path**: W1 resolver expressiveness → W2 multi-turn state → W3 choice patterns → W4 topology → W5 economy → W6 preset cross-refs + final verification.
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
### Original Request
|
||||
User: "Do 85%. There is no limit to your time or cost. Just fucking get it all done. I don't care about backwards compatibility. Just do it."
|
||||
|
||||
### Background
|
||||
After two prior epics (`thressgame-coverage` + `thressgame-templates`) shipped 23 recipes and 50 primitives covering 14/65 ThressGame rules, the user authorized full-coverage push despite the oracle's recommendation to stop after Wave 1. The system limits identified by the oracle are not blockers; they are paradigm extensions. User accepted paradigm break.
|
||||
|
||||
### Oracle Pre-Read Summary
|
||||
The oracle consultation (`session ses_2313f30cbffezgUIabEnco2AoO`) flagged:
|
||||
- Wave 4 (topology) invalidates all move-gen determinism tests
|
||||
- Wave 5 (economy) violates "engine has no global mutable state" invariant
|
||||
- 6 rules are empty `{}` stubs in `ruleHooks.js` (undefined behavior)
|
||||
- 8 rules are preset-shaped (`getLegalMoveModifiers` / `getRoyalPieces` shape, not modifier shape)
|
||||
- Honest denominator = 51 (not 65)
|
||||
|
||||
**User accepted all costs.** The plan proceeds with full scope.
|
||||
|
||||
### Locked Scope (no exceptions per user directive)
|
||||
|
||||
**Waves to execute**: 5 implementation + 1 cross-reference + final verification.
|
||||
|
||||
**Rules to ship as modifier recipes** (51 total):
|
||||
- 14 already shipped (no change)
|
||||
- 13 from Wave 1 (resolver expressiveness — Layer 1)
|
||||
- ~10 from Wave 2 (multi-turn state)
|
||||
- ~6 from Wave 3 (player choice patterns)
|
||||
- ~5 from Wave 4 (topology + pairing)
|
||||
- ~3 from Wave 5 (economy)
|
||||
|
||||
**Rules routed to RULES.md preset variants** (8):
|
||||
- `dual_king`, `coregal`, `god_kings` (king variants — preset hook `getRoyalPieces`)
|
||||
- `early_promotion` (preset row config + `override-promotion`)
|
||||
- `proletariat`, `short_stop`, `trains_rights` (preset hook `getLegalMoveModifiers`)
|
||||
- `pacman_style` (already preset `wrap-board`)
|
||||
- `ice_physics` (already shipped as recipe AND mirrored in preset for parity)
|
||||
|
||||
**Rules WONT_FIX** (6):
|
||||
- `pawns_with_viagra` (line 1626 of ruleHooks.js: `: {}`)
|
||||
- `estrogen` (line 1638: `: {}`)
|
||||
- `knee_surgery` (line 1698: `: {}`)
|
||||
- `pawns_learned_strength` (line 1699: `: {}`)
|
||||
- `parry` (line 1599: handled in moveHandler.js per upstream comment; we already have `parry` recipe via parity fixture, this just notes the upstream code-location)
|
||||
- `pacman_style` (line 1670: `: {}` body; topology in `getWrapMoves`. Already shipped as `wrap-board` preset — gets a cross-reference recipe but no new modifier work)
|
||||
|
||||
### Metis Pre-Read
|
||||
|
||||
**Hidden intentions decoded from the user's message**:
|
||||
1. **"No limit to time or cost"** — execution can take as long as needed; reviewer cycles can iterate.
|
||||
2. **"I don't care about backwards compatibility"** — schema breaking changes (V3 union order, new required fields, new trigger stages) are FINE. Existing tests that fail because the schema changed should be updated, not preserved.
|
||||
3. **"Just fucking get it all done"** — frustration with the prior wave's "53% ceiling" framing. User wants definitive completion, not partial credit.
|
||||
4. **"Do 85%"** — accepts that 100% raw coverage is impossible (6 stubs); 85% raw = 55/65 = the realistic ceiling. Plan delivers 51/65 = 78% raw + 8 preset cross-refs = 90% surfaceable.
|
||||
|
||||
**Failure modes to design against**:
|
||||
- "Fake completion" — claim coverage by shipping recipes that validate but don't actually run. Mitigation: every Wave's parity fixtures REQUIRE a `-real.test.ts` proving end-to-end behavior, plus an e2e spec.
|
||||
- Determinism drift — new primitives that break replay. Mitigation: every new primitive lands with a determinism test in the wave it ships.
|
||||
- Wave 4 topology cascade failure — wraparound invalidates move-gen caches across the entire test suite. Mitigation: Wave 4 ships behind a per-game `BoardTopology` attr flag (default = standard); only games opting in get wraparound, so existing tests stay green.
|
||||
- Wave 5 paradigm break — global score state corrupts replays. Mitigation: scores live on `GAME_ENTITY` (already a singleton with mutable attrs like `RngStream` — same pattern, not a new pattern). Replay determinism is verified by hash comparison.
|
||||
|
||||
---
|
||||
|
||||
## Locked Architectural Decisions (signed off pre-execution)
|
||||
|
||||
These are irrevocable across all 6 waves. Changing one requires a plan amendment.
|
||||
|
||||
### A. Arithmetic resolver shapes (Wave 1)
|
||||
- Add `add`, `sub`, `mul`, `mod` shapes. Arity = 2. Operands = `(resolver | integer)`. Range-check at `apply()`-time only (validator just checks shape).
|
||||
- NO comparison ops, NO boolean logic, NO conditionals inside resolver shapes. `conditional` primitive remains the only branching mechanism.
|
||||
- Union order in V3 schema: `[literal, $var, ctx-attr, ctx-build, ctx-self-id, ctx-self-marker-id, add, sub, mul, mod]` — literal first (hot path); single-key shapes by recognizability.
|
||||
|
||||
### B. Self-targeting (Wave 1)
|
||||
- New shape `{ "ctx-self-id": null }` returns `ctx.pieceId` directly. Same shape but with `null` payload for `ctx-self-marker-id` returns `ctx.markerId` (only valid inside `on-piece-entered-marker` and `on-marker-expire` triggers).
|
||||
- NOT adding `"self"` literal branch to numeric schemas (would make union irregular).
|
||||
|
||||
### C. `add-to-attribute` gains `target` field (Wave 1)
|
||||
- Optional. When omitted, defaults to `ctx.pieceId` (current behavior — backward-compatible default).
|
||||
- When present, accepts `numberOrResolver()` (V3 union). Closes adjacent-splash gap.
|
||||
- Semantically equivalent to `set-piece-attr({target: ..., attr: ..., value: { add: [{ ctx-attr: { entity: ..., attr: ... } }, N] }})` but more readable.
|
||||
|
||||
### D. Multi-turn countdowns (Wave 2)
|
||||
- Generalize `MarkerLifetime` semantics to per-piece attrs. `set-piece-attr.lifetime: { kind: "turns", count: N }` already accepted; Wave 2 wires the decrementer + expiration trigger.
|
||||
- New trigger: `on-attr-expire(target, attr)` fires when an attr with a `turns` lifetime hits zero.
|
||||
- New primitive: `decrement-attr-each-turn(target, attr)` for explicit countdown control (used by `time_bomb`).
|
||||
- Implementation: new dispatcher stage 13 (after `fireOnTurnStartHooks`), batched per-turn-boundary to avoid the 100-countdowns-cascade-overflow scenario.
|
||||
|
||||
### E. Piece-pair lifecycle (Wave 4)
|
||||
- New attr family: `PieceLink` (per-piece, list of EntityIds) — mirror of `MarkerLinks`.
|
||||
- New trigger: `on-piece-pair-link-broken` fires when a linked partner is destroyed.
|
||||
- New primitives: `link-pieces(a, b)`, `unlink-pieces(a, b)`.
|
||||
- Used by: `down_with_the_ship` (captain dies → ship dies), `soul_link`.
|
||||
|
||||
### F. Resource accumulation / inventory (Wave 5)
|
||||
- New attrs on `GAME_ENTITY`: `WhiteScore: number`, `BlackScore: number`. Default 0.
|
||||
- New primitives: `add-resource(player, amount)`, `spend-resource(player, amount, then, else)` (conditional spend with branches).
|
||||
- New trigger: `on-resource-changed(player, threshold)` fires when resource crosses threshold.
|
||||
- Replay determinism: scores persist in fact log alongside RngStream; hash includes them.
|
||||
|
||||
### G. Board topology (Wave 4)
|
||||
- New attr on `GAME_ENTITY`: `BoardTopology: "standard" | "wrap-files" | "wrap-all"`. Default `"standard"`.
|
||||
- Move-gen consumers check this attr; standard topology unchanged → no regression in existing tests.
|
||||
- New primitive: `set-board-topology(value)` (imperative; can fire on `on-rule-activated`).
|
||||
- Wraparound only affects move generation when modifier sets `BoardTopology`. Standard games skip the check entirely.
|
||||
|
||||
### H. Validator V3
|
||||
- All `numberOrResolver` / `enumOrResolverFor` callsites widen to V3 union (mechanical 9-file edit).
|
||||
- New `paramsSchema` for Wave 1 helpers reuses V3 from start.
|
||||
- Pre-V3 fixtures auto-validate clean (V3 is a superset of V2).
|
||||
|
||||
### I. Test gates per wave
|
||||
- `bun run check` exit 0 before wave can be declared complete
|
||||
- Each new recipe ships with a `*-real.test.ts` proving end-to-end runtime behavior
|
||||
- Each wave ships ≥1 new e2e Playwright spec covering its UX surface
|
||||
- Determinism: every new primitive has a property test (N=100 iterations, byte-identical state hash)
|
||||
|
||||
---
|
||||
|
||||
## Wave 1 — Resolver Expressiveness
|
||||
|
||||
**Goal**: Close Layer 1 (~13 rules). Add arithmetic shapes, self-targeting, `add-to-attribute.target`. Ship 13 recipes.
|
||||
|
||||
**Effort**: ~1.5 dev weeks → ~12 atomic tasks.
|
||||
|
||||
### Tasks
|
||||
|
||||
- [ ] **W1.0**: Initialize `.sisyphus/notepads/thressgame-100/` with decisions/learnings/issues/problems files. Lock decisions A, B, C, H from above. — 30min
|
||||
- [ ] **W1.1**: Add `ctx-self-id` resolver shape to `param-resolver.ts:139-216`. Returns `ctx.pieceId`. Add unit tests covering: bare shape, inside iteration arm, inside `add` (forward-compat). — 45min
|
||||
- [ ] **W1.2**: Add `ctx-self-marker-id` resolver shape mirroring W1.1. Throws if not inside marker-event trigger. Unit tests. — 30min
|
||||
- [ ] **W1.3**: Add `add` arithmetic resolver shape: `{ "add": [<resolver>, <integer>] }`. Resolves left operand recursively, right is integer literal, returns sum. Range-check is caller's responsibility (no implicit clamp). Unit tests. — 60min
|
||||
- [ ] **W1.4**: Add `sub`, `mul`, `mod` arithmetic shapes mirroring W1.3 pattern. — 30min
|
||||
- [ ] **W1.5**: Update `param-resolver-schema.ts` to V3 — extend the union in `numberOrResolver()` / `enumOrResolverFor()` / `stringOrResolver()` to include the 6 new shapes. Document V3 union order (locked decision A). — 45min
|
||||
- [ ] **W1.6**: `add-to-attribute.ts` schema gains optional `target: numberOrResolver()` field. `apply()` defaults to `ctx.pieceId` when absent. Update co-located test with positive cases (with/without target) AND negative case (target on capture-event when ctx.pieceId is undefined). — 60min
|
||||
- [ ] **W1.7**: Determinism hardening: 6 new tests in `param-resolver.test.ts` covering arithmetic-overflow (add(MAX_SAFE_INTEGER, 1) must throw, not silent wrap), nested arithmetic (add(add($var, 1), 8)), mixed shapes (add(ctx-attr, 1)), and replay determinism (state hash byte-identical across 100 iterations). — 60min
|
||||
- [ ] **W1.8**: Recipes batch A — self-targeting destroys (4 recipes, parallel-friendly delegation):
|
||||
- `tpl-minefield-consumer` — `on-piece-entered-marker(mine) → destroy-piece({target: {ctx-self-id: null}}) → destroy-marker({target: {ctx-self-marker-id: null}})`
|
||||
- `tpl-kamikaze-self-destruct` — `on-captured → destroy-piece({target: {ctx-self-id: null}})` (variant: kamikaze that ALSO kills self)
|
||||
- `tpl-living-bomb` — `on-captured → for-each-adjacent → destroy-piece({target: {$var: "adj"}})` then self-destruct
|
||||
- `tpl-suicidal-knight` — knight that destroys itself after 3 moves (uses Wave 2 mechanism — defer to W2 if needed; Wave 1 ships only the self-targeting half)
|
||||
|
||||
Each recipe ships with parity fixture + `-real.test.ts` + entry in `recipes.ts`. — 90min total
|
||||
- [ ] **W1.9**: Recipes batch B — position arithmetic / mass mover (6 recipes, parallel):
|
||||
- `tpl-march-of-the-pawnguins` — for-each-piece(white pawns) → move-piece({to: ctx-build with row+1})
|
||||
- `tpl-the-rumbling` — same but pawns capture diagonally as they advance (combined move + capture-cascade)
|
||||
- `tpl-back-that-shit-up` — every pawn moves backward 1
|
||||
- `tpl-chaaaarge` — chooser's pieces all step forward 1
|
||||
- `tpl-the-enemy-is-routed` — opponent's pieces all step backward 1
|
||||
- `tpl-going-woke` — shift right half of board left
|
||||
|
||||
Each recipe + parity + `-real.test.ts` + recipes.ts entry. — 120min total
|
||||
- [ ] **W1.10**: Recipes batch C — adjacent splash + mitosis (3 recipes, parallel):
|
||||
- `tpl-adjacent-splash` — REAL deal-1-HP-to-adjacent (uses W1.6's `add-to-attribute.target`)
|
||||
- `tpl-mitosis` — duplicate every piece into adjacent empty square
|
||||
- `tpl-they-deserved-it` — destroy 1 random non-king (uses `with-probability` + `random-pick` + `destroy-piece(ctx-self-id)`)
|
||||
|
||||
Each + parity + e2e. — 90min total
|
||||
- [ ] **W1.11**: e2e spec `packages/chess/e2e/wave1-recipes.spec.ts` — load all 13 new recipes via Templates modal, smoke-test 4 of them with runtime behavior assertions. — 60min
|
||||
- [ ] **W1.12**: Evidence file `.sisyphus/evidence/thressgame-100-wave1.txt` — final task list, recipe IDs, determinism test results, paper-exercise for `the_rumbling` (the hardest Layer-1 rule). — 45min
|
||||
|
||||
**Wave 1 acceptance gate**:
|
||||
- 13 new recipes pass validateCustomDescriptor
|
||||
- 13 new parity `-real.test.ts` files green
|
||||
- e2e wave1 spec green via `.sisyphus/scripts/run-pw.sh`
|
||||
- `bun run check` exits 0
|
||||
- `decisions.md` for thressgame-100 lists W1 final state
|
||||
- Coverage check: 14 + 13 = 27 modifier recipes; 27/51 = 53%
|
||||
|
||||
---
|
||||
|
||||
## Wave 2 — Multi-Turn State
|
||||
|
||||
**Goal**: Close countdown / cooldown rule family (~10 rules). Ship `on-attr-expire` trigger, `decrement-attr-each-turn` primitive, and recipes for time bombs, restrictions with duration, and second-chance mechanics.
|
||||
|
||||
**Effort**: ~2 dev weeks → ~14 atomic tasks.
|
||||
|
||||
### Tasks
|
||||
|
||||
- [ ] **W2.0**: Lock decisions D (multi-turn countdowns) in notepad. Reaffirm: implementation = generalize MarkerLifetime to per-piece attrs; new `on-attr-expire` trigger fires on countdown-zero; batched dispatch per turn boundary. — 30min
|
||||
- [ ] **W2.1**: Schema audit — every callsite of `LifetimeRegistry` (search engine.ts, modifiers/apply.ts, set-piece-attr.ts). Document the per-turn decrement loop's current shape. — 60min
|
||||
- [ ] **W2.2**: Add new dispatcher stage 13 — `fireAttrExpireHooks`. Runs at turn-end-boundary AFTER stage 12 (`fireOnTurnStartHooks`). Batches per-turn-boundary: collects all attrs whose lifetime hits zero this turn, fires `on-attr-expire` once per (target, attr) pair. — 120min
|
||||
- [ ] **W2.3**: New trigger primitive `on-attr-expire`. Schema: `{target: numberOrResolver, attr: stringOrResolver, primitives: [...]}`. Validator: imperative-in-passive check applies to inner primitives; binding-introducer (binds `expiringValue` to the attr's last value before zero). — 90min
|
||||
- [ ] **W2.4**: New primitive `decrement-attr-each-turn(target, attr)` — imperative. Sets the attr to `current - 1` at every turn-end. When it hits zero, normal `on-attr-expire` fires. — 60min
|
||||
- [ ] **W2.5**: Tests for W2.2-W2.4: 8 unit tests covering per-turn batching, simultaneous expiry of multiple attrs, expiry inside iteration arms, replay determinism with 50 in-flight countdowns. — 120min
|
||||
- [ ] **W2.6**: Performance test — 100 in-flight countdowns + per-turn dispatch must stay under 150ms p99 (matches existing markers-perf budget). Adds to `__fixtures__/perf/`. — 60min
|
||||
- [ ] **W2.7**: Recipes batch D — countdowns (5 recipes):
|
||||
- `tpl-time-bomb` — set HP-bomb attr with countdown 5; on-attr-expire detonates (destroy-piece + adjacent splash)
|
||||
- `tpl-nuclear-fallout` — countdown variant of minefield (random blocked squares for N turns)
|
||||
- `tpl-christmas-truce` — for 3 turns, no captures allowed (set CannotCapture flag with lifetime turns:3)
|
||||
- `tpl-second-chance` — when captured, remember piece; next turn it returns (uses `cancel-capture` + restore primitive)
|
||||
- `tpl-invulnerability-potion` — temp invulnerability marker (lifetime turns:N)
|
||||
— 150min
|
||||
- [ ] **W2.8**: Recipes batch E — restrictions with duration (3 recipes):
|
||||
- `tpl-anti-camping` — pieces that don't move for 3 turns get destroyed
|
||||
- `tpl-ice-age` — files a/h frozen for 5 turns
|
||||
- `tpl-no-cowards` — must move forward (lifetime turns:1, refreshes per turn)
|
||||
— 90min
|
||||
- [ ] **W2.9**: Recipes batch F — drafted_for_battle / sophies_choice prep (2 recipes):
|
||||
- `tpl-drafted-for-battle` — chooser picks bishop/knight, swaps with king (uses `request-choice` + `swap-pieces`); already buildable post-V2 — verify and ship
|
||||
- `tpl-corporate-ladder` — chooser picks 2 squares, swap pieces on them
|
||||
— 60min
|
||||
- [ ] **W2.10**: e2e spec `packages/chess/e2e/wave2-countdowns.spec.ts` covering time-bomb detonation, christmas-truce duration, second-chance restoration. Uses test-only `__test__.advance-turn` WS frame (add if missing). — 90min
|
||||
- [ ] **W2.11**: Verify all 13 Wave 1 recipes still pass `recipes.test.ts` and `wave1-recipes.spec.ts` (regression check). — 15min
|
||||
- [ ] **W2.12**: Update `param-resolver.test.ts` if `add` shape now needs to interact with countdown decrements (likely not, since decrement is its own primitive, but verify). — 30min
|
||||
- [ ] **W2.13**: Determinism property test — N=100 games with random countdown attachments, assert byte-identical state hash. — 60min
|
||||
- [ ] **W2.14**: Evidence file `.sisyphus/evidence/thressgame-100-wave2.txt`. — 45min
|
||||
|
||||
**Wave 2 acceptance gate**:
|
||||
- 10 new recipes (5 countdown + 3 restriction + 2 verified)
|
||||
- `on-attr-expire` trigger fires deterministically
|
||||
- 100-countdown perf test green
|
||||
- All 27 prior recipes still green
|
||||
- Coverage: 27 + 10 = 37 / 51 = 72%
|
||||
|
||||
---
|
||||
|
||||
## Wave 3 — Player Choice Patterns
|
||||
|
||||
**Goal**: Close request-choice driven recipes (~6 rules). Most patterns already supported by primitives; this wave is heavy on RECIPE AUTHORING, light on engine work.
|
||||
|
||||
**Effort**: ~1 dev week → ~9 atomic tasks.
|
||||
|
||||
### Tasks
|
||||
|
||||
- [ ] **W3.0**: Audit existing `request-choice` capabilities. Confirm `kind: "square"`, `kind: "piece"`, `kind: "column"`, `kind: "row"`, `kind: "rps"`, `kind: "coin-flip"`, `kind: "yes-no"`, `kind: "number"` all work. Document any gaps. — 60min
|
||||
- [ ] **W3.1**: If any choice kind is missing or buggy, fix it. Most likely target: `kind: "square"` since several Wave 3 recipes use it. — 90min
|
||||
- [ ] **W3.2**: Recipes batch G — choice-driven spawn (3 recipes):
|
||||
- `tpl-bottomless-pit` — chooser picks square, plant pit there
|
||||
- `tpl-call-down-lightning` — chooser picks square, destroy whatever's there + spawn marker
|
||||
- `tpl-portal-storm` — chooser picks 2 squares, spawn portal pair
|
||||
— 90min
|
||||
- [ ] **W3.3**: Recipes batch H — choice-driven swap/move (3 recipes):
|
||||
- `tpl-anti-camping-choice` — chooser picks an opponent piece, swap with random own piece
|
||||
- `tpl-corporate-ladder-full` (already shipped in W2.9, this is the full multi-step variant)
|
||||
- `tpl-two-kids-trenchcoat` — sacrifice 2 pawns to spawn bishop (uses request-choice for both pawns + place-piece)
|
||||
— 90min
|
||||
- [ ] **W3.4**: Recipes batch I — choice-driven self-modification (2 recipes):
|
||||
- `tpl-blood-sacrifice` — chooser picks own piece to destroy in exchange for buff to another own piece
|
||||
- `tpl-summoning-ritual-light` — sacrifice piece + roll RNG, spawn random piece (light variant — no resource cost yet, that's W5)
|
||||
— 60min
|
||||
- [ ] **W3.5**: Recipes batch J — sophies_choice variants (2 recipes):
|
||||
- `tpl-sophies-choice` — both players pick one of their own pieces to kill
|
||||
- `tpl-mind-control-full` — chooser picks enemy non-king to convert (already shipped! verify and skip if so)
|
||||
— 60min
|
||||
- [ ] **W3.6**: e2e spec `packages/chess/e2e/wave3-choices.spec.ts` covering all 8 new request-choice flows end-to-end. — 90min
|
||||
- [ ] **W3.7**: Verify Wave 1+2 regression. — 15min
|
||||
- [ ] **W3.8**: Evidence file `.sisyphus/evidence/thressgame-100-wave3.txt`. — 30min
|
||||
|
||||
**Wave 3 acceptance gate**:
|
||||
- 8 new recipes (overlap-deduped)
|
||||
- All choice kinds verified working
|
||||
- Coverage: 37 + 8 = 45 / 51 = 88% (above the 85% target — Wave 4-5 are bonus)
|
||||
|
||||
**🎯 85% TARGET HIT AT END OF WAVE 3 — Waves 4-5 are bonus rounds for completeness.**
|
||||
|
||||
---
|
||||
|
||||
## Wave 4 — Topology + Pairing
|
||||
|
||||
**Goal**: Wraparound topology + piece-pair lifecycle. ~5 rules. **High regression risk** — gated behind opt-in attr.
|
||||
|
||||
**Effort**: ~2 dev weeks → ~12 atomic tasks.
|
||||
|
||||
### Tasks
|
||||
|
||||
- [ ] **W4.0**: Lock decisions E (piece-pair) and G (board-topology). — 30min
|
||||
- [ ] **W4.1**: Add `BoardTopology` attr to `ChessAttrMap`. Default `"standard"`. Move-gen reads this; standard skips wraparound check. — 60min
|
||||
- [ ] **W4.2**: Implement wraparound move generation. Modify `engine.ts` move-gen to consult `BoardTopology` and apply wrap when set. Add `set-board-topology` primitive. — 180min
|
||||
- [ ] **W4.3**: Tests for W4.1-W4.2: 25+ unit tests covering wraparound for each piece type, edge cases (rook on a-file with wrap-files), interaction with markers (frozen squares with wraparound), determinism. — 240min
|
||||
- [ ] **W4.4**: Add `PieceLink` attr family. Per-piece, list of EntityIds. Validator + Zod schema. — 60min
|
||||
- [ ] **W4.5**: Add `link-pieces(a, b)` and `unlink-pieces(a, b)` primitives + tests. — 90min
|
||||
- [ ] **W4.6**: Add `on-piece-pair-link-broken` trigger. Fires when a linked partner is destroyed (hooks into `on-captured` cascade). — 90min
|
||||
- [ ] **W4.7**: Tests for W4.4-W4.6: 15+ unit tests. — 120min
|
||||
- [ ] **W4.8**: Recipes batch K — topology (2 recipes):
|
||||
- `tpl-pacman-style-cross-ref` — comment-only recipe pointing to `wrap-board` preset; explains why this isn't a modifier (educational)
|
||||
- `tpl-bouncing-ricochet` — modifier that locally enables wrap-files for THIS rule's lifetime (uses `set-board-topology` with lifetime turns:1)
|
||||
— 60min
|
||||
- [ ] **W4.9**: Recipes batch L — pairing (3 recipes):
|
||||
- `tpl-down-with-the-ship` — captain (king) dies → all linked pieces (rooks) die
|
||||
- `tpl-soul-link` — link 2 pieces; either dies → both die
|
||||
- `tpl-hot-drop` — spawn 2 random queens, linked (one dies → both die)
|
||||
— 90min
|
||||
- [ ] **W4.10**: e2e spec `packages/chess/e2e/wave4-topology-pairing.spec.ts`. — 90min
|
||||
- [ ] **W4.11**: Regression: verify ALL prior 45 recipes + their parity tests + every existing chess test still passes. Wave 4 highest regression risk. — 60min
|
||||
- [ ] **W4.12**: Evidence file. — 30min
|
||||
|
||||
**Wave 4 acceptance gate**:
|
||||
- Wraparound move-gen passes all 25+ unit tests
|
||||
- Piece-pair lifecycle works
|
||||
- 5 new recipes
|
||||
- ZERO regression in 45 prior recipes
|
||||
- Coverage: 45 + 5 = 50 / 51 = 98%
|
||||
|
||||
---
|
||||
|
||||
## Wave 5 — Economy + Inventory
|
||||
|
||||
**Goal**: Score/resource accumulation. ~3 rules. **Paradigm break — game-level mutable state.**
|
||||
|
||||
**Effort**: ~2 dev weeks → ~10 atomic tasks.
|
||||
|
||||
### Tasks
|
||||
|
||||
- [ ] **W5.0**: Lock decision F (resource accumulation on `GAME_ENTITY`). Document the paradigm-break rationale: scores live alongside RngStream on GAME_ENTITY (same pattern, not a new pattern). — 30min
|
||||
- [ ] **W5.1**: Add `WhiteScore` and `BlackScore` attrs to `ChessAttrMap`. Both `number`, default 0. Initialized at game-start in integration preset. — 45min
|
||||
- [ ] **W5.2**: Add primitives `add-resource(player, amount)` and `spend-resource(player, amount, then, else)`. The spend variant is conditional — branches on whether the player has enough. — 120min
|
||||
- [ ] **W5.3**: Add trigger `on-resource-changed(player, threshold, direction)` — fires when score crosses threshold up or down. — 90min
|
||||
- [ ] **W5.4**: Tests for W5.1-W5.3: 20+ unit tests covering accumulation, spending, branching, threshold crossing, determinism. — 180min
|
||||
- [ ] **W5.5**: WS protocol — score updates broadcast to clients alongside game state. Schema bump or piggyback on existing `game.delta` frames. — 90min
|
||||
- [ ] **W5.6**: UI — score display in game header (small additions to `GameView.tsx`; minimal styling). — 90min
|
||||
- [ ] **W5.7**: Recipes batch M — economy (3 recipes):
|
||||
- `tpl-treasure-chest` — capture spawns treasure markers; landing on one adds to score
|
||||
- `tpl-cash-grab` — every turn-end, randomly pick squares; landing on them awards score
|
||||
- `tpl-summoning-ritual` — spend 5 score to summon a knight
|
||||
— 90min
|
||||
- [ ] **W5.8**: e2e spec `packages/chess/e2e/wave5-economy.spec.ts` — score accumulation, threshold crossing, spend-success/spend-fail flows. — 90min
|
||||
- [ ] **W5.9**: Determinism property test — economy state reproduces across replays. — 60min
|
||||
- [ ] **W5.10**: Evidence file. — 30min
|
||||
|
||||
**Wave 5 acceptance gate**:
|
||||
- 3 new recipes
|
||||
- Scores deterministic across replays
|
||||
- All 50 prior recipes still green
|
||||
- Coverage: 50 + 3 = 53 / 51 = above target (53 > 51 because some Wave 5 recipes overlap categories with prior waves — net unique = 51/51 = 100% of denominator)
|
||||
|
||||
**🏁 100% OF DENOMINATOR HIT (51/51) AT END OF WAVE 5.**
|
||||
|
||||
---
|
||||
|
||||
## Wave 6 — Preset Cross-References + Final Verification
|
||||
|
||||
**Goal**: 8 preset-shaped rules get cross-referenced from Templates modal so users can FIND them, even though they're preset variants not modifier recipes. Then final 4-reviewer wave.
|
||||
|
||||
**Effort**: ~3 dev days → ~6 tasks.
|
||||
|
||||
### Tasks
|
||||
|
||||
- [ ] **W6.0**: For each of 8 preset-shaped rules, add a "stub recipe" entry in `recipes.ts` that:
|
||||
- Has a recognizable `id` (e.g. `tpl-preset-dual-king`, `tpl-preset-coregal`, etc.)
|
||||
- Has a `summary` explaining "This rule is a preset rule variant, not a modifier. Click here to learn more."
|
||||
- Has an EMPTY `primitives: []` array (validator-clean — passive descriptor)
|
||||
- Has a `descriptor.description` pointing at `RULES.md#<preset-id>`
|
||||
|
||||
These stubs ship in the Templates modal but loading them just shows a docs panel pointing at the preset. — 90min
|
||||
- [ ] **W6.1**: Update `RULES.md` v3 section adding cross-reference table mapping ThressGame rule names to chess preset IDs. — 60min
|
||||
- [ ] **W6.2**: Update Templates modal UI to render the "preset stub" recipes with a distinguishing visual marker (e.g. a "preset →" badge instead of a "Load" button). — 90min
|
||||
- [ ] **W6.3**: e2e spec for preset cross-references. — 60min
|
||||
- [ ] **W6.4**: WONT_FIX manifest at `packages/chess/docs/THRESSGAME_WONT_FIX.md` — 1 entry per of the 6 stub rules with reason and source-line citation. — 45min
|
||||
- [ ] **W6.5**: Final verification wave (F1-F4 in parallel) — see "Final Verification Wave" section below.
|
||||
|
||||
---
|
||||
|
||||
## Final Verification Wave (F1-F4)
|
||||
|
||||
Run after Wave 6 lands. All 4 reviewers must APPROVE.
|
||||
|
||||
- [ ] **F1 (oracle review)**: All 5 architectural decisions land cleanly; no contradictions across waves. Validator V3 union order is consistent. Determinism holds across all 5 waves. Engine paradigm break (Wave 5) is contained to GAME_ENTITY attrs as designed.
|
||||
- [ ] **F2 (manual QA)**: Run all 5 e2e specs (wave1-recipes, wave2-countdowns, wave3-choices, wave4-topology-pairing, wave5-economy). Every recipe loads and runs end-to-end against docker compose dev stack.
|
||||
- [ ] **F3 (test-suite quality)**: `bun run check` exit 0 with full test count. ZERO new fixmes/skips. Coverage delta is measurable. No flake observed.
|
||||
- [ ] **F4 (scope fidelity)**: 51 modifier recipes + 8 preset cross-refs + 6 WONT_FIX = 65 raw rules accounted for. Coverage 51/51 = 100% of effective denominator.
|
||||
|
||||
---
|
||||
|
||||
## Coverage Report Card (final state)
|
||||
|
||||
| Bucket | Count | Status |
|
||||
|---|---|---|
|
||||
| Modifier recipes shipped | 51 | 100% of effective denominator |
|
||||
| Preset cross-references | 8 | Cross-linked from Templates modal |
|
||||
| WONT_FIX (empty stubs) | 6 | Documented with source-line citations |
|
||||
| **Raw ThressGame rules accounted for** | **65/65** | **100%** |
|
||||
| **User-facing surfaceable** | **59/65** | **91%** |
|
||||
| **As true modifier recipes** | **51/65** | **78%** |
|
||||
|
||||
**Bottom line**: Every rule from `ruleHooks.js` is either shipped, cross-referenced, or documented as undefined behavior. No silent gaps.
|
||||
|
||||
---
|
||||
|
||||
## Locked Recipe IDs (final 51 modifier recipes)
|
||||
|
||||
**Pre-epic existing (14)**:
|
||||
1. recipe-boosted-pawn, recipe-three-charge-shield, recipe-aura-king, recipe-vampire, recipe-low-hp-fortress, recipe-kamikaze-knight, recipe-berserker-pawn, recipe-promotion-feast (Wave 1 originals)
|
||||
2. tpl-simple-mine, tpl-vampire-on-capture, tpl-frozen-column, tpl-coin-flip-restriction, tpl-religious-bishop, tpl-no-mans-land (T67)
|
||||
|
||||
**Prior wave thressgame-templates (9)**:
|
||||
3. tpl-religious-conversion, tpl-mr-freeze, tpl-mind-control, tpl-kamikaze, tpl-ice-physics, tpl-minefield-full, tpl-mass-destroyer-they-deserved-it, tpl-lifetime-restriction, tpl-adjacent-debuff
|
||||
|
||||
**Wave 1 (13)**:
|
||||
4. tpl-minefield-consumer, tpl-kamikaze-self-destruct, tpl-living-bomb, tpl-march-of-the-pawnguins, tpl-the-rumbling, tpl-back-that-shit-up, tpl-chaaaarge, tpl-the-enemy-is-routed, tpl-going-woke, tpl-adjacent-splash, tpl-mitosis, tpl-they-deserved-it, tpl-suicidal-knight (deferred to W2 if Wave 1 mechanism insufficient)
|
||||
|
||||
**Wave 2 (10)**:
|
||||
5. tpl-time-bomb, tpl-nuclear-fallout, tpl-christmas-truce, tpl-second-chance, tpl-invulnerability-potion, tpl-anti-camping, tpl-ice-age, tpl-no-cowards, tpl-drafted-for-battle, tpl-corporate-ladder
|
||||
|
||||
**Wave 3 (8)**:
|
||||
6. tpl-bottomless-pit, tpl-call-down-lightning, tpl-portal-storm, tpl-anti-camping-choice, tpl-two-kids-trenchcoat, tpl-blood-sacrifice, tpl-summoning-ritual-light, tpl-sophies-choice
|
||||
|
||||
**Wave 4 (5)**:
|
||||
7. tpl-pacman-style-cross-ref (preset-stub), tpl-bouncing-ricochet, tpl-down-with-the-ship, tpl-soul-link, tpl-hot-drop
|
||||
|
||||
**Wave 5 (3)**:
|
||||
8. tpl-treasure-chest, tpl-cash-grab, tpl-summoning-ritual
|
||||
|
||||
**Wave 6 preset stubs (8 — cross-references, not real recipes)**:
|
||||
9. tpl-preset-dual-king, tpl-preset-coregal, tpl-preset-god-kings, tpl-preset-early-promotion, tpl-preset-proletariat, tpl-preset-short-stop, tpl-preset-trains-rights, tpl-preset-pacman
|
||||
|
||||
**Total**: 14 + 9 + 13 + 10 + 8 + 5 + 3 = 62 modifier-shaped + 8 preset stubs = **70 entries in CUSTOM_MODIFIER_RECIPES** (was 23, +47).
|
||||
|
||||
Adjustment: dedup overlap (kamikaze full vs simplified, mind-control vs full, corporate-ladder vs full). Final unique modifier-content recipes = 51.
|
||||
|
||||
---
|
||||
|
||||
## WONT_FIX Manifest (6 rules)
|
||||
|
||||
To be documented in `packages/chess/docs/THRESSGAME_WONT_FIX.md` at end of Wave 6:
|
||||
|
||||
| Rule | Source line | Reason |
|
||||
|---|---|---|
|
||||
| `pawns_with_viagra` | `/tmp/ruleHooks.js:1626` | Empty `{}` stub in upstream ruleHooks.js; behavior undefined |
|
||||
| `estrogen` | `/tmp/ruleHooks.js:1638` | Empty `{}` stub |
|
||||
| `knee_surgery` | `/tmp/ruleHooks.js:1698` | Empty `{}` stub |
|
||||
| `pawns_learned_strength` | `/tmp/ruleHooks.js:1699` | Empty `{}` stub |
|
||||
| `parry` (RPS handler) | `/tmp/ruleHooks.js:1599-1601` | Comment: "RPS logic is handled in moveHandler.js and server.js" — already covered by parry parity recipe; this entry is a reference-only stub |
|
||||
| `pacman_style` (modifier form) | `/tmp/ruleHooks.js:1670` | Body is `{}`; topology lives in `getWrapMoves` outside hook system. Routed to chess preset `wrap-board` (already shipped). Recipe stub `tpl-preset-pacman` cross-references the preset. |
|
||||
|
||||
---
|
||||
|
||||
## Notes for Atlas (orchestrator)
|
||||
|
||||
- **Notepad layout**: `.sisyphus/notepads/thressgame-100/{decisions,learnings,issues,problems}.md`. Initialize at W1.0.
|
||||
- **Parallelism rules**: Within a wave, dispatch independent tasks in parallel (different files = parallel-safe). Across waves, strict serial.
|
||||
- **Determinism harness**: every new primitive ships a `*.test.ts` with at minimum a "replay deterministic" property test. Reuse existing harness from `packages/chess/src/__fixtures__/determinism/`.
|
||||
- **Playwright helper**: ALWAYS via `.sisyphus/scripts/run-pw.sh`. Never `CI=true`. Docker stack must be up.
|
||||
- **Backward-compat is REVOKED**: schemas can break. Tests asserting old behavior should be updated, not preserved. The user said "I don't care about backwards compatibility" — take the most direct path through any wall.
|
||||
- **Anti-fake-completion**: every recipe ships with a `*-real.test.ts` that runs the descriptor through the actual engine and asserts observable state change. Recipe validation alone is not proof of correctness.
|
||||
- **Wave-end snapshot**: at the end of each wave, append a brief progress-report to `.sisyphus/evidence/thressgame-100-progress.txt` (running tally across waves).
|
||||
- **No premature optimization**: ship the simplest correct implementation per task. Optimize only if a perf test fails.
|
||||
- **Failure handling**: when a delegated task fails, RESUME the SAME session with the actual error output (per the Atlas runbook). Maximum 3 retries per task. After 3, document in problems.md and continue with independent tasks.
|
||||
|
|
@ -38,6 +38,24 @@ describe("add-to-attribute primitive — registry", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("add-to-attribute primitive — schema", () => {
|
||||
it("schema accepts optional target field with literal", () => {
|
||||
expect(ADD_TO_ATTRIBUTE_PRIMITIVE.paramsSchema.safeParse({ attr: "Hp", delta: -1, target: 5 }).success).toBe(true);
|
||||
});
|
||||
|
||||
it("schema accepts target as $var binding", () => {
|
||||
expect(ADD_TO_ATTRIBUTE_PRIMITIVE.paramsSchema.safeParse({ attr: "Hp", delta: -1, target: { $var: "adj" } }).success).toBe(true);
|
||||
});
|
||||
|
||||
it("schema accepts target omitted (backward-compat)", () => {
|
||||
expect(ADD_TO_ATTRIBUTE_PRIMITIVE.paramsSchema.safeParse({ attr: "Hp", delta: -1 }).success).toBe(true);
|
||||
});
|
||||
|
||||
it("schema rejects non-numeric target", () => {
|
||||
expect(ADD_TO_ATTRIBUTE_PRIMITIVE.paramsSchema.safeParse({ attr: "Hp", delta: -1, target: "x" }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("add-to-attribute primitive — apply()", () => {
|
||||
it("adds delta to an existing number", () => {
|
||||
const { ctx, session } = makeContext();
|
||||
|
|
@ -64,4 +82,25 @@ describe("add-to-attribute primitive — apply()", () => {
|
|||
ADD_TO_ATTRIBUTE_PRIMITIVE.apply(ctx, { attr: "PieceType", delta: 1 });
|
||||
}).toThrow(/expected numeric value/);
|
||||
});
|
||||
|
||||
it("apply with target acts on the specified entity, not ctx.pieceId", () => {
|
||||
const { ctx, session } = makeContext();
|
||||
const targetId = session.nextId();
|
||||
session.insert(ctx.pieceId, "Hp", 10);
|
||||
session.insert(targetId, "Hp", 5);
|
||||
|
||||
ADD_TO_ATTRIBUTE_PRIMITIVE.apply(ctx, { attr: "Hp", delta: -1, target: targetId });
|
||||
|
||||
expect(session.get(ctx.pieceId, "Hp")).toBe(10);
|
||||
expect(session.get(targetId, "Hp")).toBe(4);
|
||||
});
|
||||
|
||||
it("apply without target falls back to ctx.pieceId", () => {
|
||||
const { ctx, session } = makeContext();
|
||||
session.insert(ctx.pieceId, "Hp", 5);
|
||||
|
||||
ADD_TO_ATTRIBUTE_PRIMITIVE.apply(ctx, { attr: "Hp", delta: 3 });
|
||||
|
||||
expect(session.get(ctx.pieceId, "Hp")).toBe(8);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
import { z } from "zod";
|
||||
import type { EntityId } from "@paratype/rete";
|
||||
import type { ChessAttrKey } from "../../schema.js";
|
||||
import { numberOrResolver } from "./param-resolver-schema.js";
|
||||
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||
import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js";
|
||||
|
||||
const schema = z.object({
|
||||
attr: z.string(),
|
||||
delta: z.number(),
|
||||
target: numberOrResolver({ min: 0 }).optional(),
|
||||
});
|
||||
type Params = z.infer<typeof schema>;
|
||||
|
||||
|
|
@ -14,18 +17,18 @@ const descriptor: EffectPrimitive<Params> = {
|
|||
label: "Add To Attribute",
|
||||
description: "Adds delta to the current attribute value, treating missing as 0.",
|
||||
longDescription:
|
||||
"Reads the current numeric value of attr (0 if unset) and writes existing + delta. Delta may be negative. Composes additively with other primitives and built-in modifiers — multiple add-to-attribute primitives for the same attr simply accumulate.",
|
||||
"Adds a number to a value the piece already has (treating a missing value as 0). The number can be negative to subtract. Multiple Add To Attribute steps on the same property simply pile on, so you can layer bonuses from different rules.",
|
||||
examples: [
|
||||
{
|
||||
title: "+2 HP bonus",
|
||||
params: { attr: "HpBonus", delta: 2 },
|
||||
effect: "Adds 2 to whatever HpBonus is already there.",
|
||||
effect: "Adds 2 on top of whatever HP bonus the piece already has.",
|
||||
},
|
||||
{
|
||||
title: "Heal 1/turn (inside on-turn-start)",
|
||||
params: { attr: "Hp", delta: 1 },
|
||||
effect:
|
||||
"Wrapped in on-turn-start, restores 1 HP to this piece at the start of its color's turn.",
|
||||
"Placed inside an on-turn-start trigger, this heals the piece for 1 HP at the start of its side's turn.",
|
||||
},
|
||||
],
|
||||
paramsSchema: schema,
|
||||
|
|
@ -34,14 +37,20 @@ const descriptor: EffectPrimitive<Params> = {
|
|||
return p?.attr !== undefined ? [p.attr as ChessAttrKey] : [];
|
||||
},
|
||||
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
||||
const existing = ctx.session.get(ctx.pieceId, params.attr);
|
||||
// params.target is pre-substituted by param-resolver; by apply-time it is a literal number or undefined
|
||||
const targetNum = ((params as unknown) as { target?: unknown }).target as number | undefined ?? ctx.pieceId;
|
||||
if (typeof targetNum !== "number") {
|
||||
throw new Error("add-to-attribute.apply: target must be substituted to a number before apply()");
|
||||
}
|
||||
const targetId = targetNum as EntityId;
|
||||
const existing = ctx.session.get(targetId, params.attr);
|
||||
const baseValue = existing === undefined ? 0 : existing;
|
||||
if (typeof baseValue !== "number" || Number.isNaN(baseValue)) {
|
||||
throw new Error(
|
||||
`add-to-attribute expected numeric value for attr "${params.attr}" but got ${typeof baseValue}`,
|
||||
);
|
||||
}
|
||||
ctx.session.insert(ctx.pieceId, params.attr, baseValue + params.delta);
|
||||
ctx.session.insert(targetId, params.attr, baseValue + params.delta);
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,324 @@
|
|||
/**
|
||||
* Validator widening helpers (V2) — unit tests.
|
||||
*
|
||||
* Pins the contract that T2-T8 (the 9 imperative-primitive schema
|
||||
* widenings) and T9 (ParamField UI widening) depend on:
|
||||
*
|
||||
* 1. Each helper accepts the literal scalar AND each of the 3
|
||||
* runtime-recognized resolver shapes from `param-resolver.ts`.
|
||||
* 2. Each helper rejects non-literal, non-resolver-shape inputs
|
||||
* (bare strings into a number-typed field, off-enum values,
|
||||
* out-of-range numbers).
|
||||
* 3. `enumOrResolverFor` exposes `__resolverEnumValues` as a
|
||||
* runtime-readable property — T9's ParamField widening consumes
|
||||
* this without traversing union internals.
|
||||
* 4. The structural type guards (`isResolverShape`,
|
||||
* `isLiteralNumber`) discriminate cleanly between the two cases.
|
||||
*
|
||||
* If any of these assertions break, the resolver-runtime ↔ static-
|
||||
* validator contract is desynchronized and authors will see false
|
||||
* rejections / acceptances.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
enumOrResolverFor,
|
||||
isLiteralNumber,
|
||||
isResolverShape,
|
||||
numberOrResolver,
|
||||
stringOrResolver,
|
||||
} from "./param-resolver-schema.js";
|
||||
|
||||
describe("numberOrResolver()", () => {
|
||||
it("parses a plain integer literal", () => {
|
||||
const schema = numberOrResolver();
|
||||
expect(schema.parse(42)).toBe(42);
|
||||
});
|
||||
|
||||
it("parses { $var: 'name' } binding refs", () => {
|
||||
const schema = numberOrResolver();
|
||||
expect(schema.parse({ $var: "p" })).toEqual({ $var: "p" });
|
||||
});
|
||||
|
||||
it("parses { 'ctx-attr': { entity, attr } } shape", () => {
|
||||
const schema = numberOrResolver();
|
||||
const input = {
|
||||
"ctx-attr": { entity: "self", attr: "Position" },
|
||||
};
|
||||
expect(schema.parse(input)).toEqual(input);
|
||||
});
|
||||
|
||||
it("parses { 'ctx-build': { col, row } } shape", () => {
|
||||
const schema = numberOrResolver();
|
||||
const input = { "ctx-build": { col: 4, row: 3 } };
|
||||
expect(schema.parse(input)).toEqual(input);
|
||||
});
|
||||
|
||||
it("parses ctx-build with $var col/row", () => {
|
||||
const schema = numberOrResolver();
|
||||
const input = { "ctx-build": { col: { $var: "c" }, row: 0 } };
|
||||
expect(schema.parse(input)).toEqual(input);
|
||||
});
|
||||
|
||||
it("rejects a bare string", () => {
|
||||
const schema = numberOrResolver();
|
||||
expect(schema.safeParse("nope").success).toBe(false);
|
||||
});
|
||||
|
||||
it("respects min/max on the literal branch", () => {
|
||||
const schema = numberOrResolver({ min: 0, max: 63 });
|
||||
expect(schema.safeParse(0).success).toBe(true);
|
||||
expect(schema.safeParse(63).success).toBe(true);
|
||||
expect(schema.safeParse(64).success).toBe(false);
|
||||
expect(schema.safeParse(-1).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a non-integer literal", () => {
|
||||
const schema = numberOrResolver();
|
||||
expect(schema.safeParse(3.14).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a $var with empty name", () => {
|
||||
const schema = numberOrResolver();
|
||||
expect(schema.safeParse({ $var: "" }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects ctx-attr with extra unknown keys (strict)", () => {
|
||||
const schema = numberOrResolver();
|
||||
expect(
|
||||
schema.safeParse({
|
||||
"ctx-attr": { entity: "self", attr: "HP", extra: 1 },
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects ctx-build with col out of [0..7]", () => {
|
||||
const schema = numberOrResolver();
|
||||
expect(
|
||||
schema.safeParse({ "ctx-build": { col: 8, row: 0 } }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("enumOrResolverFor()", () => {
|
||||
it("parses each enum literal AND each resolver shape", () => {
|
||||
const schema = enumOrResolverFor(["white", "black"] as const);
|
||||
expect(schema.parse("white")).toBe("white");
|
||||
expect(schema.parse("black")).toBe("black");
|
||||
expect(schema.parse({ $var: "color" })).toEqual({ $var: "color" });
|
||||
expect(
|
||||
schema.parse({
|
||||
"ctx-attr": { entity: "self", attr: "Color" },
|
||||
}),
|
||||
).toEqual({ "ctx-attr": { entity: "self", attr: "Color" } });
|
||||
});
|
||||
|
||||
it("rejects values outside the enum", () => {
|
||||
const schema = enumOrResolverFor(["white", "black"] as const);
|
||||
expect(schema.safeParse("green").success).toBe(false);
|
||||
});
|
||||
|
||||
it("exposes __resolverEnumValues for ParamField introspection", () => {
|
||||
const schema = enumOrResolverFor(["white", "black"] as const);
|
||||
expect(schema.__resolverEnumValues).toEqual(["white", "black"]);
|
||||
});
|
||||
|
||||
it("__resolverEnumValues survives a parse() call (no Zod mutation)", () => {
|
||||
const schema = enumOrResolverFor(["a", "b", "c"] as const);
|
||||
schema.parse("a");
|
||||
schema.parse({ $var: "x" });
|
||||
expect(schema.__resolverEnumValues).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stringOrResolver()", () => {
|
||||
it("parses bare strings", () => {
|
||||
const schema = stringOrResolver();
|
||||
expect(schema.parse("hello")).toBe("hello");
|
||||
expect(schema.parse("")).toBe("");
|
||||
});
|
||||
|
||||
it("parses each resolver shape", () => {
|
||||
const schema = stringOrResolver();
|
||||
expect(schema.parse({ $var: "n" })).toEqual({ $var: "n" });
|
||||
expect(
|
||||
schema.parse({
|
||||
"ctx-attr": { entity: "chooser", attr: "Color" },
|
||||
}),
|
||||
).toEqual({ "ctx-attr": { entity: "chooser", attr: "Color" } });
|
||||
});
|
||||
|
||||
it("rejects numbers", () => {
|
||||
const schema = stringOrResolver();
|
||||
expect(schema.safeParse(42).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("type-narrowing guards", () => {
|
||||
it("isResolverShape recognizes each of the 3 shapes", () => {
|
||||
expect(isResolverShape({ $var: "x" })).toBe(true);
|
||||
expect(
|
||||
isResolverShape({
|
||||
"ctx-attr": { entity: "self", attr: "HP" },
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(isResolverShape({ "ctx-build": { col: 0, row: 0 } })).toBe(true);
|
||||
});
|
||||
|
||||
it("isResolverShape rejects literals and non-magic objects", () => {
|
||||
expect(isResolverShape(42)).toBe(false);
|
||||
expect(isResolverShape("white")).toBe(false);
|
||||
expect(isResolverShape(null)).toBe(false);
|
||||
expect(isResolverShape(undefined)).toBe(false);
|
||||
expect(isResolverShape([])).toBe(false);
|
||||
expect(isResolverShape({ foo: "bar" })).toBe(false);
|
||||
// Two keys disqualifies — runtime walker also requires keys.length === 1.
|
||||
expect(isResolverShape({ $var: "x", extra: 1 })).toBe(false);
|
||||
});
|
||||
|
||||
it("isLiteralNumber accepts finite numbers, rejects everything else", () => {
|
||||
expect(isLiteralNumber(0)).toBe(true);
|
||||
expect(isLiteralNumber(-1.5)).toBe(true);
|
||||
expect(isLiteralNumber(Number.NaN)).toBe(false);
|
||||
expect(isLiteralNumber(Infinity)).toBe(false);
|
||||
expect(isLiteralNumber("42")).toBe(false);
|
||||
expect(isLiteralNumber({ $var: "n" })).toBe(false);
|
||||
expect(isLiteralNumber(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// V3 (Wave-1 thressgame-100) — schema acceptance + rejection of new shapes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("V3 — numberOrResolver accepts new shapes", () => {
|
||||
it("accepts ctx-self-id with null payload", () => {
|
||||
const schema = numberOrResolver();
|
||||
expect(schema.parse({ "ctx-self-id": null })).toEqual({
|
||||
"ctx-self-id": null,
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts ctx-self-marker-id with null payload", () => {
|
||||
const schema = numberOrResolver();
|
||||
expect(schema.parse({ "ctx-self-marker-id": null })).toEqual({
|
||||
"ctx-self-marker-id": null,
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts each arithmetic shape with literal operands", () => {
|
||||
const schema = numberOrResolver();
|
||||
expect(schema.parse({ add: [3, 4] })).toEqual({ add: [3, 4] });
|
||||
expect(schema.parse({ sub: [10, 3] })).toEqual({ sub: [10, 3] });
|
||||
expect(schema.parse({ mul: [6, 7] })).toEqual({ mul: [6, 7] });
|
||||
expect(schema.parse({ mod: [13, 5] })).toEqual({ mod: [13, 5] });
|
||||
});
|
||||
|
||||
it("accepts arithmetic with $var on the left operand", () => {
|
||||
const schema = numberOrResolver();
|
||||
const input = { add: [{ $var: "x" }, 1] };
|
||||
expect(schema.parse(input)).toEqual(input);
|
||||
});
|
||||
|
||||
it("accepts arithmetic with ctx-attr on the left operand", () => {
|
||||
const schema = numberOrResolver();
|
||||
const input = {
|
||||
add: [{ "ctx-attr": { entity: "self", attr: "Hp" } }, 1],
|
||||
};
|
||||
expect(schema.parse(input)).toEqual(input);
|
||||
});
|
||||
|
||||
it("accepts NESTED arithmetic — recursion works (lazy union)", () => {
|
||||
const schema = numberOrResolver();
|
||||
const input = {
|
||||
mod: [{ add: [{ $var: "col" }, 1] }, 8],
|
||||
};
|
||||
expect(schema.parse(input)).toEqual(input);
|
||||
});
|
||||
|
||||
it("rejects ctx-self-id with non-null payload", () => {
|
||||
const schema = numberOrResolver();
|
||||
expect(schema.safeParse({ "ctx-self-id": true }).success).toBe(false);
|
||||
expect(schema.safeParse({ "ctx-self-id": 0 }).success).toBe(false);
|
||||
expect(schema.safeParse({ "ctx-self-id": "yes" }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects arithmetic with non-integer right operand", () => {
|
||||
const schema = numberOrResolver();
|
||||
expect(schema.safeParse({ add: [1, 2.5] }).success).toBe(false);
|
||||
expect(schema.safeParse({ mul: [1, "2"] }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects arithmetic with wrong arity", () => {
|
||||
const schema = numberOrResolver();
|
||||
expect(schema.safeParse({ add: [1] }).success).toBe(false);
|
||||
expect(schema.safeParse({ add: [1, 2, 3] }).success).toBe(false);
|
||||
expect(schema.safeParse({ add: { left: 1, right: 2 } }).success).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects arithmetic with extra unknown keys (strict)", () => {
|
||||
const schema = numberOrResolver();
|
||||
expect(
|
||||
schema.safeParse({ add: [1, 2], extra: 1 } as unknown).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("V3 — backward compatibility (V2 shapes still parse)", () => {
|
||||
it("numberOrResolver still accepts every V2 shape", () => {
|
||||
const schema = numberOrResolver();
|
||||
expect(schema.safeParse(42).success).toBe(true);
|
||||
expect(schema.safeParse({ $var: "x" }).success).toBe(true);
|
||||
expect(
|
||||
schema.safeParse({ "ctx-attr": { entity: "self", attr: "Hp" } })
|
||||
.success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
schema.safeParse({ "ctx-build": { col: 3, row: 4 } }).success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("enumOrResolverFor still accepts V2 shapes + new V3 ones", () => {
|
||||
const schema = enumOrResolverFor(["white", "black"] as const);
|
||||
expect(schema.safeParse("white").success).toBe(true);
|
||||
expect(schema.safeParse({ $var: "c" }).success).toBe(true);
|
||||
expect(schema.safeParse({ "ctx-self-id": null }).success).toBe(true);
|
||||
expect(schema.safeParse({ add: [1, 2] }).success).toBe(true);
|
||||
});
|
||||
|
||||
it("__resolverEnumValues still surfaces the original tuple under V3", () => {
|
||||
const schema = enumOrResolverFor(["a", "b"] as const);
|
||||
schema.parse("a");
|
||||
schema.parse({ "ctx-self-id": null });
|
||||
expect(schema.__resolverEnumValues).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("stringOrResolver accepts new shapes alongside strings", () => {
|
||||
const schema = stringOrResolver();
|
||||
expect(schema.safeParse("hello").success).toBe(true);
|
||||
expect(schema.safeParse({ "ctx-self-marker-id": null }).success).toBe(
|
||||
true,
|
||||
);
|
||||
expect(schema.safeParse({ mod: [1, 2] }).success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("V3 — isResolverShape recognizes 6 new shapes", () => {
|
||||
it("recognizes ctx-self-id and ctx-self-marker-id", () => {
|
||||
expect(isResolverShape({ "ctx-self-id": null })).toBe(true);
|
||||
expect(isResolverShape({ "ctx-self-marker-id": null })).toBe(true);
|
||||
});
|
||||
|
||||
it("recognizes each arithmetic shape", () => {
|
||||
expect(isResolverShape({ add: [1, 2] })).toBe(true);
|
||||
expect(isResolverShape({ sub: [1, 2] })).toBe(true);
|
||||
expect(isResolverShape({ mul: [1, 2] })).toBe(true);
|
||||
expect(isResolverShape({ mod: [1, 2] })).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an unknown single-key object", () => {
|
||||
expect(isResolverShape({ unknownOp: [1, 2] })).toBe(false);
|
||||
expect(isResolverShape({ div: [1, 2] })).toBe(false);
|
||||
});
|
||||
});
|
||||
430
packages/chess/src/modifiers/primitives/param-resolver-schema.ts
Normal file
430
packages/chess/src/modifiers/primitives/param-resolver-schema.ts
Normal file
|
|
@ -0,0 +1,430 @@
|
|||
/**
|
||||
* Validator widening helpers (V3 — Wave-1 thressgame-100) — Zod
|
||||
* schema factories that accept either a LITERAL value or one of the
|
||||
* 9 runtime-recognized resolver shapes that
|
||||
* {@link ./param-resolver.ts} substitutes BEFORE a primitive's
|
||||
* `apply()` runs.
|
||||
*
|
||||
* ## Why this exists
|
||||
*
|
||||
* Pre-V2, every imperative primitive's `paramsSchema` declared its
|
||||
* positional fields (`target`, `square`, `to`, `a`, `b`, `owner`,
|
||||
* etc.) as bare scalar Zod types — `z.number().int().min(0).max(63)`,
|
||||
* `z.enum([...])`, etc. The runtime param-resolver substitutes
|
||||
* resolver shapes BEFORE `apply()`, so by the time `apply()` runs
|
||||
* the field is always a literal — but the static schema rejects
|
||||
* authors who write the resolver shape into descriptor JSON. V2/V3
|
||||
* widens the schema for those positional fields to accept BOTH the
|
||||
* literal AND any resolver shape, so author-time validation no
|
||||
* longer false-rejects valid descriptors.
|
||||
*
|
||||
* ## The 9 recognized shapes (mirror of param-resolver.ts walk())
|
||||
*
|
||||
* Each requires EXACTLY ONE key in the outer object — the runtime
|
||||
* walker checks `keys.length === 1` before treating an object as a
|
||||
* resolver shape. We mirror that with `.strict()` on each inner
|
||||
* object: an extra unknown key would NOT trigger the runtime
|
||||
* resolver, so accepting it at validation time would silently pass
|
||||
* through to `apply()` where the primitive's own literal-typed code
|
||||
* would crash. Better to reject loudly here.
|
||||
*
|
||||
* V2 (3 shapes, original):
|
||||
* { "$var": "name" } // bind lookup
|
||||
* { "ctx-attr": { entity: <selector>, attr: "<key>" } } // session.get
|
||||
* { "ctx-build": { col: 0..7, row: 0..7 } } // square build
|
||||
*
|
||||
* V3 (6 new shapes, Wave-1):
|
||||
* { "ctx-self-id": null } // ctx.pieceId identity
|
||||
* { "ctx-self-marker-id": null } // ctx.markerId (in marker arms)
|
||||
* { "add": [<resolver>, <integer>] } // left + right
|
||||
* { "sub": [<resolver>, <integer>] } // left - right
|
||||
* { "mul": [<resolver>, <integer>] } // left * right
|
||||
* { "mod": [<resolver>, <integer>] } // positive-mod for column wrap
|
||||
*
|
||||
* Arithmetic operands are RECURSIVE — the right operand is locked
|
||||
* to `z.number().int()` at validation (literal arity / overflow
|
||||
* lives at runtime), but the LEFT operand may itself be any
|
||||
* numeric-producing resolver shape, including a nested arithmetic
|
||||
* shape (`add(add($var, 1), 2)`). Recursion is expressed via
|
||||
* `z.lazy()` on the left-operand union.
|
||||
*
|
||||
* ## Union order: literal FIRST (decisions.md § A)
|
||||
*
|
||||
* Locked V3 union order across every helper:
|
||||
*
|
||||
* [literal, $var, ctx-attr, ctx-build, ctx-self-id,
|
||||
* ctx-self-marker-id, add, sub, mul, mod]
|
||||
*
|
||||
* Per `decisions.md` § "Resolver shape order in unions", the literal
|
||||
* is by far the most common shape on the hot path (every fixture
|
||||
* descriptor passes literals through), so Zod's left-to-right union
|
||||
* dispatch picks the cheapest branch first. This is also why
|
||||
* {@link enumOrResolverFor} returns a union with the `z.enum(...)`
|
||||
* at index 0 — `__resolverEnumValues` exposes the value list so
|
||||
* consumers (notably `ParamField.tsx`) don't have to introspect
|
||||
* `_def.options[0]._def.entries`.
|
||||
*
|
||||
* ## Backward compatibility
|
||||
*
|
||||
* Pre-V2 schemas using bare `z.number()`, `z.enum(...)`, `z.string()`
|
||||
* etc. continue to work unchanged. V3 is a STRICT superset of V2 —
|
||||
* every input that parsed under V2 still parses under V3.
|
||||
*/
|
||||
import { z } from "zod";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resolver-shape primitives
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* `{ $var: "name" }` — bind lookup. The `name` is a non-empty string
|
||||
* because `param-resolver.ts:142-153` uses `ctx.bindings.has(name)`
|
||||
* which requires a string key.
|
||||
*/
|
||||
const VarShape = z.object({ $var: z.string().min(1) }).strict();
|
||||
|
||||
/**
|
||||
* `ctx-attr.entity` selector — recursively allows a nested `$var`
|
||||
* shape because the runtime walker (`resolveEntity` in
|
||||
* param-resolver.ts:241-279) re-walks the entity before resolving.
|
||||
*
|
||||
* Wrapped in `z.lazy` only for forward-compatibility (the union has
|
||||
* no self-reference today, but the walker DOES recursively call
|
||||
* `walk` on the entity, so an author could nest a `$var` whose own
|
||||
* binding resolves to a number). The lazy wrapper keeps the door
|
||||
* open for future widening without an API break.
|
||||
*/
|
||||
const EntitySelectorSchema: z.ZodType<unknown> = z.lazy(() =>
|
||||
z.union([
|
||||
z.literal("self"),
|
||||
z.literal("chooser"),
|
||||
z.number().int(),
|
||||
VarShape,
|
||||
]),
|
||||
);
|
||||
|
||||
/**
|
||||
* `{ "ctx-attr": { entity, attr } }` — runtime calls
|
||||
* `ctx.session.get(resolvedEntityId, attr)`. `attr` is a non-empty
|
||||
* string (downstream cast to `ChessAttrKey`); we don't enum-pin the
|
||||
* attr name here because the universe of attr keys grows as the
|
||||
* schema evolves and over-strict validation would block legitimate
|
||||
* authoring of newly-added attrs.
|
||||
*/
|
||||
const CtxAttrShape = z
|
||||
.object({
|
||||
"ctx-attr": z
|
||||
.object({
|
||||
entity: EntitySelectorSchema,
|
||||
attr: z.string().min(1),
|
||||
})
|
||||
.strict(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* `{ "ctx-build": { col, row } }` — runtime computes
|
||||
* `col + row * 8`. Both axes accept a literal `0..7` integer OR a
|
||||
* `{ $var: "..." }` reference whose binding must resolve to an
|
||||
* integer in the same range (the walker does that range check at
|
||||
* resolve time, but we still enforce the literal range here so
|
||||
* obvious typos like `col: 8` fail at validation).
|
||||
*/
|
||||
const CtxBuildShape = z
|
||||
.object({
|
||||
"ctx-build": z
|
||||
.object({
|
||||
col: z.union([z.number().int().min(0).max(7), VarShape]),
|
||||
row: z.union([z.number().int().min(0).max(7), VarShape]),
|
||||
})
|
||||
.strict(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// V3 shapes (Wave-1 thressgame-100)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* `{ "ctx-self-id": null }` — identity shape returning
|
||||
* `ctx.pieceId` at runtime. Payload is locked to `null` (mirrors
|
||||
* the runtime check in `param-resolver.ts`); `true`, `0`, `""`, etc.
|
||||
* fail validation rather than producing surprising results.
|
||||
*/
|
||||
const CtxSelfIdShape = z.object({ "ctx-self-id": z.null() }).strict();
|
||||
|
||||
/**
|
||||
* `{ "ctx-self-marker-id": null }` — identity shape returning
|
||||
* `ctx.markerId`. Only valid inside marker-trigger arms; the
|
||||
* runtime walker throws if `ctx.markerId` is `undefined` (the
|
||||
* validator can't tell — that's a runtime-context concern).
|
||||
*/
|
||||
const CtxSelfMarkerIdShape = z
|
||||
.object({ "ctx-self-marker-id": z.null() })
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* Numeric-producing resolver shapes — i.e. every shape whose
|
||||
* runtime resolution yields a number. Used as the LEFT operand of
|
||||
* arithmetic shapes (the recursive arm) AND as the body of
|
||||
* `numberOrResolver`. Wrapped in `z.lazy` because `ArithmeticShape`
|
||||
* (defined just below) self-references this union for its left
|
||||
* operand.
|
||||
*
|
||||
* Order matches the locked V3 union order — literal numeric first,
|
||||
* resolver shapes after. The right operand of arithmetic shapes is
|
||||
* NOT this union; it's locked to `z.number().int()` so authors
|
||||
* can't bury an unbounded resolver chain on the right where
|
||||
* overflow risk compounds. (Multi-resolver expressions can still be
|
||||
* built via nested arithmetic on the left operand.)
|
||||
*/
|
||||
const NumericResolverInput: z.ZodType<unknown> = z.lazy(() =>
|
||||
z.union([
|
||||
z.number().int(),
|
||||
VarShape,
|
||||
CtxAttrShape,
|
||||
CtxBuildShape,
|
||||
CtxSelfIdShape,
|
||||
CtxSelfMarkerIdShape,
|
||||
ArithmeticShape,
|
||||
]),
|
||||
);
|
||||
|
||||
/**
|
||||
* `{ "add" | "sub" | "mul" | "mod": [<resolver>, <integer>] }` —
|
||||
* 2-arity arithmetic. Left operand is `NumericResolverInput`
|
||||
* (recursive: nested arithmetic, $var, ctx-attr, etc.); right
|
||||
* operand is a literal integer. Range / overflow / divide-by-zero
|
||||
* checks live in the walker — the schema only pins shape.
|
||||
*
|
||||
* Each op is a strict object; the union order inside is fixed but
|
||||
* not load-bearing (the helper's own union is what consumers see).
|
||||
*/
|
||||
const ArithmeticShape: z.ZodType<unknown> = z.lazy(() =>
|
||||
z.union([
|
||||
z
|
||||
.object({
|
||||
add: z.tuple([NumericResolverInput, z.number().int()]),
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
sub: z.tuple([NumericResolverInput, z.number().int()]),
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
mul: z.tuple([NumericResolverInput, z.number().int()]),
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
mod: z.tuple([NumericResolverInput, z.number().int()]),
|
||||
})
|
||||
.strict(),
|
||||
]),
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The runtime-recognized resolver shapes (V3 — Wave-1
|
||||
* thressgame-100), as a TypeScript discriminated union. Tests and
|
||||
* downstream introspection (e.g. type guards) use this to narrow
|
||||
* `unknown` to "definitely a resolver shape".
|
||||
*
|
||||
* NOTE: `ctx-attr.entity` is typed as `unknown` because the runtime
|
||||
* accepts a recursive `$var` nest there; pinning it tighter at the
|
||||
* type level would reject valid author input. Arithmetic operands
|
||||
* are `unknown` for the same reason — the left operand is
|
||||
* recursive, and pinning it tighter would force every consumer
|
||||
* touching the type to disambiguate the recursion themselves.
|
||||
*/
|
||||
export type ResolverShape =
|
||||
| { readonly $var: string }
|
||||
| {
|
||||
readonly "ctx-attr": {
|
||||
readonly entity: unknown;
|
||||
readonly attr: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
readonly "ctx-build": {
|
||||
readonly col: number | { readonly $var: string };
|
||||
readonly row: number | { readonly $var: string };
|
||||
};
|
||||
}
|
||||
| { readonly "ctx-self-id": null }
|
||||
| { readonly "ctx-self-marker-id": null }
|
||||
| { readonly add: readonly [unknown, number] }
|
||||
| { readonly sub: readonly [unknown, number] }
|
||||
| { readonly mul: readonly [unknown, number] }
|
||||
| { readonly mod: readonly [unknown, number] };
|
||||
|
||||
/**
|
||||
* Tuple of every recognised V3 resolver-shape key. Single source of
|
||||
* truth for {@link isResolverShape} so adding a shape requires one
|
||||
* edit (here) — the structural guard inherits the addition for free.
|
||||
*/
|
||||
const RESOLVER_SHAPE_KEYS = [
|
||||
"$var",
|
||||
"ctx-attr",
|
||||
"ctx-build",
|
||||
"ctx-self-id",
|
||||
"ctx-self-marker-id",
|
||||
"add",
|
||||
"sub",
|
||||
"mul",
|
||||
"mod",
|
||||
] as const;
|
||||
const RESOLVER_SHAPE_KEY_SET: ReadonlySet<string> = new Set(
|
||||
RESOLVER_SHAPE_KEYS,
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Number-or-resolver union. Use for positional fields that the
|
||||
* runtime resolver substitutes to a literal integer before `apply()`
|
||||
* runs (e.g. `square` 0..63, `target` EntityId, `to` square).
|
||||
*
|
||||
* Optional `min` / `max` clamp the LITERAL branch only — the
|
||||
* resolver-shape branches are unconstrained at validation time
|
||||
* because the runtime walker enforces its own range checks (e.g.
|
||||
* `ctx-build` col/row must land in `[0..7]`) and a `$var` binding
|
||||
* may legitimately point to any integer.
|
||||
*/
|
||||
export function numberOrResolver(opts?: {
|
||||
min?: number;
|
||||
max?: number;
|
||||
}): z.ZodType<unknown> {
|
||||
let numberSchema = z.number().int();
|
||||
if (opts?.min !== undefined) numberSchema = numberSchema.min(opts.min);
|
||||
if (opts?.max !== undefined) numberSchema = numberSchema.max(opts.max);
|
||||
// V3 union order: literal first, then 6 V2/V3 resolver shapes,
|
||||
// then the 4 arithmetic shapes. Arithmetic is a single
|
||||
// `ArithmeticShape` lazy union but expanded here so each branch
|
||||
// appears at a stable position in `_def.options` for any consumer
|
||||
// that introspects (e.g. ParamField widening).
|
||||
return z.union([
|
||||
numberSchema,
|
||||
VarShape,
|
||||
CtxAttrShape,
|
||||
CtxBuildShape,
|
||||
CtxSelfIdShape,
|
||||
CtxSelfMarkerIdShape,
|
||||
ArithmeticShape,
|
||||
]) as unknown as z.ZodType<unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Augmented union returned by {@link enumOrResolverFor}. The
|
||||
* `__resolverEnumValues` discriminator exposes the original enum
|
||||
* value list so consumers (notably `ParamField.tsx`) can detect "this
|
||||
* is a resolver-widened enum, render an enum picker for the literal
|
||||
* branch" WITHOUT having to dig into `_def.options[0]._def.entries`.
|
||||
*
|
||||
* Typed as `z.ZodType<unknown>` to accommodate the V3 union (lazy
|
||||
* arithmetic recursion); consumers downcast as they did under V2.
|
||||
*/
|
||||
export type EnumOrResolverSchema<T extends readonly [string, ...string[]]> =
|
||||
z.ZodType<unknown> & { readonly __resolverEnumValues: T };
|
||||
|
||||
/**
|
||||
* Enum-or-resolver union. Use for positional fields that ALSO accept
|
||||
* a closed string set (e.g. `owner: "white" | "black"`).
|
||||
*
|
||||
* The returned schema carries a `__resolverEnumValues` property
|
||||
* pointing at the original `values` tuple. T9 (ParamField widening)
|
||||
* reads this property to render the enum picker without traversing
|
||||
* the union internals — keeping the UI introspection logic simple.
|
||||
*
|
||||
* The enum branch is at `_def.options[0]` so Zod's left-to-right
|
||||
* union dispatch tries the literal first (hot path).
|
||||
*/
|
||||
export function enumOrResolverFor<
|
||||
const T extends readonly [string, ...string[]],
|
||||
>(values: T): EnumOrResolverSchema<T> {
|
||||
const baseEnum = z.enum(values);
|
||||
// V3 union order — enum literal first, V2 shapes, V3 shapes,
|
||||
// arithmetic. Numeric arithmetic is a strange fit for an enum
|
||||
// field at the SEMANTIC level (an enum like `"white"|"black"`
|
||||
// can't legally hold an integer), but we include the branches
|
||||
// for shape-level uniformity: a `$var` binding could resolve to
|
||||
// either color or to a number that's then compared upstream, and
|
||||
// forcing the helper's union to omit arithmetic would create a
|
||||
// confusing asymmetry with `numberOrResolver`. Authoring-time
|
||||
// intent (color-vs-arithmetic) is the descriptor author's
|
||||
// responsibility; the runtime walker enforces type after resolve.
|
||||
const schema = z.union([
|
||||
baseEnum,
|
||||
VarShape,
|
||||
CtxAttrShape,
|
||||
CtxBuildShape,
|
||||
CtxSelfIdShape,
|
||||
CtxSelfMarkerIdShape,
|
||||
ArithmeticShape,
|
||||
]);
|
||||
// Attach the enum value list directly so consumers can read it via
|
||||
// an `'__resolverEnumValues' in schema` check. Verified to survive
|
||||
// Zod 4.x parse calls — Zod stores its own state in `_def`, not on
|
||||
// the public surface, so plain expando assignment is safe.
|
||||
Object.assign(schema, { __resolverEnumValues: values });
|
||||
return schema as unknown as EnumOrResolverSchema<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* String-or-resolver union. Use sparingly: most string-typed
|
||||
* positional fields are actually closed enums (use {@link
|
||||
* enumOrResolverFor} for those). Reserved for free-form string
|
||||
* fields like `markerLabel` or descriptor display names where any
|
||||
* non-empty string is valid.
|
||||
*/
|
||||
export function stringOrResolver(): z.ZodType<unknown> {
|
||||
// V3 union — same shape order as `numberOrResolver`. Arithmetic
|
||||
// included for shape-uniformity (see `enumOrResolverFor` rationale);
|
||||
// a string-typed positional field that resolves arithmetic to a
|
||||
// number will fail downstream type checking but the SHAPE-level
|
||||
// schema is decoupled from the runtime type contract by design.
|
||||
return z.union([
|
||||
z.string(),
|
||||
VarShape,
|
||||
CtxAttrShape,
|
||||
CtxBuildShape,
|
||||
CtxSelfIdShape,
|
||||
CtxSelfMarkerIdShape,
|
||||
ArithmeticShape,
|
||||
]) as unknown as z.ZodType<unknown>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Type-narrowing guards
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns `true` when `v` matches one of the V3 resolver shapes
|
||||
* structurally (single key, expected key name). Does NOT validate the
|
||||
* inner payload — for full validation use one of the helper schemas
|
||||
* above and call `.parse()` / `.safeParse()`.
|
||||
*/
|
||||
export function isResolverShape(v: unknown): v is ResolverShape {
|
||||
if (v === null || typeof v !== "object" || Array.isArray(v)) return false;
|
||||
const obj = v as Record<string, unknown>;
|
||||
const keys = Object.keys(obj);
|
||||
if (keys.length !== 1) return false;
|
||||
const only = keys[0];
|
||||
return only !== undefined && RESOLVER_SHAPE_KEY_SET.has(only);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trivial narrowing helper paired with {@link isResolverShape} so a
|
||||
* caller can branch `isLiteralNumber(v) ? ... : isResolverShape(v) ?
|
||||
* ... : ...` without re-deriving the negative case.
|
||||
*/
|
||||
export function isLiteralNumber(v: unknown): v is number {
|
||||
return typeof v === "number" && Number.isFinite(v);
|
||||
}
|
||||
|
|
@ -315,3 +315,252 @@ describe("resolveParams — deep walk", () => {
|
|||
expect(out).toEqual({ $var: "x", extra: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// V3 (Wave-1 thressgame-100) — new resolver shapes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("resolveParams (V3) — ctx-self-id", () => {
|
||||
it("returns ctx.pieceId for the simple identity shape", () => {
|
||||
const { ctx } = makeCtx({ pieceId: 7 as EntityId });
|
||||
expect(resolveParams({ "ctx-self-id": null }, ctx)).toBe(7);
|
||||
});
|
||||
|
||||
it("survives a deep walk (substituted at any nesting depth)", () => {
|
||||
const { ctx } = makeCtx({ pieceId: 12 as EntityId });
|
||||
const out = resolveParams(
|
||||
{
|
||||
target: { "ctx-self-id": null },
|
||||
meta: { nested: { "ctx-self-id": null } },
|
||||
},
|
||||
ctx,
|
||||
) as { target: number; meta: { nested: number } };
|
||||
expect(out.target).toBe(12);
|
||||
expect(out.meta.nested).toBe(12);
|
||||
});
|
||||
|
||||
it("rejects non-null payload (true / 0 / string)", () => {
|
||||
const { ctx } = makeCtx();
|
||||
expect(() =>
|
||||
resolveParams(
|
||||
{ "ctx-self-id": true } as unknown as Record<string, unknown>,
|
||||
ctx,
|
||||
),
|
||||
).toThrow(/payload must be null/);
|
||||
expect(() =>
|
||||
resolveParams(
|
||||
{ "ctx-self-id": 0 } as unknown as Record<string, unknown>,
|
||||
ctx,
|
||||
),
|
||||
).toThrow(/payload must be null/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveParams (V3) — ctx-self-marker-id", () => {
|
||||
it("returns ctx.markerId when populated (inside a marker trigger)", () => {
|
||||
const { ctx: base } = makeCtx({ pieceId: 3 as EntityId });
|
||||
const ctx = { ...base, markerId: 42 as EntityId };
|
||||
expect(resolveParams({ "ctx-self-marker-id": null }, ctx)).toBe(42);
|
||||
});
|
||||
|
||||
it("throws BindingError-style when ctx.markerId is undefined", () => {
|
||||
// Default ctx has no markerId — this models authoring the shape
|
||||
// outside a marker trigger arm, which is the canonical mistake
|
||||
// the loud throw is meant to catch.
|
||||
const { ctx } = makeCtx();
|
||||
expect(() =>
|
||||
resolveParams({ "ctx-self-marker-id": null }, ctx),
|
||||
).toThrow(/not inside a marker trigger/);
|
||||
});
|
||||
|
||||
it("rejects non-null payload", () => {
|
||||
const { ctx: base } = makeCtx();
|
||||
const ctx = { ...base, markerId: 9 as EntityId };
|
||||
expect(() =>
|
||||
resolveParams(
|
||||
{ "ctx-self-marker-id": "yes" } as unknown as Record<string, unknown>,
|
||||
ctx,
|
||||
),
|
||||
).toThrow(/payload must be null/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveParams (V3) — arithmetic add/sub/mul/mod", () => {
|
||||
it("computes basic arithmetic with literal operands", () => {
|
||||
const { ctx } = makeCtx();
|
||||
expect(resolveParams({ add: [3, 4] }, ctx)).toBe(7);
|
||||
expect(resolveParams({ sub: [10, 3] }, ctx)).toBe(7);
|
||||
expect(resolveParams({ mul: [6, 7] }, ctx)).toBe(42);
|
||||
expect(resolveParams({ mod: [13, 5] }, ctx)).toBe(3);
|
||||
});
|
||||
|
||||
it("uses positive modulo so mod handles negative operands (column wrap)", () => {
|
||||
const { ctx } = makeCtx();
|
||||
// -1 % 8 in JS = -1, but we want 7 for column-wrap recipes.
|
||||
expect(resolveParams({ mod: [-1, 8] }, ctx)).toBe(7);
|
||||
expect(resolveParams({ mod: [-9, 8] }, ctx)).toBe(7);
|
||||
expect(resolveParams({ mod: [8, 8] }, ctx)).toBe(0);
|
||||
});
|
||||
|
||||
it("resolves $var operands recursively", () => {
|
||||
const { ctx } = makeCtx({
|
||||
bindings: new Map<string, BindingValue>([
|
||||
["a", 10],
|
||||
["b", 3],
|
||||
]),
|
||||
});
|
||||
expect(resolveParams({ add: [{ $var: "a" }, 5] }, ctx)).toBe(15);
|
||||
expect(resolveParams({ sub: [{ $var: "a" }, { $var: "b" }] }, ctx)).toBe(
|
||||
7,
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves ctx-attr operand recursively", () => {
|
||||
const { ctx } = makeCtx({
|
||||
setupSession: (s) => {
|
||||
s.insert(1 as EntityId, "Hp", 5);
|
||||
},
|
||||
});
|
||||
expect(
|
||||
resolveParams(
|
||||
{
|
||||
add: [{ "ctx-attr": { entity: "self", attr: "Hp" } }, 7],
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
).toBe(12);
|
||||
});
|
||||
|
||||
it("nests arithmetic — add inside add", () => {
|
||||
const { ctx } = makeCtx({
|
||||
bindings: new Map<string, BindingValue>([["x", 4]]),
|
||||
});
|
||||
// add(add($x, 1), 2) = add(5, 2) = 7
|
||||
expect(
|
||||
resolveParams({ add: [{ add: [{ $var: "x" }, 1] }, 2] }, ctx),
|
||||
).toBe(7);
|
||||
});
|
||||
|
||||
it("column-wrap pattern: mod(add($col, 1), 8)", () => {
|
||||
const { ctx } = makeCtx({
|
||||
bindings: new Map<string, BindingValue>([["col", 7]]),
|
||||
});
|
||||
// 7 + 1 = 8, 8 mod 8 = 0 (wraps file h → file a)
|
||||
expect(
|
||||
resolveParams(
|
||||
{ mod: [{ add: [{ $var: "col" }, 1] }, 8] },
|
||||
ctx,
|
||||
),
|
||||
).toBe(0);
|
||||
// From file a (col 0): wraps via -1 → 7
|
||||
const { ctx: ctx2 } = makeCtx({
|
||||
bindings: new Map<string, BindingValue>([["col", 0]]),
|
||||
});
|
||||
expect(
|
||||
resolveParams(
|
||||
{ mod: [{ sub: [{ $var: "col" }, 1] }, 8] },
|
||||
ctx2,
|
||||
),
|
||||
).toBe(7);
|
||||
});
|
||||
|
||||
it("throws when payload is not a 2-element array", () => {
|
||||
const { ctx } = makeCtx();
|
||||
expect(() =>
|
||||
resolveParams({ add: [1] } as unknown as Record<string, unknown>, ctx),
|
||||
).toThrow(/2-element array/);
|
||||
expect(() =>
|
||||
resolveParams(
|
||||
{ mul: [1, 2, 3] } as unknown as Record<string, unknown>,
|
||||
ctx,
|
||||
),
|
||||
).toThrow(/2-element array/);
|
||||
expect(() =>
|
||||
resolveParams(
|
||||
{ sub: { left: 1, right: 2 } } as unknown as Record<string, unknown>,
|
||||
ctx,
|
||||
),
|
||||
).toThrow(/2-element array/);
|
||||
});
|
||||
|
||||
it("throws when an operand resolves to a non-number", () => {
|
||||
const { ctx } = makeCtx({
|
||||
bindings: new Map<string, BindingValue>([["color", "white"]]),
|
||||
});
|
||||
expect(() =>
|
||||
resolveParams({ add: [{ $var: "color" }, 1] }, ctx),
|
||||
).toThrow(/operands must resolve to numbers/);
|
||||
});
|
||||
|
||||
it("throws when mod divisor is zero", () => {
|
||||
const { ctx } = makeCtx();
|
||||
expect(() => resolveParams({ mod: [10, 0] }, ctx)).toThrow(
|
||||
/divisor is zero/,
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on integer overflow at MAX_SAFE_INTEGER boundary", () => {
|
||||
const { ctx } = makeCtx();
|
||||
// MAX_SAFE_INTEGER * 2 overflows to ±Infinity domain (loses
|
||||
// precision); our check rejects results outside the safe range.
|
||||
const big = Number.MAX_SAFE_INTEGER;
|
||||
expect(() => resolveParams({ mul: [big, 2] }, ctx)).toThrow(
|
||||
/result overflow/,
|
||||
);
|
||||
expect(() => resolveParams({ add: [big, big] }, ctx)).toThrow(
|
||||
/result overflow/,
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on non-finite operand (NaN / Infinity injected via binding)", () => {
|
||||
const { ctx } = makeCtx({
|
||||
bindings: new Map<string, BindingValue>([
|
||||
["nan", Number.NaN as unknown as BindingValue],
|
||||
["inf", Infinity as unknown as BindingValue],
|
||||
]),
|
||||
});
|
||||
expect(() =>
|
||||
resolveParams({ add: [{ $var: "nan" }, 1] }, ctx),
|
||||
).toThrow(/must be finite/);
|
||||
expect(() =>
|
||||
resolveParams({ sub: [{ $var: "inf" }, 1] }, ctx),
|
||||
).toThrow(/must be finite/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveParams (V3) — composition of new shapes with old", () => {
|
||||
it("ctx-build can take an arithmetic shape for col / row", () => {
|
||||
const { ctx } = makeCtx({
|
||||
bindings: new Map<string, BindingValue>([["c", 3]]),
|
||||
});
|
||||
// ctx-build with col = (c + 1) = 4, row = 2 → square 4 + 2*8 = 20
|
||||
expect(
|
||||
resolveParams(
|
||||
{
|
||||
"ctx-build": {
|
||||
col: { add: [{ $var: "c" }, 1] },
|
||||
row: 2,
|
||||
},
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
).toBe(20);
|
||||
});
|
||||
|
||||
it("ctx-attr.entity can be a ctx-self-id shape (resolves to self)", () => {
|
||||
const { ctx } = makeCtx({
|
||||
pieceId: 5 as EntityId,
|
||||
setupSession: (s) => {
|
||||
s.insert(5 as EntityId, "Hp", 11);
|
||||
},
|
||||
});
|
||||
expect(
|
||||
resolveParams(
|
||||
{
|
||||
"ctx-attr": { entity: { "ctx-self-id": null }, attr: "Hp" },
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
).toBe(11);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
/**
|
||||
* Param walker (T12) — runtime substitution of binding refs and
|
||||
* context-attribute / context-build references inside primitive
|
||||
* `params` trees, performed BEFORE the primitive's `apply()` is
|
||||
* invoked.
|
||||
* Param walker (T12; V3 — Wave-1 thressgame-100) — runtime
|
||||
* substitution of binding refs and context-attribute /
|
||||
* context-build references inside primitive `params` trees,
|
||||
* performed BEFORE the primitive's `apply()` is invoked.
|
||||
*
|
||||
* Three shape recognisers, each triggered ONLY when an object has
|
||||
* Nine shape recognisers, each triggered ONLY when an object has
|
||||
* exactly one matching key (so a plain `{ $var: ... }` field never
|
||||
* collides with a primitive that legitimately stores a key starting
|
||||
* with `$`):
|
||||
|
|
@ -25,6 +25,30 @@
|
|||
* → `col` / `row` may themselves be `{ $var }` shapes (walked first)
|
||||
* → throws if either falls outside the integer range [0..7]
|
||||
*
|
||||
* { "ctx-self-id": null } // V3
|
||||
* → `ctx.pieceId`
|
||||
* → identity shape used for self-targeting destroy/move
|
||||
* primitives where the primitive's `target` field is widened
|
||||
* to a resolver union and the descriptor wants to be explicit
|
||||
* rather than rely on the implicit "self" default.
|
||||
*
|
||||
* { "ctx-self-marker-id": null } // V3
|
||||
* → `ctx.markerId`
|
||||
* → only valid inside `on-piece-entered-marker` /
|
||||
* `on-marker-expire` trigger arms; throws
|
||||
* BindingError-style if `ctx.markerId` is `undefined`.
|
||||
*
|
||||
* { "add" / "sub" / "mul" / "mod": [<resolver>, <resolver>] } // V3
|
||||
* → arithmetic on two operands, each of which may be a literal
|
||||
* integer OR another resolver shape (the recursive `walk()`
|
||||
* handles nesting naturally — `add(add($var, 1), 2)` works).
|
||||
* → throws on non-numeric / non-finite operands, on `mod` with
|
||||
* a zero divisor, and on integer-overflow at the
|
||||
* `Number.MAX_SAFE_INTEGER` boundary (no silent wrap).
|
||||
* → `mod` uses the positive-modulo formula `((l % r) + r) % r`
|
||||
* so column-wrapping recipes (`mod(add($col, 1), 8)`) produce
|
||||
* a 0..r-1 result for any sign of `l`.
|
||||
*
|
||||
* Anything else (primitive value, plain object, array) is returned
|
||||
* unchanged — arrays + plain-object values are deep-walked so a
|
||||
* resolver shape buried at any depth is still substituted.
|
||||
|
|
@ -213,6 +237,108 @@ function walk(node: unknown, ctx: PrimitiveApplyContext): unknown {
|
|||
}
|
||||
return col + row * 8;
|
||||
}
|
||||
|
||||
// V3 (Wave-1 thressgame-100) — identity shape returning the
|
||||
// current apply target's piece id. Payload MUST be `null` (not
|
||||
// `true`, `0`, `""`, etc.) so authoring typos surface here
|
||||
// rather than silently passing through to apply().
|
||||
if (only === "ctx-self-id") {
|
||||
const inner = obj["ctx-self-id"];
|
||||
if (inner !== null) {
|
||||
throw new Error(
|
||||
`ctx-self-id: payload must be null, got ${typeof inner} (${JSON.stringify(inner)})`,
|
||||
);
|
||||
}
|
||||
return ctx.pieceId;
|
||||
}
|
||||
|
||||
// V3 — identity shape returning the marker entity id of the
|
||||
// currently-firing marker trigger (`on-piece-entered-marker` /
|
||||
// `on-marker-expire`). Throws when used outside a marker arm
|
||||
// because `ctx.markerId` is only populated by `runPrimitives`
|
||||
// for those two event kinds. The error mirrors `BindingError`'s
|
||||
// fail-loud philosophy: silently producing `undefined` would
|
||||
// corrupt downstream facts (NaN squares, invalid EntityIds).
|
||||
if (only === "ctx-self-marker-id") {
|
||||
const inner = obj["ctx-self-marker-id"];
|
||||
if (inner !== null) {
|
||||
throw new Error(
|
||||
`ctx-self-marker-id: payload must be null, got ${typeof inner} (${JSON.stringify(inner)})`,
|
||||
);
|
||||
}
|
||||
if (ctx.markerId === undefined) {
|
||||
throw new Error(
|
||||
"ctx-self-marker-id: not inside a marker trigger (ctx.markerId is undefined). " +
|
||||
"This shape is only valid inside on-piece-entered-marker / on-marker-expire arms.",
|
||||
);
|
||||
}
|
||||
return ctx.markerId;
|
||||
}
|
||||
|
||||
// V3 — arithmetic shapes. Operands recursively walked so a
|
||||
// nested resolver (`add(add($var, 1), 2)`, `mod(ctx-attr, 8)`)
|
||||
// is fully resolved before the operation runs. Overflow check
|
||||
// at `Number.MAX_SAFE_INTEGER` rather than silent JS-number
|
||||
// wrap so authors discover bad arithmetic statically. `mod`
|
||||
// uses the positive-modulo formula so column-wrapping recipes
|
||||
// (the canonical Layer-1 mass-mover usage:
|
||||
// `mod(add($col, 1), 8)`) always produce a non-negative
|
||||
// result regardless of operand sign.
|
||||
if (
|
||||
only === "add" ||
|
||||
only === "sub" ||
|
||||
only === "mul" ||
|
||||
only === "mod"
|
||||
) {
|
||||
const op = only;
|
||||
const inner = obj[op];
|
||||
if (!Array.isArray(inner) || inner.length !== 2) {
|
||||
throw new Error(
|
||||
`${op}: payload must be a 2-element array, got ${JSON.stringify(inner)}`,
|
||||
);
|
||||
}
|
||||
const left = walk(inner[0], ctx);
|
||||
const right = walk(inner[1], ctx);
|
||||
if (typeof left !== "number" || typeof right !== "number") {
|
||||
throw new Error(
|
||||
`${op}: operands must resolve to numbers, got left=${typeof left} right=${typeof right}`,
|
||||
);
|
||||
}
|
||||
if (!Number.isFinite(left) || !Number.isFinite(right)) {
|
||||
throw new Error(
|
||||
`${op}: operands must be finite, got left=${left} right=${right}`,
|
||||
);
|
||||
}
|
||||
let result: number;
|
||||
switch (op) {
|
||||
case "add":
|
||||
result = left + right;
|
||||
break;
|
||||
case "sub":
|
||||
result = left - right;
|
||||
break;
|
||||
case "mul":
|
||||
result = left * right;
|
||||
break;
|
||||
case "mod":
|
||||
if (right === 0) {
|
||||
throw new Error(`mod: divisor is zero`);
|
||||
}
|
||||
// Positive modulo — column-wrap pattern needs a 0..r-1
|
||||
// result for any sign of `left`.
|
||||
result = ((left % right) + right) % right;
|
||||
break;
|
||||
}
|
||||
if (
|
||||
!Number.isFinite(result) ||
|
||||
Math.abs(result) > Number.MAX_SAFE_INTEGER
|
||||
) {
|
||||
throw new Error(
|
||||
`${op}: result overflow (${left} ${op} ${right} = ${result})`,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Plain object — recurse on each value.
|
||||
|
|
|
|||
|
|
@ -158,6 +158,27 @@ export interface PrimitiveApplyContext {
|
|||
* applies and for triggers that don't carry per-event payload.
|
||||
*/
|
||||
readonly event: PrimitiveEvent | undefined;
|
||||
/**
|
||||
* Wave-1 (thressgame-100) — the marker entity id this apply is
|
||||
* running ON BEHALF OF, populated EXCLUSIVELY when the dispatcher
|
||||
* is firing a marker-scoped trigger (`on-piece-entered-marker`,
|
||||
* `on-marker-expire`). Mirrors `event.markerId` but surfaces the
|
||||
* value at the top level so the resolver shape
|
||||
* `{ "ctx-self-marker-id": null }` can read it without having to
|
||||
* peek inside the event union (which is otherwise opaque to the
|
||||
* resolver). `undefined` for every non-marker trigger and every
|
||||
* profile-time apply — the resolver throws BindingError-style when
|
||||
* an author writes `ctx-self-marker-id` outside a marker-trigger
|
||||
* arm, so confusion surfaces loudly rather than silently producing
|
||||
* a numeric NaN downstream.
|
||||
*
|
||||
* Single source of truth: populated by `runPrimitives` in
|
||||
* `triggers.ts` from `event?.markerId` for the two marker
|
||||
* trigger kinds. Construction sites that don't fire marker
|
||||
* triggers (every test ctx, every direct apply call) inherit
|
||||
* `undefined` via the optional default.
|
||||
*/
|
||||
readonly markerId?: EntityId | undefined;
|
||||
/**
|
||||
* Lexically-scoped bindings (T11). Iteration primitives (T31-T35)
|
||||
* and request-choice (T47) introduce names into this map via the
|
||||
|
|
|
|||
|
|
@ -253,6 +253,19 @@ export function runPrimitives(
|
|||
// concern, not a runner concern.
|
||||
target: "self",
|
||||
event,
|
||||
// Wave-1 (thressgame-100) — surface markerId at ctx top level
|
||||
// so the `ctx-self-marker-id` resolver shape can read it
|
||||
// without peeking into the discriminated `event` union. Only
|
||||
// the two marker-scoped trigger events carry a markerId
|
||||
// (piece-entered-marker, marker-expire); every other event /
|
||||
// every profile-time apply gets `undefined` and the resolver
|
||||
// throws if an author misuses the shape outside a marker arm.
|
||||
markerId:
|
||||
event !== undefined &&
|
||||
(event.kind === "piece-entered-marker" ||
|
||||
event.kind === "marker-expire")
|
||||
? event.markerId
|
||||
: undefined,
|
||||
// T11: bindings flow inward through recursion. Trigger
|
||||
// dispatchers seed an empty map at hook entry; iteration /
|
||||
// request-choice primitives extend it via `withBinding` before
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue