|
| 1 | +# W11 — Nodes, Turn, and Settle |
| 2 | + |
| 3 | +**Status:** Draft — implementing immediately after this document (user directive: "get |
| 4 | +the next milestone, create branch, work, create PR, watch comments"). |
| 5 | + |
| 6 | +**Unit:** [`docs/docs/engine/TODO.md`](../docs/docs/engine/TODO.md) — W11 |
| 7 | + |
| 8 | +**Scope:** The four node kinds, `enter(nodeId)`, the settle loop, the `SETTLE_STEPS` |
| 9 | +guard, and `initialState` returning `InitialStateResult` (03 §3, §8.1, §8.2, §8.4). |
| 10 | + |
| 11 | +**Depends on:** W2, W3a, W9, W10 — all done, merged. |
| 12 | + |
| 13 | +## What's Actually Left to Build |
| 14 | + |
| 15 | +Nothing here exists yet. This is the first unit whose `initialState` is a literal member |
| 16 | +of the core's `Kind<KState>` interface (`kernel/types.ts`) rather than a standalone helper |
| 17 | +W12+ will later wire in — W9's `applyConsequences` and W10's `evaluateStoryGraphCondition` |
| 18 | +were both deliberately *not* part of that interface. `enter` and `settle` are internal |
| 19 | +helpers with no interface obligation, so their signatures take exactly what they need, |
| 20 | +matching W9/W10's pattern. |
| 21 | + |
| 22 | +`initialState(campaign: Campaign, ctx: KindContext)` is enough, on its own, to run through |
| 23 | +the **real, already-built** `kernel/engine.ts createGame` — that function already calls |
| 24 | +`kind.initialState(campaign, ctx)` per 04 §4's pseudocode (built in W3). This unit proves |
| 25 | +that seam works end-to-end, not just in isolation. |
| 26 | + |
| 27 | +## Decisions |
| 28 | + |
| 29 | +### 1. `enter`/`settle` are narrow helpers; `initialState` matches the real `Kind` signature |
| 30 | + |
| 31 | +`enter(state, nodeId)` and `settle(nodes, schema, state, rng, emit)` take the specific |
| 32 | +values they need — a node map, a `VariableSchema`, the current state, an `RngHandle`, a |
| 33 | +`ResolutionEmitter` — rather than a full `KindContext`. Neither is a `Kind<KState>` method, |
| 34 | +so there's no interface to match; a full `KindContext` would force fake `registry`/ |
| 35 | +`campaign`/`seq` fields into every direct unit test for no reason (the W9/W10 precedent). |
| 36 | + |
| 37 | +`initialState`, by contrast, **is** one of `Kind<KState>`'s five methods |
| 38 | +(`kernel/types.ts`), with a fixed signature the core already calls exactly this way. It |
| 39 | +narrows `campaign.content` to `StoryGraphCampaign` — the one place in this kind allowed to |
| 40 | +know that concrete shape, since the core treats `Campaign.content` as `unknown` by design. |
| 41 | + |
| 42 | +### 2. `node.entered` fires for the start-node entry too, not just settle pass-throughs |
| 43 | + |
| 44 | +03 §8.2's own callout is explicit: "every entry counts, including settle pass-throughs |
| 45 | +**and the initial start node**." `initialState` enters `startNodeId` once, *before* the |
| 46 | +settle loop even begins (03 §8.2's `createGame` pseudocode: "enters `startNodeId`... and |
| 47 | +runs `settle` once") — so if `settle` were the only place that emitted `node.entered`, the |
| 48 | +start entry would never fire it. A shared `enterAndEmit(nodes, state, nodeId, emit)` |
| 49 | +helper — `enter` plus the event, looking up the node's `kind` for the event's `nodeKind` |
| 50 | +field — is called once by `initialState` for the start node and once per pass-through |
| 51 | +inside `settle`'s `auto`/`random` branches. `enter` itself stays pure and event-free, so |
| 52 | +the done-criterion ("every entry increments its visit count") is testable against it |
| 53 | +directly with no emitter in the way. |
| 54 | + |
| 55 | +### 3. A guard trip emits the event, then throws — `InitialStateResult` has no error slot to fill |
| 56 | + |
| 57 | +03 §8.2's pseudocode says "if the guard trips → **engine error**," language this project |
| 58 | +otherwise reserves for defensive backstops (`weightedPick`, W9's undeclared-variable |
| 59 | +guard) rather than for `ValidationError`-shaped rejections. Two structural facts confirm |
| 60 | +that reading here specifically: |
| 61 | + |
| 62 | +- `InitialStateResult` (`kernel/types.ts`) is `AdvanceResult` **minus** `error` — 04 §3's |
| 63 | + own comment says a pre-validated campaign "cannot fail to start the way an action can." |
| 64 | + There is no field to report a trip through if `initialState`'s own opening `settle` |
| 65 | + call is what trips it. |
| 66 | +- By the time `settle` is mid-loop, state has already changed (turn advanced, nodes |
| 67 | + entered) — "rejected, state unchanged" (`AdvanceResult.error`'s own contract) does not |
| 68 | + describe what happened. |
| 69 | + |
| 70 | +`settle` therefore always throws a plain `Error` on a guard trip, after emitting |
| 71 | +`kind.story-graph.settle.guard_tripped` (severity `error`, `reason: "settle_guard_tripped"` |
| 72 | +— the code 03 §8.3 already names, so unlike W10's `unknown_condition_field` this isn't a |
| 73 | +new convention needing to be invented). `settle` is a shared primitive `initialState` |
| 74 | +*and* a future `submitChoice` (W12) both call; whether a mid-`advance` trip should instead |
| 75 | +be caught and surfaced as `AdvanceResult.error` is a decision for whichever unit builds |
| 76 | +`submitChoice`, not this one — `settle`'s own contract is the same regardless of caller. |
| 77 | + |
| 78 | +### 4. `StoryGraphCampaign.achievements` is a typed placeholder |
| 79 | + |
| 80 | +03 §1's `StoryGraphCampaign` includes `achievements: AchievementDefinition[]` (03 §7), |
| 81 | +which doesn't exist until W13. Nothing in `enter`/`settle`/`initialState` reads |
| 82 | +achievements, so inventing that type now would be doing W13's job early for no benefit. |
| 83 | +`achievements: readonly unknown[]` is the field's honest shape until then — present (so |
| 84 | +the type is a faithful campaign-content shape other code can start building against) but |
| 85 | +untyped (so nothing here pretends to know its structure). |
| 86 | + |
| 87 | +### 5. `Choice`/`RandomTransition` (03 §4) had to be defined now — `ChoiceNode`/`RandomNode` need them |
| 88 | + |
| 89 | +03 §4 isn't in this unit's cited spec sections, but `ChoiceNode.choices: Choice[]` and |
| 90 | +`RandomNode.transitions: RandomTransition[]` can't compile without them. Defined in full |
| 91 | +(including `showWhen`/`requirements`/`requirementFailKey` on `Choice`), but nothing in |
| 92 | +this unit evaluates them — gating a choice by `showWhen`/`requirements` is W12's |
| 93 | +`availableActions`, not the settle loop. `RandomTransition.weight` validation (positive |
| 94 | +integer, at-least-one-transition) is enforced by `RngHandle.weightedPick` itself |
| 95 | +(already built, W2) — 03 §11 calls this out explicitly ("`weightedPick` throws otherwise |
| 96 | +... so this is a load-time rule, not a runtime crash"), so `settle` adds no extra guard. |
| 97 | + |
| 98 | +### 6. Both content-controlled lookups are hardened against `Object.prototype` collisions (PR #44 review) |
| 99 | + |
| 100 | +`visitedCounts[nodeId]` (`state.ts`'s `enter`) and `nodes[nodeId]` (`settle.ts`'s |
| 101 | +`requireNode`) both read a plain-object bracket lookup keyed by content-authored ids — |
| 102 | +`"toString"` or `"__proto__"` would otherwise resolve an inherited `Object.prototype` |
| 103 | +value instead of `undefined`/a missing-node error, the same class of gap W9's |
| 104 | +`requireDecl`/null-prototype `variables` guard against. `enter` now rebuilds |
| 105 | +`visitedCounts` null-prototype (`Object.create(null)`) on every call — the same fix |
| 106 | +W9 applies to `variables` — and `requireNode` gained the `Object.hasOwn` check |
| 107 | +`requireDecl` already uses. `nodes` itself doesn't need to become null-prototype, since |
| 108 | +nothing ever writes to it (content, read-only). |
| 109 | + |
| 110 | +## Design |
| 111 | + |
| 112 | +### New files |
| 113 | + |
| 114 | +| File | Contents | |
| 115 | +|---|---| |
| 116 | +| `kinds/story-graph/nodes.ts` **(new)** | `Choice`, `RandomTransition`, `ChoiceNode`/`RandomNode`/`AutoNode`/`EndingNode`, `Node`. Types only. | |
| 117 | +| `kinds/story-graph/campaign.ts` **(new)** | `StoryGraphCampaign` (03 §1). | |
| 118 | +| `kinds/story-graph/state.ts` **(new)** | `StoryGraphKindState` (03 §8.1); `enter`. | |
| 119 | +| `kinds/story-graph/state.test.ts` **(new)** | `enter`'s visit-count and current-node behavior. | |
| 120 | +| `kinds/story-graph/settle.ts` **(new)** | `SETTLE_STEPS`; `enterAndEmit`; `settle`; `initialState`. | |
| 121 | +| `kinds/story-graph/settle.test.ts` **(new)** | Everything below, plus one `createEngine`/`createGame` integration test. | |
| 122 | + |
| 123 | +`nodes.ts`/`campaign.ts` get no `.test.ts` sibling — pure type declarations, the same, |
| 124 | +already-established exception the co-located-test rule has had since PR #17 (declined |
| 125 | +again in PR #43 for `condition/types.ts`). |
| 126 | + |
| 127 | +### Test Plan |
| 128 | + |
| 129 | +Against TODO's W11 done-criteria directly: |
| 130 | + |
| 131 | +- [ ] An auto→auto→choice chain and an auto→random→ending chain both settle correctly — |
| 132 | + the loop stops exactly at the choice/ending node. |
| 133 | +- [ ] Every entry increments `visitedCounts`, proven for: the start node (via |
| 134 | + `initialState`, before any settle pass-through), a settle pass-through, and a |
| 135 | + node entered twice (a loop back to an earlier auto node). |
| 136 | +- [ ] A 64-step non-terminating auto→auto→...→auto cycle throws, and the thrown call |
| 137 | + first emits `kind.story-graph.settle.guard_tripped` with `reason: |
| 138 | + "settle_guard_tripped"` and the tripping `nodeId`. |
| 139 | +- [ ] `initialState` on a campaign whose start settles straight through to an |
| 140 | + `EndingNode` returns `status: "ended"` with `state.endingId` set — and does **not** |
| 141 | + report `active`. |
| 142 | +- [ ] Two `initialState` calls against the same campaign, same seed (via `rngHandleFor`, |
| 143 | + independently constructed each time) but a `random` node in the settle chain, |
| 144 | + produce byte-identical resulting `state` — proving reproducibility from seed alone. |
| 145 | +- [ ] `settle.step` fires once per loop iteration (including the first, for whatever node |
| 146 | + `initialState` already entered); `node.entered` carries the correct `nodeKind` and |
| 147 | + the just-incremented `visitCount`; `random.picked` carries the chosen transition's |
| 148 | + `goto`/`weight` and fires only for `random` nodes, never `auto`. |
| 149 | +- [ ] An `auto`/`random` node's `effects` apply through W9's `applyConsequences` (clamp |
| 150 | + included) before `turn` advances — proven with a node whose effect pushes a |
| 151 | + variable past its declared bound. |
| 152 | +- [ ] One integration test: a hand-built `Kind<StoryGraphKindState>` (other members |
| 153 | + stubbed, matching `kernel/engine.test.ts`'s `makeTestKind` pattern) whose |
| 154 | + `initialState` is this unit's real function and whose `eventNames` declares all |
| 155 | + four emitted names, run through the real `createEngine(...).createGame(...)` — |
| 156 | + proving the seam 04 §4 describes actually holds, not just this unit's own direct |
| 157 | + calls. |
| 158 | + |
| 159 | +### Explicit Non-Goals |
| 160 | + |
| 161 | +- No `showWhen`/`requirements` gating, `availableActions`, `scene`, or `StoryGraphView` — |
| 162 | + W12. |
| 163 | +- No `submitChoice`/`advance` itself, and no decision about whether a mid-`advance` |
| 164 | + guard trip becomes an `AdvanceResult.error` — W12 (Decision 3). |
| 165 | +- No achievement evaluation — W13; `StoryGraphCampaign.achievements` is a placeholder |
| 166 | + (Decision 4). |
| 167 | +- No Tier 1/2 `validateCampaign` wiring — W14. This unit's node/goto/weight shapes are |
| 168 | + exactly what W14 will check; nothing here performs that check itself, matching W9/W10's |
| 169 | + relationship to it. |
| 170 | +- No text interpolation (03 §3.1) — reads nothing about a node's `textKey` beyond |
| 171 | + treating it as an opaque `LocKey`; rendering is W12's `scene`. |
0 commit comments