houserules/.sisyphus/notepads/thressgame-coverage/decisions.md
Joey Yakimowich-Payne 2368a24b15
feat(thressgame-coverage): Wave 0-1 foundation (ADR + baseline + harness + audits)
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.
2026-04-26 08:16:26 -06:00

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 .json fixtures): 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 in e2e/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-activated fires 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-expire fires 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 reaches expiresAtMove. Field is an absolute target, NOT a countdown remainder.
      • one-shot — consumed (removed) by on-piece-entered-marker after 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 existing Position-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 existing engine.spawnPiece pattern from engine.ts:741-769.

Player Choice — Suspended Execution

  • NEW imperative primitive request-choice halts 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 }.
  • 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 attr PendingChoices: readonly PendingChoice[]. Plan T45 (line 1469) locks PendingChoice shape 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 performAction hook, on submit-choice action, pops top PendingChoice, restores bindings into a fresh PrimitiveApplyContext, resumes runPrimitives at saved triggerPath + primitiveIndex + 1, and injects the submitted value under the request-choice's bind key. No async/await anywhere.
  • request-choice primitive 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-attr inside on-captured) MAY indirectly cause additional triggers to fire (e.g. an on-attribute-changed watcher). 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 cascadeDepth counter that is SEPARATE from the existing depth counter for nested primitives — they are orthogonal and MUST NOT be mixed (plan T15 must-not-do line 947). Reject when cascadeDepth > 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 onAfterMove dispatch (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: boolean flag, default false for actual move commits, true for 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 suppressTriggers flag 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: number and RngStream: 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). RngStream increments per draw (line 697).
  • Helper (plan T28 line 699): engine.rng() returns new SeededRng(RngSeed + RngStream) and increments RngStream after every next() call.
  • Primitives that draw: with-probability and random-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-probability happens BEFORE any nested primitive arm runs; nesting request-choice inside then/else is 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 CreateGameRequest schema. 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-default mode: 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-default mode + disconnect on a pending choice → forfeit the game (GameStatus set to opponent-wins).
    • no-timeout mode + 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 constant RUNTIME_DEPTH_HARD_CAP reused).
  • 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.