Slice 5 — ←/→ "Kurskorrektur" chatter + CHATTER → JSON-SSOT migration¶
Date: 2026-06-28 Spec: Spec 1 (Story-Arcs + Corruption), Slice 5 — "Variabilität Ebene 4" (world→story feedback) Status: design approved (brainstorm 2026-06-28), ready for implementation plan Depends on: Slice 1 (JSON SSOT) — shipped.
Summary¶
The ←/→ arrow keys already cycle the 3D flight scene. Slice 5 makes a user-initiated
scene change emit a new sceneChange event on the flight bus, which triggers reactive,
phase-aware operator radio chatter — the operator verbally reacts to the viewer steering
("Kurskorrektur"). It is deliberately the lightest world→story-feedback touch.
This slice also pays down a debt discovered during brainstorm: the spec assumed chatter
content got "free parity via the SSOT", but chatter is NOT in story-content.json today —
chatter-bank.ts (web) and Chatter.swift (native) are hand-mirrored twins. Before adding the
new sceneChange content we migrate the existing CHATTER pool into the JSON SSOT (byte-true
move, hash-guarded), making the web side a facade like presets.ts/dictionary.ts.
Ratified scope decisions (brainstorm 2026-06-28)¶
- Chatter SSOT: migrate the whole
CHATTERbank intostory-content.json(byte-true, FNV-hash guarded à la Slice 1b), then add the newsceneChangecontent there. Web reads the JSON via a facade; the native Swift twin stays hand-mirrored (its JSON parity is Slice 1.3's job, identical treatment to presets/dict today). - Content shape: per-phase variants —
sceneChange:ROUTINE … sceneChange:SILENCE— so the operator's tone shifts with the threat phase. A basesceneChangepool is the fallback. - Native + test: ship web and native this slice (native is purely additive — keys are already bound there). Acceptance covered by an automated web behavioural test + an on-device sign-off by Johannes.
1-9direct-pick keys also emitsceneChange(any manual scene pick = course-correction).- Cooldown 1.2 s against arrow-mashing chatter spam.
dir(left = −1 / right = +1) is carried but does NOT change content — reserved for future use.
Held from the ratified Spec-1 scope (non-goals)¶
- No recklessness machinery: ←/→ does not touch arc selection,
threat,peakStage, endings, or persistedStoryMemory.divertOnRecklessness/ArcState.recklessnessstay reserved-and-absent. - The automated web↔native chatter content-parity guard stays deferred to Slice 1.3
(Swift
Codable+ schema test). This slice does not build a cross-language guard.
Current state (verified against live code)¶
- Web chatter exists and is populated:
src/engine/terminal/chatter-bank.tsexportsCHATTER: Record<string, ChatterLine[][]>, keyed byFlightEventKind(incomingFire/unitArrive/unitCrash) orphase:*. (The design doc's stale "native-only" note was wrong.) Chatter.swiftis a byte-identical twin ofchatter-bank.tstoday (same pools/texts/ speakers/categories) — no pre-existing drift to reconcile.ChatterDirector(chatter-director.tsweb /Chatter.swiftnative) dispatches by exact key, picks a non-repeating exchange via seeded RNG (mkRng(seed ^ 0xc4a77e)/LCG), and streams the exchange's turns at 520 ms stagger. A missing/empty pool is a silent no-op.- Controller wiring:
this.engine.bus.subscribe('*', …)routes flight events tothis.chatter?.fire(e.kind);onPhaseEnterfiresphase:<PHASE>.reduceMotionis already captured in the subscriber but the existing chatterfirecalls are not calm-gated. - Scene plumbing:
switchScene(id)is the shared low-level path. It is called bycycleScene()(←/→), the1-9block, the automatic film-mode phase warp (onPhaseEnter), and the startup transition (main.ts).cycleScene()/switchScene()emit no bus event today. - Keybindings: ←/→ are unbound on web today; any unhandled key falls through and closes
the saver. Native ←/→ are already bound to
cycleScene(±1)(only insceneHotkeyEnabledmodes) but emit no bus event. - JSON SSOT:
story-content.jsonhas 15 top-level keys; nochatterkey yet. The native Swift twin does not read the JSON at all — it is hand-mirrored, verified against the JSON only by parity tests / on-device golden, with the Codable bundle path being Slice 1.3. - Facade precedent:
presets.tsimports the JSON and rebuilds the original runtime shape so the export is byte-identical to the old literal, pinned by an FNV-1a hash intests/dict-presets-parity.test.ts(explicitly a web-internal move-fidelity guard). - Path correction: the bus type lives at
src/engine/events/bus.ts(spec/plan saidcore/bus.ts);FlightEventalready carries an optionaldir?: number.
Architecture¶
Part A — CHATTER → story-content.json (byte-true move)¶
- Add a new top-level
chattersection tostory-content.json: a JSON object keyed by the chatter key (incomingFire,unitArrive,unitCrash,phase:INTRUSION,phase:ALARM,phase:PANIC,phase:SILENCE). Unlikepresets(array-with-id, because the swatch UI needs order), chatter dispatch is purely key-based, so an object is correct; byte-identity of the facade output is preserved by authoring the keys in the current literal order. chatter-bank.tsbecomes a facade: keep theChatterLineinterface + doc comment, replace the literal withexport const CHATTER = (story as …).chatter. Same mechanism aspresets.ts.- Move guard: pin
fnv1a(JSON.stringify(CHATTER))captured from the pre-move literal (independently reproducible fromgit show HEAD:…chatter-bank.ts), then re-hash the facade output — green ⇒ byte-identical. Add to a chatter parity test (sibling of the dict/presets guard). The newsceneChangepools are added after the guard hash is pinned, so the move and the content addition are separable and the hash proves only the move.
Part B — sceneChange event + per-phase chatter (web)¶
- Type: add
'sceneChange'toFlightEventKindinsrc/engine/events/bus.ts.dir?already exists — no interface change. - Emit seam (centralised, auto-safe): change
switchScene(id, opts?: { user?: boolean }). Emitbus.emit({ kind: 'sceneChange', dir })only whenopts.useris true. Automatic callers (onPhaseEnterwarp,main.tsstartup transition) omit the flag → never emit, so the fixed-seed narrative stream and the golden snapshot are untouched. User callers pass{ user: true }:cycleScene(dir)(←/→,dir = ∓1) and the1-9block (dir = 0). - Binding: in
handleLiveHotkey, bindArrowLeft → cycleScene(-1)andArrowRight → cycleScene(+1), each callingpreventDefault()+stopPropagation()and returningtrue(so the key does not close the saver). They run under the same context gate as the already-working1-9keys (active-watch /sceneHotkeyEnabled). - Route + calm gate: in the bus
'*'subscriber add asceneChangebranch that (a) suppresses whenprefers-reduced-motionis set; (b) applies the 1.2 s cooldown; (c) builds the key'sceneChange:' + currentPhaseand callsthis.chatter?.fire(key), with a fallback to the basesceneChangepool if the phase-keyed pool is missing. The controller must expose the current narrative phase (trackthis.currentPhase, set inonPhaseEnter). - Cooldown: a single timestamp in the
sceneChangebranch; drop chatter if the last sceneChange chatter fired < 1.2 s ago. Scene cycling itself is not throttled — only the chatter. - Content: author
sceneChange:ROUTINE(calm operator ack) → escalating →sceneChange:PANIC(terse / overwhelmed) →sceneChange:SILENCE(near-silent), plus a basesceneChangefallback, in theHQ/GHOSTLINKregisters consistent with the existing bank.
Part C — native Swift twin (additive)¶
Renderer.swift cycleScene: emit.sceneChange(keys already routed here; only the emit is new). Add.sceneChangeto the nativeFlightEventKindenum and a subscriber next to the combat subscriptions that callschatter.fire("sceneChange:" + phase, t:)with the same calm gate + 1.2 s cooldown. Do not rebind keys; keep behaviour gated tosceneHotkeyEnabled.Chatter.swift: hand-mirror the samesceneChange:PHASEpools (consistent with all existing native chatter). No new cross-language guard (Slice 1.3).
Part D — tests + acceptance¶
- Move fidelity (web): FNV-1a hash of the
CHATTERfacade output equals the pinned pre-move hash. - Behavioural (web, the automated acceptance test): in an active-watch context, a simulated
ArrowLeft/ArrowRightkeydown (a) emits asceneChangebus event with the correctdir, and (b) causesChatterDirector.fireto be called with the phase-keyedsceneChangekey and stream a non-empty line. Also assert:prefers-reduced-motionsuppresses the chatter; the automatic phase warp does not emitsceneChange. - Golden snapshot: unchanged when no arrow is pressed (sceneChange is user-only).
- Native: CI compile-check + on-device sign-off by Johannes (←/→ in active-watch fires chatter; respects reduced-motion).
Files touched¶
Web
- src/engine/data/story-content.json — new chatter section (migrated bank + sceneChange:*).
- src/engine/terminal/chatter-bank.ts — literal → facade over story.chatter.
- src/engine/events/bus.ts — 'sceneChange' added to FlightEventKind.
- src/engine/controller.ts — switchScene {user} opt + emit; cycleScene(dir); 1-9 emit;
←/→ binding in handleLiveHotkey; sceneChange branch (calm gate + cooldown) in the bus
subscriber; currentPhase tracking.
- tests/chatter-parity.test.ts (new) — move-fidelity FNV guard.
- tests/scene-change.test.ts (new) — behavioural acceptance.
Native (macOS)
- native/macos/KuroNativeSaver/Core/Renderer.swift — .sceneChange emit + subscriber.
- native/macos/KuroNativeSaver/Core/Chatter.swift — sceneChange:* pools (hand-mirror).
- native FlightEventKind enum — .sceneChange case.
Risks / invariants¶
- Auto-emit hazard: the emit MUST be gated to user-initiated scene changes — verified by the golden-snapshot test and the "auto warp emits nothing" assertion.
- Saver-close hazard: the ←/→ binding MUST
preventDefault/stopPropagation/return true. - Byte-true move: the FNV guard hash is pinned from the pre-move literal, never re-derived
from the migrated value;
sceneChangecontent is added only after pinning. - Calm parity: sceneChange chatter needs its own explicit reduced-motion gate — it cannot rely on a threat threshold (calm only damps threat, does not zero chatter triggers).
- Sign convention: left = −1, right = +1 must match across both twins (matters only if
dirlater becomes content-bearing). - Native parity: the
sceneChangepools must be added identically by hand in both files; no automated guard catches drift until Slice 1.3.