Skip to content

Commit 6d85917

Browse files
W11 — Nodes, Turn, and Settle: the node graph, enter/settle, and initialState (#44)
* W11 — Nodes, Turn, and Settle: the node graph, enter/settle, and initialState - kinds/story-graph/nodes.ts: Choice, RandomTransition, ChoiceNode/RandomNode/ AutoNode/EndingNode, Node (03 §3-4). Types only; showWhen/requirements gating is W12's, not evaluated here. - kinds/story-graph/campaign.ts: StoryGraphCampaign (03 §1). achievements is a typed placeholder (readonly unknown[]) until W13 builds AchievementDefinition. - kinds/story-graph/state.ts: StoryGraphKindState (03 §8.1); enter — pure, event-free, sets currentNodeId and increments visitedCounts. - kinds/story-graph/settle.ts: SETTLE_STEPS (64); enterAndEmit (enter + the node.entered event, shared so the start node and every pass-through both fire it); settle — resolves auto/random pass-throughs to a choice/ending, applying consequences (clamp included) via W9's applyConsequences and drawing random.picked from the supplied RngHandle; initialState — the real Kind<StoryGraphKindState>.initialState, enters startNodeId then settles once. A settle-guard trip emits kind.story-graph.settle.guard_tripped (reason: settle_guard_tripped, already named in 03 §8.3) and throws rather than returning a ValidationError — InitialStateResult has no error slot, and state has already changed by the time settle is mid-loop, so "rejected, unchanged" doesn't describe it. Whether a mid-advance trip should instead become AdvanceResult.error is left to whichever unit builds submitChoice (W12). Tested directly against enter/settle/initialState, plus one integration test proving initialState plugs into the real createEngine().createGame() — the core already calls kind.initialState(campaign, ctx) exactly this way (built in W3). Full reasoning in plans/18-w11-nodes-turn-and-settle.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * Address Qodo review: harden visitedCounts and node-map lookups against prototype keys enter() now rebuilds visitedCounts as a null-prototype object on every call, and requireNode() uses Object.hasOwn — both were plain bracket lookups keyed by content-controlled ids (node ids), so "toString"/"__proto__" could resolve an inherited Object.prototype value instead of a real count or a missing-node error. Same class of hardening W9's variables.ts already applies to VariableSchema lookups. Declined the co-located-test-file finding for campaign.ts/nodes.ts, for the same reason as PR #17 and PR #43 — established, unbroken pattern for pure-types files across this codebase. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 388f2dd commit 6d85917

7 files changed

Lines changed: 803 additions & 0 deletions

File tree

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
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`.
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/**
2+
* Story-graph kind — the campaign content envelope.
3+
*
4+
* Contract: `03-story-graph-kind.md` §1.
5+
*
6+
* The runtime `content` inside the core's `Campaign` envelope (`registry/types.ts`) —
7+
* `id`/`version`/`kind`/`titleKey` live on `Campaign` itself, not here (the
8+
* envelope-duplication rule `CLAUDE.md` tracks).
9+
*/
10+
11+
import type { LocKey } from "../../core/localization/types.js";
12+
import type { VariableSchema } from "./variables.js";
13+
import type { Node } from "./nodes.js";
14+
15+
export interface StoryGraphCampaign {
16+
descriptionKey: LocKey;
17+
variables: VariableSchema;
18+
nodes: Record<string, Node>;
19+
startNodeId: string;
20+
21+
/**
22+
* `AchievementDefinition[]` (03 §7) doesn't exist until W13 — nothing in W11 reads
23+
* this field, so it stays an honest placeholder rather than a type invented ahead of
24+
* the unit that owns it. See `plans/18-w11-nodes-turn-and-settle.md`, Decision 4.
25+
*/
26+
achievements: readonly unknown[];
27+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/**
2+
* Story-graph kind — the node graph (03 §3, §4).
3+
*
4+
* Contract: `03-story-graph-kind.md` §3, §4.
5+
*
6+
* `Choice`/`RandomTransition` (§4) aren't in this unit's own cited sections, but
7+
* `ChoiceNode`/`RandomNode` need them to compile — see
8+
* `plans/18-w11-nodes-turn-and-settle.md`, Decision 5. Nothing here evaluates
9+
* `showWhen`/`requirements`; that's W12's `availableActions`.
10+
*/
11+
12+
import type { LocKey } from "../../core/localization/types.js";
13+
import type { Condition } from "../../core/condition/types.js";
14+
import type { Consequence } from "./variables.js";
15+
16+
export interface Choice {
17+
id: string;
18+
labelKey: LocKey;
19+
20+
showWhen?: Condition;
21+
requirements?: Condition;
22+
requirementFailKey?: LocKey;
23+
24+
effects?: Consequence[];
25+
goto: string;
26+
}
27+
28+
export interface RandomTransition {
29+
weight: number;
30+
effects?: Consequence[];
31+
goto: string;
32+
}
33+
34+
interface NodeBase {
35+
id: string;
36+
textKey: LocKey;
37+
}
38+
39+
export interface ChoiceNode extends NodeBase {
40+
kind: "choice";
41+
choices: Choice[];
42+
}
43+
44+
export interface RandomNode extends NodeBase {
45+
kind: "random";
46+
transitions: RandomTransition[];
47+
}
48+
49+
export interface AutoNode extends NodeBase {
50+
kind: "auto";
51+
effects?: Consequence[];
52+
goto: string;
53+
}
54+
55+
export interface EndingNode extends NodeBase {
56+
kind: "ending";
57+
endingId: string;
58+
outcome?: "win" | "loss" | "neutral";
59+
}
60+
61+
export type Node = ChoiceNode | RandomNode | AutoNode | EndingNode;

0 commit comments

Comments
 (0)