VerseCraft Cohesion Harness 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: Make generated VerseCraft stories cohere across chapters by feeding forward a Story Bible (hard constraints), a two-tier Arc Outline (planted setups/payoffs, revised at act boundaries), and a Running Ledger (what actually happened), and by making player choices reshape later chapter prose.
Architecture: Three new pure layers — Bible, Outline, Ledger — plus a scriptwriting craft module, assembled into every DeepSeek prompt. Choices are tracked as an ordered path and hashed into the chapter cache key so divergent choices produce divergent prose. The Bible and skeleton Outline are deterministic from the seed (shareable shell); Tier-2 outline detail and the ledger are per-player and ride in client game state, with each chapter’s ledger entry persisted alongside its cached prose row.
Tech Stack: TypeScript, TanStack Start file routes, Zustand store, Prisma/Postgres, DeepSeek via the OpenAI SDK, Vitest (node environment).
Global Constraints¶
No DOM test env.
vitest.config.tsusesenvironment: 'node'. React components are NOT unit-tested — put testable logic in purelib/helpers; verify components/stores/routes via typecheck + lint only.Run binaries directly (pnpm wrappers are blocked):
./node_modules/.bin/vitest run <file>,./node_modules/.bin/tsc --noEmit,./node_modules/.bin/eslint <files>,./node_modules/.bin/vite build.Adding a file route (
app/routes/**) breakstscuntilapp/routeTree.gen.tsregenerates — run./node_modules/.bin/vite buildto regenerate, then commit the updatedrouteTree.gen.ts. Revert any incidentalpnpm-workspace.yamlchange.Determinism: the same seed must still reproduce the same world, cast, route, Story Bible, and skeleton Outline. Only chapter prose and the ledger may diverge by choice path.
Fallback safety: every AI call must degrade to a deterministic, non-throwing result when
DEEPSEEK_API_KEYis unset (isVersecraftAIConfigured()is false) or the call fails.Tests live in
lib/versecraft/gen/__tests__/**/*.test.ts(glob added in Task 1).Choice tones are the existing
ChoiceToneunion:'kind' | 'flirt' | 'guarded' | 'bold' | 'honest' | 'playful' | 'deep'.
File Structure¶
New files:
lib/versecraft/gen/choice-path.ts— choice-path entry type + deterministic hash.lib/versecraft/gen/outline.ts— Arc Outline types’ deterministic builders (skeleton + detail) + lookup.lib/versecraft/gen/bible.ts— Story Bible renderer.lib/versecraft/gen/craft.ts— scriptwriting craft system prompt + directives.lib/versecraft/gen/ledger.ts— ledger fallback entry, compaction, render.app/routes/api/versecraft/outline.ts— background outline generation/revision endpoint.lib/versecraft/gen/__tests__/*.test.ts— unit tests for the pure layers.
Modified files:
lib/versecraft/gen/world-types.ts— addActPlan,ChapterBeat,ArcOutline,LedgerEntry.lib/versecraft/gen/generate.server.ts— new prompt assembly;generateOutline,scribeChapter; newgenerateChapter/generateChapterOpeningsignatures.lib/versecraft/gen/client.ts— thread choice-path/ledger/beat; addfetchOutline.app/routes/api/versecraft/chapter.ts— choice-path cache key, ledger persistence, scribe.prisma/schema.prisma+ a new migration — extend the chapter cache key, addledgercolumn.lib/versecraft/store.ts— choice-path/ledger/outline state; assembly; prefetch timing; act-boundary revision.components/versecraft/GeneratedDialogueScreen.tsx— prefetch on final scene, not on chapter load.vitest.config.ts— add the versecraft gen test glob.
Task 1: Choice-path tracking + deterministic hash¶
Files:
Create:
lib/versecraft/gen/choice-path.tsCreate:
lib/versecraft/gen/__tests__/choice-path.test.tsModify:
vitest.config.ts(add test glob)
Interfaces:
Consumes:
ChoiceTonefrom./world-types.Produces:
interface ChoicePathEntry { chapter: number; tone: ChoiceTone }function choicePathHash(entries: ChoicePathEntry[]): string—''for empty input; otherwise a short stable hex string. Order-sensitive.
Step 1: Add the test glob to
vitest.config.ts
In the include array, add the line (after 'lib/__tests__/**/*.test.ts',):
'lib/versecraft/gen/__tests__/**/*.test.ts',
Step 2: Write the failing test
Create lib/versecraft/gen/__tests__/choice-path.test.ts:
import { describe, it, expect } from 'vitest';
import { choicePathHash, type ChoicePathEntry } from '../choice-path';
describe('choicePathHash', () => {
it('returns empty string for an empty path', () => {
expect(choicePathHash([])).toBe('');
});
it('is deterministic for the same path', () => {
const path: ChoicePathEntry[] = [
{ chapter: 0, tone: 'kind' },
{ chapter: 1, tone: 'bold' },
];
expect(choicePathHash(path)).toBe(choicePathHash([...path]));
});
it('is order-sensitive', () => {
const a: ChoicePathEntry[] = [{ chapter: 0, tone: 'kind' }, { chapter: 1, tone: 'bold' }];
const b: ChoicePathEntry[] = [{ chapter: 1, tone: 'bold' }, { chapter: 0, tone: 'kind' }];
expect(choicePathHash(a)).not.toBe(choicePathHash(b));
});
it('differs when a tone differs', () => {
const a: ChoicePathEntry[] = [{ chapter: 0, tone: 'kind' }];
const b: ChoicePathEntry[] = [{ chapter: 0, tone: 'guarded' }];
expect(choicePathHash(a)).not.toBe(choicePathHash(b));
});
});
Step 3: Run the test to verify it fails
Run: ./node_modules/.bin/vitest run lib/versecraft/gen/__tests__/choice-path.test.ts
Expected: FAIL — cannot find module ../choice-path.
Step 4: Write the implementation
Create lib/versecraft/gen/choice-path.ts:
// ─── Choice Path ──────────────────────────────────────────────────────────────
// The ordered list of choices a player has committed, hashed into a short stable
// key. Chapter prose is cached per (seed, index, choicePathHash) so that players
// who make different choices get different — but individually reproducible —
// stories. An empty path hashes to '' so chapter 0 (no choices yet) stays shared
// across everyone on a seed.
import type { ChoiceTone } from './world-types';
export interface ChoicePathEntry {
/** 0-based chapter the choice was made in. */
chapter: number;
/** The tone the player picked. */
tone: ChoiceTone;
}
/** Deterministic, order-sensitive short hash of a choice path. */
export function choicePathHash(entries: ChoicePathEntry[]): string {
if (!entries.length) return '';
const serialized = entries.map((e) => `${e.chapter}:${e.tone}`).join('|');
// FNV-1a 32-bit, rendered as hex — short and collision-resistant enough for
// a per-player cache key.
let h = 0x811c9dc5;
for (let i = 0; i < serialized.length; i++) {
h ^= serialized.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return (h >>> 0).toString(16).padStart(8, '0');
}
Step 5: Run the test to verify it passes
Run: ./node_modules/.bin/vitest run lib/versecraft/gen/__tests__/choice-path.test.ts
Expected: PASS (4 tests).
Step 6: Commit
git add lib/versecraft/gen/choice-path.ts lib/versecraft/gen/__tests__/choice-path.test.ts vitest.config.ts
git commit -m "feat(versecraft): deterministic choice-path hash for per-player chapter caching"
Task 2: Arc Outline types + deterministic builders¶
Files:
Modify:
lib/versecraft/gen/world-types.ts(append new types)Create:
lib/versecraft/gen/outline.tsCreate:
lib/versecraft/gen/__tests__/outline.test.ts
Interfaces:
Consumes:
GeneratedWorld,RoutePlan,StoryBeatfrom./world-types.Produces (in
world-types.ts):interface ActPlan { act: number; goal: string; endpoint: string; focusArc: string }interface ChapterBeat { index: number; act: number; dramaticQuestion: string; plant: string[]; payoff: string[]; intent: string }interface ArcOutline { acts: ActPlan[]; chapters: ChapterBeat[]; source: 'ai' | 'fallback' }
Produces (in
outline.ts):function buildSkeletonOutline(world: GeneratedWorld): ArcOutline—actsfilled from the route plan;chapterscarry only act + a coarsedramaticQuestion(no plant/payoff).source: 'fallback'.function buildDetailedOutline(world: GeneratedWorld): ArcOutline— deterministic Tier-2 detail: per-chapterplant/payoff/intentderived from the beat and cast.source: 'fallback'.function beatForChapter(outline: ArcOutline, index: number): ChapterBeat— the beat for a 0-based chapter, clamped.
Step 1: Add the types to
world-types.ts
Append to the end of lib/versecraft/gen/world-types.ts:
// ─── Arc Outline & Ledger (cohesion harness) ──────────────────────────────────
// The showrunner layer. The skeleton (acts) is deterministic from the route plan
// and shareable; per-chapter detail (plant/payoff/intent) is enriched by AI and
// revised at act boundaries based on the player's path, so it is per-player.
export interface ActPlan {
/** 1-based act number. */
act: number;
/** The dramatic goal the act pursues. */
goal: string;
/** The emotional endpoint the act must land on. */
endpoint: string;
/** Which character arc this act advances (character id, or a short label). */
focusArc: string;
}
export interface ChapterBeat {
/** 0-based chapter index. */
index: number;
act: number;
/** The question this chapter raises or answers. */
dramaticQuestion: string;
/** Setups to plant in this chapter (paid off later). */
plant: string[];
/** Earlier setups this chapter pays off / calls back to. */
payoff: string[];
/** A one-line statement of what this chapter is for. */
intent: string;
}
export interface ArcOutline {
acts: ActPlan[];
chapters: ChapterBeat[];
source: 'ai' | 'fallback';
}
/** One distilled record of what actually happened in a chapter, fed forward to
* keep later chapters continuous. Per-player (depends on the choice path). */
export interface LedgerEntry {
/** 0-based chapter index this entry summarizes. */
index: number;
/** One-paragraph recap of what happened. */
summary: string;
/** Facts/secrets revealed. */
revealed: string[];
/** Story threads opened. */
threadsOpened: string[];
/** Story threads resolved. */
threadsClosed: string[];
/** Relationship shifts ("MC grew closer to <name>"). */
relationshipShifts: string[];
/** New established facts that must stay true. */
facts: string[];
}
Step 2: Write the failing test
Create lib/versecraft/gen/__tests__/outline.test.ts:
import { describe, it, expect } from 'vitest';
import { fallbackWorld } from '../fallback';
import { buildSkeletonOutline, buildDetailedOutline, beatForChapter } from '../outline';
const world = fallbackWorld('ember-tide-hush-417', '');
describe('buildSkeletonOutline', () => {
it('produces one ActPlan per act in the route plan', () => {
const outline = buildSkeletonOutline(world);
expect(outline.acts).toHaveLength(world.routePlan.actCount);
expect(outline.acts.map((a) => a.act)).toEqual([1, 2, 3, 4, 5]);
expect(outline.source).toBe('fallback');
});
it('produces one ChapterBeat per chapter, each tagged to a valid act', () => {
const outline = buildSkeletonOutline(world);
expect(outline.chapters).toHaveLength(world.routePlan.totalChapters);
for (const beat of outline.chapters) {
expect(beat.act).toBeGreaterThanOrEqual(1);
expect(beat.act).toBeLessThanOrEqual(world.routePlan.actCount);
expect(beat.dramaticQuestion.length).toBeGreaterThan(0);
}
});
it('is deterministic for a seed', () => {
expect(buildSkeletonOutline(world)).toEqual(buildSkeletonOutline(fallbackWorld('ember-tide-hush-417', '')));
});
});
describe('buildDetailedOutline', () => {
it('fills plant/payoff/intent for every chapter', () => {
const outline = buildDetailedOutline(world);
expect(outline.chapters).toHaveLength(world.routePlan.totalChapters);
for (const beat of outline.chapters) {
expect(beat.intent.length).toBeGreaterThan(0);
expect(Array.isArray(beat.plant)).toBe(true);
expect(Array.isArray(beat.payoff)).toBe(true);
}
});
});
describe('beatForChapter', () => {
it('returns the matching beat and clamps out-of-range indices', () => {
const outline = buildDetailedOutline(world);
expect(beatForChapter(outline, 0).index).toBe(0);
expect(beatForChapter(outline, 999).index).toBe(world.routePlan.totalChapters - 1);
});
});
Step 3: Run the test to verify it fails
Run: ./node_modules/.bin/vitest run lib/versecraft/gen/__tests__/outline.test.ts
Expected: FAIL — cannot find module ../outline.
Step 4: Write the implementation
Create lib/versecraft/gen/outline.ts:
// ─── Arc Outline builders ─────────────────────────────────────────────────────
// Deterministic Tier-1 (skeleton) and Tier-2 (detailed) outlines derived from the
// world's route plan. The AI generator (generate.server.ts) may replace the
// detailed version, but these fallbacks guarantee a coherent arc with no network.
import { Rng } from './rng';
import type { GeneratedWorld, ArcOutline, ActPlan, ChapterBeat, StoryBeat } from './world-types';
/** The act a 0-based chapter index belongs to, mirroring fallback.beatForIndex. */
function actForIndex(world: GeneratedWorld, index: number): { beat: StoryBeat; act: number } {
const beats = world.routePlan.beats;
const ratio = index / Math.max(1, world.routePlan.totalChapters);
const beat = beats[Math.min(beats.length - 1, Math.floor(ratio * beats.length))];
return { beat, act: beat.act };
}
/** Tier-1: one ActPlan per act + a coarse per-chapter beat (no plant/payoff). */
export function buildSkeletonOutline(world: GeneratedWorld): ArcOutline {
const { actCount, beats } = world.routePlan;
const acts: ActPlan[] = [];
for (let act = 1; act <= actCount; act++) {
const actBeats = beats.filter((b) => b.act === act);
const goal = actBeats.map((b) => b.title).join(' → ') || `act ${act}`;
const endpoint = actBeats[actBeats.length - 1]?.emotionalGoal ?? 'a turning point';
const focusArc = actBeats[0]?.focus[0] ?? world.characters[0]?.id ?? 'the cast';
acts.push({ act, goal, endpoint, focusArc });
}
const chapters: ChapterBeat[] = [];
for (let index = 0; index < world.routePlan.totalChapters; index++) {
const { beat, act } = actForIndex(world, index);
chapters.push({
index,
act,
dramaticQuestion: `Will this chapter land "${beat.emotionalGoal}"?`,
plant: [],
payoff: [],
intent: beat.emotionalGoal,
});
}
return { acts, chapters, source: 'fallback' };
}
/** Tier-2: deterministic plant/payoff/intent woven from the beat and cast, so a
* no-AI playthrough still has setups that recur and pay off. */
export function buildDetailedOutline(world: GeneratedWorld): ArcOutline {
const skeleton = buildSkeletonOutline(world);
const rng = new Rng(`outline|${world.seed}`);
const motifs = world.motifs.length ? world.motifs : ['what remains'];
const chapters = skeleton.chapters.map((beat) => {
const { beat: storyBeat } = actForIndex(world, beat.index);
const focusId = storyBeat.focus[0] ?? world.characters[0]?.id ?? 'the cast';
const focus = world.characters.find((c) => c.id === focusId);
const motif = motifs[beat.index % motifs.length];
const plant = beat.act <= 3
? [`A small detail about ${focus?.name ?? 'the focus character'} tied to ${motif}.`]
: [];
const payoff = beat.act >= 3 && focus
? [`Pay off ${focus.name}'s ${focus.secret.replace(/^Secretly /, '').replace(/\.$/, '')}.`]
: [];
return {
...beat,
dramaticQuestion: `What does ${focus?.name ?? 'the cast'} risk to reach "${storyBeat.emotionalGoal}"?`,
plant,
payoff,
intent: `${storyBeat.emotionalGoal} — centered on ${focus?.name ?? 'the cast'}, returning to ${motif}.`,
};
});
void rng;
return { acts: skeleton.acts, chapters, source: 'fallback' };
}
/** The beat for a 0-based chapter index, clamped to the outline's range. */
export function beatForChapter(outline: ArcOutline, index: number): ChapterBeat {
const clamped = Math.max(0, Math.min(outline.chapters.length - 1, index));
return outline.chapters[clamped];
}
Step 5: Run the test to verify it passes
Run: ./node_modules/.bin/vitest run lib/versecraft/gen/__tests__/outline.test.ts
Expected: PASS.
Step 6: Typecheck the new module
Run: ./node_modules/.bin/tsc --noEmit
Expected: no errors.
Step 7: Commit
git add lib/versecraft/gen/world-types.ts lib/versecraft/gen/outline.ts lib/versecraft/gen/__tests__/outline.test.ts
git commit -m "feat(versecraft): arc outline + ledger types and deterministic outline builders"
Task 3: Story Bible renderer¶
Files:
Create:
lib/versecraft/gen/bible.tsCreate:
lib/versecraft/gen/__tests__/bible.test.ts
Interfaces:
Consumes:
GeneratedWorldfrom./world-types.Produces:
function renderBible(world: GeneratedWorld): string— an authoritative constraint block listing every character’s canonical id, name, pronouns, voice anchor, secret/fear, plus world facts, ending with an explicit name-lock instruction.Step 1: Write the failing test
Create lib/versecraft/gen/__tests__/bible.test.ts:
import { describe, it, expect } from 'vitest';
import { fallbackWorld } from '../fallback';
import { renderBible } from '../bible';
const world = fallbackWorld('ember-tide-hush-417', '');
describe('renderBible', () => {
it('includes every character id and name', () => {
const bible = renderBible(world);
for (const c of world.characters) {
expect(bible).toContain(c.id);
expect(bible).toContain(c.name);
}
});
it('states the name-lock constraint', () => {
const bible = renderBible(world).toLowerCase();
expect(bible).toContain('never rename');
});
it('includes the story title and setting', () => {
const bible = renderBible(world);
expect(bible).toContain(world.title);
expect(bible).toContain(world.setting);
});
});
Step 2: Run the test to verify it fails
Run: ./node_modules/.bin/vitest run lib/versecraft/gen/__tests__/bible.test.ts
Expected: FAIL — cannot find module ../bible.
Step 3: Write the implementation
Create lib/versecraft/gen/bible.ts:
// ─── Story Bible ──────────────────────────────────────────────────────────────
// The authoritative, immutable fact sheet for a world, rendered as hard
// constraints injected at the top of every generation prompt. This is what stops
// the model renaming characters, drifting their voice, or contradicting facts.
import type { GeneratedWorld } from './world-types';
export function renderBible(world: GeneratedWorld): string {
const cast = world.characters.map((c) =>
`- id=${c.id} | NAME=${c.name} (${c.fullName}) | ${c.pronouns} | age ${c.age} | ` +
`${c.archetype}, ${c.role}. Voice: ${c.speechStyle} Personality: ${c.personality} ` +
`Secret: ${c.secret} Fear: ${c.fear} Dream: ${c.dream}`,
).join('\n');
return (
`STORY BIBLE (authoritative — these facts are FIXED):\n` +
`TITLE: "${world.title}". PREMISE: ${world.premise}\n` +
`SETTING: ${world.setting}\n` +
`TONE: ${world.toneTags.join(', ')}. MOTIFS: ${world.motifs.join(', ')}.\n` +
`PLAYER (MC): ${world.mc.name} (${world.mc.pronouns}) — ${world.mc.premise}\n` +
`CAST (refer to characters ONLY by these exact names and ids):\n${cast}\n` +
`ALLOWED environments: ${world.environments.join(', ')}.\n` +
`RULES: Never rename a character. Never change a character's pronouns, age, or ` +
`established secret/fear. Keep each character flawlessly in their established voice. ` +
`Never contradict an established fact from the bible or the story-so-far.`
);
}
Step 4: Run the test to verify it passes
Run: ./node_modules/.bin/vitest run lib/versecraft/gen/__tests__/bible.test.ts
Expected: PASS.
Step 5: Commit
git add lib/versecraft/gen/bible.ts lib/versecraft/gen/__tests__/bible.test.ts
git commit -m "feat(versecraft): story bible renderer (hard cast/fact constraints)"
Task 4: Scriptwriting craft module¶
Files:
Create:
lib/versecraft/gen/craft.tsCreate:
lib/versecraft/gen/__tests__/craft.test.ts
Interfaces:
Produces:
const CRAFT_SYSTEM: string— the craft layer appended to the chapter system prompt.function craftDirectives(): string— per-call user-side directives reminding the model of plant/payoff and choice-echo discipline.
Step 1: Write the failing test
Create lib/versecraft/gen/__tests__/craft.test.ts:
import { describe, it, expect } from 'vitest';
import { CRAFT_SYSTEM, craftDirectives } from '../craft';
describe('craft module', () => {
it('CRAFT_SYSTEM names the core craft rules', () => {
const s = CRAFT_SYSTEM.toLowerCase();
expect(s).toContain('goal');
expect(s).toContain('subtext');
expect(s).toContain('payoff');
});
it('craftDirectives reminds the model that choices must echo', () => {
expect(craftDirectives().toLowerCase()).toContain('choice');
});
});
Step 2: Run the test to verify it fails
Run: ./node_modules/.bin/vitest run lib/versecraft/gen/__tests__/craft.test.ts
Expected: FAIL — cannot find module ../craft.
Step 3: Write the implementation
Create lib/versecraft/gen/craft.ts:
// ─── Scriptwriting Craft Module ───────────────────────────────────────────────
// The "scriptwriting skill" for DeepSeek: a reusable block of screenwriting craft
// injected on chapter generation so prose is dramatically shaped, not just
// continuous. Pairs with the bible (constraints) and outline (structure).
export const CRAFT_SYSTEM =
'CRAFT RULES — write like a master visual-novel dramatist:\n' +
'- Every scene has a GOAL, a CONFLICT, and a TURN; nothing is filler.\n' +
'- Convey feeling through SUBTEXT, action, and specific sensory detail — never on-the-nose exposition.\n' +
'- Keep each character in their established VOICE from the bible; let their secret/fear quietly drive them.\n' +
'- PLANT and PAY OFF: honor the setups and payoffs the outline assigns this chapter; call back to earlier moments.\n' +
'- The player\'s recent CHOICES must visibly echo — characters remember and react to what the player did.\n' +
'- Earn every emotional beat; avoid melodrama, clichés, and tidy resolutions that the story has not paid for.';
export function craftDirectives(): string {
return (
'Honor this chapter\'s outline beat: plant its setups, deliver its payoffs, and answer/raise its dramatic question. ' +
'Make the player\'s recent choices echo in how characters treat the MC.'
);
}
Step 4: Run the test to verify it passes
Run: ./node_modules/.bin/vitest run lib/versecraft/gen/__tests__/craft.test.ts
Expected: PASS.
Step 5: Commit
git add lib/versecraft/gen/craft.ts lib/versecraft/gen/__tests__/craft.test.ts
git commit -m "feat(versecraft): scriptwriting craft module for chapter generation"
Task 5: Ledger fallback, compaction, and render¶
Files:
Create:
lib/versecraft/gen/ledger.tsCreate:
lib/versecraft/gen/__tests__/ledger.test.ts
Interfaces:
Consumes:
GeneratedWorld,GenChapter,LedgerEntryfrom./world-types.Produces:
function fallbackLedgerEntry(world: GeneratedWorld, chapter: GenChapter): LedgerEntry— deterministic distillation when AI is unavailable.function compactLedger(entries: LedgerEntry[], recentKeep?: number): { digest: string; recent: LedgerEntry[] }—recentKeepdefaults to 4; older entries fold into a one-line-per-chapterdigest, recent ones stay verbatim.function renderLedger(entries: LedgerEntry[]): string— the “STORY SO FAR” block for the prompt (uses compaction internally).
Step 1: Write the failing test
Create lib/versecraft/gen/__tests__/ledger.test.ts:
import { describe, it, expect } from 'vitest';
import { fallbackWorld, fallbackChapter } from '../fallback';
import { fallbackLedgerEntry, compactLedger, renderLedger } from '../ledger';
import type { LedgerEntry } from '../world-types';
const world = fallbackWorld('ember-tide-hush-417', '');
describe('fallbackLedgerEntry', () => {
it('produces a well-formed entry for the chapter index', () => {
const chapter = fallbackChapter(world, 0);
const entry = fallbackLedgerEntry(world, chapter);
expect(entry.index).toBe(0);
expect(entry.summary.length).toBeGreaterThan(0);
expect(Array.isArray(entry.facts)).toBe(true);
});
});
describe('compactLedger', () => {
it('keeps recent entries verbatim and digests the rest', () => {
const entries: LedgerEntry[] = Array.from({ length: 7 }, (_, i) => ({
index: i, summary: `Ch${i}`, revealed: [], threadsOpened: [], threadsClosed: [],
relationshipShifts: [], facts: [],
}));
const { digest, recent } = compactLedger(entries, 4);
expect(recent).toHaveLength(4);
expect(recent.map((e) => e.index)).toEqual([3, 4, 5, 6]);
expect(digest).toContain('Ch0');
expect(digest).toContain('Ch2');
});
it('returns no digest when within the keep window', () => {
const entries: LedgerEntry[] = [{ index: 0, summary: 'A', revealed: [], threadsOpened: [], threadsClosed: [], relationshipShifts: [], facts: [] }];
const { digest, recent } = compactLedger(entries, 4);
expect(digest).toBe('');
expect(recent).toHaveLength(1);
});
});
describe('renderLedger', () => {
it('is empty for no entries', () => {
expect(renderLedger([])).toBe('');
});
it('includes a STORY SO FAR header when entries exist', () => {
const entry = fallbackLedgerEntry(world, fallbackChapter(world, 0));
expect(renderLedger([entry])).toContain('STORY SO FAR');
});
});
Step 2: Run the test to verify it fails
Run: ./node_modules/.bin/vitest run lib/versecraft/gen/__tests__/ledger.test.ts
Expected: FAIL — cannot find module ../ledger.
Step 3: Write the implementation
Create lib/versecraft/gen/ledger.ts:
// ─── Running Ledger ───────────────────────────────────────────────────────────
// A compact, accumulating record of what actually happened, fed forward so later
// chapters stay continuous. Entries are produced by the AI scribe (see
// generate.server.ts) or by the deterministic fallback below.
import type { GeneratedWorld, GenChapter, LedgerEntry } from './world-types';
const RECENT_KEEP = 4;
/** Deterministic ledger entry from a chapter's own structure (no network). */
export function fallbackLedgerEntry(world: GeneratedWorld, chapter: GenChapter): LedgerEntry {
const present = [...new Set(chapter.scenes.flatMap((s) => s.charactersPresent))];
const names = present.map((id) => world.characters.find((c) => c.id === id)?.name).filter(Boolean);
return {
index: chapter.index,
summary: `Chapter ${chapter.index + 1} "${chapter.title}": ${chapter.emotionalGoal}. ` +
`Featured ${names.join(', ') || 'the cast'}.`,
revealed: [],
threadsOpened: [],
threadsClosed: [],
relationshipShifts: names.map((n) => `Time spent with ${n}.`),
facts: [`Chapter ${chapter.index + 1} reached "${chapter.emotionalGoal}".`],
};
}
/** Split a ledger into a digested prefix + recent verbatim entries. */
export function compactLedger(
entries: LedgerEntry[], recentKeep = RECENT_KEEP,
): { digest: string; recent: LedgerEntry[] } {
if (entries.length <= recentKeep) return { digest: '', recent: entries };
const older = entries.slice(0, entries.length - recentKeep);
const recent = entries.slice(entries.length - recentKeep);
const digest = older.map((e) => `Ch${e.index + 1}: ${e.summary}`).join(' ');
return { digest, recent };
}
/** Render the accumulated ledger as a prompt block. Empty string when no history. */
export function renderLedger(entries: LedgerEntry[]): string {
if (!entries.length) return '';
const { digest, recent } = compactLedger(entries);
const recentText = recent.map((e) => {
const parts = [
`Ch${e.index + 1}: ${e.summary}`,
e.revealed.length ? `Revealed: ${e.revealed.join('; ')}.` : '',
e.threadsOpened.length ? `Open threads: ${e.threadsOpened.join('; ')}.` : '',
e.threadsClosed.length ? `Closed: ${e.threadsClosed.join('; ')}.` : '',
e.relationshipShifts.length ? `Relationships: ${e.relationshipShifts.join('; ')}.` : '',
e.facts.length ? `Facts: ${e.facts.join('; ')}.` : '',
].filter(Boolean);
return parts.join(' ');
}).join('\n');
return `STORY SO FAR (honor this continuity — do not contradict it):\n` +
(digest ? `Earlier: ${digest}\n` : '') + recentText;
}
Step 4: Run the test to verify it passes
Run: ./node_modules/.bin/vitest run lib/versecraft/gen/__tests__/ledger.test.ts
Expected: PASS.
Step 5: Commit
git add lib/versecraft/gen/ledger.ts lib/versecraft/gen/__tests__/ledger.test.ts
git commit -m "feat(versecraft): running ledger fallback, compaction, and render"
Task 6: Server prompt assembly, outline generation, and scribe¶
Files:
Modify:
lib/versecraft/gen/generate.server.tsCreate:
lib/versecraft/gen/__tests__/generate-fallback.test.ts
Interfaces:
Consumes:
renderBible(./bible),CRAFT_SYSTEM/craftDirectives(./craft),renderLedger(./ledger),buildSkeletonOutline/buildDetailedOutline/beatForChapter(./outline),fallbackLedgerEntry(./ledger), and the new types from./world-types.Produces:
function buildChapterContext(world: GeneratedWorld, beat: ChapterBeat | undefined, ledger: LedgerEntry[]): string— bible + outline-beat + ledger, replacingworldContext.async function generateOutline(world: GeneratedWorld, ledger?: LedgerEntry[], fromAct?: number): Promise<ArcOutline>— AI Tier-2 detail (or revision fromfromAct), falling back tobuildDetailedOutline.async function scribeChapter(world: GeneratedWorld, chapter: GenChapter): Promise<LedgerEntry>— AI distillation, falling back tofallbackLedgerEntry.New
generateChapter(world, index, opts: { beat?: ChapterBeat; ledger?: LedgerEntry[]; opening?: GenScene | null }): Promise<GenChapter>.New
generateChapterOpening(world, index, opts: { beat?: ChapterBeat; ledger?: LedgerEntry[] }): Promise<GenChapter | null>.
Step 1: Update imports in
generate.server.ts
Replace the existing import block (lines 11–18) with:
import OpenAI from 'openai';
import { z } from 'zod';
import { fallbackWorld, fallbackChapter } from './fallback';
import { renderBible } from './bible';
import { CRAFT_SYSTEM, craftDirectives } from './craft';
import { renderLedger, fallbackLedgerEntry } from './ledger';
import { buildDetailedOutline, beatForChapter } from './outline';
import {
EMOTIONS, ENVIRONMENTS, normalizeEmotion, WORLD_SCHEMA_VERSION,
type GeneratedWorld, type GenChapter, type GenNode, type GenScene,
type Pronouns, type Environment, type TimeOfDay, type ChoiceTone,
type ArcOutline, type ChapterBeat, type LedgerEntry,
} from './world-types';
Step 2: Replace
worldContextwithbuildChapterContext
Replace the worldContext function (lines 181–190) with:
/** The full context block for a chapter prompt: bible (hard constraints) +
* this chapter's outline beat + the running ledger. */
function buildChapterContext(world: GeneratedWorld, beat: ChapterBeat | undefined, ledger: LedgerEntry[]): string {
const beatBlock = beat
? `THIS CHAPTER'S BEAT (act ${beat.act}): ${beat.intent}\n` +
`Dramatic question: ${beat.dramaticQuestion}\n` +
(beat.plant.length ? `PLANT (set up, pay off later): ${beat.plant.join('; ')}\n` : '') +
(beat.payoff.length ? `PAY OFF NOW (callbacks): ${beat.payoff.join('; ')}\n` : '')
: '';
const ledgerBlock = renderLedger(ledger);
return `${renderBible(world)}\n${beatBlock}${ledgerBlock ? ledgerBlock + '\n' : ''}`;
}
Step 3: Fold the craft module into the chapter system prompt
In chapterSystemPrompt (lines 165–179), change the final return ( expression so the craft block is appended before the JSON instruction. Replace the line:
'Respond ONLY with a JSON object.'
inside chapterSystemPrompt with:
CRAFT_SYSTEM + '\n' +
'Respond ONLY with a JSON object.'
Step 4: Update
generateChapterOpeningto the new signature
Replace generateChapterOpening (lines 229–261) with:
export async function generateChapterOpening(
world: GeneratedWorld, index: number,
opts: { beat?: ChapterBeat; ledger?: LedgerEntry[] } = {},
): Promise<GenChapter | null> {
if (!isVersecraftAIConfigured()) return null;
const routeBeat = beatFor(world, index);
const ids = new Set(world.characters.map((c) => c.id));
try {
const user = buildChapterContext(world, opts.beat, opts.ledger ?? []) +
`\n${craftDirectives()}\n` +
`\nWrite ONLY the OPENING SCENE of CHAPTER ${index + 1} (Act ${routeBeat.act}). Emotional goal of the chapter: ` +
`"${routeBeat.emotionalGoal}". Open on these characters: ${routeBeat.focus.join(', ')}.\n` +
`- One scene: an environment from the allowed list, charactersPresent (ids), 9–13 nodes.\n` +
`- Hook the player emotionally and end the scene on a small beat of tension or warmth.\n` +
`- Include exactly ONE "choices" node (2–3 options) where ${world.mc.name} responds; each option has a "tone" ` +
`from (${TONES.join(', ')}) and may set "affinity" (character id → small +integer).\n` +
`Also give the chapter a short "title".\n` +
`Return JSON: {"title","scenes":[{"environment","timeOfDay","charactersPresent":[ids],"nodes":[{"speaker":id|null,"text","emotion","choices?":[{"text","tone","affinity"}]}]}]}.`;
const parsed = ChapterSchema.parse(await chatJson(chapterSystemPrompt(), user, 2200));
const raw = parsed.scenes[0];
if (!raw) return null;
let seq = 0;
const scene = sanitizeScene(raw, world, index, 0, ids, () => `ch${index}_n${seq++}`);
return {
index, act: routeBeat.act,
title: parsed.title || routeBeat.title,
subtitle: `Act ${routeBeat.act} — ${world.title}`,
emotionalGoal: routeBeat.emotionalGoal,
scenes: [scene],
source: 'ai',
partial: true,
};
} catch (err) {
console.error('generateChapterOpening failed:', err);
return null;
}
}
Step 5: Update
generateChapterto the new signature
Replace generateChapter (lines 263–308) with:
export async function generateChapter(
world: GeneratedWorld, index: number,
opts: { beat?: ChapterBeat; ledger?: LedgerEntry[]; opening?: GenScene | null } = {},
): Promise<GenChapter> {
if (!isVersecraftAIConfigured()) return fallbackChapter(world, index);
const routeBeat = beatFor(world, index);
const ids = new Set(world.characters.map((c) => c.id));
const opening = opts.opening ?? null;
try {
const continuing = !!opening;
const user = buildChapterContext(world, opts.beat, opts.ledger ?? []) +
`\n${craftDirectives()}\n` +
(continuing
? `\nThe opening scene of CHAPTER ${index + 1} (Act ${routeBeat.act}) has already been written:\n"${sceneDigest(opening!)}"\n` +
`CONTINUE this chapter with 2–3 MORE scenes that carry the emotional goal "${routeBeat.emotionalGoal}" to a ` +
`resolution (complication → turn → landing). Do NOT repeat the opening scene.\n` +
`- Each scene: an environment from the allowed list, charactersPresent (ids), 12–18 nodes.\n` +
`- Include exactly ONE "choices" node across these scenes.\n`
: `\nWrite CHAPTER ${index + 1} (Act ${routeBeat.act}). Emotional goal: "${routeBeat.emotionalGoal}". Center on: ${routeBeat.focus.join(', ')}.\n` +
`- 3–4 scenes. Each scene: an environment from the allowed list, charactersPresent (ids), 12–18 nodes.\n` +
`- Clear emotional arc (setup → complication → turn → resolution that lands the goal).\n` +
`- Include 2 "choices" nodes total.\n`) +
`- Rich, specific dialogue and evocative narration; vary line length; let characters DO things.\n` +
`- Choice options carry a "tone" from (${TONES.join(', ')}) and may set "affinity" (character id → small +integer 1–6).\n` +
`Return JSON: {"title","scenes":[{"environment","timeOfDay","charactersPresent":[ids],"nodes":[{"speaker":id|null,"text","emotion","choices?":[{"text","tone","affinity"}]}]}]}.`;
const parsed = ChapterSchema.parse(await chatJson(chapterSystemPrompt(), user, continuing ? 5500 : 7000));
const startScene = continuing ? 1 : 0;
let seq = continuing ? (opening!.nodes.length) : 0;
const nid = () => `ch${index}_n${seq++}`;
const rest = parsed.scenes.map((sc, i) => sanitizeScene(sc, world, index, startScene + i, ids, nid));
const scenes = continuing ? [opening!, ...rest] : rest;
if (!scenes.length) return fallbackChapter(world, index);
return {
index, act: routeBeat.act,
title: parsed.title || routeBeat.title,
subtitle: parsed.subtitle || `Act ${routeBeat.act} — ${world.title}`,
emotionalGoal: routeBeat.emotionalGoal,
scenes,
source: 'ai',
};
} catch (err) {
console.error('generateChapter AI failed, using fallback:', err);
return fallbackChapter(world, index);
}
}
Step 6: Add
generateOutlineandscribeChapterat the end of the file
Append to lib/versecraft/gen/generate.server.ts:
// ─── Outline (showrunner) ─────────────────────────────────────────────────────
const OutlineSchema = z.object({
chapters: z.array(z.object({
index: z.number(),
dramaticQuestion: z.string().default(''),
plant: z.array(z.string()).default([]),
payoff: z.array(z.string()).default([]),
intent: z.string().default(''),
})).default([]),
});
/** Generate (or, with fromAct, revise) the detailed Tier-2 outline. Falls back
* to the deterministic detailed outline when AI is unavailable or fails. */
export async function generateOutline(
world: GeneratedWorld, ledger: LedgerEntry[] = [], fromAct = 1,
): Promise<ArcOutline> {
const base = buildDetailedOutline(world);
if (!isVersecraftAIConfigured()) return base;
try {
const acts = base.acts.map((a) => `Act ${a.act}: goal=${a.goal}; endpoint=${a.endpoint}; focus=${a.focusArc}`).join('\n');
const toPlan = base.chapters.filter((c) => c.act >= fromAct);
const system =
'You are the showrunner for an emotional anime visual novel. You design a tight dramatic arc with setups ' +
'planted early and paid off later, one beat per chapter. Respond ONLY with a JSON object.';
const user = `${renderBible(world)}\nACT PLAN:\n${acts}\n` +
(ledger.length ? `${renderLedger(ledger)}\n` : '') +
`Plan chapters ${toPlan[0]?.index ?? 0}..${toPlan[toPlan.length - 1]?.index ?? 0} (0-based). ` +
`Honor the act endpoints and any open threads above. For each chapter give a dramaticQuestion, ` +
`plant (setups), payoff (callbacks to earlier setups), and a one-line intent.\n` +
`Return JSON: {"chapters":[{"index","dramaticQuestion","plant":[],"payoff":[],"intent"}]}.`;
const parsed = OutlineSchema.parse(await chatJson(system, user, 3000));
const byIndex = new Map(parsed.chapters.map((c) => [c.index, c]));
const chapters = base.chapters.map((c) => {
const ai = byIndex.get(c.index);
if (!ai || c.act < fromAct) return c;
return {
...c,
dramaticQuestion: ai.dramaticQuestion || c.dramaticQuestion,
plant: ai.plant.length ? ai.plant : c.plant,
payoff: ai.payoff.length ? ai.payoff : c.payoff,
intent: ai.intent || c.intent,
};
});
return { acts: base.acts, chapters, source: 'ai' };
} catch (err) {
console.error('generateOutline AI failed, using deterministic detail:', err);
return base;
}
}
// ─── Scribe (ledger distillation) ─────────────────────────────────────────────
const ScribeSchema = z.object({
summary: z.string().default(''),
revealed: z.array(z.string()).default([]),
threadsOpened: z.array(z.string()).default([]),
threadsClosed: z.array(z.string()).default([]),
relationshipShifts: z.array(z.string()).default([]),
facts: z.array(z.string()).default([]),
});
/** Distill a finished chapter into a ledger entry. Falls back to the
* deterministic entry when AI is unavailable or fails. */
export async function scribeChapter(world: GeneratedWorld, chapter: GenChapter): Promise<LedgerEntry> {
const base = fallbackLedgerEntry(world, chapter);
if (!isVersecraftAIConfigured()) return base;
try {
const prose = chapter.scenes
.flatMap((s) => s.nodes.map((n) => (n.speaker ? `${n.speaker}: ` : '') + n.text))
.join('\n').slice(0, 6000);
const system =
'You are a story continuity editor. Read a finished chapter and distill ONLY what actually happened, ' +
'so later chapters stay consistent. Be concise and factual. Respond ONLY with a JSON object.';
const user = `${renderBible(world)}\nCHAPTER ${chapter.index + 1} TEXT:\n${prose}\n` +
`Return JSON: {"summary","revealed":[],"threadsOpened":[],"threadsClosed":[],"relationshipShifts":[],"facts":[]}. ` +
`summary = one paragraph; the arrays = short bullet phrases (use character names from the bible).`;
const parsed = ScribeSchema.parse(await chatJson(system, user, 700));
return {
index: chapter.index,
summary: parsed.summary || base.summary,
revealed: parsed.revealed,
threadsOpened: parsed.threadsOpened,
threadsClosed: parsed.threadsClosed,
relationshipShifts: parsed.relationshipShifts.length ? parsed.relationshipShifts : base.relationshipShifts,
facts: parsed.facts.length ? parsed.facts : base.facts,
};
} catch (err) {
console.error('scribeChapter AI failed, using fallback entry:', err);
return base;
}
}
Step 7: Write the fallback-path test
Create lib/versecraft/gen/__tests__/generate-fallback.test.ts:
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { fallbackWorld, fallbackChapter } from '../fallback';
import { generateOutline, scribeChapter } from '../generate.server';
const world = fallbackWorld('ember-tide-hush-417', '');
const hadKey = process.env.DEEPSEEK_API_KEY;
beforeAll(() => { delete process.env.DEEPSEEK_API_KEY; });
afterAll(() => { if (hadKey !== undefined) process.env.DEEPSEEK_API_KEY = hadKey; });
describe('generateOutline (no AI)', () => {
it('returns a deterministic detailed outline', async () => {
const outline = await generateOutline(world);
expect(outline.source).toBe('fallback');
expect(outline.chapters).toHaveLength(world.routePlan.totalChapters);
expect(outline.chapters[0].intent.length).toBeGreaterThan(0);
});
});
describe('scribeChapter (no AI)', () => {
it('returns a well-formed fallback ledger entry', async () => {
const entry = await scribeChapter(world, fallbackChapter(world, 0));
expect(entry.index).toBe(0);
expect(entry.summary.length).toBeGreaterThan(0);
});
});
Step 8: Run the test to verify it passes
Run: ./node_modules/.bin/vitest run lib/versecraft/gen/__tests__/generate-fallback.test.ts
Expected: PASS.
Step 9: Typecheck (signatures changed — callers in Task 8 will be updated next)
Run: ./node_modules/.bin/tsc --noEmit 2>&1 | grep -E "generate.server|chapter.ts|world.ts" || echo "no errors in generate/world/chapter (expected after Task 8)"
Expected: errors only in app/routes/api/versecraft/chapter.ts and world.ts (old call sites) — these are fixed in Task 8. No errors inside generate.server.ts itself.
Step 10: Commit
git add lib/versecraft/gen/generate.server.ts lib/versecraft/gen/__tests__/generate-fallback.test.ts
git commit -m "feat(versecraft): bible+outline+ledger+craft prompt assembly, generateOutline, scribeChapter"
Task 7: Prisma — choice-path cache key + ledger column¶
Files:
Modify:
prisma/schema.prisma(lines 1029–1040,VersecraftGenChapter)Create:
prisma/migrations/<timestamp>_versecraft_choicepath_ledger/migration.sql
Interfaces:
Produces:
VersecraftGenChapterkeyed by@@unique([seed, index, choicePathHash])with a newchoicePathHash String @default("")andledger Json?column.Step 1: Update the Prisma model
Replace the VersecraftGenChapter model (lines 1027–1040) with:
// A generated chapter cached per (seed, index, choicePathHash) so each player's
// choice-divergent prose is reproducible, and the DeepSeek call for a given
// (seed, index, path) happens at most once. `ledger` holds the scribe's
// distillation of this chapter, persisted alongside the prose.
model VersecraftGenChapter {
id String @id @default(cuid())
seed String
index Int
choicePathHash String @default("")
source String @default("fallback")
content Json
ledger Json?
world VersecraftWorld @relation(fields: [seed], references: [seed], onDelete: Cascade)
@@unique([seed, index, choicePathHash])
@@map("versecraft_gen_chapter")
}
Step 2: Create the migration SQL
Create prisma/migrations/20260626120000_versecraft_choicepath_ledger/migration.sql:
-- Add choice-path-aware caching + ledger storage to generated chapters.
ALTER TABLE "versecraft_gen_chapter"
ADD COLUMN "choicePathHash" TEXT NOT NULL DEFAULT '',
ADD COLUMN "ledger" JSONB;
-- Replace the (seed, index) unique with (seed, index, choicePathHash).
DROP INDEX IF EXISTS "versecraft_gen_chapter_seed_index_key";
CREATE UNIQUE INDEX "versecraft_gen_chapter_seed_index_choicePathHash_key"
ON "versecraft_gen_chapter" ("seed", "index", "choicePathHash");
Step 3: Validate the schema and regenerate the client
Run: ./node_modules/.bin/prisma validate && ./node_modules/.bin/prisma generate
Expected: “The schema at prisma/schema.prisma is valid” and the client regenerates without error.
Step 4: Commit
git add prisma/schema.prisma prisma/migrations/20260626120000_versecraft_choicepath_ledger
git commit -m "feat(versecraft): cache chapters per choice path; persist ledger per chapter"
Task 8: API routes — chapter (choice-path + scribe) and outline endpoint¶
Files:
Modify:
app/routes/api/versecraft/chapter.tsModify:
app/routes/api/versecraft/world.tsCreate:
app/routes/api/versecraft/outline.ts
Interfaces:
Consumes: new
generateChapter/generateChapterOpeningsignatures,scribeChapter,generateOutlinefromgenerate.server;ChapterBeat,LedgerEntry,ArcOutlinetypes;choicePathHashis supplied pre-computed by the client as a string.Produces:
POST /api/versecraft/chapterbody:{ seed, index, choicePathHash?, beat?, ledger?, part?, opening? }; response:{ chapter, ledgerEntry?, partial, cached? }.POST /api/versecraft/outlinebody:{ seed, ledger?, fromAct? }; response:{ outline }.
Step 1: Rewrite
app/routes/api/versecraft/chapter.ts
Replace the entire file with:
import { createFileRoute } from '@tanstack/react-router';
import { prisma } from '@/lib/prisma.server';
import { rateLimit, getClientIp } from '@/lib/rate-limit';
import { generateChapter, generateChapterOpening, scribeChapter } from '@/lib/versecraft/gen/generate.server';
import { normalizeSeed } from '@/lib/versecraft/gen/rng';
import type { GeneratedWorld, GenScene, GenChapter, ChapterBeat, LedgerEntry } from '@/lib/versecraft/gen/world-types';
/**
* Seed + index + choicePathHash → generated chapter, cached per that triple so a
* player's choice-divergent prose is reproducible. The chapter's ledger entry
* (scribe distillation) is generated once and persisted alongside the prose, and
* returned so the client can feed it forward.
*
* With `part: 'opening'` it returns just the opening scene (fast, NOT cached);
* the client then calls again with the `opening` scene to generate + cache the
* full chapter.
*/
export const Route = createFileRoute('/api/versecraft/chapter')({
server: {
handlers: {
POST: async ({ request }) => {
const ip = getClientIp(request);
const { allowed, retryAfter } = rateLimit(ip, { limit: 40, windowMs: 60_000, prefix: 'versecraft-chapter' });
if (!allowed) {
return Response.json({ error: 'Too many requests' }, { status: 429, headers: { 'Retry-After': String(retryAfter) } });
}
let body: {
seed?: string; index?: number; choicePathHash?: string;
beat?: ChapterBeat; ledger?: LedgerEntry[]; part?: string; opening?: GenScene;
};
try { body = await request.json(); } catch { return Response.json({ error: 'Invalid JSON' }, { status: 400 }); }
const seed = normalizeSeed(body.seed ?? '');
const index = Math.max(0, Math.min(200, Math.floor(body.index ?? 0)));
if (!seed) return Response.json({ error: 'Missing seed' }, { status: 400 });
const choicePathHash = (body.choicePathHash ?? '').slice(0, 16);
const beat = body.beat;
const ledger = Array.isArray(body.ledger) ? body.ledger.slice(0, 30) : [];
// A cached full chapter for this exact path always wins.
const cached = await prisma.versecraftGenChapter.findUnique({
where: { seed_index_choicePathHash: { seed, index, choicePathHash } },
});
if (cached) {
return Response.json({ chapter: cached.content, ledgerEntry: cached.ledger ?? null, partial: false, cached: true });
}
const worldRow = await prisma.versecraftWorld.findUnique({ where: { seed } });
if (!worldRow) return Response.json({ error: 'Unknown seed — create the world first' }, { status: 404 });
const world = worldRow.world as unknown as GeneratedWorld;
// Fast first paint: just the opening scene (not persisted).
if (body.part === 'opening') {
const opening = await generateChapterOpening(world, index, { beat, ledger });
if (opening) return Response.json({ chapter: opening, partial: true });
const full = await generateChapter(world, index, { beat, ledger });
const ledgerEntry = await scribeChapter(world, full);
await persistChapter(seed, index, choicePathHash, full, ledgerEntry);
return Response.json({ chapter: full, ledgerEntry, partial: false });
}
// Full chapter (optionally continuing from a streamed opening scene).
const chapter = await generateChapter(world, index, { beat, ledger, opening: body.opening ?? null });
const ledgerEntry = await scribeChapter(world, chapter);
await persistChapter(seed, index, choicePathHash, chapter, ledgerEntry);
return Response.json({ chapter, ledgerEntry, partial: false });
},
},
},
});
async function persistChapter(
seed: string, index: number, choicePathHash: string, chapter: GenChapter, ledgerEntry: LedgerEntry,
): Promise<void> {
await prisma.versecraftGenChapter.upsert({
where: { seed_index_choicePathHash: { seed, index, choicePathHash } },
create: { seed, index, choicePathHash, source: chapter.source, content: chapter as unknown as object, ledger: ledgerEntry as unknown as object },
update: {},
});
}
Step 2: Update
openingForinworld.tsto the new opening signature
In app/routes/api/versecraft/world.ts, replace the openingFor helper (lines 11–16) with:
async function openingFor(world: GeneratedWorld, seed: string) {
const cached = await prisma.versecraftGenChapter.findUnique({
where: { seed_index_choicePathHash: { seed, index: 0, choicePathHash: '' } },
});
if (cached) return { chapter: cached.content as unknown as GenChapter, partial: false };
const opening = await generateChapterOpening(world, 0, {});
return opening ? { chapter: opening, partial: true } : null;
}
Step 3: Create the outline endpoint
Create app/routes/api/versecraft/outline.ts:
import { createFileRoute } from '@tanstack/react-router';
import { prisma } from '@/lib/prisma.server';
import { rateLimit, getClientIp } from '@/lib/rate-limit';
import { generateOutline } from '@/lib/versecraft/gen/generate.server';
import { normalizeSeed } from '@/lib/versecraft/gen/rng';
import type { GeneratedWorld, LedgerEntry } from '@/lib/versecraft/gen/world-types';
/**
* Seed (+ optional ledger, fromAct) → detailed Arc Outline. Used for the
* background Tier-2 enrich after world creation and for act-boundary revisions.
* Not persisted server-side: the skeleton is deterministic and the detail is
* per-player, so the client holds it in game state.
*/
export const Route = createFileRoute('/api/versecraft/outline')({
server: {
handlers: {
POST: async ({ request }) => {
const ip = getClientIp(request);
const { allowed, retryAfter } = rateLimit(ip, { limit: 20, windowMs: 60_000, prefix: 'versecraft-outline' });
if (!allowed) {
return Response.json({ error: 'Too many requests' }, { status: 429, headers: { 'Retry-After': String(retryAfter) } });
}
let body: { seed?: string; ledger?: LedgerEntry[]; fromAct?: number };
try { body = await request.json(); } catch { return Response.json({ error: 'Invalid JSON' }, { status: 400 }); }
const seed = normalizeSeed(body.seed ?? '');
if (!seed) return Response.json({ error: 'Missing seed' }, { status: 400 });
const ledger = Array.isArray(body.ledger) ? body.ledger.slice(0, 30) : [];
const fromAct = Math.max(1, Math.min(5, Math.floor(body.fromAct ?? 1)));
const worldRow = await prisma.versecraftWorld.findUnique({ where: { seed } });
if (!worldRow) return Response.json({ error: 'Unknown seed — create the world first' }, { status: 404 });
const world = worldRow.world as unknown as GeneratedWorld;
const outline = await generateOutline(world, ledger, fromAct);
return Response.json({ outline });
},
},
},
});
Step 4: Regenerate the route tree and typecheck
Run: ./node_modules/.bin/vite build
Then: ./node_modules/.bin/tsc --noEmit
Expected: app/routeTree.gen.ts updates to include /api/versecraft/outline; tsc reports no errors. (If pnpm-workspace.yaml changed, revert it: git checkout pnpm-workspace.yaml.)
Step 5: Lint the changed routes
Run: ./node_modules/.bin/eslint app/routes/api/versecraft/chapter.ts app/routes/api/versecraft/world.ts app/routes/api/versecraft/outline.ts
Expected: no errors.
Step 6: Commit
git add app/routes/api/versecraft/chapter.ts app/routes/api/versecraft/world.ts app/routes/api/versecraft/outline.ts app/routeTree.gen.ts
git commit -m "feat(versecraft): choice-path chapter cache + scribe persistence + outline endpoint"
Task 9: Generation client — thread choice-path/ledger/beat + fetchOutline¶
Files:
Modify:
lib/versecraft/gen/client.ts
Interfaces:
Consumes: the new chapter/outline endpoint contracts.
Produces:
fetchChapter(seed, index, opts: { choicePathHash: string; beat?: ChapterBeat; ledger?: LedgerEntry[]; opening?: GenScene }): Promise<{ chapter: GenChapter; ledgerEntry: LedgerEntry | null } | null>fetchOutline(seed, ledger?: LedgerEntry[], fromAct?: number): Promise<ArcOutline | null>createWorldWithOpeningunchanged in signature.
Step 1: Update imports in
client.ts
Replace the import line (line 7) with:
import type { GeneratedWorld, GenChapter, GenScene, Pronouns, ChapterBeat, LedgerEntry, ArcOutline } from './world-types';
Step 2: Replace
fetchChapterand remove the unusedfetchOpeningChapter
Replace fetchChapter (lines 44–60) and fetchOpeningChapter (lines 62–80) with:
/** Fetch the full chapter for a specific choice path (optionally continuing from
* a streamed opening scene). Returns the chapter plus its ledger entry. */
export async function fetchChapter(
seed: string, index: number,
opts: { choicePathHash: string; beat?: ChapterBeat; ledger?: LedgerEntry[]; opening?: GenScene },
): Promise<{ chapter: GenChapter; ledgerEntry: LedgerEntry | null } | null> {
try {
const res = await fetch('/api/versecraft/chapter', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
seed, index,
choicePathHash: opts.choicePathHash,
beat: opts.beat,
ledger: opts.ledger ?? [],
opening: opts.opening,
}),
});
if (!res.ok) return null;
const data = await res.json() as { chapter: GenChapter | null; ledgerEntry?: LedgerEntry | null };
return validChapter(data.chapter) ? { chapter: data.chapter, ledgerEntry: data.ledgerEntry ?? null } : null;
} catch {
return null;
}
}
/** Fetch the detailed Arc Outline (Tier-2 enrich, or act-boundary revision). */
export async function fetchOutline(
seed: string, ledger: LedgerEntry[] = [], fromAct = 1,
): Promise<ArcOutline | null> {
try {
const res = await fetch('/api/versecraft/outline', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ seed, ledger, fromAct }),
});
if (!res.ok) return null;
const data = await res.json() as { outline: ArcOutline | null };
return data.outline && Array.isArray(data.outline.chapters) ? data.outline : null;
} catch {
return null;
}
}
Step 3: Typecheck (store call sites updated next in Task 10)
Run: ./node_modules/.bin/tsc --noEmit 2>&1 | grep -E "client.ts|store.ts" || echo "errors only in store.ts (expected, fixed in Task 10)"
Expected: no errors inside client.ts; remaining errors only in store.ts (old fetchChapter call sites).
Step 4: Commit
git add lib/versecraft/gen/client.ts
git commit -m "feat(versecraft): client threads choice-path/ledger/beat; add fetchOutline"
Task 10: Store — choice-path/ledger/outline state, assembly, prefetch timing, act revisions¶
Files:
Modify:
lib/versecraft/store.tsModify:
lib/versecraft/types.ts(extendGameStatewith the new generated fields)
Interfaces:
Consumes:
choicePathHash/ChoicePathEntry(./gen/choice-path),buildSkeletonOutline/beatForChapter(./gen/outline),fetchChapter/fetchOutline(./gen/client),ArcOutline/LedgerEntrytypes.Produces: store state
choicePath: ChoicePathEntry[],ledger: LedgerEntry[],outline: ArcOutline | null;genApplyChoiceappends tochoicePath; chapter fetches pass{ choicePathHash, beat, ledger }and absorb returnedledgerEntry;prefetchChapteruses the current path; act-boundary revision viafetchOutline.Step 1: Extend
GameStateinlib/versecraft/types.ts
Find the GameState interface in lib/versecraft/types.ts (it already contains seed, world, generatedChapters, currentChapterIndex, genChoiceLog). Add these three fields next to genChoiceLog:
/** Ordered committed choices, hashed into the chapter cache key. */
choicePath: ChoicePathEntry[];
/** Accumulated per-chapter ledger entries (continuity memory). */
ledger: LedgerEntry[];
/** Detailed arc outline (skeleton synchronously, Tier-2 enriched in background). */
outline: ArcOutline | null;
At the top of lib/versecraft/types.ts, add the imports (place near the other type imports):
import type { ChoicePathEntry } from './gen/choice-path';
import type { LedgerEntry, ArcOutline } from './gen/world-types';
Step 2: Update imports and
createInitialStateinstore.ts
Replace the store import block (lines 12–17) with:
import { fallbackWorld, fallbackChapter } from './gen/fallback';
import { createWorldWithOpening, fetchChapter, fetchOutline } from './gen/client';
import { makeSeedCode, normalizeSeed } from './gen/rng';
import { choicePathHash, type ChoicePathEntry } from './gen/choice-path';
import { buildSkeletonOutline, beatForChapter } from './gen/outline';
import type { GeneratedWorld, GenChapter, GenChoice, ArcOutline, LedgerEntry } from './gen/world-types';
import type { Word } from './types';
In createInitialState (lines 75–102), add the three new fields to the returned object, next to genChoiceLog: [],:
genChoiceLog: [],
choicePath: [],
ledger: [],
outline: null,
Step 3: Replace
buildContextSummarywith ledger/beat helpers
Replace buildContextSummary (lines 108–132) with:
/** The outline beat for the current chapter, from the player's outline (skeleton
* if Tier-2 hasn't enriched yet). */
function beatFor(state: GameState & GameActions, index: number) {
const outline = state.outline ?? (state.world ? buildSkeletonOutline(state.world) : null);
return outline ? beatForChapter(outline, index) : undefined;
}
Step 4: Seed choice-path/ledger/outline on game start
In startGeneratedGame, after the world is built and before the big set({...}) (around line 306), compute the skeleton outline. Replace the set({ ...createInitialState(), mode: 'generated', ... }) block (lines 306–321) with:
const skeleton = buildSkeletonOutline(world);
set({
...createInitialState(),
mode: 'generated',
seed: cleanSeed,
mcPrompt: prompt ?? '',
world,
outline: skeleton,
generatedChapters: { 0: ch0 },
currentChapterIndex: 0,
currentSceneIndex: 0,
currentDialogueIndex: 0,
affinity: affinityForWorld(world),
gameStarted: true,
genLoading: false,
screen: 'dialogue',
isLoggedIn: get().isLoggedIn,
});
Then, at the very end of startGeneratedGame (after get().prefetchChapter(1);, line 328), add the background Tier-2 outline enrich:
// Enrich the outline (Tier-2) in the background; ready before chapter 1.
(async () => {
const detailed = await fetchOutline(cleanSeed, [], 1);
if (detailed) set({ outline: detailed });
})();
Step 5: Update
streamChapterRestto the new fetch contract + ledger
Replace the body of streamChapterRest’s async IIFE try block (lines 341–347) with:
const res = await fetchChapter(s.seed, index, {
choicePathHash: choicePathHash(get().choicePath),
beat: beatFor(get(), index),
ledger: get().ledger,
opening,
});
const full = res?.chapter;
const merged = (full && full.scenes?.length) ? full : { ...ch, partial: false };
set({ generatedChapters: { ...get().generatedChapters, [index]: { ...merged, partial: false } } });
if (res?.ledgerEntry) absorbLedger(get, set, res.ledgerEntry);
const st = get();
autoSave(st);
debouncedDbSave(get);
Step 6: Add the
absorbLedgerhelper
Add this helper just above the useGameStore definition (before line 223):
/** Merge a chapter's ledger entry into state (replacing any prior entry for that
* index so re-streams don't duplicate). */
function absorbLedger(
get: () => GameState & GameActions,
set: (partial: Partial<GameState & GameActions>) => void,
entry: LedgerEntry,
): void {
const ledger = get().ledger.filter((e) => e.index !== entry.index);
ledger.push(entry);
ledger.sort((a, b) => a.index - b.index);
set({ ledger });
}
Step 7: Update
ensureGeneratedChapter
Replace the try/catch chapter fetch in ensureGeneratedChapter (lines 364–370) with:
let ch: GenChapter;
try {
const res = await fetchChapter(state.seed, index, {
choicePathHash: choicePathHash(state.choicePath),
beat: beatFor(state, index),
ledger: state.ledger,
});
ch = res?.chapter ?? fallbackChapter(world, index);
if (res?.ledgerEntry) absorbLedger(get, set, res.ledgerEntry);
} catch {
ch = fallbackChapter(world, index);
}
if (!ch.scenes?.length) ch = fallbackChapter(world, index);
Step 8: Update
prefetchChapter
Replace the async IIFE in prefetchChapter (lines 384–397) with:
(async () => {
try {
const res = await fetchChapter(s.seed, index, {
choicePathHash: choicePathHash(get().choicePath),
beat: beatFor(get(), index),
ledger: get().ledger,
});
const ch = (res?.chapter && res.chapter.scenes?.length) ? res.chapter : fallbackChapter(world, index);
if (res?.ledgerEntry) absorbLedger(get, set, res.ledgerEntry);
if (!get().generatedChapters[index]) {
set({ generatedChapters: { ...get().generatedChapters, [index]: ch } });
}
} catch {
/* leave it; it'll generate on demand when reached */
} finally {
chapterInFlight.delete(key);
}
})();
Step 9: Record the choice path in
genApplyChoice
In genApplyChoice (lines 400–424), add choicePath to the returned object inside set(s => ({ ... })). Replace the returned object (lines 416–421) with:
return {
affinity: newAffinity,
storyFlags: newFlags,
genChoiceLog: [...s.genChoiceLog, { chapter: s.currentChapterIndex, tone: choice.tone, text: choice.text }],
choicePath: [...s.choicePath, { chapter: s.currentChapterIndex, tone: choice.tone }],
currentDialogueIndex: s.currentDialogueIndex + 1,
};
Step 10: Update
advanceGeneratedChapter(fetch contract + act-boundary revision)
Replace the try/catch fetch block (lines 442–449) with:
const cached = state.generatedChapters[nextIndex];
let ch: GenChapter;
try {
if (cached) {
ch = cached;
} else {
const res = await fetchChapter(state.seed, nextIndex, {
choicePathHash: choicePathHash(state.choicePath),
beat: beatFor(state, nextIndex),
ledger: state.ledger,
});
ch = res?.chapter ?? fallbackChapter(world, nextIndex);
if (res?.ledgerEntry) absorbLedger(get, set, res.ledgerEntry);
}
} catch {
ch = fallbackChapter(world, nextIndex);
}
if (!ch.scenes?.length) ch = fallbackChapter(world, nextIndex);
Then, after the get().prefetchChapter(nextIndex + 1); line (line 463), add the act-boundary revision:
// Crossed into a new act → revise the remaining outline from the path so far.
const prevAct = state.outline ? beatForChapter(state.outline, state.currentChapterIndex).act : 1;
const nextAct = get().outline ? beatForChapter(get().outline!, nextIndex).act : 1;
if (nextAct > prevAct) {
(async () => {
const revised = await fetchOutline(get().seed, get().ledger, nextAct);
if (revised) set({ outline: revised });
})();
}
Step 11: Typecheck and lint
Run: ./node_modules/.bin/tsc --noEmit
Expected: no errors.
Run: ./node_modules/.bin/eslint lib/versecraft/store.ts lib/versecraft/types.ts
Expected: no errors.
Step 12: Run the full versecraft gen test suite (regression)
Run: ./node_modules/.bin/vitest run lib/versecraft/gen/__tests__
Expected: PASS (all suites).
Step 13: Commit
git add lib/versecraft/store.ts lib/versecraft/types.ts
git commit -m "feat(versecraft): store tracks choice path, ledger, outline; act-boundary revision"
Task 11: Dialogue screen — prefetch on final scene, not on chapter load¶
Files:
Modify:
components/versecraft/GeneratedDialogueScreen.tsx(lines 106–110)
Interfaces:
Consumes:
prefetchChapter(unchanged signature). No new exports.
Rationale: Today the next chapter is prefetched as soon as the current chapter loads (line 109), before the player’s choices for this chapter are committed. Since the chapter cache key now includes the choice path, prefetching that early would cache the wrong variant. Gating the prefetch on reaching the chapter’s final scene means (almost) all of this chapter’s choices are already in the path.
Step 1: Update the prefetch effect
Replace the prefetch effect (lines 106–110) with:
// Warm the next chapter once the player reaches this chapter's final scene, so
// its choice path is (almost) complete before we cache the next variant.
useEffect(() => {
if (chapter && sceneIndex >= chapter.scenes.length - 1) prefetchChapter(chapterIndex + 1);
}, [chapter, chapterIndex, sceneIndex, prefetchChapter]);
Step 2: Typecheck and lint
Run: ./node_modules/.bin/tsc --noEmit
Expected: no errors.
Run: ./node_modules/.bin/eslint components/versecraft/GeneratedDialogueScreen.tsx
Expected: no errors (the effect now reads sceneIndex, already in scope at line 72-ish via useGameStore).
Step 3: Commit
git add components/versecraft/GeneratedDialogueScreen.tsx
git commit -m "fix(versecraft): prefetch next chapter on final scene so choice path is complete"
Final verification¶
Step 1: Full test suite for the new modules
Run: ./node_modules/.bin/vitest run lib/versecraft/gen/__tests__
Expected: all suites PASS.
Step 2: Full typecheck
Run: ./node_modules/.bin/tsc --noEmit
Expected: no errors.
Step 3: Lint the full changed surface
Run: ./node_modules/.bin/eslint lib/versecraft components/versecraft/GeneratedDialogueScreen.tsx app/routes/api/versecraft
Expected: no errors.
Step 4: Build (regenerates route tree, confirms the new route compiles)
Run: ./node_modules/.bin/vite build
Expected: build succeeds. Revert pnpm-workspace.yaml if the build touched it.
Step 5: Senior review before pushing
Per repo convention for game work, run the senior-swe-reviewer agent on the branch diff and address findings before opening a PR.
Spec Coverage Check¶
Story Bible (hard constraints, name-lock) → Task 3 (
bible.ts), injected in Task 6 (buildChapterContext).Arc Outline two-tier (skeleton sync + Tier-2 background) → Task 2 (builders), Task 6 (
generateOutline), Task 8 (endpoint), Task 10 (sync skeleton on start + background enrich).Act-boundary revision → Task 10 Step 10.
Running Ledger (scribe + compaction + feed-forward) → Task 5, Task 6 (
scribeChapter), Task 8 (persist + return), Task 10 (absorbLedger, fed viafetchChapter).Scriptwriting craft module → Task 4, injected in Task 6.
Choices reshape prose (choice-path cache key) → Task 1, Task 7 (schema), Task 8 (cache key), Task 10 (
genApplyChoicerecords path).Prefetch-after-choice fix → Task 11.
Deterministic shareable shell preserved → world unchanged; skeleton outline deterministic (Task 2 test); chapter 0 path-empty stays shared (Task 1 empty-hash behavior).
Fallback parity → Tasks 2/5/6 fallbacks; Task 6 no-AI test.
Ledger token growth mitigation → Task 5
compactLedger.Tier-2-not-ready-before-chapter-1 → Task 10
beatForfalls back to skeleton.