Wave 0: - T0: Architectural decisions (10 sections, 215 lines) + 5-rule paper exercise Wave 1 (parallel): - T1: Backward-compat baseline fixture (1961 tests / 167 files snapshot + regression guard) - T2: Determinism property-test harness (runDeterminismCheck, N=100 default, 1.7s) - T3: State-hash util (SHA256 of session.allFacts, insertion-order independent) - T4: Position-attr caller audit (75 prod callsites classified, 17 fixes seeded for T6/T7) - T5: $var conflict audit (CLEAN — T12 binding shape safe) Tests: 1961 -> 1970 (+9). bun run check exits 0. No production source modified.
13 KiB
Architectural Decisions — ThressGame Rule-Coverage DSL Extension
Source of truth for ALL 68 downstream tasks in
.sisyphus/plans/thressgame-coverage.md. Captured at Wave 0 (Task 0). Every locked invariant below MUST be honored by downstream implementation; deviation requires a new plan, not an in-flight re-litigation.
Scope and Coverage Target
- Target: 95%+ of ThressGame's 66-rule catalog expressible via the extended DSL.
- Concrete count: ~62 of 66 rules expressible after this plan lands.
- Explicitly deferred:
pacman_style(board topology mutation — out of scope, separate plan). - Also deferred: ~3 nuance cases (animation-driven, free-text "narrative-only", or rules that depend on out-of-band UI affordances) — listed in plan appendix, not addressed here.
- Locked parity test list (8 ThressGame descriptors recreated as
.jsonfixtures):minefield, mr_freeze, parry, all_on_red, religious_conversion, ice_physics, kamikaze, mind_control. This list is FROZEN — additions require a plan amendment. - Coverage is verified by the paper exercise (5 hardest rules — see
paper-exercise.md) plus the 8 parity Playwright e2e tests ine2e/thressgame-parity.spec.ts. - Coverage is NOT measured by line-of-code count; it is measured by descriptor expressibility: a rule "counts as covered" when its behavior is reproduced by a validator-clean descriptor that passes a deterministic e2e test.
Activation Model
- NEW trigger
on-rule-activatedfires EXACTLY ONCE per modifier instance, at the moment the modifier attaches to a piece/game (i.e., when the rule "turns on"). - Counterpart:
on-rule-expirefires once when the modifier detaches (lifetime ends, piece captured, manual remove). - Imperative primitives (board-mutating:
set-piece-attr,spawn-marker,seed-attribute,cancel-capture, etc.) are LEGAL ONLY inside trigger arrays — never as top-level descriptor body, never inside a passive aura. - Validator enforces this with error code
descriptor.primitives.imperative-in-passive. - Passive primitives (predicates, aura emitters) remain pure / declarative as today.
- Rationale: separates "describe the world" (passive) from "mutate the world" (imperative inside triggers). Mirrors Rete's truth-maintenance model — facts derive purely; mutations are explicit timed events.
Square State via Marker Entities
- Marker = full session entity (id from
session.nextId()), NOT a property bag on a square. - Required attrs on every marker (locked verbatim by plan T6, line 574):
MarkerKind— discriminator enum (8 kinds locked, see Marker Collision Priority).Position— square the marker occupies; participates in same Position-attr index as pieces.MarkerLifetime—{ kind: "permanent" } | { kind: "moves"; expiresAtMove: number } | { kind: "one-shot" }.permanent— never auto-expires.moves— absolute target move count; the marker expires when current move count reachesexpiresAtMove. Field is an absolute target, NOT a countdown remainder.one-shot— consumed (removed) byon-piece-entered-markerafter firing once (cross-task contract noted in plan T18 / T22 line 1106).
- Optional attrs:
MarkerOwner—"white" | "black" | undefined; used by mine/treasure scoping.MarkerLinks—readonly EntityId[]for paired markers (e.g. portal endpoints).
- New attr
EntityKind: 'piece' | 'marker'audited across every existingPosition-attr caller — move-gen, aura-compute, capture resolution, etc. — so that markers are filtered IN where they should participate (auras, on-piece-entered-marker) and filtered OUT where they should not (move legality, check detection, capture). - Markers participate in aura compute: an aura emitter may match against marker facts using a discriminator filter (e.g. "any frozen-square within radius 2").
- Engine factory:
engine.spawnMarker(kind, pos, lifetime, owner?, links?)mirrors the existingengine.spawnPiecepattern fromengine.ts:741-769.
Player Choice — Suspended Execution
- NEW imperative primitive
request-choicehalts trigger execution and returns control to the network layer pending a player decision. - WS protocol bumped to v2 (additive). New message types (plan T43, line 1440):
- Server→client
RequestChoiceMessage:{ kind: "request-choice", choiceId, prompt: { kind: "piece"|"square"|"column"|"row"|"coin-flip"|"rps", filter?, forPlayer: Color, timeout? } }. - Client→server
SubmitChoiceMessage:{ kind: "submit-choice", choiceId, value: unknown }.
- Server→client
- v1/v2 negotiation (plan T43, line 1442): server stores client's protocolVersion; if version mismatch and a request-choice would fire, server falls back to "auto-resolve with first option" AND emits a warning. Existing v1 message kinds remain byte-identical.
- State storage: STACK on
GAME_ENTITY(id 0) via attrPendingChoices: readonly PendingChoice[]. Plan T45 (line 1469) locksPendingChoiceshape verbatim:{ choiceId: string, descriptorId: string, triggerPath: readonly number[], primitiveIndex: number, bindings: Record<string, JsonValue>, kind, prompt, forPlayer, timeout?: number, expiresAtTimestamp?: number }. No function references in the frame (must be JSON-serializable). - Helpers (plan T45, line 1470):
engine.pushPendingChoice,engine.popPendingChoice,engine.peekPendingChoice. - LIFO ordering: nested choices push onto the stack; the player resolving the innermost choice pops their frame and the next-outer continuation resumes.
- Nested choices ARE allowed. Maximum stack depth = 8 (plan must-not-do at line 1472: "allow >8 deep stack — cascade depth limit"). Validator enforces depth ≤ 8 (plan deliverable line 75: "request-choice depth ≤ 8 check").
- Resume mechanism (plan T46, line 1483): integration preset's
performActionhook, onsubmit-choiceaction, pops top PendingChoice, restores bindings into a fresh PrimitiveApplyContext, resumesrunPrimitivesat savedtriggerPath+primitiveIndex + 1, and injects the submitted value under the request-choice'sbindkey. No async/await anywhere. request-choiceprimitive schema (plan T47, line 1496):{ kind: "piece"|"square"|"column"|"row"|"coin-flip"|"rps", forPlayer: "chooser"|"opponent"|"both", filter?, bind: string, then: NodeArray }.
Trigger Reentrance — Deferred Queue
- Imperative primitives (e.g. a
set-piece-attrinsideon-captured) MAY indirectly cause additional triggers to fire (e.g. anon-attribute-changedwatcher). To avoid stack-recursion bugs and arm reentrance hazards, these secondary triggers are ENQUEUED, not invoked synchronously. - Dispatcher rule: drain the deferred queue ONLY after the current arm has fully completed. One arm = one atomic dispatch frame.
- Cascade depth limit = 8 (plan line 307: "matches existing RUNTIME_DEPTH_HARD_CAP").
- Implementation (plan T15, line 942): each arm increments a
cascadeDepthcounter that is SEPARATE from the existingdepthcounter for nested primitives — they are orthogonal and MUST NOT be mixed (plan T15 must-not-do line 947). Reject whencascadeDepth > 8. - Overflow error code (plan T15, line 942):
runtime.cascade-depth-exceeded. Runtime error, not validator, since cascade depth is data-dependent. - Existing 12-stage
onAfterMovedispatch (apply.ts:996-1090) becomes 14-stage with the two new attach/expire stages slotted in.
Move-Generation Dry Mode
- Move generator gains a
suppressTriggers: booleanflag, defaultfalsefor actual move commits,truefor legality pre-check (move enumeration, mate detection). - Dry mode: triggers do NOT fire, RNG draws are NOT performed, markers are NOT spawned/expired, pendingChoices are NOT pushed.
- Wet mode (commit): full dispatcher runs.
- Rationale: avoids cycle risk where computing legal moves recursively re-enters trigger dispatch (e.g. "is this move legal?" → fires on-move → spawns marker → invalidates legality assumption). Dry mode = pure read.
- Single source of truth: the
suppressTriggersflag is checked at the dispatcher entry point; individual primitives do NOT branch on it.
Seeded RNG
- PRNG: Mulberry32 (plan T28 line 697: "one-line, well-known, deterministic").
- State storage: two attrs on
GAME_ENTITY—RngSeed: numberandRngStream: number. Seed initialized at game start (plan T28 line 698):engine.session.insert(GAME_ENTITY, "RngSeed", deriveSeedFromGameId(gameId)),engine.session.insert(GAME_ENTITY, "RngStream", 0).RngStreamincrements per draw (line 697). - Helper (plan T28 line 699):
engine.rng()returnsnew SeededRng(RngSeed + RngStream)and incrementsRngStreamafter everynext()call. - Primitives that draw:
with-probabilityandrandom-pick. - Determinism invariant (plan DoD line 89): N=100 replays of the same descriptor + same seed produce byte-identical state hashes. Asserted via property test.
- "Draws-before-suspension" invariant (plan T34 line 1360 + must-not-do line 1361):
RNG draw inside
with-probabilityhappens BEFORE any nested primitive arm runs; nestingrequest-choiceinsidethen/elseis REJECTED by the validator (V1 simplification — "no draws-then-suspend interleaving"). Rationale: choice timeouts and disconnects must not change RNG output, or resume produces a divergent state. - NO
Math.random/Date.now/ unsorted Map iteration anywhere in the engine hot path (plan must-not-have line 107).
Marker Collision Priority
-
Markers are STACKABLE on the same square. When a piece enters a square containing multiple markers, they fire in HARDCODED priority order (lowest number first).
-
Priority table is fixed by this plan; it is NOT user-configurable:
portal-end = 1 (lowest priority number = fires first) frozen-square = 2 mine = 3 pit = 4 death-square = 5 tornado = 6 treasure = 7 blocked = 8 -
Rationale for ordering: portal teleport must execute BEFORE any other effect (it changes which square the piece is actually on); freeze must execute before damaging effects so the freeze prevents the damage from dispatching turn-end recovery; treasure/blocked are passive markers that fire last because they alter legality not state.
-
New marker kinds beyond these 8 require a plan amendment with explicit priority insertion (no "auto-assign" for stability).
-
(Tie-break ordering for two markers of the same kind on one square is NOT locked by this plan — leave to T18 implementation. Do not invent a rule here.)
Choice Timeout & Disconnect
- Per-game configurable in
CreateGameRequestschema. Field shape locked verbatim by plan T50 (line 1542):choiceTimeout: { mode: "timeout-with-default", seconds: number } | { mode: "no-timeout" }. - Default value (plan T50, line 1542):
{ mode: "timeout-with-default", seconds: 60 }. - On expiry in
timeout-with-defaultmode: the FIRST option of the request-choice is auto-selected and the game resumes (plan interview-summary line 40). - Disconnect policy (plan T49, line 1529):
timeout-with-defaultmode + disconnect on a pending choice → forfeit the game (GameStatusset to opponent-wins).no-timeoutmode + disconnect → game enters "paused" state; no auto-action; resume on reconnect.
- Must-not (plan T49, line 1531): cancel a pending choice on disconnect mid-choice without policy decision; forfeit in no-timeout mode.
Locked Lists (Mirror)
This section duplicates lists fixed elsewhere in the plan so downstream tasks have a single read-once reference. If these lists ever drift between plan and notepad, the PLAN wins — but they MUST be re-synced before the affected wave begins.
- 8 parity test descriptor names (LOCKED — see plan deliverables):
minefield, mr_freeze, parry, all_on_red, religious_conversion, ice_physics, kamikaze, mind_control. - 6 template descriptor names (LOCKED — shipped in modifier library):
simple-mine, vampire-on-capture, frozen-column, coin-flip-restriction, religious-bishop, no-mans-land. - 6 palette categories (per Task 51):
State, Mechanic, Trigger, Imperative, Iteration, Restriction. - 4 ParamField custom renderers (Task 50/T14 lineage):
square (square picker), piece (piece picker), marker-kind (enum select), lifetime (composite duration config). - Cascade depth limit:
8(shared between trigger reentrance AND choice stack depth — same constantRUNTIME_DEPTH_HARD_CAPreused). - 28 new primitives total (frozen count — adding a 29th requires a new plan).
- 4 new triggers total (frozen count):
on-rule-activated, on-rule-expire, on-piece-entered-marker, on-marker-expire. - 8 marker kinds (frozen): see Marker Collision Priority section above for the authoritative priority-ordered list.