Slice 8 — Arc-aware threatCurve in ReactiveWorld — Implementation Plan¶
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax.
Goal: Each story arc's per-phase threatCurve overrides ReactiveWorld's default threat BAND, so different arcs reach different corruption stages — and therefore different endings (the diverts gate at stage 3) — while the no-arc / no-curve path stays byte-identical to today.
Architecture: threatCurve?: Partial<Record<Phase, [lo, hi]>> is an absolute drop-in replacement for the static BAND[phase] (design doc §"override reactive-world BAND per phase"). One seam line in reactive-world.update() reads narrative.currentArc?.arc.threatCurve?.[phase] ?? BAND[phase]. The threat path is RNG-free; the existing quantizeThreat → updateThreatStage → peakStage → resolveEnding chain (Slice 2) turns a curve into a stage/ending change — fully testable now, independent of the Slice-4 corruption render.
Tech Stack: TypeScript/Vite, Vitest (environment: 'node').
Global Constraints¶
- Additive invariant: with
currentArcundefined, or an arc withoutthreatCurve, or athreatCurvemissing the current phase, the threat output MUST be byte-identical to today via?? BAND[phase]. No new RNG on the threat path (golden + determinism hold). - Semantics:
threatCurve[phase] = [lo, hi]is an absolute band, lerped acrossphaseProgressby the existingsmoothstep— NOT a multiplier/bias/ramp. Same math asBAND. - Validation: every declared curve entry must satisfy
0 ≤ lo ≤ hi ≤ 1(content-test guard;lo > hiwould invert the ramp). - Do NOT change peakStage tracking (
narrative.updateThreatStagerunning-max;resolveEndingat SILENCE) — Slice 8 only changes the threat input. forcedThreat(URL?threat=) keeps bypassing curves (reactive-world.ts:87-89) — intentional tuning escape hatch.- Web-only this slice. No native arc/threat-band layer exists yet (native band is an inline switch in
Renderer.swift); native threatCurve rides with the native-1.3 on-device session. Native untouched → golden stays green. - Verify after each task:
npm test,npm run typecheck,npm run build; native untouched but runbash scripts/run-native-tests.shonce at the end.
Decisions (resolved during understand-phase)¶
A. Semantics → absolute per-phase BAND replacement (design-doc-backed). B. Seam via narrative.currentArc (no deps/constructor change). C. Author curves now (else the slice is inert). D. Validate 0≤lo≤hi≤1 in a content test. E. peakStage tracking unchanged.
Reference (verified)¶
BAND(reactive-world.ts:16-22): ROUTINE[0,0.12], INTRUSION[0.12,0.38], ALARM[0.38,0.70], PANIC[0.70,1.00], SILENCE[1,1].quantizeThreat(arc.ts:38-44):calm⇒0; else<0.15→0,<0.40→1,<0.70→2, else3.- Diverts (all gate at stage 3): harmonized→cold-path, cold-path→captured, karsen→captured; normal & wraith have none (
story-content.json). - Seam line:
reactive-world.ts:95const [lo, hi] = BAND[phase];.
Task 1: Arc-aware threat band in ReactiveWorld + seam test¶
Files:
- Modify: src/engine/fx/reactive-world.ts (line 95)
- Create: tests/reactive-world.test.ts
Interfaces:
- Consumes: NarrativeRunner.currentArc (narrative.ts:133), ArcState.arc.threatCurve (arc.ts:27), quantizeThreat.
- Produces: no API change — ReactiveWorld.update() now applies the arc's per-phase band override.
- [ ] Step 1: Write the failing seam test
Create tests/reactive-world.test.ts:
import { describe, it, expect } from 'vitest';
import { ReactiveWorld } from '../src/engine/fx/reactive-world';
import type { ArcState } from '../src/engine/terminal/arc';
import type { Phase } from '../src/engine/terminal/narrative';
// Minimal fakes: ReactiveWorld only touches these members of engine/narrative.
function fakeEngine() {
return {
seed: 1, onFrame: null as unknown,
bus: { emit() {} },
threat: 0, storm: 0, fogThreatMult: 1,
setEnemyFraction() {}, setCrtThreat() {}, setBloomThreat() {},
};
}
function fakeNarrative(phase: Phase, progress: number, arc?: ArcState) {
const stages: number[] = [];
return {
obj: {
currentPhase: phase,
phaseProgress: progress,
currentArc: arc,
updateThreatStage: (s: number) => stages.push(s),
d: { onIntrusion: undefined as undefined | (() => void) },
},
stages,
};
}
function arcWith(threatCurve: ArcState['arc']['threatCurve']): ArcState {
return { arc: { id: 'x', weight: 1, antagonist: '', mentor: null, beatTags: {}, threatCurve, ending: { default: 'normal' } } as any, threatStage: 0, peakStage: 0 };
}
/** Drive update() to steady state at the given phase/progress; return the last stage fed. */
function steadyStage(arc: ArcState | undefined, phase: Phase = 'PANIC', progress = 1): number {
const eng = fakeEngine();
const narr = fakeNarrative(phase, progress, arc);
const rw = new ReactiveWorld({ engine: eng as any, narrative: narr.obj as any, crt: null, settings: {} as any, calm: false });
for (let i = 0; i < 4000; i++) rw.update(i * 16, 16); // low-pass converges to target
return narr.stages[narr.stages.length - 1];
}
describe('ReactiveWorld arc threatCurve', () => {
it('additive invariant: no arc / arc-without-curve / curve-missing-phase all reach the BAND stage', () => {
const baseline = steadyStage(undefined); // BAND PANIC [0.70,1.0] → 3
expect(baseline).toBe(3);
expect(steadyStage(arcWith(undefined))).toBe(baseline); // arc, no curve
expect(steadyStage(arcWith({})).valueOf()).toBe(baseline); // empty curve
expect(steadyStage(arcWith({ ROUTINE: [0, 0.05] }))).toBe(baseline); // curve missing PANIC
});
it('a capping curve keeps PANIC below the stage-3 threshold', () => {
expect(steadyStage(arcWith({ PANIC: [0.0, 0.65] }))).toBe(2); // < 0.70 → stage 2
});
it('a full curve still reaches stage 3', () => {
expect(steadyStage(arcWith({ PANIC: [0.0, 1.0] }))).toBe(3);
});
it('an ALARM cap lowers the ALARM-phase stage', () => {
expect(steadyStage(arcWith({ ALARM: [0.0, 0.30] }), 'ALARM', 1)).toBe(1); // < 0.40 → stage 1
expect(steadyStage(undefined, 'ALARM', 1)).toBe(2); // BAND ALARM hi 0.70 → stage 2
});
});
- [ ] Step 2: Run the tests — the curve tests must FAIL
Run: npx vitest run tests/reactive-world.test.ts
Expected: the additive-invariant test passes (current code uses BAND), but the capping/full/ALARM-cap tests FAIL (the curve is ignored → still BAND stages).
- [ ] Step 3: Apply the seam
In src/engine/fx/reactive-world.ts, replace line 95:
with:
// Slice 8: an arc may override the per-phase band; absent/partial → BAND (additive).
const [lo, hi] = this.d.narrative.currentArc?.arc.threatCurve?.[phase] ?? BAND[phase];
(this.d.narrative is non-null in this else if branch at :90; currentArc is the Slice-7 accessor; no RNG, no deps change.)
- [ ] Step 4: Run the tests — all PASS; then the full suite
Run: npx vitest run tests/reactive-world.test.ts && npm test && npm run typecheck
Expected: all green (curve shifts stages; additive invariant holds; pre-existing suite + arc/quantize tests unaffected).
- [ ] Step 5: Commit
git add src/engine/fx/reactive-world.ts tests/reactive-world.test.ts
git commit -m "feat(reactive-world): arc threatCurve overrides per-phase threat band (additive)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
Task 2: Author per-arc threatCurve content + validation test¶
Files:
- Modify: src/engine/data/story-content.json (add threatCurve to harmonized/cold-path/karsen/wraith)
- Create: tests/arc-threatcurve.test.ts
Interfaces:
- Consumes: ARCS (script-bank.ts:72).
- Produces: validated threatCurve on four arcs; an ending-divergence assertion proving curves matter.
- [ ] Step 1: Write the failing validation + divergence test
Create tests/arc-threatcurve.test.ts:
import { describe, it, expect } from 'vitest';
import { ARCS } from '../src/engine/terminal/script-bank';
import { resolveEnding, quantizeThreat } from '../src/engine/terminal/arc';
const PHASES = new Set(['ROUTINE', 'INTRUSION', 'ALARM', 'PANIC', 'SILENCE']);
describe('arc threatCurve content', () => {
it('every declared threatCurve entry is a valid [lo,hi] with 0 ≤ lo ≤ hi ≤ 1', () => {
for (const arc of ARCS) {
if (!arc.threatCurve) continue;
for (const [phase, band] of Object.entries(arc.threatCurve)) {
expect(PHASES.has(phase), `arc ${arc.id} bad phase ${phase}`).toBe(true);
expect(Array.isArray(band) && band!.length === 2, `arc ${arc.id}/${phase} not a pair`).toBe(true);
const [lo, hi] = band!;
expect(lo >= 0 && lo <= hi && hi <= 1, `arc ${arc.id}/${phase} bad band [${lo},${hi}]`).toBe(true);
}
}
});
it('the flavoured arcs declare threatCurve; normal stays pure', () => {
const byId = Object.fromEntries(ARCS.map((a) => [a.id, a]));
for (const id of ['harmonized', 'cold-path', 'karsen', 'wraith']) {
expect(byId[id]?.threatCurve, `arc ${id} should declare threatCurve`).toBeTruthy();
}
expect(byId['normal']?.threatCurve).toBeUndefined();
});
it('curves drive divergent endings: harmonized stays shallow, cold-path/karsen reach captured', () => {
const byId = Object.fromEntries(ARCS.map((a) => [a.id, a]));
// harmonized PANIC band caps below 0.70 → peakStage 2 → no divert → 'harmonized'
const hPanic = byId['harmonized'].threatCurve!.PANIC!;
expect(quantizeThreat(hPanic[1], false)).toBeLessThan(3);
expect(resolveEnding(byId['harmonized'], quantizeThreat(hPanic[1], false))).toBe('harmonized');
// cold-path PANIC band reaches stage 3 → divert → 'captured'
const cPanic = byId['cold-path'].threatCurve!.PANIC!;
expect(quantizeThreat(cPanic[1], false)).toBe(3);
expect(resolveEnding(byId['cold-path'], 3)).toBe('captured');
// karsen likewise
expect(resolveEnding(byId['karsen'], quantizeThreat(byId['karsen'].threatCurve!.PANIC![1], false))).toBe('captured');
});
});
- [ ] Step 2: Run it — the flavoured/divergence tests must FAIL (no arc declares threatCurve yet)
Run: npx vitest run tests/arc-threatcurve.test.ts
Expected: the "declare threatCurve" + "divergent endings" tests FAIL; the validation test passes vacuously.
- [ ] Step 3: Add
threatCurveto the four arcs instory-content.json
Add a "threatCurve" key to each arc object (alongside scenes/beatTags). harmonized (stays shallow — caps PANIC below 0.70):
cold-path (front-loads, reliably hits stage 3):
karsen (sustained high peak):
wraith (hardest/earliest peak; no divert so ending stays wraith, but the steeper curve feeds Slice-4 render later):
(Do NOT add to normal. Insert each threatCurve after that arc's scenes block and before ending; mind JSON commas.)
- [ ] Step 4: Run the test — all PASS; then the full suite
Run: npx vitest run tests/arc-threatcurve.test.ts && npm test && npm run typecheck
Expected: all green. No parity guard hashes the full arcs (verified in Slice 7) → no re-pin.
- [ ] Step 5: Commit
git add src/engine/data/story-content.json tests/arc-threatcurve.test.ts
git commit -m "feat(content): per-arc threatCurve bands → divergent corruption stages/endings
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
Task 3: Full verification gate¶
Files: none.
- [ ] Step 1: Web gates —
npm test && npm run typecheck && npm run build→ all green (reactive-world + arc-threatcurve + pre-existing suite; build emitsdist/). - [ ] Step 2: Native untouched —
bash scripts/run-native-tests.sh→ ALL PASS, golden hash15657498981793719251unchanged. - [ ] Step 3: Inspect —
git grep -n "threatCurve" src/engine→ only the seam inreactive-world.tsconsumes it;arc.ts:27declares it. ConfirmforcedThreatbypass (reactive-world.ts:87-89) is intact.
Self-Review¶
Spec coverage: threatCurve consumption (Task 1) · per-arc content (Task 2) · acceptance "arc threat-curve alters corruption stage" (Task 1 stage tests + Task 2 ending-divergence). Native deferral + Slice-4 independence documented. ✓
Placeholder scan: none.
Type consistency: seam reads currentArc?.arc.threatCurve?.[phase] ?? BAND[phase] matching arc.ts:27 shape; steadyStage fakes match the exact members ReactiveWorld touches (reactive-world.ts:51-138); band guard 0≤lo≤hi≤1 consistent with smoothstep; golden untouched (no re-pin). ✓