Story Arcs + Corruption (Spec 1) Implementation Plan¶
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add a behaviour-additive Arc-Template layer over the 5-phase narrative machine (branching arcs, variable hybrid endings, cross-session variety/escalation memory), threat+reduced-motion-gated terminal corruption, a shared JSON content SSOT, ←/→ scene-change parity + reactive chatter, and native persona-typing parity — web (TS) and native (Swift/Metal) as a twin.
Architecture: The phase machine stays the substrate. An optional ArcState on the narrative runner gates content (tag-filtered pickArc), routing/duration, mentor, scene-bias, threat-curve, and ending. Content moves from hand-mirrored TS+Swift into one story-content.json; logic stays a deliberate twin. Corruption derives a stepped 0–3 stage from threat, gated off in calm/reduced-motion. The whole thing is inert when no arc is selected and corruption is off, enforced by a golden-snapshot test for a fixed seed.
Tech Stack: TypeScript/Vite (web) + vitest (new, unit/parity tests) + Playwright (existing visual); Swift/Metal compiled Xcode-free via swiftc, assert-based tests in tests/main.swift.
Spec: docs/specs/2026-06-15-story-arcs-corruption-design.md (read it first — types, decisions, verified citations).
File structure¶
Web — create
- src/engine/data/story-content.json — the single content SSOT (presets, boot, dicts, beats, endings, mentor, film, arcs).
- src/engine/data/story-content.ts — typed StoryContent interface + imported default export (typed facade over the JSON).
- src/engine/terminal/arc.ts — ArcTemplate/ArcState/EndingSpec types, arcAllows(), pickArc(), resolveEnding(), selectArc().
- tests/narrative-golden.test.ts, tests/arc.test.ts, tests/corruption.test.ts, tests/persistence.test.ts — vitest.
Web — modify
- src/engine/terminal/script-bank.ts — becomes a facade re-exporting from story-content.ts (same exported names/types).
- src/engine/terminal/narrative.ts — pickArc at call sites, arc field, selection in silentReset callback, per-phase durationScale, phaseRouting, ending resolution.
- src/engine/data/defaults.ts — story persistence block + defaults.
- src/engine/fx/reactive-world.ts — corruptionStage output, calm gate, optional arc threatCurve.
- src/engine/hud/index.ts — per-line dataset.category + cat-* class, --ks-corrupt var, materialization.
- src/engine/modes/film-director.ts — SceneWeight, arc scenes bias.
- src/engine/core/bus.ts — sceneChange event.
- src/engine/controller.ts — arrow hotkeys, sceneChange emit, story snapshot + force-flush.
- src/engine/terminal/chatter-bank.ts — sceneChange pool (web parity for native chatter).
- package.json — "test": "vitest run", vitest devDep.
Native — create
- native/macos/KuroNativeSaver/Core/StoryContent.swift — Codable structs + StoryContent.load() (+ embedded fallback).
Native — modify
- Script.swift, Palette.swift — facades over StoryContent.
- Terminal.swift — foreshadow durationScale fix, arc state + pickArc twin, persona TraitProfile/phaseModulate, ending resolution.
- Hud.swift — renderOverlay(... corruptionStage:calm:), colorFor(category, stage).
- TextRenderer.swift — optional glyphColors param.
- FontAtlas.swift — corruption glyph audit/extra slots.
- Chatter.swift — sceneChange pool.
- Renderer.swift — cycleScene bus emit, threat→Hud, arc threat-curve.
- AppSettings.swift — StoryMemory Codable persistence.
- scripts/build-native-app.sh — copy story-content.json into Resources.
- native/macos/KuroNativeSaver/tests/main.swift — new assert tests.
Slice sequence (each slice = working, testable increment; commit per task)¶
| Slice | Goal | Depends on | Acceptance |
|---|---|---|---|
| 0 Prep/de-risk | Web test runner; native foreshadow latent-bug fix; golden snapshot harnesses (web+native) | — | npm test + run-native-tests.sh green; foreshadow parity test passes |
| 1 JSON SSOT | Content → story-content.json; facades; native Codable+loader+Bundle de-risk; schema test |
0 | golden tests still green; schema-decode test green; app builds with bundled JSON |
| 2 Arc layer | arc.ts twin + pickArc + selection + endings + 5 arcs; native twin |
1 | arc=undefined → golden identical; arc parity test green |
| 3 Persistence | story block + force-flush + record arcs/endings; native StoryMemory |
2 | forward-compat + kill-mid-reset tests green |
| 4 Corruption | stage twin + calm gate; web+native tint; per-glyph (prototype-gated); materialization | 1 | stage parity green; visual sign-off (on-device) |
| 5 ←/→ + chatter | bus sceneChange + web binding + native emit + chatter parity |
1 | chatter fires on ←/→ in active-watch modes |
| 6 Persona-typing | native TraitProfile/phaseModulate twin |
1 | persona parity test green |
| 7 FilmDirector bias | SceneWeight + arc scenes (web+native) |
2 | arc biases scene itinerary |
| 8 threatCurve | ReactiveWorld arc-aware (web+native) | 2,4 | arc threat-curve alters corruption stage |
Slices 2–8 are detailed into bite-sized tasks when their prerequisites land — several depend on outcomes that only exist after earlier slices run (Slice 1's Bundle-loading confirmation; Slice 4's per-glyph perf measurement and katakana-atlas audit). This is deliberate iterative planning per the spec's "prototype FIRST", not placeholdering. Slices 0–1 are fully specified below and executable now.
Slice 0 — Prep / de-risk¶
Task 0.1: Native foreshadow durationScale parity fix¶
Files:
- Modify: native/macos/KuroNativeSaver/Core/Terminal.swift:60 (foreshadow), init for durationScale
- Test: native/macos/KuroNativeSaver/tests/main.swift
- [ ] Step 1: Write the failing assert in
tests/main.swift— a test that constructs aTerminalwithdurationScale = 0.1, drivesupdate(t:)to just before a scaled boundary, and asserts the foreshadow fires atphaseEndsAt - 6 * 0.1, notphaseEndsAt - 6. (IfTerminalforeshadow timing isn't directly observable, assert via asceneForeshadowclosure capture: set a flag, advancettophaseEndsAt - 0.6 + ε, assert flag set; advance a separate run tophaseEndsAt - 6 + εwith scale 0.1, assert flag NOT yet set.) - [ ] Step 2: Run
bash scripts/run-native-tests.sh— Expected: FAIL (foreshadow fires at the wrong time / flag set too early). - [ ] Step 3: Implement — add
var durationScale: Double = 1toTerminal(settable at init/host), and changeTerminal.swift:60fromt >= phaseEndsAt - 6tot >= phaseEndsAt - 6 * durationScale. WiredurationScalefrom the host whereTerminalis constructed (mirror webnarrative.ts:93/157); default 1 so production is unchanged. - [ ] Step 4: Run
bash scripts/run-native-tests.sh— Expected: PASS. - [ ] Step 5: Commit —
git commit -am "fix(native): foreshadow lead honours durationScale (web parity)"
Task 0.2: Add vitest web test runner¶
Files:
- Modify: package.json
- Create: vitest.config.ts, tests/smoke.test.ts
- [ ] Step 1:
npm i -D vitest(adds devDep). - [ ] Step 2: Add
"test": "vitest run"topackage.jsonscripts. - [ ] Step 3: Create
vitest.config.ts:
import { defineConfig } from 'vitest/config';
export default defineConfig({ test: { environment: 'node', include: ['tests/**/*.test.ts'] } });
- [ ] Step 4: Create
tests/smoke.test.ts:
import { describe, it, expect } from 'vitest';
describe('smoke', () => { it('runs', () => { expect(1 + 1).toBe(2); }); });
- [ ] Step 5: Run
npm test— Expected: 1 passed. - [ ] Step 6: Commit —
git commit -am "test(web): add vitest runner (PROF-TS-01)"
Task 0.3: Web narrative golden-snapshot harness (additive-invariant guard)¶
Files:
- Create: tests/narrative-golden.test.ts
- Reference: src/engine/terminal/narrative.ts, src/engine/engine/rng.ts
The point: capture the exact line stream the narrative produces for a fixed seed, so every later change can prove inertness when arc is undefined + corruption off.
- [ ] Step 1: Write
tests/narrative-golden.test.tsthat instantiates aNarrativeRunnerwith a fixed seed (use the LCGmkRng(seed)fromrng.ts, NOTMath.random), a stubNarrativeRunnerDepswhosehud.addLine/typing callbacks push{phase, cat, text}into an array, a smalldurationScale(e.g. 0.02) so a full multi-phase cycle runs fast, andMath.randomstubbed to the seeded rng for the calls narrative makes viaMath.random(notenarrative.tsmixesrngandMath.random— the test must seed BOTH; the golden test pins the seed for both paths). Run one full shift, collect the stream. - [ ] Step 2: Run
npm test tests/narrative-golden.test.ts— Expected: it produces a deterministic array (snapshot viaexpect(stream).toMatchSnapshot()). - [ ] Step 3: Commit the generated snapshot. This is the golden — Slice 2's "arc=undefined ⇒ identical" assertion reuses it.
- [ ] Step 4: Commit —
git commit -am "test(web): golden narrative snapshot (additive-invariant guard)"
Note:
narrative.tscurrently callsMath.random()directly in several beats (e.g.:330). Slice 2 should route arc-affecting randomness through the injectedrngfor determinism; until then the golden test stubsMath.random. Record this in the test file's header comment.
Task 0.4: Native golden-snapshot harness¶
Files:
- Modify: native/macos/KuroNativeSaver/tests/main.swift
- [ ] Step 1: Add an assert test that drives
Terminalwith a fixedLCGseed + smalldurationScale, collects the emitted line stream (phase, category, text) into an array, and asserts it equals a checked-in expected array (hardcode the expected array after first capture — print it once, paste it in). - [ ] Step 2: Run
bash scripts/run-native-tests.sh— Expected: PASS. - [ ] Step 3: Commit —
git commit -am "test(native): golden narrative snapshot (additive-invariant guard)"
Slice 1 — JSON content SSOT¶
Discovery (2026-06-15, during execution): This slice is NOT a verbatim move on the web side.
RoutineBeat.cmdisstring | ((p: OperatorPersona) => string)(script-bank.ts:16-18) — several beats are functions that template{node}/{hq}/…via JS. They cannot serialize to JSON. Native already stores the token-string form (Script.swift+Terminal.subst()). Worse, the substitution models are asymmetric: web resolves cmd tokens via the functions and onlyreplace('$P', …)on resp (narrative.ts:309-318), while native appliessubst()(all tokens) to every line. They agree today only because resp strings happen to contain no{…}tokens.Revised approach: the JSON stores token strings (native's form). Web gains a
subst(persona, str)twin ofTerminal.subst()and applies it where it currently calls the cmd function /$P-replaces (localized tobeatRoutine,narrative.ts:308-319). Parity guard (write FIRST): for a fixed-seed persona, hash the resolved cmd+resp strings of everyROUTINE_BEATSentry via the current function logic, pin the hash; after the token+substmigration the hash must be byte-identical. Other pools (HQ/INTRUSIONS/REACTIONS/HESITATIONS/DRAFTS/FAREWELLS/MENTOR/SCENE/COMBAT) are plain strings — verbatim.genHash/foreshadowLine/arrivalLine/combatLinestay code-side.Native 1.3 has an on-device dependency:
Bundle.mainresource loading is unproven (zero current resource loads). Step 1 of Task 1.3 is Johannes' on-device confirmation.Discovery (2026-06-25, Slice 1b — presets+dict move, 5-lens adversarial review): -
presetsis stored as an ordered ARRAY of{ id, ...fields }(per design §2), NOT an object-keyed-by-id. This matches the nativePalette.swiftordered[ColorPreset](id +presets[3]default), so 1.3 decodes straight into it; a JSON object would decode to an unordered Swift[String:ColorPreset]and scramble the swatch/dropdown order. The web facade stripsidon rebuild →PRESETSbyte-identical. - Nostory-content.tstyped facade was created (Task 1.1/1.2 listed it) — dropped, consistent with shipped 1a: facadesimport story from './story-content.json'+as. - 1.3 decoder notes:commandSessions[].respis heterogeneous (string | {cat,text}) → needs a customDecodableRespItemenum (string-first fallthrough) + an uppercase-cat→Catmapper (no uniform-object precedent inbeats).boot.headersis one string per scene (not an array) →[String:String].Palette.swiftonly consumesdarkAccent.color→accentHex(UInt32) and dropslightAccent/rgb.
Task 1.1: Define StoryContent TS types + extract content to JSON¶
Files:
- Create: src/engine/data/story-content.json, src/engine/data/story-content.ts
- Reference: src/engine/terminal/script-bank.ts, src/engine/data/dictionary.ts, src/engine/data/presets.ts
- [ ] Step 1: Write
story-content.tsdeclaring theStoryContentinterface mirroring the JSON schema in the design spec §2 (presets, boot, terminalDicts, beats{routine,intrusionsQuotes,…}, endings, mentor, film, arcs), thenimport data from './story-content.json'andexport default data as StoryContent;. - [ ] Step 2: Create
story-content.jsonby moving the existing array/object literals verbatim out ofscript-bank.ts,dictionary.ts,presets.tsinto the namespaced JSON sections. Keep placeholder strings (${p.node}→ store as"{node}"tokens; the substitution stays in code). Add an empty"arcs": []and"endings"keyed by EndingId (split currentFAREWELLS/LAST_WORDSunder a"normal"key for now).schemaVersion: 1. - [ ] Step 3: Verify
resolveJsonModuleis on (tsconfig.json:17) — it is. Runnpm run typecheck— Expected: PASS (types match the JSON). - [ ] Step 4: Commit —
git commit -am "feat(data): story-content.json SSOT + typed facade"
Task 1.2: Make script-bank.ts / presets.ts / dictionary.ts facades¶
Files:
- Modify: src/engine/terminal/script-bank.ts, src/engine/data/presets.ts, src/engine/data/dictionary.ts
- [ ] Step 1: Replace the moved literals with re-exports from
story-content.ts, preserving every exported name and type (e.g.export const ROUTINE_BEATS = story.beats.routine;). Keep code-side logic (weighting indictionary.ts:204-221, computed preset math) intact, now reading from the facade. - [ ] Step 2: Run
npm run typecheck— Expected: PASS (no consumer changes needed). - [ ] Step 3: Run
npm test tests/narrative-golden.test.ts— Expected: golden snapshot UNCHANGED (content move is behaviour-preserving). - [ ] Step 4: Run
npm run build— Expected: succeeds (Vite inlines the JSON). - [ ] Step 5: Commit —
git commit -am "refactor(terminal): script-bank/presets/dictionary read from SSOT"
Task 1.3: Native StoryContent Codable + Bundle de-risk + loader + schema test¶
Files:
- Create: native/macos/KuroNativeSaver/Core/StoryContent.swift
- Modify: scripts/build-native-app.sh, Script.swift, Palette.swift, scripts/run-native-tests.sh, tests/main.swift
- [ ] Step 1 (de-risk FIRST): Add a tiny debug print in the app startup that logs
Bundle.main.resourcePathand whetherBundle.main.url(forResource:"story-content", withExtension:"json")resolves. Addmkdir -p "$RES" && cp "$ROOT/src/engine/data/story-content.json" "$RES/"tobuild-native-app.shafter the Resources mkdir (~line 35). Build viascripts/build-native-app.sh, run the.app, confirm the path resolves. If it does not resolve, STOP and report — the fallback strategy (embedded JSON string) becomes the path. (This is Johannes' on-device confirmation point.) - [ ] Step 2: Write
StoryContent.swift— Codable structs matching the JSON exactly (Optional<String>for nullablefollowup), andstatic func load() -> StoryContentthat decodes the Bundle resource, with aprecondition/fallback on failure. - [ ] Step 3 (schema test): In
tests/main.swift, add a test that callsStoryContent.load()(or decodes a path passed in) and asserts section counts/keys against a manifest of expected counts (read from the same JSON via a tiny manifest). Wire the JSON path into the test build. Runbash scripts/run-native-tests.sh— Expected: FAIL first (struct mismatch), then iterate the structs until PASS. - [ ] Step 4: Make
Script.swift/Palette.swiftthin facades overStoryContent.load()(lazy static). Keep the native golden test (0.4) green. - [ ] Step 5: Run
bash scripts/run-native-tests.sh— Expected: schema test + golden test PASS. - [ ] Step 6: Commit —
git commit -am "feat(native): StoryContent Codable loader + schema test + bundled JSON"
Slices 2–8¶
Detailed into bite-sized TDD tasks at the start of each slice (see Slice sequence table for goal/deps/acceptance). Key anchors carried from the design spec so the sequence is unambiguous:
- Slice 2 (Arc layer):
arc.ts(arcAllows/pickArc/resolveEnding/selectArc, ring-buffer N=3, peakStage); wirepickArcat narrative pick sites (:308/:331/:399/:415/…); arc selection insidesilentResetcallback (:169-172);phaseRouting/per-phasedurationScale; mentor override; author 5 arcs instory-content.json. Native twin. Gate:arc=undefined⇒ golden identical (0.3/0.4). - Slice 3 (Persistence):
storyblock indefaults.ts(schema/layerCounter/arcsCompleted[≤3]/endingsReached); force-flush ordering (select arc → record ending → immediate save →enterPhase('ROUTINE')); nativeStoryMemoryJSON blob inUserDefaults(reuse Slice-1 Codable). Gate: forward-compat + kill-mid-reset. - Slice 4 (Corruption):
corruptionStage(threat)twin + calm/reduced-motion → stage 0; web per-categorycat-*+--ks-corrupt; nativerenderOverlay(corruptionStage:calm:)→colorFor(category,stage); prototype nativeglyphColorssparse param + measure draw calls before committing; auditFontAtlaskatakana reuse before baking; materialization as its own task with on-device sign-off. - Slice 5 (←/→):
bus.tssceneChange; bind arrows inhandleLiveHotkey; emit on webcycleScene/nativeRenderer.cycleScene;sceneChangechatter pool both sides; calm-gated. - Slice 6 (Persona-typing): port
TraitProfile/profileFor/phaseModulate(persona.ts:52-120) to Swift; wire at cps compute (Terminal.swift:177); parity test (same seed/trait/PANIC ⇒ matching typo/abandon counts). - Slice 7 (FilmDirector bias):
SceneWeight; arcscenesbiasesPHASE_CANDIDATESpick (webfilm-director.ts, nativeRendererscene pick). - Slice 8 (threatCurve): make
ReactiveWorldarc-aware (readarc.threatCurveper frame) web + native.
Self-review (against the spec)¶
- Spec coverage: §1 Arc-templates → Slice 2/7/8; §2 SSOT → Slice 1; §3 Corruption → Slice 4; §4 Persistence → Slice 3; §5 ←/→ → Slice 5; §6 testing+typing → Slice 0 (foreshadow, harnesses) + Slice 6 + per-slice tests. Latent foreshadow bug → Task 0.1. Reduced-motion gate → Slice 4. All decisions-log items map to a slice.
- No placeholders: Slices 0–1 carry exact files, commands, and code; the JSON content move is "verbatim relocation of existing literals" (precise, not vague). Slices 2–8 are intentionally expanded just-in-time (prototype-dependent), with concrete anchors.
- Type consistency:
ArcTemplate/ArcState/EndingSpec/EndingId/SceneWeight/Phasedefined in the spec's shared-types block;pickArc/arcAllows/resolveEnding/selectArcnamed consistently across Slice 2 and the file structure.