Slice 7 — Arc-biased FilmDirector scene itinerary — 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 scenes weights bias the FilmDirector's scene-itinerary pick (web), so different arcs visit visually-distinct scene sequences — while the arc-less path stays byte-identical to today.
Architecture: sceneAt gains an optional trailing arc?: ArcState param. When the arc declares scenes[phase], the candidate list is the weight-expanded array (each scene repeated weight times) consumed by the unchanged single-RNG-draw index math; otherwise it is the exact PHASE_CANDIDATES[phase] array (zero behavioural change, zero extra RNG). The arc reaches the FilmDirector via a new NarrativeRunner.currentArc accessor, passed at the two controller call sites.
Tech Stack: TypeScript/Vite, Vitest (environment: 'node').
Global Constraints¶
- Additive invariant (load-bearing):
sceneAt(i, phase, prev, undefined)— and with an arc whosescenesis absent/empty/missing-this-phase — MUST equal the pre-Slice-7 algorithm byte-for-byte (proven by an oracle test). No extra RNG draw on the arc-less path. - One RNG draw per call, unchanged:
mkRng((seed ^ (index * 0x9e3779b1)) >>> 0)(). Weighting is done by pre-expanding the candidate array, NOT by a second draw or float cumsum (avoids TS↔Swift float divergence later). - Web-only this slice. The native arc layer does NOT exist yet (Slice 2/3 were web-only; native twin is hand-maintained until the bundled native-1.3 session). Native
FilmDirector/golden are untouched → native tests stay trivially green. Native Slice 7 (thearcparam + fallback) folds into the future native-arc-layer port. - Weights: positive integers;
weightfloored, min 1. Duplicate ids in one phase list sum naturally via expansion. - Off-list scenes allowed: a
scenes[phase]entry may name any validFlightSceneId(the arc author owns the itinerary); the JSON validation test guards that ids are valid and weights are positive integers. - Golden re-pin: NO. The native golden + FilmDirector determinism asserts are the additive-invariant proof; they must not move.
- Verify after each task:
npm test,npm run typecheck,npm run build; native untouched but runbash scripts/run-native-tests.shonce at the end to confirm no incidental breakage.
Decisions (resolved during understand-phase)¶
- Native scope → web-only; native param/fallback deferred to the native-arc-layer port (no native arc to bias).
- Weight semantics → pre-expanded integer-repeat array + existing single draw (rejected: cumsum-with-extra-draw).
- Off-list scenes → allow any valid
FlightSceneId, validate in JSON test. - Anti-repeat → generalised to "rotate to next slot whose scene differs from
prevScene"; byte-identical to the old(i+1)%lenrule on the all-distinctPHASE_CANDIDATESarrays (proven by oracle test), and honours no-immediate-repeat on weighted arrays. - Content → author
scenesfor harmonized/cold-path/karsen/wraith; normal gets none (anchors the additive invariant in production content).
Task 1: Arc-biased sceneAt + SceneWeight + additive-invariant/distribution tests¶
Files:
- Modify: src/engine/modes/film-director.ts
- Create: tests/film-director.test.ts
Interfaces:
- Consumes: ArcState (src/engine/terminal/arc.ts:31), mkRng (src/engine/engine/rng.ts).
- Produces: FilmDirector.sceneAt(index: number, phase: Phase, prevScene: SceneId | null, arc?: ArcState): FlightSceneId; export interface SceneWeight { scene: FlightSceneId; weight: number }.
- [ ] Step 1: Write the failing tests
Create tests/film-director.test.ts:
import { describe, it, expect } from 'vitest';
import { FilmDirector } from '../src/engine/modes/film-director';
import { mkRng } from '../src/engine/engine/rng';
import type { ArcState } from '../src/engine/terminal/arc';
import type { Phase } from '../src/engine/terminal/narrative';
// Oracle = the pre-Slice-7 algorithm, verbatim, as the additive-invariant reference.
const PHASE_CANDIDATES: Record<Phase, string[]> = {
ROUTINE: ['terrain', 'city'],
INTRUSION: ['city', 'rift'],
ALARM: ['rift', 'tunnel'],
PANIC: ['tunnel', 'void', 'wreckage'],
SILENCE: ['void', 'wreckage'],
};
function oracle(seed: number, index: number, phase: Phase, prev: string | null): string {
const cands = PHASE_CANDIDATES[phase];
const r = mkRng((seed ^ (index * 0x9e3779b1)) >>> 0)();
let i = Math.floor(r * cands.length) % cands.length;
if (cands[i] === prev && cands.length > 1) i = (i + 1) % cands.length;
return cands[i];
}
const PHASES: Phase[] = ['ROUTINE', 'INTRUSION', 'ALARM', 'PANIC', 'SILENCE'];
const ALL = ['terrain', 'city', 'rift', 'tunnel', 'void', 'wreckage', null] as const;
function arcWith(scenes: ArcState['arc']['scenes']): ArcState {
return { arc: { id: 'x', weight: 1, antagonist: '', mentor: null, beatTags: {}, scenes, ending: { default: 'normal' } } as any, threatStage: 0, peakStage: 0 };
}
describe('FilmDirector additive invariant (arc-less ≡ pre-Slice-7 oracle)', () => {
it('matches the oracle across a sweep with arc=undefined / empty / missing-phase', () => {
for (const seed of [1, 42, 1337, 0x9e3779b1 | 0]) {
const fd = new FilmDirector(seed);
for (let index = 0; index < 60; index++) {
for (const phase of PHASES) {
for (const prev of ALL) {
const want = oracle(seed, index, phase, prev);
expect(fd.sceneAt(index, phase, prev)).toBe(want); // undefined arc
expect(fd.sceneAt(index, phase, prev, arcWith(undefined))).toBe(want); // no scenes
expect(fd.sceneAt(index, phase, prev, arcWith({}))).toBe(want); // empty scenes
expect(fd.sceneAt(index, phase, prev, arcWith({ ROUTINE: [] }))).toBe(want); // empty phase list + other phases missing
}
}
}
}
});
});
describe('FilmDirector arc bias', () => {
it('weights the heavy scene roughly proportionally and never picks off-override scenes', () => {
const fd = new FilmDirector(12345);
const arc = arcWith({ PANIC: [{ scene: 'wreckage', weight: 3 }, { scene: 'void', weight: 1 }] });
let wreck = 0, voidc = 0;
const N = 600;
for (let index = 0; index < N; index++) {
const s = fd.sceneAt(index, 'PANIC', null, arc); // prev=null → anti-repeat never fires
expect(s === 'wreckage' || s === 'void').toBe(true);
if (s === 'wreckage') wreck++; else voidc++;
}
expect(wreck).toBeGreaterThan(voidc); // heavy scene dominates
expect(wreck / voidc).toBeGreaterThan(1.8); // ≈3:1, generous tolerance
expect(wreck / voidc).toBeLessThan(5.0);
});
it('allows an off-list scene the phase candidates do not include', () => {
const fd = new FilmDirector(7);
const arc = arcWith({ PANIC: [{ scene: 'terrain', weight: 1 }] });
for (let index = 0; index < 10; index++) {
expect(fd.sceneAt(index, 'PANIC', null, arc)).toBe('terrain');
}
});
it('avoids an immediate repeat on a weighted array when an alternative exists', () => {
const fd = new FilmDirector(99);
const arc = arcWith({ PANIC: [{ scene: 'wreckage', weight: 3 }, { scene: 'void', weight: 1 }] });
for (let index = 0; index < 50; index++) {
expect(fd.sceneAt(index, 'PANIC', 'wreckage', arc)).not.toBe('wreckage');
}
});
});
- [ ] Step 2: Run the tests — they must FAIL
Run: npx vitest run tests/film-director.test.ts
Expected: FAIL — sceneAt does not accept a 4th arg / arc bias not implemented (the bias + off-list tests fail; the invariant test may pass coincidentally on the current code but the arc-arg calls are type/runtime errors).
- [ ] Step 3: Implement the arc-biased
sceneAt
Rewrite src/engine/modes/film-director.ts:
// FilmDirector — seeded scene selection for the narrative arc. Pure: a function of
// (seed, monotonic phase index, phase, prevScene[, arc]). "Fester Bogen, prozedurale Welt":
// the phase decides the intensity tier (candidate list); the index decides which one.
// Slice 7: an arc may bias the per-phase candidates via weighted SceneWeights.
import { mkRng } from '../engine/rng';
import type { SceneId } from '../data/defaults';
import type { Phase } from '../terminal/narrative';
import type { ArcState } from '../terminal/arc';
/** Scenes that are real flights (excludes the screen-space 'matrix' fx scene). */
export type FlightSceneId = Exclude<SceneId, 'matrix'>;
/** A weighted scene candidate for arc-biased itineraries (Slice 7). */
export interface SceneWeight { scene: FlightSceneId; weight: number; }
const PHASE_CANDIDATES: Record<Phase, FlightSceneId[]> = {
ROUTINE: ['terrain', 'city'],
INTRUSION: ['city', 'rift'],
ALARM: ['rift', 'tunnel'],
PANIC: ['tunnel', 'void', 'wreckage'],
SILENCE: ['void', 'wreckage'],
};
/** Candidate list for a phase, optionally biased by the arc's per-phase SceneWeights.
* Arc-less / absent / empty override → the exact PHASE_CANDIDATES array (byte-identical
* path: same order, same length → the draw + anti-repeat below run as before). */
function candidatesFor(phase: Phase, arc?: ArcState): FlightSceneId[] {
const override = arc?.arc.scenes?.[phase];
if (!override || override.length === 0) return PHASE_CANDIDATES[phase];
const out: FlightSceneId[] = [];
for (const w of override) {
const n = Math.max(1, Math.floor(w.weight));
for (let k = 0; k < n; k++) out.push(w.scene as FlightSceneId);
}
return out;
}
export class FilmDirector {
constructor(private seed: number) {}
/** Deterministic scene for the given monotonic index + phase, avoiding prevScene.
* `arc` (Slice 7) optionally biases the candidate list via weighted repetition. */
sceneAt(index: number, phase: Phase, prevScene: SceneId | null, arc?: ArcState): FlightSceneId {
const cands = candidatesFor(phase, arc);
const r = mkRng((this.seed ^ (index * 0x9e3779b1)) >>> 0)(); // deterministic 0..1 per index
let i = Math.floor(r * cands.length) % cands.length;
// anti-repeat: rotate to the next slot whose scene differs from prevScene. On the
// all-distinct PHASE_CANDIDATES this is the old `(i+1)%len` single step (byte-identical);
// on a weighted array it skips repeated heavy-scene slots.
if (cands.length > 1) {
let guard = 0;
while (cands[i] === prevScene && guard < cands.length) { i = (i + 1) % cands.length; guard++; }
}
return cands[i];
}
}
- [ ] Step 4: Run the tests — they must PASS; then the full suite
Run: npx vitest run tests/film-director.test.ts && npm test && npm run typecheck
Expected: all green (additive invariant matches oracle; distribution + off-list + anti-repeat pass).
- [ ] Step 5: Commit
git add src/engine/modes/film-director.ts tests/film-director.test.ts
git commit -m "feat(film): arc-biased scene itinerary via weighted SceneWeights (additive-invariant)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
Task 2: Author arc scenes content + JSON validation test¶
Files:
- Modify: src/engine/data/story-content.json (add scenes to 4 arcs)
- Create: tests/arc-scenes.test.ts
Interfaces:
- Consumes: ARCS (src/engine/terminal/script-bank.ts:72, story.arcs as readonly ArcTemplate[]).
- Produces: validated scenes on the harmonized/cold-path/karsen/wraith arc objects.
- [ ] Step 1: Write the failing validation test
Create tests/arc-scenes.test.ts:
import { describe, it, expect } from 'vitest';
import { ARCS } from '../src/engine/terminal/script-bank';
const VALID_SCENES = new Set(['terrain', 'city', 'rift', 'tunnel', 'void', 'wreckage']);
const PHASES = new Set(['ROUTINE', 'INTRUSION', 'ALARM', 'PANIC', 'SILENCE']);
describe('arc scenes content', () => {
it('every declared scenes entry is a valid FlightSceneId with a positive integer weight', () => {
for (const arc of ARCS) {
if (!arc.scenes) continue;
for (const [phase, list] of Object.entries(arc.scenes)) {
expect(PHASES.has(phase), `arc ${arc.id} bad phase ${phase}`).toBe(true);
expect(Array.isArray(list)).toBe(true);
for (const w of list!) {
expect(VALID_SCENES.has(w.scene), `arc ${arc.id}/${phase} bad scene ${w.scene}`).toBe(true);
expect(Number.isInteger(w.weight) && w.weight > 0, `arc ${arc.id}/${phase} bad weight ${w.weight}`).toBe(true);
}
}
}
});
it('at least the four flavoured arcs declare scenes; normal stays a pure fallback', () => {
const byId = Object.fromEntries(ARCS.map((a) => [a.id, a]));
for (const id of ['harmonized', 'cold-path', 'karsen', 'wraith']) {
expect(byId[id]?.scenes, `arc ${id} should declare scenes`).toBeTruthy();
}
expect(byId['normal']?.scenes).toBeUndefined();
});
});
- [ ] Step 2: Run it — the second test must FAIL (no arc declares
scenesyet)
Run: npx vitest run tests/arc-scenes.test.ts
Expected: the "flavoured arcs declare scenes" test FAILS; the validation test passes vacuously.
- [ ] Step 3: Add
scenesto the four arcs instory-content.json
For each arc object in the arcs array, add a "scenes" key (alongside phaseRouting/durationScale/beatTags). Keep biases in-tier except where deliberately expressive. Add to harmonized:
"scenes": {
"ROUTINE": [{ "scene": "terrain", "weight": 3 }, { "scene": "city", "weight": 1 }],
"INTRUSION": [{ "scene": "city", "weight": 2 }, { "scene": "rift", "weight": 1 }],
"SILENCE": [{ "scene": "void", "weight": 3 }, { "scene": "wreckage", "weight": 1 }]
},
cold-path (note FilmDirector sees the routed phase):
"scenes": {
"ROUTINE": [{ "scene": "city", "weight": 2 }, { "scene": "terrain", "weight": 1 }],
"PANIC": [{ "scene": "tunnel", "weight": 3 }, { "scene": "void", "weight": 1 }, { "scene": "wreckage", "weight": 1 }]
},
karsen:
"scenes": {
"ALARM": [{ "scene": "rift", "weight": 1 }, { "scene": "tunnel", "weight": 3 }],
"PANIC": [{ "scene": "tunnel", "weight": 2 }, { "scene": "void", "weight": 2 }, { "scene": "wreckage", "weight": 1 }]
},
wraith:
"scenes": {
"ROUTINE": [{ "scene": "city", "weight": 2 }, { "scene": "terrain", "weight": 1 }],
"PANIC": [{ "scene": "wreckage", "weight": 3 }, { "scene": "void", "weight": 2 }, { "scene": "tunnel", "weight": 1 }],
"SILENCE": [{ "scene": "wreckage", "weight": 3 }, { "scene": "void", "weight": 1 }]
},
(Do NOT add scenes to normal. Mind JSON commas — scenes is a normal object member of each arc.)
- [ ] Step 4: Run the test — it must PASS; then the full suite
Run: npx vitest run tests/arc-scenes.test.ts && npm test && npm run typecheck
Expected: all green. Verified: no guard hashes the full arcs export (script-bank-parity hashes routineStream()/plainPools(); arc-content checks ids/structure only) — so adding scenes trips nothing and needs no re-pin.
- [ ] Step 5: Commit
git add src/engine/data/story-content.json tests/arc-scenes.test.ts
git commit -m "feat(content): per-arc scene-itinerary biases + validation guard
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
Task 3: Wire the arc into sceneAt (narrative accessor + controller call sites)¶
Files:
- Modify: src/engine/terminal/narrative.ts (add currentArc accessor)
- Modify: src/engine/controller.ts (pass the arc at both sceneAt call sites)
Interfaces:
- Consumes: FilmDirector.sceneAt(..., arc?) (Task 1), ArcState.
- Produces: NarrativeRunner.currentArc: ArcState | undefined (getter).
- [ ] Step 1: Add the accessor to
NarrativeRunner
In src/engine/terminal/narrative.ts, after updateThreatStage (~line 130), add:
/** The shift's selected arc (Slice 7: lets the FilmDirector bias its itinerary). */
get currentArc(): ArcState | undefined { return this.arc; }
- [ ] Step 2: Pass the arc at both controller call sites
In src/engine/controller.ts, line ~858 (onFilmPhase):
const next = this.film.sceneAt(this.phaseCounter, phase, this.currentFilmScene, this.narrative?.currentArc);
Line ~867 (foreshadowFor):
const sc = this.film.sceneAt(this.phaseCounter, nextPhase, this.currentFilmScene, this.narrative?.currentArc);
- [ ] Step 3: Verify — typecheck, build, full suite
Run: npm run typecheck && npm run build && npm test
Expected: typecheck clean (the getter + 4th arg type correctly); build succeeds; all tests green. No new DOM test — the wiring is thin; the biasing logic is covered by Task 1's unit tests, and the accessor is a one-line passthrough.
- [ ] Step 4: Commit
git add src/engine/terminal/narrative.ts src/engine/controller.ts
git commit -m "feat(controller): feed the shift arc into the FilmDirector scene itinerary
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
Task 4: Full verification gate¶
Files: none.
- [ ] Step 1: Web gates
Run: npm test && npm run typecheck && npm run build
Expected: all green — film-director (invariant + bias), arc-scenes (content validation), and the pre-existing suite; typecheck clean; build emits dist/.
- [ ] Step 2: Native untouched — confirm no incidental breakage
Run: bash scripts/run-native-tests.sh
Expected: ALL PASS — FilmDirector determinism + golden hash 15657498981793719251 unchanged (native code not modified this slice).
-
[ ] Step 3: Confirm invariants by inspection
-
git grep -n "sceneAt(" src/engine→ both controller call sites passthis.narrative?.currentArc;film-director.tsdefines the 4-arg signature. - The additive-invariant test (
tests/film-director.test.ts) passing against the inline oracle is the byte-identity proof for the arc-less path.
Self-Review¶
Spec coverage: SceneWeight type + arc scenes consumption (Task 1) · per-arc content (Task 2) · wiring (Task 3) · acceptance "arc biases scene itinerary" (Task 1 distribution test + Task 3 live wiring). Native deferral documented (Global Constraints). ✓
Placeholder scan: none — every code/JSON/command step concrete.
Type consistency: sceneAt(index, phase, prevScene, arc?) defined in Task 1, consumed in Task 3; currentArc getter defined in Task 3 Step 1, consumed Step 2; SceneWeight exported (used by future native port / external readers); ArcState/ArcTemplate.scenes shapes match arc.ts:26,31. FNV/golden untouched (no re-pin). Distribution tolerance bounds (1.8–5.0) and weight semantics (floor, min 1) consistent across plan + tests.