/**
* Deterministic assembly of the role-play **system prompt**.
*
* With the prediction-loop-handler architecture the plugin builds the whole
* chat it sends to the model, so the context lives in a real `system` message
* (set via `Chat.replaceSystemPrompt`) that is rebuilt every turn and preserved
* by the context window. The player's action stays a normal `user` message and
* is NOT part of this block.
*
* Order: system rules → world → world lore → player character
* → characters in scene → off-scene characters → relationships → status
* → story so far (whole-story memory) → chapter so far (chapter memory)
* → relevant past events (RAG recall) → action resolution
* → direction (tone + persistent directives) → this turn only
* → output format → conclusion → language (LAST, hard override)
*/
import { bigFiveBlurb, LoadedCast } from "../characters/index.js";
import { chronosBlock, type ChronosModel } from "../chronos/index.js";
import { loreDisclosureKey, npcDisclosureKey } from "../disclosure/index.js";
import { chapterBlock, memoryBlock, recallBlock } from "../memory/index.js";
import {
initiativeBlock,
INITIATIVE_CLAUSE,
stanceBlock,
STANCE_CLAUSE,
STANCE_ROLL_PRECEDENCE_CLAUSE,
type Stance,
} from "../psyche/index.js";
import { relationshipBlock } from "../relationships/index.js";
import {
adjudicationBlock,
conclusionBlock,
consequencesBlock,
resolutionBlock,
statusBlock,
} from "../rules/index.js";
import type { Resolution, RulesDefinition } from "../rules/index.js";
import { ChronosState, DirectorState, MemoryChunk, Psyche, Relationship, Sheet, WorldState } from "../state/schema.js";
import { LoreEntry } from "../world/index.js";
import { renderMacros } from "./macros.js";
/**
* One entry in the `# Off-scene characters` block (scene-presence tiering): an
* NPC known to the recent conversation but not present. `link`, when set, is the
* one-line reason they are referenceable (an on-stage character's authored
* relation, e.g. "Linnea: an old grudge"); a plain recent/semantic mention has none.
*/
export interface OffSceneMention {
name: string;
link?: string;
}
export interface AssembleInput {
world: WorldState;
/**
* World clock (the chronos subsystem): the authored model + the live state, for
* the `# Time & weather` block. Undefined = the feature is off → the block is
* omitted and the turn stays byte-identical. The planner passes it only when the
* time-and-weather feature is on.
*/
chronos?: { model: ChronosModel; state: ChronosState };
director: DirectorState;
/**
* The cast for this turn: player persona (or null) + NPCs. Optional so
* callers that don't manage characters (and older tests) still type-check.
*/
characters?: LoadedCast;
/**
* Scene-presence tiering: NPCs known to the recent conversation but NOT present
* in this scene. Rendered as the thin `# Off-scene characters` block (names
* only) — the narrator may reference them but must not voice or stage them.
* Undefined/empty (feature off) omits the block, leaving the prompt
* byte-identical.
*/
mentionable?: OffSceneMention[];
/**
* Lore entries selected for this turn (already keyword-matched and
* budget-fit by the planner). Optional; the block is omitted when empty.
*/
lore?: LoreEntry[];
/**
* The whole-story summary (the two-tier memory's top tier): a summary of
* chapter summaries, updated only at a chapter close. For the `# Story so far`
* block. Optional; omitted when empty (the opening chapter, before any close).
*/
storySummary?: string;
/**
* The current chapter's rolling summary (the two-tier memory's bottom tier),
* recompressed every few messages while the act is in progress. For the
* `# Chapter so far` block. Optional; omitted when empty (an early chapter).
*/
chapterSummary?: string;
/**
* Past messages recalled from the vector-RAG store (Phase F) as relevant to
* the current scene. Optional; the `# Relevant past events` block is omitted
* when empty.
*/
recalledMemories?: MemoryChunk[];
/**
* Relationship memory: per-pair standing keyed `"player|<npc>"`, for the
* `# Relationships` block (how the cast regards the player + their shared
* history). `undefined` = the feature is off → the block is omitted and the
* turn stays byte-identical. A defined (even empty) record renders the active
* cast, defaulting a never-met NPC to a stranger so the name-gate fires.
*/
relationships?: Record<string, Relationship>;
/**
* Character psyche (Phase J — the fluctuating state layer): per-NPC mood /
* intent / goalFocus keyed by lowercased name, written by the social pass. When
* present, each in-scene NPC gets a compact "Right now:" line in the cast block
* so the narrator voices their current state. `undefined` (feature off) leaves
* the cast block byte-identical; an NPC with no record simply gets no line.
*/
psyche?: Record<string, Psyche>;
/** Baseline narration style from per-chat config. */
narrationStyle: string;
/** Whether to instruct the model to offer numbered choices. */
enableChoices: boolean;
/** How many numbered options to request. */
choiceCount: number;
/** Optional language constraint (empty = let the model decide). */
languageInstruction?: string;
/**
* Structured-mechanics rules for this universe (Phase G), or null/undefined
* when the universe has no `rules.json`. Its presence turns on the `# Status`
* block, the mechanics clause in the response format, and game-over handling.
*/
rulesDef?: RulesDefinition | null;
/** The player's sheet (post-resolution), for the `# Status` block. */
sheet?: Sheet;
/** Live combatant sheets (post-resolution), for the `# Status` block. */
combatants?: Record<string, Sheet>;
/**
* This turn's move resolution (already rolled by the handler), for the
* `# Action resolution` block. Null/absent on a plain narrative turn.
*/
resolution?: Resolution | null;
/**
* On a free-form `roll` (Phase G3), the referee's one-line framing of what was
* attempted, appended to the `# Action resolution` block so the narrator reads
* the dice outcome with its stakes. Empty/absent for a numbered-pick roll.
*/
resolutionNote?: string;
/**
* The referee's no-roll ruling on the player's free-form action (Phase G3),
* for the `# Adjudication` block. Set only on a `resisted`/`claim` verdict;
* null/absent otherwise (an `allowed` verdict, a `roll` — which uses
* `# Action resolution` instead — a numbered pick, or adjudication off). Its
* presence also adds one honour-clause to the narrator's base instruction.
*/
adjudication?: { kind: "resisted" | "claim"; reason: string } | null;
/**
* The tracked resources the referee forecast this free-form action puts in
* play (Phase G3), for the `# Consequences` block — so the narrator reflects
* the change as a concrete event instead of forgetting it. Empty/absent on an
* ordinary action (the common case), so the prompt is then unchanged.
*/
affects?: string[];
/**
* Character volition (Phase J — the social referee): how each in-scene NPC is
* disposed to respond to the player's action, computed by the handler's
* pre-narration stance pass and forwarded as-is (pure planner). Two orthogonal
* axes ride on each entry: the REACTIVE stance (only the resisting ones render a
* `# Character stance` block) and the PROACTIVE initiative (only the acting ones
* render a `# Character initiative` block). A turn where everyone complies and no
* one acts, or the feature is off, stays byte-identical; a present block adds its
* own honour-clause to the narrator's base instruction. Empty/absent → no blocks.
*/
stances?: Stance[];
/** When true, the player's run has ended — suppress choices, add `# Conclusion`. */
gameOver?: boolean;
/**
* Knowledge gating (the revelation system): per in-scene NPC, what the narrator
* may see of their card-declared secrets THIS turn — content-free `guarded`
* tells (show the evasion) and `unlocked` payloads (may now be voiced). Keyed by
* the NPC's display name to match the cast block. The gate (`src/knowledge/`)
* computes it in the planner; a locked secret is simply absent, so it can never
* leak. Undefined/empty (no `secrets` declared) leaves the cast block unchanged.
*/
revelations?: Record<string, { guarded: string[]; unlocked: string[] }>;
/**
* Dramatic arc (Phase I): the current act's narration MOOD — the stable colour
* of this stretch of story, rendered into the direction block like a tone.
* Empty when the feature is off / the act sets no mood, leaving it unchanged.
*/
actMood?: string;
/**
* Dramatic arc (Phase I): the current act opens NO secrets/lore yet — the
* "establish, no shadow" phase (e.g. Act I with empty `reveals`). When true the
* narrator is firmly held to presenting the world as it is: no anomaly, omen, or
* mystery in the prose, and only ordinary options. False/undefined leaves the
* prompt unchanged (later acts, or the feature off).
*/
actEstablishing?: boolean;
/**
* First-mention introduction (the disclosure system): the disclosure keys of
* the selected lore entries the narration has NOT introduced yet — each gets a
* "ground it on first appearance" note appended in the `# World lore` block.
* Computed by the planner (`planDisclosure`). Undefined/empty (feature off or
* everything already introduced) leaves the block byte-identical.
*/
undisclosedLore?: Set<string>;
/**
* First-mention introduction: the disclosure keys of the in-scene NPCs the
* narration has not introduced yet — each gets an "introduce them" note in the
* `# Characters in scene` block. Undefined/empty leaves the cast block
* byte-identical (orthogonal to the secret/relationship annotations).
*/
undisclosedNpcs?: Set<string>;
/**
* Narration-length steering: a compact baseline plus a soft word target that
* scales INVERSELY with the scene's dramatic intensity (charged ⇒ short and
* clipped; calm ⇒ room to breathe), for the `# Narration length` block. The
* planner resolves `intensity` (the conductor's tension, a neutral baseline when
* the arc is off, floored by live combat) and forwards the word anchors from
* tuning. Undefined (feature off) omits the block — byte-identical to before.
*/
narrationLength?: {
/** Dramatic intensity for this turn, 0 (calm) → 1 (charged). */
intensity: number;
/** Soft word target at intensity 0 — the long, unhurried end. */
calmWords: number;
/** Soft word target at intensity 1 — the short, clipped end. */
intenseWords: number;
};
}
/**
* First-mention note appended to a lore entry the player has not met yet (the
* disclosure system). A principle, not a checklist (the project's house style),
* and phrased so the narrator weaves the framing in rather than pausing to
* lecture. Disappears once the element is named in the prose (marked introduced).
*/
export const LORE_INTRO_NOTE =
"(First appearance — the player has not encountered this in the story yet. " +
"When you bring it in, ground it in a line or two so the player grasps what it " +
"is; introduce it, do not presume it already known. Weave the framing into the " +
"scene rather than pausing to explain.)";
/**
* First-mention note for an NPC the narration has not brought on yet. Distinct
* from the relationship name-gate (which is about whether the NPC knows the
* PLAYER) and from secrets: this is purely about introducing the character to the
* player/reader on their first appearance.
*/
export const NPC_INTRO_NOTE =
"First appearance — this character has not yet entered the story. On bringing " +
"them on, establish who they are in a line or two so the player can place them; " +
"introduce them, do not drop the name as if already known.";
export const SYSTEM_PROMPT =
"You are the game master of an interactive, collaborative role-play. " +
"Narrate vividly, stay in character for the world and NPCs, and never " +
"decide the player character's actions for them. React only to the " +
"player's stated action. Stay consistent with the established world, its " +
"lore, and what has already happened: never introduce a place, passage, " +
"exit, or object that contradicts them, and never treat as real something " +
"the player only asserts or assumes. If the player presupposes a place or " +
"thing that has not been established, have the world contradict the false " +
"premise rather than accommodate it — the player cannot add facts to the " +
"world by stating them. Keep the story moving: when the player's action covers " +
"dead time or routine with nothing at stake — travel, waiting, sleep, a search " +
"over hours — do not play it out beat by beat across turns; summarise the " +
"uneventful passage in a line or two and bring the scene to the next thing that " +
"actually matters.";
/**
* The narrator's honour-clause for a referee ruling (Phase G3). Appended to the
* base instruction ONLY on a turn that carries an `# Adjudication` block, so a
* turn without one (adjudication off, no `rules.json`, or an `allowed`/`roll`
* verdict) stays byte-identical to Phase G2. The `# Action resolution` block
* carries its own "do not contradict" instruction, so the clause names only the
* `# Adjudication` block it is paired with.
*/
export const ADJUDICATION_CLAUSE =
"An `# Adjudication` block, when present, is a binding ruling from an " +
"impartial referee on what the player may attempt: honour it and never grant " +
"an outcome it denies.";
function macroContext(input: AssembleInput): Record<string, string> {
const ctx: Record<string, string> = {
tone: input.director.tone,
style: input.narrationStyle,
"world.name": input.world.name,
"world.setting": input.world.setting,
};
// {{user}} = the player persona; {{char}} = the focal NPC. With a single NPC
// that's unambiguous; with several we pick the first (name-sorted) one and
// leave finer targeting to the keyword-driven activation of Phase B.
const player = input.characters?.player;
if (player) ctx.user = player.name;
const cast = input.characters?.cast ?? [];
if (cast.length > 0) ctx.char = cast[0].name;
return ctx;
}
/**
* Build the persistent game-master direction block (style + tone + the
* `persistent` directives). One-shot `/mj!` directives are rendered separately
* (see `turnOnlyBlock`) so the model reads them as ephemeral. With no `once`
* directives this is byte-identical to the pre-Phase-D block.
*/
function directionBlock(input: AssembleInput): string | null {
const ctx = macroContext(input);
const lines: string[] = [];
if (input.narrationStyle.trim()) {
lines.push(`- Style: ${renderMacros(input.narrationStyle, ctx)}`);
}
// Dramatic arc (Phase I): the current act's COLOUR only — its `mood`, rendered
// like a tone between the static style and the dynamic directives. The act's
// `goal` is deliberately NOT injected here: it is director-level intent (e.g.
// "open ONE thread through ONE character") that tends to name concrete tells,
// which the narrator would front-run into prose or a recommended option. The
// goal is for the conductor (see `buildConductorPrompt`); the narrator gets the
// mood plus the conductor's own `arc-` nudges (added as directives below).
if (input.actMood?.trim()) {
lines.push(`- Act tone: ${renderMacros(input.actMood.trim(), ctx)}`);
}
if (input.director.tone.trim()) {
lines.push(`- Current tone: ${renderMacros(input.director.tone, ctx)}`);
}
for (const d of input.director.directives) {
if (d.scope === "once") continue;
lines.push(`- ${renderMacros(d.text, ctx)}`);
}
if (lines.length === 0) return null;
return ["# Game Master Direction", ...lines].join("\n");
}
/**
* `# This turn only` block: the one-shot `/mj!` directives, rendered apart from
* the persistent direction so the model reads them as ephemeral (apply this
* turn, then gone). Placed AFTER `# Game Master Direction` and BEFORE
* `# Response format` — closest to generation, where instructions are best
* obeyed. Omitted (null) when there are no `once` directives, so a turn without
* any leaves the prompt byte-identical to before.
*/
function turnOnlyBlock(input: AssembleInput): string | null {
const ctx = macroContext(input);
const lines = input.director.directives
.filter((d) => d.scope === "once")
.map((d) => `- ${renderMacros(d.text, ctx)}`);
if (lines.length === 0) return null;
return ["# This turn only", ...lines].join("\n");
}
/**
* Author's-note (Phase D, Lot 3): a compact one-line reminder condensing the
* current tone + persistent directives, for injection NEAR THE END of the chat
* (see the handler) so steering survives a long context — the top
* `# Game Master Direction` block can scroll far from generation. This is a
* deliberate DUPLICATE of that block, terse; the top block stays for stable
* context. `once` directives are excluded (they have their own `# This turn
* only` block). Returns null when there is nothing to remind, so the handler
* injects nothing. Pure — depends only on the director state.
*/
export function authorNote(director: DirectorState): string | null {
const parts: string[] = [];
if (director.tone.trim()) parts.push(director.tone.trim());
for (const d of director.directives) {
if (d.scope === "once") continue;
if (d.text.trim()) parts.push(d.text.trim());
}
if (parts.length === 0) return null;
return `Reminder — keep to: ${parts.join("; ")}`;
}
function worldBlock(input: AssembleInput): string | null {
const { name, setting } = input.world;
const lines: string[] = [];
if (name.trim() && name !== "Untitled") lines.push(`Name: ${name}`);
if (setting.trim()) lines.push(setting.trim());
if (lines.length === 0) return null;
return ["# World", ...lines].join("\n");
}
/**
* `# Time & weather` block (the chronos subsystem): the current hour, day and
* weather, rendered right after `# World` so it reads as the world's present
* state. Omitted when the feature is off (no `chronos` passed), keeping the turn
* byte-identical.
*/
function chronosInfoBlock(input: AssembleInput): string | null {
if (!input.chronos) return null;
return chronosBlock(input.chronos.model, input.chronos.state);
}
/**
* World-lore block: the entries the scanner selected as relevant this turn,
* one paragraph each (macros rendered). Placed after the static world block and
* before the player/characters blocks. Omitted when nothing matched.
*/
function loreBlock(input: AssembleInput): string | null {
const lore = input.lore ?? [];
if (lore.length === 0) return null;
const ctx = macroContext(input);
const undisclosed = input.undisclosedLore;
const parts = lore.map((entry) => {
const text = renderMacros(entry.content.trim(), ctx);
// First-mention introduction (the disclosure system): flag an entry the
// narration has not introduced yet so the narrator grounds it on first use.
// Already-introduced entries (or feature off) render byte-identically.
if (undisclosed?.has(loreDisclosureKey(entry))) {
return `${text}\n${LORE_INTRO_NOTE}`;
}
return text;
});
return ["# World lore", ...parts].join("\n\n");
}
/** Player-persona block: who the player is voicing (name, gender, age, appearance, description). */
function playerBlock(input: AssembleInput): string | null {
const player = input.characters?.player;
if (!player) return null;
const ctx = macroContext(input);
const lines = [`Name: ${player.name}`];
if (player.gender?.trim()) lines.push(`Gender: ${player.gender.trim()}`);
if (player.age?.trim()) lines.push(`Age: ${player.age.trim()}`);
if (player.appearance?.trim()) {
lines.push(`Appearance: ${renderMacros(player.appearance.trim(), ctx)}`);
}
if (player.description.trim()) {
lines.push(renderMacros(player.description.trim(), ctx));
}
return ["# Player character", ...lines].join("\n");
}
/** One sub-block per NPC: name, gender, age, appearance, description, personality, scenario (if set). */
function castBlock(input: AssembleInput): string | null {
const cast = input.characters?.cast ?? [];
if (cast.length === 0) return null;
const ctx = macroContext(input);
const subs = cast.map((npc) => {
const lines = [`## ${npc.name}`];
if (npc.gender?.trim()) lines.push(`Gender: ${npc.gender.trim()}`);
if (npc.age?.trim()) lines.push(`Age: ${npc.age.trim()}`);
if (npc.appearance?.trim()) {
lines.push(`Appearance: ${renderMacros(npc.appearance.trim(), ctx)}`);
}
if (npc.description.trim()) {
lines.push(renderMacros(npc.description.trim(), ctx));
}
if (npc.personality.trim()) {
lines.push(`Personality: ${renderMacros(npc.personality.trim(), ctx)}`);
}
// Phase J — the stable trait layer + the agenda that gives this character a
// life of their own. The Big Five renders qualitatively (only salient traits);
// each line is omitted when the card declares none, so a card without them
// stays byte-identical.
const traits = bigFiveBlurb(npc.bigFive);
if (traits) {
lines.push(`Traits (Big Five): ${traits}`);
}
const desires = (npc.desires ?? "").trim();
if (desires) {
lines.push(`Wants: ${renderMacros(desires, ctx)}`);
}
const needs = (npc.needs ?? "").trim();
if (needs) {
lines.push(`Needs: ${renderMacros(needs, ctx)}`);
}
const boundaries = (npc.boundaries ?? "").trim();
if (boundaries) {
lines.push(`Will not: ${renderMacros(boundaries, ctx)}`);
}
if (npc.scenario.trim()) {
lines.push(`Scenario: ${renderMacros(npc.scenario.trim(), ctx)}`);
}
// Phase J — the fluctuating STATE layer: the character's current mood,
// short-term intention, and live agenda (goalFocus), so the narrator voices
// them as they are right now — pursuing their own thing — not from the static
// card alone. The same three fields the social referee weighs, so ruling and
// narration agree. Present only when the feature is on and the social pass has
// written something for this NPC (else byte-identical).
const ps = input.psyche?.[npc.name.trim().toLowerCase()];
if (ps) {
const focus = (ps.goalFocus ?? "").trim();
const now = [ps.mood, ps.intent, focus ? `pursuing: ${focus}` : ""]
.map((s) => (s ?? "").trim())
.filter(Boolean)
.join("; ");
if (now) lines.push(`Right now: ${renderMacros(now, ctx)}`);
}
// Knowledge gating (the revelation system): only what the gate unlocked for
// this turn. A guarded tell carries no payload — the narrator plays the
// evasion and must not invent the content; an unlocked payload may finally be
// voiced, earned and gradual. A locked secret never reaches here at all.
const rev = input.revelations?.[npc.name];
if (rev) {
for (const surface of rev.guarded) {
if (!surface.trim()) continue;
lines.push(
`Holds back here — stay evasive and deflect; do NOT state or invent ` +
`what lies under it (you do not have it): ${renderMacros(surface.trim(), ctx)}`,
);
}
for (const content of rev.unlocked) {
if (!content.trim()) continue;
lines.push(
`Will now share this, in character and only as the moment earns it — ` +
`not blurted all at once: ${renderMacros(content.trim(), ctx)}`,
);
}
}
// First-mention introduction (the disclosure system): an NPC the narration
// has not brought on yet gets a short "introduce them" instruction —
// orthogonal to the secret/relationship lines above. Absent (or feature off)
// leaves the sub-block byte-identical.
if (input.undisclosedNpcs?.has(npcDisclosureKey(npc.name))) {
lines.push(NPC_INTRO_NOTE);
}
return lines.join("\n");
});
return ["# Characters in scene", ...subs].join("\n\n");
}
/**
* `# Off-scene characters` block (scene-presence tiering): NPCs known to the
* recent conversation but NOT present in this scene — names only, no card, no
* secrets. This is what stops the narrator giving lines to characters who have
* left or were merely named: they may be referenced in passing, but not voiced or
* staged. It also keeps a deliberate entry point — the narrator knows they exist
* and can bring one on when the scene genuinely calls for it. Omitted (null) when
* empty, so scene-presence off / no off-scene NPC leaves the prompt byte-identical.
*/
function offSceneBlock(input: AssembleInput): string | null {
const mentions = input.mentionable ?? [];
if (mentions.length === 0) return null;
return [
"# Off-scene characters",
"Known here but NOT present in this scene. You may mention them in passing if " +
"it arises naturally; do NOT give them dialogue or actions, and do NOT bring " +
"them on stage unless the scene genuinely calls for it:",
...mentions.map((m) =>
m.link?.trim() ? `- ${m.name} — ${m.link.trim()}` : `- ${m.name}`,
),
].join("\n");
}
/**
* `# Relationships` block: how the active cast currently regards the player and
* the history they share (familiarity gates the name; disposition colours the
* tone). Placed right after the cast so it reads as a lens on those characters.
* Omitted when relationship memory is off, there is no player, or no cast.
*/
function relationshipsInfoBlock(input: AssembleInput): string | null {
return relationshipBlock(
input.characters?.player?.name,
input.characters?.cast ?? [],
input.relationships,
);
}
/**
* `# Story so far` block: the whole-story summary (the two-tier memory's top
* tier) — a summary of chapter summaries, the story's stable backbone. Placed
* after the cast and before the current-chapter summary. Omitted until the first
* chapter closes.
*/
function storyBlock(input: AssembleInput): string | null {
return memoryBlock(input.storySummary ?? "");
}
/**
* `# Chapter so far` block: the current chapter's rolling summary (the two-tier
* memory's bottom tier), preserving the substance of this chapter's messages
* that scrolled out of the window. Placed right after `# Story so far`, broad →
* narrow. Omitted when there is no chapter summary yet.
*/
function chapterInfoBlock(input: AssembleInput): string | null {
return chapterBlock(input.chapterSummary ?? "");
}
/**
* `# Relevant past events` block: verbatim excerpts pulled from the RAG store
* (Phase F) because they relate to the current scene — the precise-recall
* complement to the bounded `# Story so far` summary. Placed right after it.
* Omitted when nothing was recalled.
*/
function recallInfoBlock(input: AssembleInput): string | null {
return recallBlock(input.recalledMemories ?? []);
}
/**
* `# Status` block: the authoritative current sheet (player + live combatants),
* rendered by the rules engine. Present only when the universe has rules.
*/
function statusInfoBlock(input: AssembleInput): string | null {
if (!input.rulesDef || !input.sheet) return null;
return statusBlock(input.sheet, input.combatants ?? {}, input.rulesDef);
}
/**
* `# Action resolution` block: this turn's roll + applied effects, so the model
* narrates an outcome the dice already decided. Null on a plain narrative turn.
*/
function actionResolutionBlock(input: AssembleInput): string | null {
if (!input.rulesDef) return null;
return resolutionBlock(input.resolution ?? null, input.rulesDef, input.resolutionNote ?? "");
}
/**
* `# Adjudication` block: the referee's no-roll ruling on the player's free-form
* action (Phase G3), telling the narrator to honour a `resisted`/`claim` verdict.
* Placed beside `# Action resolution` (the two never co-occur). Null when there
* is no ruling — so a turn without adjudication is byte-identical to before.
*/
function adjudicationInfoBlock(input: AssembleInput): string | null {
return adjudicationBlock(input.adjudication ?? null);
}
/**
* `# Consequences` block: the referee's forecast (Phase G3) of which tracked
* resources the free-form action puts in play, so the narrator reflects the
* change as a concrete event. Present only with rules + a non-empty forecast;
* complementary to `# Action resolution` on a `roll`, absent on a `resisted`/
* `claim` (cleared upstream) or an ordinary action.
*/
function consequencesInfoBlock(input: AssembleInput): string | null {
if (!input.rulesDef || !input.affects || input.affects.length === 0) return null;
return consequencesBlock(input.affects, input.rulesDef);
}
/** `# Conclusion` block: shown once the player's run has ended. */
function conclusion(input: AssembleInput): string | null {
if (!input.rulesDef || !input.gameOver) return null;
return conclusionBlock();
}
function outputFormatBlock(input: AssembleInput): string | null {
// Once the run is over, offer no choices — the `# Conclusion` block takes over.
if (input.rulesDef && input.gameOver) return null;
if (!input.enableChoices) return null;
const n = Math.max(2, Math.floor(input.choiceCount));
const lines = [
"# Response format",
`End your reply with exactly ${n} numbered options (1.–${n}.) the player ` +
"could take next. After the list, add one short closing line that openly " +
"invites the player to set the options aside and act in their own words. " +
"Address the player directly and make the meaning unmistakable: these are " +
"only suggestions, and a free, perceptive spirit may well find a better move " +
"of their own. Keep it in the narration's language and tone — never a dry, " +
"mechanical disclaimer — but it must read clearly as an INVITATION to " +
"improvise, not as another line of scenery or atmosphere. Stay light and " +
"brief, give nothing away about the scene, and vary the wording every turn so " +
"it never hardens into boilerplate.",
"An option is an ordinary action available from what the character already " +
"perceives — a way to move, act, look, speak, wait, or move on. It is a " +
"convenience, never the story's discovery or plot beat decided for the player.",
"Vary the options' scope so they do not all poke at the same spot one tiny " +
"step at a time: alongside any close-in action, offer at least one that lets " +
"the player act decisively or carry the scene onward — leave, set out for " +
"somewhere, or let an uneventful stretch of time pass — so choosing an option " +
"never traps the player in place. Carrying the scene forward in space or time " +
"is NOT the same as pulling a hidden thread or handing over a discovery (see " +
"the limits below): an option may carry the player onward freely, and must " +
"never do the latter.",
"Two hard limits on every option: (1) it must not reveal, name, or hint at " +
"anything the narration has not already shown — no undisclosed lore, no " +
"off-screen fact; (2) it must not be the discovery or the solution itself, " +
"nor name or interpret a thread the scene has only hinted at. If the scene " +
"holds something worth pursuing, leave the pursuing to the player's own " +
"words — never hand them the thread-pull as a ready-made option.",
"Never decide on your own to end the story, conclude a chapter or " +
"episode, or stop offering the options: always continue the scene and " +
"close with the options above. The adventure only ends on game over.",
];
// Establishing phase (an act that opens nothing yet): the story's opening must
// let the player simply inhabit the world. Hold the narrator hard against
// front-running the mystery — the single most common way the opening is spoiled.
if (input.actEstablishing) {
lines.push(
"This scene is part of the story's OPENING. Establish the world exactly as " +
"it presents itself and let the player simply live in it. Introduce no " +
"anomaly, wrongness, mystery, omen, or unexplained event — not in the " +
"narration and not in the options. Any too-perfect ease stays unremarked; " +
"no character notices or names it. Every option is an ordinary way to be " +
"in the scene, never a thread to pull.",
);
}
// With mechanics on, let the prose carry the risk: the difficulty the narrator
// implies is what the accountant (Pass 2) reads back and the engine rolls
// against. Odds are never shown to the player (decisions 4 & 5).
if (input.rulesDef) {
lines.push(
"When an option is a risky or uncertain action, let your prose telegraph " +
"how dangerous it feels — a safe bet should read differently from a " +
"desperate gamble — without ever stating odds, percentages, or numbers.",
);
}
return lines.join("\n");
}
/**
* `# LANGUAGE — MANDATORY` block: a hard, last-position language override.
*
* Placed LAST in the prompt (closest to generation) and worded as an absolute
* requirement, because small / English-leaning models otherwise code-switch —
* borrowing English terms or leaving loanwords mid-sentence — when the whole
* prompt around them (world, lore, sheets, weather like "sea haar") is in
* English. This block must out-weigh all of that. Null for "model-default"
* (empty instruction) → prompt stays byte-identical.
*/
function languageBlock(input: AssembleInput): string | null {
const instruction = input.languageInstruction?.trim();
if (!instruction) return null;
return [
"# LANGUAGE — MANDATORY",
instruction,
"Every word must be in that language, overriding the English used elsewhere " +
"in this prompt. Never code-switch or borrow a foreign word: render " +
"descriptive and evocative vocabulary — weather, materials, sensations, " +
"everyday objects — in that language rather than echoing the source word. " +
"Keep in their original spelling only proper names: the names of specific " +
"people and places.",
].join("\n");
}
function clamp01(n: number): number {
if (!Number.isFinite(n)) return 0;
return n < 0 ? 0 : n > 1 ? 1 : n;
}
/**
* `# Narration length` block: a compact baseline plus a soft word target that
* scales INVERSELY with the scene's dramatic intensity. The target is a linear
* interpolation between `calmWords` (at intensity 0) and `intenseWords` (at
* intensity 1); the prose-pacing note is keyed to three intensity bands so the
* wording matches where the scene sits. A principle, not a worked example (the
* project's house style — a sample line would bias the narration). Omitted when
* the feature is off or `calmWords` is 0, leaving the prompt byte-identical.
*/
function narrationLengthBlock(input: AssembleInput): string | null {
const nl = input.narrationLength;
if (!nl || nl.calmWords <= 0) return null;
const i = clamp01(nl.intensity);
const target = Math.round(nl.calmWords + (nl.intenseWords - nl.calmWords) * i);
const baseline =
"Match the narration's length to the moment described below, and don't pad to " +
"reach it: cut throat-clearing, redundant description, and recap so each " +
"sentence earns its place.";
let pacing: string;
if (i >= 0.66) {
pacing =
"This is a charged, high-tension moment: lean into a tighter, more clipped " +
`rhythm — shorter sentences, the pressure carried by what you leave out — about ${target} words.`;
} else if (i >= 0.33) {
pacing = `Hold a measured pace — about ${target} words.`;
} else {
pacing =
"This is a calm, low-tension or establishing moment: take an unhurried, " +
`atmospheric pace and give the scene room — up to about ${target} words.`;
}
return ["# Narration length", baseline, pacing].join("\n");
}
/**
* Build the full system prompt for this turn. Rebuilt every turn and set on the
* chat via `replaceSystemPrompt`, so it is always present and current — no
* reliance on a first message surviving the context window.
*/
export function assembleSystemPrompt(input: AssembleInput): string {
// Character volition (Phase J): the binding `# Character stance` block, present
// only when at least one NPC resists (otherwise null → byte-identical).
const stance = stanceBlock(input.stances ?? []);
// The proactive twin: the `# Character initiative` block, present only when at
// least one NPC takes an unprompted move (otherwise null → byte-identical).
const initiative = initiativeBlock(input.stances ?? []);
// The narrator's base instruction gains an honour-clause only for a ruling
// actually present this turn (physical referee and/or social referee) — keeps
// off/no-ruling turns byte-identical.
let systemHeader = SYSTEM_PROMPT;
if (input.adjudication) systemHeader += ` ${ADJUDICATION_CLAUSE}`;
if (stance) systemHeader += ` ${STANCE_CLAUSE}`;
// When the dice already resolved the action AND a stance is binding, rank them:
// the dice decide the outcome, the stance only colours the manner — so a binding
// refusal can never overturn a successful roll (Phase J ⊕ G3). A `noRoll`
// resolution (a certain cost/reward, no dice) is not a contest, so it does not
// trigger the clause.
const rolledThisTurn = Boolean(input.resolution) && !input.resolution!.noRoll;
if (stance && rolledThisTurn) systemHeader += ` ${STANCE_ROLL_PRECEDENCE_CLAUSE}`;
// Proactive agency: the initiative clause overrides the base "react only to the
// player's action" for the listed beats — present only when something is staged.
if (initiative) systemHeader += ` ${INITIATIVE_CLAUSE}`;
const blocks = [
systemHeader,
worldBlock(input),
chronosInfoBlock(input),
loreBlock(input),
playerBlock(input),
castBlock(input),
offSceneBlock(input),
relationshipsInfoBlock(input),
statusInfoBlock(input),
storyBlock(input),
chapterInfoBlock(input),
recallInfoBlock(input),
actionResolutionBlock(input),
adjudicationInfoBlock(input),
consequencesInfoBlock(input),
stance,
initiative,
directionBlock(input),
turnOnlyBlock(input),
narrationLengthBlock(input),
outputFormatBlock(input),
conclusion(input),
// LAST, by design: the hard language override sits closest to generation so
// it out-weighs the English mass of the prompt above it (see languageBlock).
languageBlock(input),
].filter((b): b is string => Boolean(b));
return blocks.join("\n\n");
}