/**
* The chronicler — the conductor's chapter-close sheet evolution (fitted to our
* architecture, alongside the two-tier memory's story-summary integration).
*
* When the conductor advances the authored act (a chapter closes), the engine
* runs two cheap chapter-close steps off the player's critical path:
* 1. the two-tier memory integration (in `memory/`): the chapter summary is
* folded into the whole-story summary;
* 2. THIS pass — a single `respond({ structured })` call, at a low temperature
* (faithful, not creative), that may apply a few CONSERVATIVE sheet changes
* so the player's stats/resources reflect lasting growth or decline earned
* over the chapter (a hard-won skill, a lasting toll).
*
* Discipline: most chapters justify NO change. Evolution is the exception,
* bounded to ±`deltaCap` so there is no power creep, and applied only to the
* declared stats / non-vital resources (the model proposes, the engine clamps to
* the rules' bounds — the "numbers belong to the code" discipline). Vital pools
* (HP) are never touched here: a chapter close does not heal or wound; that is
* combat's job.
*
* Pure + unit-testable: the schema, the prompt and the pure apply all live here;
* the single LLM call (the impure boundary) is in the handler, mirroring the
* conductor / sheetgen split. No `@lmstudio/sdk` import.
*/
import { z } from "zod";
import type { Sheet } from "../state/schema.js";
import { RulesDefinition } from "../rules/schema.js";
/** Everything the chronicler reads to decide a chapter's sheet evolution. */
export interface ChronicleContext {
/** The act that just ended (its title + goal frame the judgment). */
act: { title: string; goal: string; mood: string };
/** The just-ended chapter's summary (what actually happened this chapter). */
chapterSummary: string;
/** The final scene of the chapter (the turn that triggered the advance). */
recentScene: string;
/** The player's current sheet values, for grounding any proposed change. */
sheet?: Sheet;
}
/** A stat that can evolve at a chapter close, with its label + bounds. */
interface EvolvableKey {
key: string;
label: string;
min: number;
max: number;
kind: "stat" | "resource";
}
/**
* The declared keys a chapter close may nudge: every stat, plus every resource
* that is NOT a vital pool (`endWhenZero`). Vital pools (HP) start full and are
* combat's to move — a chapter close never heals or wounds. Resources unbounded
* above (`max: null`, e.g. money) use +∞ as the ceiling for the clamp.
*/
export function evolvableKeys(rules: RulesDefinition): EvolvableKey[] {
const out: EvolvableKey[] = [];
for (const [key, def] of Object.entries(rules.stats)) {
out.push({ key, label: def.label || key, min: def.min, max: def.max, kind: "stat" });
}
for (const [key, def] of Object.entries(rules.resources)) {
if (def.endWhenZero) continue;
out.push({
key,
label: def.label || key,
min: def.min,
max: def.max ?? Number.POSITIVE_INFINITY,
kind: "resource",
});
}
return out;
}
/**
* Build the chronicler's schema: an array of `{ key, delta, reason }`, with
* `key` constrained by a grammar enum to the declared evolvable keys so the
* model physically cannot invent one. Returns null when the universe has no
* evolvable key (no rules, or only vital pools) — then there is nothing to
* evolve and the handler skips the pass entirely.
*/
export function buildChronicleSchema(rules: RulesDefinition | null): z.ZodTypeAny | null {
const keys = rules ? evolvableKeys(rules).map((k) => k.key) : [];
if (keys.length === 0) return null;
return z.object({
changes: z.array(
z.object({
key: z.enum(keys as [string, ...string[]]),
delta: z.number().int(),
reason: z.string(),
}),
),
});
}
/**
* Build the chronicler's prompt. The system message sets the role and the
* conservative-evolution discipline; the user block lays out the act that
* closed, the chapter summary, its final scene, and the evolvable keys with
* their current values and bounds. Only called when there is something to evolve
* (the schema is non-null), so `rules` is always present here.
*/
export function buildChroniclePrompt(
ctx: ChronicleContext,
rules: RulesDefinition,
deltaCap: number,
): { system: string; user: string } {
const keys = evolvableKeys(rules);
const system = [
"You are the CHRONICLER of an ongoing role-play. A chapter has just ended. " +
"You do not narrate and you do not speak in character. Your only job is to " +
"decide whether the chapter earned any lasting change to the player's sheet.",
"",
"Propose `changes`: CONSERVATIVE adjustments to the player's stats/resources, " +
"and ONLY when the chapter's events genuinely justify lasting growth or " +
`decline (a hard-won skill, a lasting toll). Most chapters justify NONE — ` +
`prefer an empty list. Each delta is small: between -${deltaCap} and +${deltaCap}. ` +
"Never invent a key; use only the keys listed below. Give a one-line reason " +
"grounded in the chapter for each change.",
"",
"Output only the structured object.",
].join("\n");
const keyLines = keys.map((k) => {
const cur =
k.kind === "stat" ? ctx.sheet?.stats?.[k.key] : ctx.sheet?.resources?.[k.key];
const bound = k.max === Number.POSITIVE_INFINITY ? `${k.min}..∞` : `${k.min}..${k.max}`;
return `- ${k.key} (${k.label}): now ${cur ?? "?"}, range ${bound}`;
});
const user = [
"# The chapter that just ended",
`Title: ${ctx.act.title || "(untitled)"}`,
ctx.act.goal ? `What it was for: ${ctx.act.goal}` : "",
ctx.act.mood ? `Mood: ${ctx.act.mood}` : "",
"",
"# What happened this chapter",
ctx.chapterSummary.trim() || "(no chapter summary)",
"",
"# The final scene of the chapter",
ctx.recentScene.trim() || "(none)",
"",
"# The player's sheet (propose changes only against these keys)",
keyLines.join("\n"),
"",
"---",
"",
"# Your task",
"List any justified `changes` (often none). Output only the structured object:",
]
.filter((l) => l !== "")
.join("\n");
return { system, user };
}
export interface ApplyChronicleOptions {
rules: RulesDefinition | null;
/** The player's sheet to evolve (post-resolution, this turn). */
sheet: Sheet;
/** Hard bound on each proposed delta (symmetric). */
deltaCap: number;
}
export interface ChronicleResult {
/** The sheet after applying the (clamped) changes — unchanged when none apply. */
sheet: Sheet;
/** The changes actually applied, for the debug log. */
applied: { key: string; from: number; to: number }[];
}
function clamp(v: number, min: number, max: number): number {
return Math.max(min, Math.min(max, v));
}
/**
* Fold a parsed chronicler object into an evolved sheet (pure). Defensive like
* the other apply steps: junk fields are ignored, never throw. Each change is
* clamped to `±deltaCap`, applied to the matching declared key, and the result
* clamped to the rules' bounds. Unknown keys, vital pools (never evolvable) and
* non-finite/zero deltas are dropped. The sheet is cloned (never mutated in
* place); with no rules the sheet is returned untouched.
*/
export function applyChronicle(
parsed: unknown,
opts: ApplyChronicleOptions,
): ChronicleResult {
const obj =
parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {};
if (!opts.rules) return { sheet: opts.sheet, applied: [] };
const keyMap = new Map(evolvableKeys(opts.rules).map((k) => [k.key, k]));
const nextStats: Record<string, number> = { ...opts.sheet.stats };
const nextResources: Record<string, number> = { ...opts.sheet.resources };
const applied: { key: string; from: number; to: number }[] = [];
const rawChanges = Array.isArray(obj.changes) ? obj.changes : [];
const cap = Math.max(0, Math.abs(opts.deltaCap));
for (const c of rawChanges) {
if (!c || typeof c !== "object") continue;
const rec = c as Record<string, unknown>;
const key = typeof rec.key === "string" ? rec.key : "";
const k = keyMap.get(key);
if (!k) continue; // unknown / vital pool → dropped
const rawDelta = Number(rec.delta);
if (!Number.isFinite(rawDelta) || rawDelta === 0) continue;
const delta = clamp(Math.round(rawDelta), -cap, cap);
const bag = k.kind === "stat" ? nextStats : nextResources;
const from = bag[key] ?? 0;
const to = clamp(Math.round(from + delta), k.min, k.max);
if (to === from) continue; // already at the bound → no-op
bag[key] = to;
applied.push({ key, from, to });
}
const sheet: Sheet =
applied.length > 0
? { ...opts.sheet, stats: nextStats, resources: nextResources }
: opts.sheet;
return { sheet, applied };
}