/**
* Dramatic arc (Phase I) — the authored "score" of a universe.
*
* A universe optionally ships `universes/<u>/arc.json`: an ORDERED list of acts
* the story walks front to back (establish → complicate → … → resolve). It is
* *content*, loaded fresh each turn like rules/lore — never written into the
* playthrough state (that is `state.pacing`, which records only WHERE in the arc
* a save currently is).
*
* The arc does two things, both reusing machinery that already exists:
* 1. Gates which card `secrets` are eligible BY ACT (a second axis on top of
* the knowledge gate's earned-by-trust axis) — so an authored Act I with
* empty `reveals` keeps every secret out of the prompt and the world stays
* shadowless by construction. See `actEligibleIds`.
* 2. Colours the narration with the current act's `mood`, rendered into the
* direction block like `/mj` tone. (The `goal` is NOT shown to the narrator —
* it is director-level intent that tends to name concrete tells, which the
* narrator would front-run; it feeds only the conductor.)
*
* The runtime conductor (`conductor.ts`) reads the arc + live state to decide
* when to advance and what soft pressure to emit. This file is pure schema +
* resolution helpers — no model, no `@lmstudio/sdk`, unit-testable.
*/
import { z } from "zod";
import type { PacingState } from "../state/schema.js";
/** Sentinel in `reveals` meaning "open every earned secret" — i.e. no act gate. */
export const REVEAL_ALL = "*";
/** One act of the arc — a beat in the intended dramatic shape. */
export const ArcAct = z.object({
/** Stable id, referenced by `state.pacing.actId`. */
id: z.string(),
/** Author/debug-facing label, e.g. "The Unblemished Garden". */
title: z.string().default(""),
/** One line: what this act is FOR. Fed to the conductor as its objective. */
goal: z.string().default(""),
/**
* Narration bias for this act, rendered like `director.tone` into the
* direction block — the stable "colour" of the act (NOT a per-turn directive).
*/
mood: z.string().default(""),
/**
* Secret/fact ids this act OPENS for eligibility (cumulative across earlier
* acts). `["*"]` opens everything (no act gate). Empty `[]` opens nothing —
* every gated secret is suppressed this act, however earned.
*/
reveals: z.array(z.string()).default([]),
/** Ids explicitly SUPPRESSED this act even if otherwise eligible (rare re-lock). */
holds: z.array(z.string()).default([]),
/** Cast keys the conductor MAY bring on / pin this act (the dormant-asset hints). */
introduces: z.array(z.string()).default([]),
/** Floor: never advance before this many turns spent in this act. */
minTurns: z.number().int().nonnegative().default(0),
/** Ceiling: force-advance after this many turns in the act (0 = no cap). */
maxTurns: z.number().int().nonnegative().default(0),
/** Natural-language condition the conductor judges to advance (within bounds). */
advanceWhen: z.string().default(""),
});
export type ArcAct = z.infer<typeof ArcAct>;
export const Arc = z.object({
version: z.literal(1).default(1),
acts: z.array(ArcAct).default([]),
});
export type Arc = z.infer<typeof Arc>;
/**
* The built-in generic arc, used when the dramatic-arc feature is on but the
* universe ships no `arc.json`. It still gives "establish before escalate"
* pacing through the conductor, but its first act reveals `["*"]` — so knowledge
* gating is byte-identical to having no arc at all (no secret is act-suppressed).
* Holding the mystery back in Act I is a bespoke, AUTHORED choice (see Verdance),
* never imposed on every universe by default.
*/
export const DEFAULT_ARC: Arc = {
version: 1,
acts: [
{
id: "establish",
title: "Establish",
goal: "Ground the player in the world; let it be what it is before any problem.",
mood: "",
reveals: [REVEAL_ALL],
holds: [],
introduces: [],
minTurns: 5,
maxTurns: 12,
advanceWhen:
"the player has a feel for the world, or pushes at a seam themselves",
},
{
id: "complicate",
title: "Complicate",
goal: "Open the central question; let one thread pull and the stakes turn personal.",
mood: "",
reveals: [REVEAL_ALL],
holds: [],
introduces: [],
minTurns: 6,
maxTurns: 14,
advanceWhen: "the question is well joined and the stakes are personal",
},
{
id: "resolve",
title: "Resolve",
goal: "Force a choice and let consequences land; the arc may end.",
mood: "",
reveals: [REVEAL_ALL],
holds: [],
introduces: [],
minTurns: 4,
maxTurns: 0,
advanceWhen: "never (final act)",
},
],
};
/**
* Normalize an unknown parsed value into an `Arc`, or `null` if it has no usable
* acts. Defensive (like `normalizeRules`): a bad shape yields `null` so the
* caller falls back to the default / disables the feature, never throws.
*/
export function normalizeArc(raw: unknown): Arc | null {
const parsed = Arc.safeParse(raw);
if (!parsed.success) return null;
if (parsed.data.acts.length === 0) return null;
return parsed.data;
}
/** Index of the act with this id, or -1. */
function actIndex(arc: Arc, actId: string): number {
return arc.acts.findIndex((a) => a.id === actId);
}
/**
* Resolve the act the story is currently in. An empty / unknown `pacing.actId`
* (a fresh save, or one whose arc changed) falls back to the FIRST act, so the
* story always has a current act. Returns null only for an empty arc.
*/
export function resolveAct(arc: Arc, pacing: PacingState): ArcAct | null {
if (arc.acts.length === 0) return null;
const idx = actIndex(arc, pacing.actId);
return idx >= 0 ? arc.acts[idx] : arc.acts[0];
}
/** The act after the current one, or null if already at the final act. */
export function nextAct(arc: Arc, actId: string): ArcAct | null {
const idx = actIndex(arc, actId);
const at = idx >= 0 ? idx : 0;
return at + 1 < arc.acts.length ? arc.acts[at + 1] : null;
}
/**
* The set of secret ids that are act-eligible right now: the union of `reveals`
* across every act up to and INCLUDING the current one, minus the current act's
* `holds`. Returns `null` when the cumulative reveals contain the `"*"` sentinel
* — meaning NO act restriction (the knowledge gate then behaves exactly as if
* there were no arc). Pure.
*/
export function actEligibleIds(arc: Arc, actId: string): Set<string> | null {
const idx = actIndex(arc, actId);
const upto = idx >= 0 ? idx : 0;
const eligible = new Set<string>();
for (let i = 0; i <= upto && i < arc.acts.length; i++) {
for (const id of arc.acts[i].reveals) {
if (id === REVEAL_ALL) return null; // wildcard anywhere → no restriction
eligible.add(id);
}
}
for (const id of arc.acts[upto]?.holds ?? []) eligible.delete(id);
return eligible;
}
/**
* Ensure pacing has a valid current act. If `actId` already names an act, return
* pacing unchanged; otherwise seed it to the first act, stamping `actStartedTurn`
* with the given turn. Pure — the engine calls this once per turn before reading
* the current act, so an old save with no pacing starts cleanly at act one.
*/
export function initPacing(arc: Arc, pacing: PacingState, turn: number): PacingState {
if (arc.acts.length === 0) return pacing;
if (actIndex(arc, pacing.actId) >= 0) return pacing;
return { ...pacing, actId: arc.acts[0].id, actStartedTurn: turn };
}