diff --git a/.sisyphus/notepads/thressgame-coverage/learnings.md b/.sisyphus/notepads/thressgame-coverage/learnings.md index 4435ba8..71f29cf 100644 --- a/.sisyphus/notepads/thressgame-coverage/learnings.md +++ b/.sisyphus/notepads/thressgame-coverage/learnings.md @@ -185,3 +185,314 @@ The 100-iteration loop itself (excluding Vitest startup) executes well under the - Audit file: **342** lines, **75** numbered callsite rows - Evidence: `.sisyphus/evidence/task-4-position-audit.txt` + +## [2026-04-26T08:21Z] T6 entity / marker / RNG attrs + +### What landed +- 3 named type aliases ABOVE `ChessAttrMap` in `packages/chess/src/schema.ts`: + `EntityKindValue`, `MarkerKindValue`, `MarkerLifetimeValue`. + All exported so downstream tasks (T7 EntityKind filters; T10 marker + factory; T19 lifetime decrementer; RNG primitives) can import them. +- 7 attrs appended to `ChessAttrMap` in declaration order: + `EntityKind, MarkerKind, MarkerLifetime, MarkerOwner, MarkerLinks, + RngSeed, RngStream`. +- 7 `registerAttrConsumer(...)` calls appended to the END of the + block in `packages/chess/src/modifiers/apply.ts` (lines 116-122). + Placement at end-of-block minimises merge friction with T8 (which + the brief warned will also append here). +- 7 new tests in `packages/chess/src/schema.test.ts` under describe + `T6 marker + RNG attrs`. + +### Import paths verified (use these in T7/T8/T10) +- `EntityId` — `import type { EntityId } from "@paratype/rete"` (already + imported at `schema.ts:9`; no re-import needed by future tasks that + pull from `./schema.js`). +- New value types — `import { type EntityKindValue, type MarkerKindValue, + type MarkerLifetimeValue } from "./schema.js"`. + +### Conventions followed +- `MarkerOwner` value type is `PieceColor` (`"white" | "black"`); absence + of the fact encodes "no owner" — do NOT insert `undefined`. (Brief + rationale was correct.) +- `MarkerLifetime` field names are `kind` + `expiresAtMove` exactly, + matching `decisions.md:44`. `expiresAtMove` is an ABSOLUTE move count + comparison (T19 will compare against `FullmoveNumber` or equivalent). +- `MarkerKind` is the locked 8-value union — no `string` widening. + +### Surprises / gotchas +- Brief said `apply.ts` block is at `packages/chess/src/modifiers/custom/apply.ts` + lines 95-113; ACTUAL location is `packages/chess/src/modifiers/apply.ts` + lines 95-114. The `custom/apply.ts` file is the descriptor walker, not + the consumer registration site. Mirror this when wiring T7/T8. +- `schema.test.ts` uses `chessFact()` factory + `EntityId` brand cast + via `mkId(n)` helper — NOT a live `Session`. Mirror this lighter + pattern; full Session round-trip belongs in engine-level integration + tests, not the pure-schema test file. +- `bun run check` runs typecheck → lint → vitest (workspace mode). + Vitest project name for chess is `|chess|`. Test count after T6 = + **1990** (was 1970 baseline; +20 because parallel tasks have already + landed in the same wave window — the +7 from T6 alone is included). +- `assertSeedConsumerIntegrity` only asserts that EVERY primitive-seeded + attr has a consumer; it does NOT complain about consumers without + seed-side coverage. So registering `RngSeed`/`RngStream` consumers + before any RNG primitive seeds them is safe and forward-compatible. + +### Verification +- `bun test packages/chess/src/schema.test.ts` → 18 pass / 0 fail +- `bunx vitest run schema.test.ts manifest.test.ts` → 23 pass +- `bun run check` → exit 0, 1990 tests across 170 files +- Evidence: `.sisyphus/evidence/task-6-attrs.txt` + + +## [2026-04-26T08:24Z] T9 RNG (Mulberry32 + engine.rng + GAME_ENTITY seeding) + +### What landed +- `packages/chess/src/util/rng.ts` — `class SeededRng` (Mulberry32) + + `deriveSeedFromGameId(gameId)` (FNV-1a 32-bit). No deps; verbatim + reference Mulberry32 constants — do NOT "optimize" the bit pattern, + it's load-bearing for cross-impl determinism. +- `packages/chess/src/util/rng.test.ts` — 13 tests / 5086 expects. + Includes 1000-iter range guards on `next()` and `nextInt(N)`, + golden-sequence regression for seed=1 (any value change is a plan- + amending event), pick/empty throw, FNV uint32 invariant. +- `packages/chess/src/engine.ts` — + 1. New `gameId?: string` field on `EngineOptions`. + 2. RngSeed/RngStream seeded UNCONDITIONALLY in the constructor, + BEFORE `applyProfileToSession` / preset activation, so any + `onActivate` hook that draws (future RNG-shuffled starting + positions, etc.) sees initialized facts. Default seed when no + gameId = `deriveSeedFromGameId(undefined)` = 1. + 3. `engine.rng()` returns a handle with `next/nextInt/pick`. EACH + call advances the persistent RngStream by 1 by re-reading the + fact, constructing a fresh `SeededRng(seed + currentStream)`, + then `insert(GAME_ENTITY, "RngStream", currentStream + 1)`. + 4. `engine.setRngSeed(numberOrString)` resets stream to 0 and + overrides seed; for tests / late-binding server boot. + +### RNG init location decision +- **Constructor**, NOT integration preset boot. Rationale: + RngSeed/RngStream are GAME-level facts (not modifier-related); the + constructor is the only call site guaranteed to fire for every + engine regardless of whether a profile / preset is active. The + `__modifier-profile-integration__` preset only activates when a + profile is supplied — putting RNG init there would make any + profileless engine throw at first `engine.rng()` call. +- This decision keeps T9 independent of T6's preset boot path; no + edits to `modifiers/custom/apply.ts` or `modifiers/apply.ts` + needed. + +### engine.rng() return shape (for Wave 7 — T36 with-probability, T37 random-pick) +```ts +{ + next(): number; // [0, 1) — advances stream by 1 + nextInt(max: number): number; // [0, max) — advances stream by 1 + pick(arr: readonly T[]): T; // throws on empty — advances stream by 1 +} +``` +**Critical for T36/T37**: ONE call = ONE stream advance = ONE +draw. Don't cache the handle then call `next()` 5 times expecting +the same value; each call re-reads the stream. To draw N times for +one effect, call N times — the stream offset is what makes each +distinct. + +### Subtleties for downstream RNG consumers +- The "draws-before-suspension" invariant (decisions.md:140) is + enforced in the VALIDATOR (T19), not in the engine — the RNG + itself doesn't know about suspension. T36's `with-probability` + must call `engine.rng().next()` BEFORE selecting then/else arm, + and the validator must reject any `request-choice` nested inside + with-probability. +- `setRngSeed` mid-game IS allowed but breaks replay-from-this- + point. Server boot path: pass `gameId` to constructor for the + authoritative session; clients receive `RngSeed`/`RngStream` + facts via the standard fact-sync (no special-casing needed — + they're just facts on GAME_ENTITY). +- `assertSeedConsumerIntegrity` (T6) already covers + RngSeed/RngStream consumer registration; T9 added zero + registerAttrConsumer calls (per brief — would have raced T6). + +### Verification +- `bun test packages/chess/src/util/rng.test.ts` → 13 pass / 0 fail +- `bun test packages/chess/src/engine-presets.test.ts` → 24 pass / 0 fail (added 9 rng integration tests) +- `bun run check` → exit 0, 1999 tests across 170 files (added 13 unit + 9 engine-integration; net delta vs previous T6 baseline reflects parallel tasks landing in the same wave) +- Evidence: `.sisyphus/evidence/task-9-rng.txt` + +## [2026-04-26T08:30:00Z] T8 movement-replacement attrs + +### Added (Wave 2 schema/registration only) + +5 new attrs on `ChessAttrMap` in `packages/chess/src/schema.ts`: + +| Attr | Type | Scope | +|------|------|-------| +| `MovesAs` | `PieceType` | per-piece (override native pattern) | +| `MovesAlsoAs` | `PieceType` | per-piece (additive secondary pattern) | +| `SlideMustBeMaxDistance` | `boolean` | per-piece OR `GAME_ENTITY` | +| `BlockAllExceptKing` | `boolean` | `GAME_ENTITY` only | +| `KingExtraReach` | `number` | per-piece | + +5 matching `registerAttrConsumer(...)` calls appended to +`packages/chess/src/modifiers/apply.ts` (location confirmed +consistent with T6 — NOT `modifiers/custom/apply.ts`). Append-only +edit; T6's 7 RNG/marker registrations were already present, so no +race / overwrite occurred. + +### Confirmation re: apply.ts location + +T6's earlier learnings entry was correct: the file is +`packages/chess/src/modifiers/apply.ts` (line ~88+ holds the existing +`registerAttrConsumer` block). The `modifiers/custom/apply.ts` file +exists but is for custom-modifier *application*, not consumer +registration. T8 followed T6's pattern. + +### Scope guardrails honoured + +- NO move-gen wiring touched. `MovesAs` / `MovesAlsoAs` / etc. are + pure schema entries; the readers land in Wave 7 (T40 set-moves-as, + T41 pawn-pushes-pieces) and Wave 10 (T64 ice-physics test consumes + `SlideMustBeMaxDistance`). +- NO defaults seeded. Move-gen treats absence-of-fact as "use + natural movement", per the brief. +- NO validator gate on `MovesAs: "king"` — V1 just stores; the + reject-on-king restriction is T40's job (validator wave). + +### Test deltas + +- Added 5 tests in `packages/chess/src/schema.test.ts` under + `describe("T8 movement-replacement attrs (Wave 2)")` mirroring + T6's chessFact round-trip pattern. +- Schema test file: 18 → 23 pass. +- Workspace total: 1999 → **2006 pass** (delta +7 = my 5 + 2 from + expanding existing T6-style "every PieceType" loops). + +### Verification + +- `bun test packages/chess/src/schema.test.ts` → 23 pass / 0 fail. +- `bun test packages/chess/src/modifiers/primitives/consumer-integration.test.ts` → 6 pass / 0 fail (no regression — `assertSeedConsumerIntegrity` happy with the 5 new declarations). +- `bun run check` → **exit 0, 2006 tests / 170 files**. +- Evidence: `.sisyphus/evidence/task-8-movement-attrs.txt` + +## [2026-04-26T14:29Z] T7 aura compute + markers + +### What landed +- `packages/chess/src/modifiers/auras.ts` — added exported `getEntityKind(session, id)` helper + renamed internal walker `collectPiecesWithPositions` → `collectPositionedEntities`. Walker now filters via the `EntityKind` discriminator (admits `"piece"` and `"marker"`) instead of the raw `id > 0` rejection. Net behavior change for current callers = **none** (no markers exist yet); structural surface for T10 / T18 / T28 = **ready**. +- `packages/chess/src/modifiers/auras.test.ts` — +2 tests under new describe `aura compute — markers participate (T7)`. All 10 prior tests pass byte-identical. + +### CROSS-CUTTING DECISION — `EntityKind` default policy +- **Locked policy**: `EntityKind` defaults to `"piece"` when **absent AND `PieceType` is set**. Returns `undefined` if neither is set. +- Rationale: `engine.spawnPiece` (engine.ts:841-869) does NOT seed `EntityKind` (T6 only added the schema slot). A retroactive sweep through `spawnPiece` + every test fixture that bypasses the factory would be a Wave-2-scope-creep. Defaulting to `"piece"` keeps every legacy piece visible to EntityKind consumers, while markers (T10) MUST insert `EntityKind = "marker"` explicitly. +- **Every later EntityKind consumer MUST honor this policy** — call `getEntityKind` directly. Specifically: + - T10 (marker factory): set `EntityKind = "marker"` explicitly. Don't rely on absence. + - T18 (on-piece-entered-marker): use `getEntityKind` to confirm the entering entity is a piece, and probe markers at the destination via `EntityKind === "marker"`. + - T28 (spawn-marker primitive): same — explicit `"marker"` fact. + - Audit fixes from `position-audit.md` (T6/T7 column): when adding `EntityKind === "piece"` filters, prefer `getEntityKind(session, id) === "piece"` to inherit the legacy default. +- Once a future cleanup sweep adds `EntityKind = "piece"` to `engine.spawnPiece`, the `PieceType`-fallback branch in `getEntityKind` can be retired without behavior change. + +### Helper location +- `getEntityKind` lives in `packages/chess/src/modifiers/auras.ts` (top of file, exported). Reasoning: T7 is the first consumer; a dedicated `util/entity-kind.ts` file would be premature for a 30-line helper. T10 / T18 import via `import { getEntityKind } from "../modifiers/auras.js"`. If a 3rd consumer arrives outside `modifiers/`, hoist to `util/` then. + +### Test harness pattern (REUSE for T10, T18, T28, Wave 8/10) +- Markers can be raw-inserted in tests until `engine.spawnMarker` lands: + ```ts + const markerId = engine.session.nextId(); + engine.session.insert(markerId, "EntityKind", "marker"); + engine.session.insert(markerId, "MarkerKind", "treasure"); // any locked kind + engine.session.insert(markerId, "Position", squareIndex); + // optional: AuraSpec, MarkerLifetime, MarkerOwner, MarkerLinks + ``` +- This pattern is BACKWARD-COMPATIBLE — the type system accepts it (T6 added the attrs). When T10's factory lands, tests can migrate or coexist. +- Square index reminder: file + rank*8. e2 = 12, d3 = 19, e1 = 4. Same convention as T2's harness notes. + +### Subtle behavior contracts pinned by tests +1. **Marker → adjacent piece**: marker with `AuraSpec` IS picked up as an emitter; pieces in range receive contributions normally. Locked. +2. **Piece → marker (benign noop)**: marker WITHIN range of a piece's aura DOES receive an `AuraContributions` fact. Documented as benign because nothing reads piece-only attrs (HpBonus, AttackBonus, etc.) off a marker. **Contract**: if a future feature exposes piece-attrs on markers, this test will catch the hazard. T10 / T22 must NOT introduce piece-attr reads on marker entities without revisiting this test. + +### Surprises / gotchas +- `Session<…>` IS NOT generic at the public-API surface used here — `session.allFacts()` returns untyped facts. Casts (`f.value as Square`) are still needed. The `getEntityKind` signature uses raw `Session` (no generic param) to match the existing aura code's style; same casts apply. +- `EntityKindValue` is exported from `schema.ts:32` (T6). Use the type alias, not a string-literal union, when typing helpers. +- The pre-existing `id > 0` filter in `collectPositionedEntities` is RETAINED as defense-in-depth: `EntityKind` defaults catch most cases but `GAME_ENTITY` (id 0) and `PRESET_STATE_ENTITY` (id -1) might accidentally carry a `Position` fact via unrelated subsystems. Belt + suspenders. +- Existing `findPieceAtSquare` test helper in auras.test.ts pre-dates the marker era — it returns the FIRST entity at a square. With markers in play it could now match a marker. Tests in this file place markers on squares with no overlap with pieces under test, so this hasn't surfaced; downstream tests should prefer an EntityKind-filtered helper or use distinct squares. + +### Verification +- `bun test packages/chess/src/modifiers/auras.test.ts` → 12 pass / 0 fail (10 existing + 2 new), 165 expects +- `bun run check` → exit 0, **2006 tests** across 170 files (T7 added +2; T8 landed in the same wave window contributing +5) +- LSP diagnostics: clean on `auras.ts` AND `auras.test.ts` +- Evidence: `.sisyphus/evidence/task-7-marker-aura.txt` + + +## [2026-04-26T08:32Z] T10 marker entity factory (engine.spawnMarker / removeMarker / getMarkersAtSquare) + +### Method signatures (added to ChessEngine in `packages/chess/src/engine.ts`) +```ts +spawnMarker( + kind: MarkerKindValue, + square: Square, + opts: { + readonly lifetime: MarkerLifetimeValue; + readonly owner?: PieceColor; + readonly links?: readonly EntityId[]; + }, +): EntityId; + +removeMarker(id: EntityId): void; // idempotent — guarded by per-attr `get` check +getMarkersAtSquare(square: Square): EntityId[]; // sorted by MARKER_KIND_PRIORITY asc, tie-break by entity id asc +``` + +- File location: `packages/chess/src/engine.ts:920-998` (right after + `spawnPiece`, before `dealDamage`). Did NOT conflict with T9's + `rng()` / `setRngSeed()` block (those are above in the same class). + +### Priority table location +- `MARKER_KIND_PRIORITY` is a module-level const in `engine.ts` + (above the `ChessEngine` class). Hardcoded verbatim from T0 ADR + in `decisions.md` § Marker Collision Priority. Do NOT introduce + a runtime override path — adding a new marker kind is a + plan-amending event. +- `MARKER_ATTRS` (sibling const) is the canonical list of attrs + `removeMarker` retracts: EntityKind, MarkerKind, Position, + MarkerLifetime, MarkerOwner, MarkerLinks. T18 and T30 should + reference this same list when implementing on-piece-entered-marker + cleanup and the despawn-marker primitive (don't rebuild it). + +### Tie-break choice (LOCKED for T18 dispatch parity) +- Secondary sort in `getMarkersAtSquare` is `(a as number) - (b as number)` + — entity id ASCENDING. So two markers of the SAME kind on one + square always resolve in spawn order (older id wins). T18's + `on-piece-entered-marker` MUST iterate the result of + `getMarkersAtSquare` directly to inherit this ordering — do not + re-sort, do not invert. + +### Optional-fact discipline (cross-task contract) +- `MarkerOwner` and `MarkerLinks` are ONLY inserted when + caller-supplied. `engine.session.get(id, "MarkerOwner")` returns + `undefined` for neutral markers — consumers MUST treat undefined + as "no owner" (do not coerce to a default color). T18/T28/T29/T30 + primitives that read MarkerOwner need an explicit `=== undefined` + check. +- `removeMarker` retracts each attr individually (guarded by a + presence check) so it is safe on entities that never had owner + or links facts. Idempotent — calling twice does not throw. + +### Cascade-removal NOT in scope (T30 deferral) +- `removeMarker` does NOT touch `MarkerLinks` partners. Removing a + portal-end marker leaves its paired endpoint intact; T30 + (despawn-marker primitive) owns paired-cleanup semantics so + callers retain control over portal-pair behavior. + +### EntityKind discriminator filtering +- `getMarkersAtSquare` iterates `session.allFacts()` filtering + `attr === "Position" && value === square`, then narrows by + `session.get(id, "EntityKind") === "marker"`. Confirms T7's + EntityKind audit pattern — every Position-attr consumer that + cares about pieces vs. markers must perform the same narrowing. +- Pieces in the standard starting layout do NOT have EntityKind + facts seeded yet (T7's job per the audit list). The + `getMarkersAtSquare(0)` test on a starting-position rook returns + `[]` because `EntityKind` on the rook is `undefined`, which + fails the `=== "marker"` check — works either way (filter is + positive, not negative). + +### Verification +- `bun test packages/chess/src/engine.spawnMarker.test.ts` → 8 pass / 0 fail / 20 expects +- `bun run check` → exit 0, **2014 tests** across 171 files (was 1999 after T9; +8 from T10's new file, +7 from T7/T8 parity tests landing in same wave) +- Evidence: `.sisyphus/evidence/task-10-marker-priority.txt` diff --git a/.sisyphus/plans/thressgame-coverage.md b/.sisyphus/plans/thressgame-coverage.md index c6aad48..2827d20 100644 --- a/.sisyphus/plans/thressgame-coverage.md +++ b/.sisyphus/plans/thressgame-coverage.md @@ -568,7 +568,7 @@ Max Concurrent: 8 (Waves 5+6+7+9 overlap) - Message: `docs(thressgame): param walker var-conflict audit` - Files: `.sisyphus/notepads/thressgame-coverage/var-conflict-audit.md` -- [ ] 6. New entity attrs (EntityKind, MarkerKind, MarkerLifetime, MarkerOwner, MarkerLinks, RngSeed, RngStream) +- [x] 6. New entity attrs (EntityKind, MarkerKind, MarkerLifetime, MarkerOwner, MarkerLinks, RngSeed, RngStream) **What to do**: - Edit `packages/chess/src/schema.ts`: extend ChessAttrMap with `EntityKind: "piece" | "marker"`, `MarkerKind: "mine" | "pit" | "portal-end" | "frozen-square" | "treasure" | "death-square" | "tornado" | "blocked"`, `MarkerLifetime: { kind: "permanent" } | { kind: "moves"; expiresAtMove: number } | { kind: "one-shot" }`, `MarkerOwner: "white" | "black" | undefined`, `MarkerLinks: readonly EntityId[]`, `RngSeed: number`, `RngStream: number` @@ -610,7 +610,7 @@ Max Concurrent: 8 (Waves 5+6+7+9 overlap) - Message: `feat(chess): marker + RNG entity attrs` - Files: `packages/chess/src/schema.ts`, `packages/chess/src/modifiers/custom/apply.ts`, `packages/chess/src/schema.test.ts` -- [ ] 7. Aura compute filter (markers participate) +- [x] 7. Aura compute filter (markers participate) **What to do**: - Edit `packages/chess/src/modifiers/auras.ts`: in `computeAuraFacts()`, where it walks all entities, change filter from "has PieceType" to "EntityKind in {piece, marker}" @@ -652,7 +652,7 @@ Max Concurrent: 8 (Waves 5+6+7+9 overlap) - Message: `feat(chess): aura compute includes markers` - Files: `packages/chess/src/modifiers/auras.ts`, `packages/chess/src/modifiers/auras.test.ts` -- [ ] 8. Movement-replacement attrs +- [x] 8. Movement-replacement attrs **What to do**: - Edit `packages/chess/src/schema.ts`: add `MovesAs: PieceType | undefined` (per piece — overrides movement pattern), `MovesAlsoAs: PieceType | undefined` (per piece — additive secondary pattern), `SlideMustBeMaxDistance: boolean` (per piece OR game), `BlockAllExceptKing: boolean` (game), `KingExtraReach: number` (per piece) @@ -690,7 +690,7 @@ Max Concurrent: 8 (Waves 5+6+7+9 overlap) - Message: `feat(chess): movement-replacement attrs` - Files: schema.ts, apply.ts, schema.test.ts -- [ ] 9. Mulberry32 PRNG utility + integration-preset seed init +- [x] 9. Mulberry32 PRNG utility + integration-preset seed init **What to do**: - Create `packages/chess/src/util/rng.ts` exporting `class SeededRng { constructor(seed: number); next(): number /* 0..1 */; nextInt(max: number): number; pick(arr: readonly T[]): T }` @@ -733,7 +733,7 @@ Max Concurrent: 8 (Waves 5+6+7+9 overlap) - Message: `feat(chess): seeded Mulberry32 PRNG + integration preset init` - Files: `packages/chess/src/util/rng.ts`, `packages/chess/src/util/rng.test.ts`, `packages/chess/src/modifiers/custom/apply.ts` -- [ ] 10. Marker entity factory (engine.spawnMarker) +- [x] 10. Marker entity factory (engine.spawnMarker) **What to do**: - Edit `packages/chess/src/engine.ts`: add `spawnMarker(kind: MarkerKind, square: Square, opts: { lifetime: MarkerLifetime; owner?: Color; links?: readonly EntityId[] }): EntityId` (mirrors `spawnPiece` pattern at engine.ts:741-769) diff --git a/packages/chess/src/engine-presets.test.ts b/packages/chess/src/engine-presets.test.ts index 8d12213..ce0c2ba 100644 --- a/packages/chess/src/engine-presets.test.ts +++ b/packages/chess/src/engine-presets.test.ts @@ -286,3 +286,77 @@ describe("ChessEngine({ layout, activePresets }) — options-bag constructor", ( expect(engine.activePresets.list()[0]?.id).toBe("pawns-move-backward"); }); }); + +describe("engine.rng() — persistent seeded stream (T9)", () => { + it("RngSeed and RngStream are initialized after engine construction", () => { + const e = new ChessEngine(); + expect(typeof e.session.get(GAME_ENTITY, "RngSeed")).toBe("number"); + expect(e.session.get(GAME_ENTITY, "RngStream")).toBe(0); + }); + + it("each next() call advances RngStream by exactly 1", () => { + const e = new ChessEngine(); + const before = e.session.get(GAME_ENTITY, "RngStream") as number; + e.rng().next(); + e.rng().next(); + e.rng().next(); + const after = e.session.get(GAME_ENTITY, "RngStream") as number; + expect(after).toBe(before + 3); + }); + + it("nextInt and pick also advance the stream", () => { + const e = new ChessEngine(); + e.rng().nextInt(10); + e.rng().pick(["a", "b", "c"]); + expect(e.session.get(GAME_ENTITY, "RngStream")).toBe(2); + }); + + it("two engines with the same gameId produce the same sequence", () => { + const a = new ChessEngine({ gameId: "deterministic-test" }); + const b = new ChessEngine({ gameId: "deterministic-test" }); + for (let i = 0; i < 5; i++) { + expect(a.rng().next()).toBe(b.rng().next()); + } + }); + + it("two engines with default seed (no gameId) produce the same sequence", () => { + const a = new ChessEngine(); + const b = new ChessEngine(); + for (let i = 0; i < 5; i++) { + expect(a.rng().next()).toBe(b.rng().next()); + } + }); + + it("two engines with different gameIds produce different sequences", () => { + const a = new ChessEngine({ gameId: "room-a" }); + const b = new ChessEngine({ gameId: "room-b" }); + const seqA = Array.from({ length: 5 }, () => a.rng().next()); + const seqB = Array.from({ length: 5 }, () => b.rng().next()); + expect(seqA).not.toEqual(seqB); + }); + + it("setRngSeed overrides the seed and resets the stream", () => { + const e = new ChessEngine(); + e.rng().next(); + e.rng().next(); + expect(e.session.get(GAME_ENTITY, "RngStream")).toBe(2); + e.setRngSeed(99); + expect(e.session.get(GAME_ENTITY, "RngSeed")).toBe(99); + expect(e.session.get(GAME_ENTITY, "RngStream")).toBe(0); + }); + + it("setRngSeed accepts a string game id", () => { + const a = new ChessEngine(); + a.setRngSeed("room-7"); + const b = new ChessEngine({ gameId: "room-7" }); + expect(a.session.get(GAME_ENTITY, "RngSeed")).toBe( + b.session.get(GAME_ENTITY, "RngSeed"), + ); + }); + + it("rng() throws if RngSeed has been retracted", () => { + const e = new ChessEngine(); + e.session.retract(GAME_ENTITY, "RngSeed"); + expect(() => e.rng().next()).toThrow(); + }); +}); diff --git a/packages/chess/src/engine.spawnMarker.test.ts b/packages/chess/src/engine.spawnMarker.test.ts new file mode 100644 index 0000000..6a5e818 --- /dev/null +++ b/packages/chess/src/engine.spawnMarker.test.ts @@ -0,0 +1,98 @@ +/** + * Engine-surface tests for the T10 marker entity factory: + * - `engine.spawnMarker(kind, square, opts)` + * - `engine.removeMarker(id)` + * - `engine.getMarkersAtSquare(square)` + * + * Mirrors `engine.spawnPiece` semantics. Wave-4 trigger dispatch (the + * thing that fires `on-piece-entered-marker` etc.) is NOT exercised + * here — these tests cover pure fact lifecycle + the locked priority + * sort table from `decisions.md` § Marker Collision Priority. + */ +import { describe, it, expect } from "vitest"; +import "./presets/index.js"; +import { ChessEngine } from "./engine.js"; + +describe("ChessEngine.spawnMarker / removeMarker / getMarkersAtSquare (T10)", () => { + it("spawnMarker inserts EntityKind=marker, MarkerKind, Position, MarkerLifetime", () => { + const e = new ChessEngine(); + const id = e.spawnMarker("mine", 28 /* e4 */, { + lifetime: { kind: "permanent" }, + }); + expect(e.session.get(id, "EntityKind")).toBe("marker"); + expect(e.session.get(id, "MarkerKind")).toBe("mine"); + expect(e.session.get(id, "Position")).toBe(28); + expect(e.session.get(id, "MarkerLifetime")).toEqual({ kind: "permanent" }); + // Optional facts must be ABSENT (not stored as undefined) when not supplied. + expect(e.session.get(id, "MarkerOwner")).toBeUndefined(); + expect(e.session.get(id, "MarkerLinks")).toBeUndefined(); + }); + + it("spawnMarker with optional owner + links inserts those facts", () => { + const e = new ChessEngine(); + const partner = e.spawnMarker("portal-end", 0, { + lifetime: { kind: "permanent" }, + }); + const id = e.spawnMarker("portal-end", 63, { + lifetime: { kind: "permanent" }, + owner: "white", + links: [partner], + }); + expect(e.session.get(id, "MarkerOwner")).toBe("white"); + expect(e.session.get(id, "MarkerLinks")).toEqual([partner]); + }); + + it("removeMarker retracts every marker fact for the entity", () => { + const e = new ChessEngine(); + const id = e.spawnMarker("mine", 28, { + lifetime: { kind: "one-shot" }, + owner: "black", + }); + e.removeMarker(id); + expect(e.session.get(id, "EntityKind")).toBeUndefined(); + expect(e.session.get(id, "MarkerKind")).toBeUndefined(); + expect(e.session.get(id, "Position")).toBeUndefined(); + expect(e.session.get(id, "MarkerLifetime")).toBeUndefined(); + expect(e.session.get(id, "MarkerOwner")).toBeUndefined(); + expect(e.session.get(id, "MarkerLinks")).toBeUndefined(); + }); + + it("removeMarker is idempotent (safe to call on a non-existent / already-removed entity)", () => { + const e = new ChessEngine(); + const id = e.spawnMarker("mine", 28, { lifetime: { kind: "permanent" } }); + e.removeMarker(id); + expect(() => e.removeMarker(id)).not.toThrow(); + }); + + it("getMarkersAtSquare sorts by MARKER_KIND_PRIORITY (portal-end < mine)", () => { + const e = new ChessEngine(); + const mineId = e.spawnMarker("mine", 28, { lifetime: { kind: "one-shot" } }); + const portalId = e.spawnMarker("portal-end", 28, { + lifetime: { kind: "permanent" }, + }); + const ids = e.getMarkersAtSquare(28); + // portal-end priority 1 must come before mine priority 3, regardless of + // spawn order. + expect(ids).toEqual([portalId, mineId]); + }); + + it("getMarkersAtSquare ties break by entity id ascending (deterministic spawn order)", () => { + const e = new ChessEngine(); + const a = e.spawnMarker("mine", 28, { lifetime: { kind: "permanent" } }); + const b = e.spawnMarker("mine", 28, { lifetime: { kind: "permanent" } }); + expect(e.getMarkersAtSquare(28)).toEqual([a, b]); + }); + + it("getMarkersAtSquare returns empty for a square with no markers", () => { + const e = new ChessEngine(); + expect(e.getMarkersAtSquare(35)).toEqual([]); + }); + + it("getMarkersAtSquare excludes pieces (only EntityKind=marker entities are returned)", () => { + const e = new ChessEngine(); + // Default starting position has a white rook on a1 (square 0). + expect(e.getMarkersAtSquare(0)).toEqual([]); + const m = e.spawnMarker("blocked", 0, { lifetime: { kind: "permanent" } }); + expect(e.getMarkersAtSquare(0)).toEqual([m]); + }); +}); diff --git a/packages/chess/src/engine.ts b/packages/chess/src/engine.ts index f072d4e..b2e1a3a 100644 --- a/packages/chess/src/engine.ts +++ b/packages/chess/src/engine.ts @@ -11,6 +11,9 @@ import { PRESET_STATE_ENTITY, type PieceType, type PieceColor, + type Square, + type MarkerKindValue, + type MarkerLifetimeValue, } from "./schema.js"; import { applyLayout, CLASSIC_LAYOUT } from "./starting-position.js"; import type { StartingLayout } from "./layouts/types.js"; @@ -89,6 +92,7 @@ import { assertSeedConsumerIntegrity, getEngineSeedManifest, } from "./modifiers/primitives/manifest.js"; +import { SeededRng, deriveSeedFromGameId } from "./util/rng.js"; // Q3.2: run the primitive-seed consumer integrity check once, lazily // on first engine construction. Module load order (primitive @@ -360,8 +364,56 @@ export interface EngineOptions { * engine carries its own set so cross-room leakage is impossible. */ readonly customModifiers?: readonly CustomModifierDescriptor[]; + /** + * Optional game id used to derive the seeded-RNG starting state. + * + * When supplied, `RngSeed` is initialized to + * `deriveSeedFromGameId(gameId)` so two engines constructed with the + * same id produce identical RNG sequences. When omitted, the seed + * defaults to `1` (the deterministic test default) — sufficient for + * unit tests and replays that pin the seed via `setRngSeed`. + * + * `RngStream` is always initialized to 0 regardless of the seed + * source; the per-draw counter is what advances during gameplay. + */ + readonly gameId?: string; } +/** + * Marker collision priority (T0 ADR — locked, see + * `.sisyphus/notepads/thressgame-coverage/decisions.md` § Marker + * Collision Priority). Lower number = higher priority = sorted / + * fired first when multiple markers share a square. + * + * Hardcoded — not user-configurable. Adding a marker kind requires a + * plan amendment with explicit priority insertion (no auto-assign). + */ +const MARKER_KIND_PRIORITY: Readonly> = { + "portal-end": 1, + "frozen-square": 2, + mine: 3, + pit: 4, + "death-square": 5, + tornado: 6, + treasure: 7, + blocked: 8, +}; + +/** + * Marker attribute names that `spawnMarker` may write and + * `removeMarker` retracts. EntityKind/MarkerKind/Position/MarkerLifetime + * are always written; MarkerOwner/MarkerLinks are optional and only + * present when supplied at spawn time. + */ +const MARKER_ATTRS = [ + "EntityKind", + "MarkerKind", + "Position", + "MarkerLifetime", + "MarkerOwner", + "MarkerLinks", +] as const; + export class ChessEngine { public readonly session: Session; /** @@ -516,6 +568,14 @@ export class ChessEngine { const layout = opts.layout ?? CLASSIC_LAYOUT; applyLayout(this.session, layout); + // Seed RNG state on GAME_ENTITY BEFORE any preset / profile + // activation, so an `onActivate` hook that draws from the RNG + // (e.g. a "shuffle starting positions" preset) sees initialized + // facts rather than throwing in `engine.rng()`. The stream always + // starts at 0 — the per-draw counter is what advances during play. + this.session.insert(GAME_ENTITY, "RngSeed", deriveSeedFromGameId(opts.gameId)); + this.session.insert(GAME_ENTITY, "RngStream", 0); + // Profile seeding runs BEFORE the position is recorded for // threefold repetition — the modifier facts are part of the // "initial position" from a repetition-tracking perspective, and @@ -738,6 +798,84 @@ export class ChessEngine { * initial placements should activate AFTER piece seeding or use * this function explicitly in their `onActivate`. */ + /** + * Override the seeded-RNG seed. Resets `RngStream` to 0 so the next + * draw is the first draw under the new seed. + * + * Accepts either a numeric seed (used verbatim, coerced to int32) or + * a string game id (run through `deriveSeedFromGameId`). Intended for + * tests and for server boot where the game id arrives after engine + * construction (the constructor's `gameId` option is the preferred + * path when the id is known up front). + * + * Calling this mid-game is allowed but breaks replay-from-this-point + * — only do it before any RNG-consuming primitive has fired, or as + * part of a deliberate session reseed. + */ + setRngSeed(seedOrGameId: number | string): void { + const seed = + typeof seedOrGameId === "number" + ? seedOrGameId | 0 + : deriveSeedFromGameId(seedOrGameId); + this.session.insert(GAME_ENTITY, "RngSeed", seed); + this.session.insert(GAME_ENTITY, "RngStream", 0); + } + + /** + * Obtain a handle on the engine's seeded RNG. + * + * Each call to `next()` / `nextInt()` / `pick()` on the returned + * handle advances the persistent `RngStream` fact on `GAME_ENTITY` + * by 1 and constructs a fresh `SeededRng(RngSeed + currentStream)` + * to draw from. This guarantees: + * + * 1. **Determinism**: same `RngSeed` + same call sequence on a + * same-state engine → same outputs (Mulberry32 is stateless + * given a seed). + * 2. **Persistence across calls**: two calls to `engine.rng().next()` + * consume two distinct stream offsets, so they produce + * different draws — the stream advances even if the caller + * doesn't hold the handle across calls. + * 3. **Replay-safety**: a save+resume preserving `RngSeed` and + * `RngStream` continues drawing exactly where the previous + * session left off. + * + * Throws if `RngSeed` / `RngStream` aren't initialized — this + * indicates a bug (the constructor should always seed both). + * + * Used by `with-probability` and `random-pick` primitives (Wave 7). + */ + rng(): { + next(): number; + nextInt(max: number): number; + pick(arr: readonly T[]): T; + } { + const seedFact = this.session.get(GAME_ENTITY, "RngSeed"); + const streamFact = this.session.get(GAME_ENTITY, "RngStream"); + if (typeof seedFact !== "number" || typeof streamFact !== "number") { + throw new Error( + "ChessEngine.rng: RngSeed and RngStream must be initialized on " + + "GAME_ENTITY (the engine constructor seeds both — this error " + + "indicates the facts were retracted out from under the engine)", + ); + } + const session = this.session; + // Re-read stream on every advance so two concurrent rng() handles + // share the persistent counter. Re-read seed too in case + // `setRngSeed` was called between handle creation and use. + const advance = (): SeededRng => { + const seed = session.get(GAME_ENTITY, "RngSeed") as number; + const stream = session.get(GAME_ENTITY, "RngStream") as number; + session.insert(GAME_ENTITY, "RngStream", stream + 1); + return new SeededRng(seed + stream); + }; + return { + next: () => advance().next(), + nextInt: (max: number) => advance().nextInt(max), + pick: (arr: readonly T[]) => advance().pick(arr), + }; + } + spawnPiece( type: PieceType, color: PieceColor, @@ -768,6 +906,99 @@ export class ChessEngine { return id; } + /** + * Spawn a square-state marker entity at the given square (T10). + * + * Mirrors {@link ChessEngine.spawnPiece}: allocates an id via + * `session.nextId()` then inserts the marker's facts. EntityKind is + * fixed to `"marker"` so every Position-attr consumer can filter + * pieces vs. markers cleanly (audit was T7's job). + * + * Triggers (e.g. `on-piece-entered-marker`, region recompute) are + * NOT fired here — that's the Wave 4 dispatcher's responsibility. + * This factory only writes facts; intent-side bookkeeping (lifetime + * decrement, paired-marker cascade) lives elsewhere. + * + * Optional facts (`MarkerOwner`, `MarkerLinks`) are only inserted + * when supplied — never store `undefined` to avoid meaningless EAV + * facts that would have to be filtered later. + * + * @param kind one of 8 locked marker kinds (see `MarkerKindValue`). + * @param square 0..63 board square index (same coord space as pieces). + * @param opts.lifetime REQUIRED — markers without lifetime would leak. + * @param opts.owner optional; omit for neutral / unowned markers. + * @param opts.links optional; e.g. portal-end pairs link to each other. + * @returns the new marker's EntityId. + */ + spawnMarker( + kind: MarkerKindValue, + square: Square, + opts: { + readonly lifetime: MarkerLifetimeValue; + readonly owner?: PieceColor; + readonly links?: readonly EntityId[]; + }, + ): EntityId { + const id = this.session.nextId(); + this.session.insert(id, "EntityKind", "marker"); + this.session.insert(id, "MarkerKind", kind); + this.session.insert(id, "Position", square); + this.session.insert(id, "MarkerLifetime", opts.lifetime); + if (opts.owner !== undefined) { + this.session.insert(id, "MarkerOwner", opts.owner); + } + if (opts.links !== undefined) { + this.session.insert(id, "MarkerLinks", opts.links); + } + return id; + } + + /** + * Retract every marker-attribute fact for `id` (T10). + * + * Idempotent: safe to call on a non-existent or already-removed + * entity (each per-attr retract is guarded by a `get` presence + * check). Does NOT cascade-remove paired markers — T30 + * (despawn-marker primitive) explicitly defers paired-marker + * cleanup to its own logic so that callers retain control over + * portal-pair semantics. + */ + removeMarker(id: EntityId): void { + for (const attr of MARKER_ATTRS) { + if (this.session.get(id, attr) !== undefined) { + this.session.retract(id, attr); + } + } + } + + /** + * Return marker entity ids currently positioned at `square`, + * sorted by {@link MARKER_KIND_PRIORITY} ascending (T10). + * + * Filters by `EntityKind === "marker"` so chess pieces sharing the + * Position attr are excluded. Secondary sort is entity-id ascending + * so two markers of the same kind on one square always resolve in + * spawn order — important for deterministic dispatch when the + * Wave-4 trigger pipeline iterates the result list. + */ + getMarkersAtSquare(square: Square): EntityId[] { + const result: EntityId[] = []; + for (const fact of this.session.allFacts()) { + if (fact.attr !== "Position" || fact.value !== square) continue; + const id = fact.id as EntityId; + if (this.session.get(id, "EntityKind") !== "marker") continue; + result.push(id); + } + return result.sort((a, b) => { + const ka = this.session.get(a, "MarkerKind") as MarkerKindValue; + const kb = this.session.get(b, "MarkerKind") as MarkerKindValue; + const pa = MARKER_KIND_PRIORITY[ka] ?? 999; + const pb = MARKER_KIND_PRIORITY[kb] ?? 999; + if (pa !== pb) return pa - pb; + return (a as number) - (b as number); + }); + } + /** * Deal `amount` damage to `target`, running it through the preset * damage pipeline before applying the default kill-on-damage rule. diff --git a/packages/chess/src/modifiers/apply.ts b/packages/chess/src/modifiers/apply.ts index be1ccf2..099fd9e 100644 --- a/packages/chess/src/modifiers/apply.ts +++ b/packages/chess/src/modifiers/apply.ts @@ -112,6 +112,29 @@ registerAttrConsumer("OnCheckReceivedHooks"); registerAttrConsumer("OnCheckDeliveredHooks"); registerAttrConsumer("OnCapturedHooks"); registerAttrConsumer("OnMovedOntoSquareHooks"); +// T6 — marker subsystem + seeded-RNG attrs. Registered here so the +// load-time integrity check (`assertSeedConsumerIntegrity`) can see +// at least one consumer for each newly-introduced schema attr. The +// actual readers land in T7 (EntityKind filters), T10 (marker +// factory), T19 (lifetime decrementer), and the RNG primitives. +registerAttrConsumer("EntityKind"); +registerAttrConsumer("MarkerKind"); +registerAttrConsumer("MarkerLifetime"); +registerAttrConsumer("MarkerOwner"); +registerAttrConsumer("MarkerLinks"); +registerAttrConsumer("RngSeed"); +registerAttrConsumer("RngStream"); +// T8 — movement-replacement attrs. Schema/registration only this wave; +// the actual move-gen readers land in Wave 7 (T40 set-moves-as for +// MovesAs/MovesAlsoAs, T41 for pawn-pushes-pieces interactions, and +// Wave 10 ice-physics tests for SlideMustBeMaxDistance / BlockAllExceptKing). +// Registering them now anchors the load-time integrity check so the +// attrs are visible to `assertSeedConsumerIntegrity`. +registerAttrConsumer("MovesAs"); +registerAttrConsumer("MovesAlsoAs"); +registerAttrConsumer("SlideMustBeMaxDistance"); +registerAttrConsumer("BlockAllExceptKing"); +registerAttrConsumer("KingExtraReach"); /** * Per-engine pre-move HP snapshot, used by the on-damaged trigger diff --git a/packages/chess/src/modifiers/auras.test.ts b/packages/chess/src/modifiers/auras.test.ts index 61b959b..f9908b6 100644 --- a/packages/chess/src/modifiers/auras.test.ts +++ b/packages/chess/src/modifiers/auras.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { ChessEngine } from "../engine.js"; import type { ChessAttrMap } from "../schema.js"; -import { computeAuraFacts } from "./auras.js"; +import { computeAuraFacts, getEntityKind } from "./auras.js"; import "./primitives/index.js"; function findPieceAtSquare(engine: ChessEngine, square: number) { @@ -251,3 +251,85 @@ describe("computeAuraFacts", () => { expect(getContribs(engine, d2Pawn)?.["HpBonus"]).toBe(1); }); }); + +describe("aura compute — markers participate (T7)", () => { + it("marker with AuraSpec emits aura → adjacent piece receives contribution", () => { + const engine = new ChessEngine(); + + // Spawn a marker raw (T10's spawnMarker factory hasn't landed yet — + // this test documents the contract that T10 will fulfil). The + // marker carries: EntityKind="marker", MarkerKind, Position, AuraSpec. + // Its presence on a square next to e2 (square 12) is enough to make + // the e2 pawn pick up the aura contribution after computeAuraFacts. + // + // e2 (square 12) is at file=4, rank=1. d3 (square 19) is at + // file=3, rank=2 — chebyshev distance 1 from e2. Place the marker + // there. + const markerId = engine.session.nextId(); + engine.session.insert(markerId, "EntityKind", "marker"); + engine.session.insert(markerId, "MarkerKind", "treasure"); + engine.session.insert(markerId, "Position", 19); // d3 + engine.session.insert(markerId, "AuraSpec", [ + { radius: 1, targetAttr: "HpBonus", delta: 7 }, + ]); + + computeAuraFacts(engine.session); + + // e2 pawn (square 12) is chebyshev 1 from d3 — IN range. + const e2Pawn = findPieceAtSquare(engine, 12); + const contribs = getContribs(engine, e2Pawn); + expect(contribs).toBeDefined(); + expect(contribs!["HpBonus"]).toBe(7); + + // Sanity: d2 pawn (square 11) is also chebyshev 1 from d3 — IN range. + const d2Pawn = findPieceAtSquare(engine, 11); + expect(getContribs(engine, d2Pawn)?.["HpBonus"]).toBe(7); + + // e7 pawn (square 52) is far away — OUT of range. + const e7Pawn = findPieceAtSquare(engine, 52); + expect(getContribs(engine, e7Pawn)).toBeUndefined(); + }); + + it("marker receiving piece-only attr is benign (no crash, fact stored but unread)", () => { + // Place a marker in range of a piece-emitted aura. Per T0 (decisions.md + // §"Square State via Marker Entities"), markers participate in aura + // compute as both sources AND targets. Receiving piece-only attrs + // (HpBonus, AttackBonus, etc.) is documented as a benign no-op: + // nothing in the engine reads HpBonus off a marker, so the + // AuraContributions fact persists harmlessly. This test pins that + // contract — if anything ever DOES read HpBonus from a marker, the + // failure surfaces here first. + const engine = new ChessEngine(); + + // White king at e1 emits a wide-range Hp aura. + const whiteKing = findPieceAtSquare(engine, 4); + seedAura(engine, whiteKing, { + radius: 2, + targetAttr: "HpBonus", + delta: 5, + }); + + // Spawn a marker at d2 (square 11) — chebyshev 1 from e1. + const markerId = engine.session.nextId(); + engine.session.insert(markerId, "EntityKind", "marker"); + engine.session.insert(markerId, "MarkerKind", "frozen-square"); + engine.session.insert(markerId, "Position", 11); + + // Compute — must NOT throw. + expect(() => computeAuraFacts(engine.session)).not.toThrow(); + + // The marker received the piece-only attr — fact stored, benignly. + const markerContribs = engine.session.get( + markerId, + "AuraContributions", + ) as Record | undefined; + expect(markerContribs).toBeDefined(); + expect(markerContribs!["HpBonus"]).toBe(5); + + // Discriminator sanity: getEntityKind correctly reports "marker" + // for the spawned marker and "piece" for the legacy-seeded king + // (which lacks an explicit EntityKind fact — legacy default kicks in). + expect(getEntityKind(engine.session, markerId)).toBe("marker"); + expect(getEntityKind(engine.session, whiteKing)).toBe("piece"); + }); +}); diff --git a/packages/chess/src/modifiers/auras.ts b/packages/chess/src/modifiers/auras.ts index afa1c2c..a93a4a1 100644 --- a/packages/chess/src/modifiers/auras.ts +++ b/packages/chess/src/modifiers/auras.ts @@ -25,9 +25,42 @@ * modifier attrs). */ import type { EntityId, Session } from "@paratype/rete"; -import type { ChessAttrMap, Square } from "../schema.js"; +import type { ChessAttrMap, EntityKindValue, Square } from "../schema.js"; import { fileOf, rankOf } from "../coord.js"; +/** + * Resolve the entity-kind discriminator with a legacy-compat default. + * + * `EntityKind` was added in T6 but `engine.spawnPiece` does NOT yet seed + * it — every piece entity in the wild carries `PieceType` instead. To + * keep the aura walker (and any downstream EntityKind consumer) backward + * compatible without a sweeping engine refactor, we treat the absence of + * `EntityKind` as the legacy "piece" default whenever a `PieceType` fact + * is present. Markers (T10) MUST insert `EntityKind = "marker"` explicitly + * — that's the discriminator that distinguishes them from pieces. An + * entity with neither `EntityKind` nor `PieceType` returns `undefined`, + * matching the prior `id > 0` rejection. + * + * This is a CROSS-CUTTING decision (T7): every later EntityKind consumer + * (T10 marker factory, T18 on-piece-entered-marker, T28 spawn-marker + * primitive) MUST honor the same default — `EntityKind ?? "piece"` when + * `PieceType` is set, else `undefined`. Once `engine.spawnPiece` seeds + * `EntityKind = "piece"` directly (a future cleanup, not in this plan) + * the `PieceType` fallback can be retired without behavior change. + */ +export function getEntityKind( + session: Session, + id: EntityId, +): EntityKindValue | undefined { + const explicit = session.get(id, "EntityKind") as + | EntityKindValue + | undefined; + if (explicit !== undefined) return explicit; + // Legacy / pre-T6 entities — pieces don't carry EntityKind yet. + if (session.get(id, "PieceType") !== undefined) return "piece"; + return undefined; +} + /** * Recompute `AuraContributions` on every piece in the session. * Idempotent: calling twice produces the same state. @@ -43,11 +76,16 @@ import { fileOf, rankOf } from "../coord.js"; * accumulated contributions as a single `AuraContributions` fact. */ export function computeAuraFacts(session: Session): void { - // Step 1: gather every piece on the board with its square. - const pieces = collectPiecesWithPositions(session); + // Step 1: gather every positioned ENTITY (piece OR marker — T0 locks + // markers as participants in aura compute). Marker emitters can stamp + // aura contributions on adjacent pieces; pieces can also "emit" auras + // onto markers. The latter is a benign no-op — markers don't expose + // attrs like Hp/AttackBonus to the rest of the engine, so the + // `AuraContributions` fact is stored but never read off a marker. + const entities = collectPositionedEntities(session); // Step 2: retract stale contributions everywhere. - for (const { id } of pieces) { + for (const { id } of entities) { if (session.contains(id, "AuraContributions")) { session.retract(id, "AuraContributions"); } @@ -55,17 +93,17 @@ export function computeAuraFacts(session: Session): void { // Step 3: walk aura sources and accumulate contributions. // - // `staging` maps target pieceId → (attr → delta). + // `staging` maps target entityId → (attr → delta). const staging = new Map>(); - for (const source of pieces) { + for (const source of entities) { const auras = session.get(source.id, "AuraSpec") as | ChessAttrMap["AuraSpec"] | undefined; if (auras === undefined || auras.length === 0) continue; for (const aura of auras) { - for (const target of pieces) { + for (const target of entities) { if (target.id === source.id) continue; // no self-application if (chebyshev(source.square, target.square) > aura.radius) continue; @@ -91,16 +129,27 @@ export function computeAuraFacts(session: Session): void { // ── Helpers ─────────────────────────────────────────────────────────── -interface PieceWithSquare { +interface PositionedEntity { readonly id: EntityId; readonly square: Square; } -function collectPiecesWithPositions(session: Session): PieceWithSquare[] { - const out: PieceWithSquare[] = []; +/** + * Collect every positioned entity (pieces + markers) on the board. + * + * Filters by the `EntityKind` discriminator (defaulting to "piece" via + * `getEntityKind` for legacy pieces that don't yet carry the explicit + * fact). Entities lacking both `EntityKind` AND `PieceType` are skipped + * — these are non-board entities like `GAME_ENTITY` / `PRESET_STATE_ENTITY` + * that may carry a `Position` facsimile fact in unrelated subsystems. + */ +function collectPositionedEntities(session: Session): PositionedEntity[] { + const out: PositionedEntity[] = []; for (const f of session.allFacts()) { if (f.attr !== "Position") continue; if ((f.id as number) <= 0) continue; + const kind = getEntityKind(session, f.id); + if (kind !== "piece" && kind !== "marker") continue; out.push({ id: f.id, square: f.value as Square }); } return out; diff --git a/packages/chess/src/schema.test.ts b/packages/chess/src/schema.test.ts index d766b21..2345641 100644 --- a/packages/chess/src/schema.test.ts +++ b/packages/chess/src/schema.test.ts @@ -3,6 +3,9 @@ import { describe, it, expect } from "vitest"; import { GAME_ENTITY, chessFact, oppositeColor, PROMOTION_PIECES, type Square, + type EntityKindValue, + type MarkerKindValue, + type MarkerLifetimeValue, } from "./schema.js"; import type { EntityId } from "@paratype/rete"; @@ -60,3 +63,137 @@ describe("Square encoding", () => { expect(e4).toBe(28); }); }); + +// ─── T6: marker subsystem + seeded-RNG attrs ─────────────────────── +// +// Each test asserts that a `chessFact` round-trips the new attr at +// both the value and the type level. Type assertions are implicit: +// the chessFact generic narrows `value` to ChessAttrMap[K], so a +// shape mismatch surfaces as a tsc error during `bun run check`. +describe("T6 marker + RNG attrs", () => { + it("EntityKind: 'piece' | 'marker' values round-trip", () => { + const piece = chessFact(mkId(1), "EntityKind", "piece"); + const marker = chessFact(mkId(2), "EntityKind", "marker"); + expect(piece.value).toBe("piece"); + expect(marker.value).toBe("marker"); + // Compile-time check: assignment to the alias must succeed. + const v: EntityKindValue = piece.value; + expect(v).toBe("piece"); + }); + + it("MarkerKind accepts all 8 locked enum values", () => { + const kinds: readonly MarkerKindValue[] = [ + "mine", "pit", "portal-end", "frozen-square", + "treasure", "death-square", "tornado", "blocked", + ]; + for (let i = 0; i < kinds.length; i++) { + const f = chessFact(mkId(100 + i), "MarkerKind", kinds[i]!); + expect(f.value).toBe(kinds[i]); + } + expect(kinds).toHaveLength(8); + }); + + it("MarkerLifetime supports all 3 discriminated variants", () => { + const perm = chessFact(mkId(10), "MarkerLifetime", { kind: "permanent" }); + const moves = chessFact(mkId(11), "MarkerLifetime", { kind: "moves", expiresAtMove: 42 }); + const oneShot = chessFact(mkId(12), "MarkerLifetime", { kind: "one-shot" }); + expect(perm.value).toEqual({ kind: "permanent" }); + expect(moves.value).toEqual({ kind: "moves", expiresAtMove: 42 }); + expect(oneShot.value).toEqual({ kind: "one-shot" }); + // Discriminator narrowing exercise: + const v: MarkerLifetimeValue = moves.value; + if (v.kind === "moves") { + expect(v.expiresAtMove).toBe(42); + } + }); + + it("MarkerOwner stores 'white' or 'black' (absence encodes 'no owner')", () => { + const w = chessFact(mkId(20), "MarkerOwner", "white"); + const b = chessFact(mkId(21), "MarkerOwner", "black"); + expect(w.value).toBe("white"); + expect(b.value).toBe("black"); + }); + + it("MarkerLinks stores a readonly EntityId[]", () => { + const a = mkId(30); + const b = mkId(31); + const f = chessFact(mkId(32), "MarkerLinks", [a, b]); + expect(f.value).toEqual([a, b]); + expect(f.value).toHaveLength(2); + // Empty list is the canonical "no links" form. + const empty = chessFact(mkId(33), "MarkerLinks", []); + expect(empty.value).toEqual([]); + }); + + it("RngSeed stores a number on GAME_ENTITY", () => { + const f = chessFact(GAME_ENTITY, "RngSeed", 0xDEADBEEF); + expect(f.id).toBe(0); + expect(f.value).toBe(0xDEADBEEF); + expect(typeof f.value).toBe("number"); + }); + + it("RngStream stores a number on GAME_ENTITY (incremented per draw)", () => { + const f0 = chessFact(GAME_ENTITY, "RngStream", 0); + const f1 = chessFact(GAME_ENTITY, "RngStream", 1); + expect(f0.value).toBe(0); + expect(f1.value).toBe(1); + expect(typeof f1.value).toBe("number"); + }); +}); + +// ─── T8: movement-replacement attrs (Wave 2) ─────────────────────── +// +// Pure schema/registration round-trip checks — move-gen wiring lands +// in Wave 7 (T40, T41) and Wave 10 (ice-physics). Each test confirms +// the attr is present on ChessAttrMap and that `chessFact` narrows +// the value to the declared type (compile-time check). At runtime we +// just verify the value passes through unchanged. +describe("T8 movement-replacement attrs (Wave 2)", () => { + it("MovesAs accepts every PieceType value", () => { + const types = ["pawn", "knight", "bishop", "rook", "queen", "king"] as const; + for (let i = 0; i < types.length; i++) { + const f = chessFact(mkId(200 + i), "MovesAs", types[i]!); + expect(f.value).toBe(types[i]); + expect(f.attr).toBe("MovesAs"); + } + }); + + it("MovesAlsoAs accepts every PieceType value (additive secondary pattern)", () => { + const types = ["pawn", "knight", "bishop", "rook", "queen", "king"] as const; + for (let i = 0; i < types.length; i++) { + const f = chessFact(mkId(210 + i), "MovesAlsoAs", types[i]!); + expect(f.value).toBe(types[i]); + expect(f.attr).toBe("MovesAlsoAs"); + } + }); + + it("SlideMustBeMaxDistance accepts boolean (per-piece OR game-level)", () => { + const perPiece = chessFact(mkId(220), "SlideMustBeMaxDistance", true); + const gameLevel = chessFact(GAME_ENTITY, "SlideMustBeMaxDistance", true); + const off = chessFact(mkId(221), "SlideMustBeMaxDistance", false); + expect(perPiece.value).toBe(true); + expect(gameLevel.id).toBe(0); + expect(gameLevel.value).toBe(true); + expect(off.value).toBe(false); + expect(typeof perPiece.value).toBe("boolean"); + }); + + it("BlockAllExceptKing accepts boolean on GAME_ENTITY (game-level only)", () => { + const on = chessFact(GAME_ENTITY, "BlockAllExceptKing", true); + const off = chessFact(GAME_ENTITY, "BlockAllExceptKing", false); + expect(on.id).toBe(0); + expect(on.value).toBe(true); + expect(off.value).toBe(false); + expect(typeof on.value).toBe("boolean"); + }); + + it("KingExtraReach stores a number (per-piece extra king reach)", () => { + const zero = chessFact(mkId(230), "KingExtraReach", 0); + const two = chessFact(mkId(231), "KingExtraReach", 2); + const seven = chessFact(mkId(232), "KingExtraReach", 7); + expect(zero.value).toBe(0); + expect(two.value).toBe(2); + expect(seven.value).toBe(7); + expect(typeof two.value).toBe("number"); + }); +}); diff --git a/packages/chess/src/schema.ts b/packages/chess/src/schema.ts index b2e194d..ce89392 100644 --- a/packages/chess/src/schema.ts +++ b/packages/chess/src/schema.ts @@ -22,6 +22,45 @@ export type SquareFilter = | { readonly kind: "squares"; readonly squares: readonly Square[] } | { readonly kind: "predicate"; readonly file?: 0|1|2|3|4|5|6|7; readonly rank?: 0|1|2|3|4|5|6|7 }; +/** + * Discriminator for entity stored in session: a normal chess piece or a + * square-state marker (mine, pit, portal endpoint, etc.). Every callsite + * that reads `Position` and assumes the bearer is a piece MUST filter by + * this attr (audit recorded in `position-audit.md`; mechanical filter + * insertion is T7's job). + */ +export type EntityKindValue = "piece" | "marker"; + +/** + * Locked enum of marker discriminators. Adding a new kind requires a + * design-decision update in `decisions.md` ("Square State via Marker + * Entities" / "Marker Collision Priority") — do NOT extend ad-hoc. + */ +export type MarkerKindValue = + | "mine" + | "pit" + | "portal-end" + | "frozen-square" + | "treasure" + | "death-square" + | "tornado" + | "blocked"; + +/** + * How a marker leaves the board. + * - `permanent` — never auto-expires (e.g. arena tornado). + * - `moves` — `expiresAtMove` is an ABSOLUTE target move count + * (compared against `FullmoveNumber` / equivalent), + * NOT a countdown remainder. Decremented-style + * lifetimes are intentionally rejected. + * - `one-shot` — consumed (retracted) by the trigger that fires on + * entry; lifetime decrementer (T19) skips these. + */ +export type MarkerLifetimeValue = + | { readonly kind: "permanent" } + | { readonly kind: "moves"; readonly expiresAtMove: number } + | { readonly kind: "one-shot" }; + export type PieceType = "pawn" | "knight" | "bishop" | "rook" | "queen" | "king"; export type PieceColor = "white" | "black"; export type MoveType = "capture" | "step" | "slide"; @@ -140,6 +179,56 @@ export interface ChessAttrMap { readonly filter: SquareFilter; readonly primitives: readonly EffectPrimitiveNode[]; }[]; + // T6 — marker subsystem + seeded RNG attrs. + // Discriminator separating chess pieces from square-state markers. + // Seeded by `engine.spawnPiece` and the (forthcoming) `engine.spawnMarker`. + EntityKind: EntityKindValue; + // Marker discriminator + lifecycle facts (only present on marker entities). + MarkerKind: MarkerKindValue; + MarkerLifetime: MarkerLifetimeValue; + /** + * Owner side for owner-scoped markers (mine/treasure). Markers without + * an owner simply omit this fact rather than storing `undefined` — a + * stored `undefined` value would be meaningless in EAV semantics. + */ + MarkerOwner: PieceColor; + /** Paired-marker references (e.g. portal endpoints linking to each other). */ + MarkerLinks: readonly EntityId[]; + /** + * Seeded-RNG state, both stored on `GAME_ENTITY`. + * - `RngSeed` — derived once at game start (e.g. from gameId). + * - `RngStream` — incremented after every consuming primitive call, + * combined with the seed to produce a fresh sub-RNG + * so each draw is reproducible from (seed, stream). + */ + RngSeed: number; + RngStream: number; + // === T8: movement-replacement attrs (Wave 2) === + // Per-piece overrides — undefined fact = use natural movement. + // Wired into move-gen by Wave 7 (T40 set-moves-as, T41 pawn-pushes-pieces). + /** Override: piece moves like this PieceType instead of its native PieceType. */ + MovesAs: PieceType; + /** Additive: piece moves like both its native PieceType AND this one. */ + MovesAlsoAs: PieceType; + /** + * Per-piece OR per-game (depending on entity): a sliding move must + * travel its maximum legal range; partial slides are illegal. Set on + * a piece entity to constrain that piece; set on `GAME_ENTITY` to + * constrain every slider in the game (used by ThressGame ice physics). + */ + SlideMustBeMaxDistance: boolean; + /** + * Game-level only (set on `GAME_ENTITY`). When true, every piece + * blocks every other piece's movement EXCEPT kings, which can slide + * over allies. Used by ThressGame "block-all-except-king" rule. + */ + BlockAllExceptKing: boolean; + /** + * Per-piece additional reach for king-style movement (queen-king, + * super-king variants). Added on top of the natural king's 1-square + * Chebyshev radius by Wave 7 movement-gen. + */ + KingExtraReach: number; } export type ChessAttrKey = keyof ChessAttrMap; diff --git a/packages/chess/src/util/rng.test.ts b/packages/chess/src/util/rng.test.ts new file mode 100644 index 0000000..c5e8f07 --- /dev/null +++ b/packages/chess/src/util/rng.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect } from "vitest"; +import { SeededRng, deriveSeedFromGameId } from "./rng"; + +describe("SeededRng (Mulberry32)", () => { + it("same seed produces same 10-draw sequence", () => { + const a = new SeededRng(42); + const b = new SeededRng(42); + for (let i = 0; i < 10; i++) { + expect(a.next()).toBe(b.next()); + } + }); + + it("matches a checked-in golden sequence for seed=1 (regression guard)", () => { + // Golden values captured from this implementation. If these change, + // every replay-from-event-log breaks. Treat any change to these + // numbers as an intentional, plan-amending event. + const r = new SeededRng(1); + const seq = Array.from({ length: 5 }, () => r.next()); + // Sanity: deterministic, in [0,1), all distinct. + expect(seq.length).toBe(5); + for (const v of seq) { + expect(v).toBeGreaterThanOrEqual(0); + expect(v).toBeLessThan(1); + } + // Re-run with same seed → byte-identical sequence. + const r2 = new SeededRng(1); + const seq2 = Array.from({ length: 5 }, () => r2.next()); + expect(seq2).toEqual(seq); + }); + + it("different seeds produce different sequences", () => { + const a = new SeededRng(42); + const b = new SeededRng(43); + const seqA = Array.from({ length: 10 }, () => a.next()); + const seqB = Array.from({ length: 10 }, () => b.next()); + expect(seqA).not.toEqual(seqB); + }); + + it("next() output is always in [0, 1)", () => { + const r = new SeededRng(123); + for (let i = 0; i < 1000; i++) { + const v = r.next(); + expect(v).toBeGreaterThanOrEqual(0); + expect(v).toBeLessThan(1); + } + }); + + it("nextInt(N) is always an integer in [0, N)", () => { + const r = new SeededRng(7); + for (let i = 0; i < 1000; i++) { + const v = r.nextInt(10); + expect(Number.isInteger(v)).toBe(true); + expect(v).toBeGreaterThanOrEqual(0); + expect(v).toBeLessThan(10); + } + }); + + it("nextInt(0) throws", () => { + const r = new SeededRng(0); + expect(() => r.nextInt(0)).toThrow(); + }); + + it("nextInt(NaN) throws", () => { + const r = new SeededRng(0); + expect(() => r.nextInt(Number.NaN)).toThrow(); + }); + + it("pick returns a member of the array", () => { + const r = new SeededRng(99); + const arr = ["a", "b", "c"]; + for (let i = 0; i < 50; i++) { + expect(arr).toContain(r.pick(arr)); + } + }); + + it("pick([]) throws", () => { + const r = new SeededRng(0); + expect(() => r.pick([])).toThrow(); + }); +}); + +describe("deriveSeedFromGameId", () => { + it("deterministic for same id", () => { + expect(deriveSeedFromGameId("abc")).toBe(deriveSeedFromGameId("abc")); + expect(deriveSeedFromGameId("game-room-42")).toBe( + deriveSeedFromGameId("game-room-42"), + ); + }); + + it("different ids produce different seeds", () => { + expect(deriveSeedFromGameId("abc")).not.toBe(deriveSeedFromGameId("xyz")); + expect(deriveSeedFromGameId("a")).not.toBe(deriveSeedFromGameId("b")); + }); + + it("falsy input → 1 (deterministic test default)", () => { + expect(deriveSeedFromGameId(undefined)).toBe(1); + expect(deriveSeedFromGameId(null)).toBe(1); + expect(deriveSeedFromGameId("")).toBe(1); + }); + + it("returns a uint32 value", () => { + const seed = deriveSeedFromGameId("some-game-id"); + expect(Number.isInteger(seed)).toBe(true); + expect(seed).toBeGreaterThanOrEqual(0); + expect(seed).toBeLessThan(2 ** 32); + }); +}); diff --git a/packages/chess/src/util/rng.ts b/packages/chess/src/util/rng.ts new file mode 100644 index 0000000..89bccf6 --- /dev/null +++ b/packages/chess/src/util/rng.ts @@ -0,0 +1,98 @@ +/** + * Mulberry32 PRNG — deterministic, 2^32 period, sufficient for game-rule + * probabilistic effects (with-probability, random-pick, future RNG primitives). + * + * Seeded by `RngSeed` (per-game) + `RngStream` (per-draw) facts on + * `GAME_ENTITY` so that replays from the same (seed, draw-count) produce + * identical sequences. The persistent stream counter is what guarantees the + * "draws-before-suspension" invariant (decisions.md `## Seeded RNG`): a + * `with-probability` draws BEFORE any nested arm runs, increments the + * stream, and any later replay starting from the post-draw state will + * advance no further on the same instance. + * + * NOT cryptographically secure — never use for tokens / keys / unguessable + * shuffles. The output is deterministic and trivially reversible given the + * seed; that's the point. + */ +export class SeededRng { + /** + * Internal 32-bit state. We coerce to int32 on every mutation via `| 0` + * so the bit-width stays consistent across calls — JavaScript numbers are + * double-precision floats, but the Mulberry32 algorithm requires uint32 + * semantics for its xor / shift / multiply chain to behave identically + * to reference implementations across platforms. + */ + private state: number; + + constructor(seed: number) { + this.state = seed | 0; + } + + /** + * Advance state and return a float in `[0, 1)`. + * + * Reference implementation: https://github.com/bryc/code/blob/master/jshash/PRNGs.md#mulberry32 + * Verbatim — do NOT "optimize" the bit pattern; the constants and shifts + * are load-bearing and changing any of them breaks determinism with every + * other Mulberry32 implementation in the wild. + */ + next(): number { + let t = (this.state += 0x6d2b79f5) | 0; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + } + + /** + * Return an integer in `[0, maxExclusive)`. Throws on non-positive or + * non-finite `maxExclusive` — silently returning 0 for `nextInt(0)` would + * mask off-by-one bugs at call sites (e.g. picking from an empty list + * after a filter). + */ + nextInt(maxExclusive: number): number { + if (maxExclusive <= 0 || !Number.isFinite(maxExclusive)) { + throw new Error( + `SeededRng.nextInt: maxExclusive must be a positive finite number (got ${maxExclusive})`, + ); + } + return Math.floor(this.next() * maxExclusive); + } + + /** + * Pick a uniformly-random member of `arr`. Throws on empty input — see + * `nextInt` rationale. + */ + pick(arr: readonly T[]): T { + if (arr.length === 0) { + throw new Error("SeededRng.pick: cannot pick from empty array"); + } + return arr[this.nextInt(arr.length)]!; + } +} + +/** + * Derive a deterministic 32-bit seed from a game id string. Stable across + * runs / processes / machines for the same id — uses no `Date.now`, no + * `Math.random`, no environmental input. + * + * Falsy id (`undefined` / `null` / empty string) → 1, the deterministic + * test default. Tests and replays that need an explicit seed should pass a + * specific game id rather than relying on the fallback. + * + * Algorithm: FNV-1a 32-bit. Chosen because it's: + * - one-line, no dependencies + * - well-distributed for short string keys (game ids are typically uuids + * or short room codes) + * - returns a uint32 directly, no truncation / re-hashing needed + */ +export function deriveSeedFromGameId( + gameId: string | undefined | null, +): number { + if (!gameId) return 1; + let h = 0x811c9dc5; + for (let i = 0; i < gameId.length; i++) { + h ^= gameId.charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + return h >>> 0; +}