/**
* Pure per-turn planner.
*
* Given the latest user text, the previous assistant reply, the current state
* and config, produce everything the loop handler needs — with no LM Studio
* dependency, so it's fully unit-testable. The handler is then a thin wrapper
* that wires this to `pullHistory()` / `tokenSource()`.
*/
import { actEligibleIds, resolveAct, type Arc } from "../arc/index.js";
import type { ChronosModel } from "../chronos/index.js";
import {
CharacterCard,
LoadedCast,
relatedOffScene,
selectCast,
selectScene,
} from "../characters/index.js";
import { planDisclosure, type DisclosureTarget } from "../disclosure/index.js";
import { planRevelations, type RevelationCandidate } from "../knowledge/index.js";
import { selectMemories } from "../memory/index.js";
import type { Stance } from "../psyche/index.js";
import type { Resolution, RulesDefinition } from "../rules/index.js";
import { DirectorState, MemoryChunk, Sheet, State } from "../state/schema.js";
import { LoreEntry, selectLore } from "../world/index.js";
import { assembleSystemPrompt, type OffSceneMention } from "./assemble.js";
import {
detectSelection,
expandSelection,
parseChoices,
} from "./choices.js";
import { applyDirectives, parseDirectives } from "./directives.js";
import { languageInstruction } from "./language.js";
export interface TurnConfig {
narrationStyle: string;
enableChoices: boolean;
choiceCount: number;
/** Response-language code from global config. */
responseLanguage: string;
/** Token budget for the injected `# World lore` block. */
loreBudgetTokens: number;
/**
* Semantic noise floor: minimum cosine similarity to be a semantic candidate.
* Only used when the handler supplies embeddings (see `recentTextEmbedding`).
*/
loreSemanticThreshold: number;
/** Keep at most this many semantic-only matches per turn (the closest). */
loreSemanticTopK: number;
/**
* Activate NPCs by relevance (Phase B+) instead of injecting the whole cast.
* When false, every loaded NPC is put in scene (the pre-B+ behavior).
*/
npcActivation: boolean;
/**
* Scene-presence tiering: of the activated cast, only those ON STAGE — named
* in the prior narration or the current action, or `always_active` — get a
* full card (and may be voiced); the rest (named only further back, or
* semantically near) are listed in a thin `# Off-scene characters` block the
* narrator may reference but not voice. Also drops the never-empty-scene
* whole-cast fallback (a quiet scene yields no cast cards). Requires
* `npcActivation`. When false the whole activated set is handed as full cards
* (the pre-tiering behaviour) and the turn is byte-identical. Optional so
* older callers/tests still type-check.
*/
scenePresence?: boolean;
/**
* Minimum |disposition| for an authored NPC↔NPC `relations` bond to pull the
* other character into the off-scene block while this one is on stage (a neutral
* acquaintance is background, not surfaced). Only used when `scenePresence` is
* on. Optional; defaults to 15 (the neutral band) when omitted.
*/
relationPullMinDisposition?: number;
/** Semantic noise floor for NPC activation (see `loreSemanticThreshold`). */
npcSemanticThreshold: number;
/** Keep at most this many semantic-only NPC activations per turn (the closest). */
npcSemanticTopK: number;
/**
* Recall relevant past messages from the vector-RAG store (Phase F). When
* false, nothing is recalled (the `# Relevant past events` block is absent).
*/
ragEnabled: boolean;
/** Cosine floor for a recalled memory (see `loreSemanticThreshold`). */
ragThreshold: number;
/** Keep at most this many recalled memories per turn (the closest). */
ragTopK: number;
/** Token budget for the injected `# Relevant past events` block. */
ragBudgetTokens: number;
/**
* Inject the `# Relationships` block (the relationship pass): how the active
* cast regards the player + their shared history. When false the block is
* omitted and the turn is byte-identical to before.
*/
relationshipMemory: boolean;
/**
* Character volition (Phase J): when on, the narrator's cast block carries each
* NPC's live psyche (the `Right now:` mood/intent line). The social referee that
* produces the binding stances runs in the handler (impure); this flag only
* controls the read-side block here. When false the psyche is not injected and
* the cast block is byte-identical.
*/
volition: boolean;
/**
* Knowledge gating (the revelation system): gate each NPC's card-declared
* secrets by what the player has earned/learned, so a locked secret never
* enters the narrator's prompt. When false (or no NPC declares `secrets`) no
* secrets are injected and the turn is byte-identical to before.
*/
knowledgeGating: boolean;
/**
* Dramatic arc (Phase I): gate card secrets by the current act and colour the
* narration with the act's mood/goal. When false (or no arc supplied) the act
* has no effect and the turn is byte-identical to before.
*/
dramaticArc: boolean;
/**
* World clock (the chronos subsystem): render the `# Time & weather` block from
* the universe's clock model + this save's live clock state. When false (or no
* chronos model supplied) the block is omitted and the turn is byte-identical.
* Optional so older callers/tests still type-check.
*/
timeWeather?: boolean;
/**
* First-mention introduction (the disclosure system): flag the lore elements
* and NPCs the narration has not introduced yet, so the narrator grounds a
* place/faction/character on first appearance. When false the prompt is
* byte-identical and the handler's post-narration marker stays idle.
*/
firstMentionIntros: boolean;
/**
* Narration-length steering: a compact baseline + a soft word target that scales
* INVERSELY with the scene's dramatic intensity (the `# Narration length` block).
* When false (or `narrationCalmWords` is 0) the block is omitted and the turn is
* byte-identical to before. Optional so older callers/tests still type-check.
*/
narrationLength?: boolean;
/** Soft word target at minimum intensity (the long end); 0 also disables. */
narrationCalmWords?: number;
/** Soft word target at maximum intensity (the short, clipped end). */
narrationIntenseWords?: number;
/** Intensity (0–1) used when there is no live tension signal (arc off). */
narrationBaselineIntensity?: number;
/** Intensity floor while combat is live (0 = ignore combat). */
narrationCombatIntensity?: number;
}
export interface TurnInput {
state: State;
/** Raw text of the latest user message. */
lastUserText: string;
/** Text of the previous assistant reply, for numbered-choice expansion. */
prevAssistantText: string | null;
/**
* Cast for this turn (player persona + NPCs), loaded from files by the
* handler. Optional: defaults to an empty cast, keeping the planner pure and
* usable without character data.
*/
characters?: LoadedCast;
/**
* Candidate lore entries for this turn (standalone file + cards'
* `character_book`s), loaded from files by the handler. Optional: defaults to
* none, keeping the planner pure and usable without lore data.
*/
lore?: LoreEntry[];
/**
* The last N messages joined into one string, scanned for lore keywords.
* Optional: defaults to empty (only `constant` entries would then fire).
*/
recentText?: string;
/**
* Embedding of `recentText`, supplied by the handler when semantic lore
* matching is enabled. Its presence turns on the semantic path; absent (or
* null) keeps matching keyword-only. Kept out of the planner's I/O so this
* stays pure.
*/
recentTextEmbedding?: number[] | null;
/** Per-entry embeddings aligned by index to `lore`. */
loreEmbeddings?: (number[] | null)[];
/**
* Per-NPC embeddings aligned by index to `characters.cast`, for semantic NPC
* activation. The query is the shared `recentTextEmbedding` (same recent
* text). Absent/empty keeps NPC matching name-only.
*/
castEmbeddings?: (number[] | null)[];
/**
* The vector-RAG store (Phase F): past messages eligible for recall this turn,
* loaded from state by the handler. Optional; defaults to none.
*/
memoryStore?: MemoryChunk[];
/**
* Per-chunk embeddings aligned by index to `memoryStore`, for recall. The
* query is the shared `recentTextEmbedding`. Absent/empty recalls nothing.
*/
memoryEmbeddings?: (number[] | null)[];
/**
* Raw texts of the messages that will REMAIN in the sent window this turn
* (the handler computes them from the prune boundary). A recalled chunk whose
* body matches one of these is dropped: it is still verbatim in the window, so
* surfacing it under `# Relevant past events` would only duplicate context.
* RAG recall thus stays strictly complementary to what the window already
* carries. Absent = no dedup (recall everything selected).
*/
windowMessages?: string[];
/**
* Structured-mechanics rules for this universe (Phase G), or null when there
* is no `rules.json`. Forwarded to the assembler to turn on the `# Status`
* block, the mechanics clause, and game-over handling. The dice resolution
* itself is rolled by the handler (the impure boundary) and passed in via
* `resolution` / `sheet` / `combatants`, keeping this planner pure.
*/
rulesDef?: RulesDefinition | null;
/**
* The universe's dramatic arc (Phase I), or null when none / the feature is
* off. The current act is resolved from `state.pacing`; it gates secret
* eligibility and supplies the narration's act mood/goal. The conductor that
* advances the arc runs in the handler (post-narration), keeping this pure.
*/
arc?: Arc | null;
/**
* World clock (the chronos subsystem): the universe's authored clock/climate
* model, loaded fresh by the handler (like the arc). The live clock STATE is
* read from `state.chronos`; the model + state together render the
* `# Time & weather` block when `config.timeWeather` is on. Null/absent → the
* block is omitted (feature off / no model).
*/
chronosModel?: ChronosModel | null;
/** Player sheet after this turn's resolution (for the `# Status` block). */
sheet?: Sheet;
/** Live combatant sheets after this turn's resolution. */
combatants?: Record<string, Sheet>;
/** This turn's resolved move (already rolled), for `# Action resolution`. */
resolution?: Resolution | null;
/**
* On a free-form `roll` (Phase G3), the referee's framing of the attempt,
* appended to the `# Action resolution` block. Forwarded as-is (pure planner).
*/
resolutionNote?: string;
/**
* The referee's no-roll ruling on a free-form action (Phase G3), for the
* `# Adjudication` block. Computed by the handler (the impure `respond` call)
* and forwarded as-is, keeping this planner pure — mirrors `resolution`.
*/
adjudication?: { kind: "resisted" | "claim"; reason: string } | null;
/**
* Resources the referee forecast this free-form action puts in play (Phase
* G3), for the `# Consequences` block. Computed by the handler and forwarded
* as-is, keeping this planner pure — mirrors `resolution` / `adjudication`.
*/
affects?: string[];
/**
* Character volition (Phase J — the social referee): per-NPC stances computed
* by the handler's pre-narration stance pass (the impure `respond` call), for
* the `# Character stance` block. Forwarded as-is, keeping this planner pure —
* mirrors `adjudication` / `affects`. Empty/absent → no block (byte-identical).
*/
stances?: Stance[];
/** Whether the player's run has ended (suppress choices, add `# Conclusion`). */
gameOver?: boolean;
config: TurnConfig;
}
export interface TurnPlan {
/** The system prompt to set on the chat sent to the model. */
systemPrompt: string;
/** The processed player action (cleaned of /mj, choice expanded). */
playerAction: string;
/**
* Director state after applying this turn's directives — NOT yet consumed.
* The caller persists `consumeOnce(director)` after generation.
*/
director: DirectorState;
/**
* The lore entries actually selected for (and injected into) this turn, after
* keyword matching and the token budget. Exposed so the handler can log how
* many candidates were dropped — no silent truncation.
*/
lore: LoreEntry[];
/**
* The NPCs actually put in scene this turn (after Phase B+ activation).
* Exposed so the handler can log how many of the cast were activated — no
* silent truncation.
*/
cast: CharacterCard[];
/**
* Scene-presence tiering: NPCs known to the recent conversation but NOT on
* stage this turn — listed in the thin `# Off-scene characters` block (a plain
* name, or a `name — link` line when pulled in by an on-stage character's
* authored relation; never voiced). Empty when scene-presence is off. Exposed
* so the handler can log the present/off-scene split — no silent truncation.
*/
mentioned: OffSceneMention[];
/**
* The numbered option the player resolved this turn (Phase D, Lot 2), or null
* when the turn was a free action or a bare number that couldn't be matched.
* The planner only EXPOSES it (stays pure); the handler records it into
* `state.choiceHistory` before persisting.
*/
resolvedChoice: { index: number; text: string } | null;
/**
* The past messages recalled from the RAG store this turn (Phase F), after
* scoring and the token budget. Exposed so the handler can log how many of the
* store were surfaced — no silent truncation.
*/
recalled: MemoryChunk[];
/**
* Knowledge gating (the revelation system): the unlocked-but-not-yet-known
* secrets in play this turn — what the revelation digest pass should watch for
* in the narration. Empty when gating is off, no NPC declares `secrets`, or
* nothing newly unlocked this turn (then the handler runs no digest pass).
*/
revelationCandidates: RevelationCandidate[];
/**
* First-mention introduction (the disclosure system): the lore elements and
* NPCs flagged as first appearances this turn — what the handler's pure
* post-narration marker watches for in the reply, to record as introduced.
* Empty when the feature is off or nothing undisclosed was in play (the marker
* then does nothing).
*/
disclosureTargets: DisclosureTarget[];
}
/** Plan a turn: parse directives, resolve the action, assemble the prompt. */
export function planTurn(input: TurnInput): TurnPlan {
const turn = input.state.turn + 1;
// 1. Directives: strip /mj lines and fold them into director state.
const { commands, cleanedText } = parseDirectives(input.lastUserText);
const director = applyDirectives(input.state.director, commands, turn);
// 2. Resolve the action: a bare number expands to the previous option's text.
let playerAction = cleanedText;
let resolvedChoice: { index: number; text: string } | null = null;
const selection = detectSelection(cleanedText);
if (selection !== null && input.prevAssistantText) {
const expanded = expandSelection(
selection,
parseChoices(input.prevAssistantText),
);
if (expanded) {
playerAction = `I choose: ${expanded}`;
// Expose the resolved pick so the handler can record it (store-only).
resolvedChoice = { index: selection, text: expanded };
}
}
// A turn made only of /mj directives (no action) → carry on with the update.
if (!playerAction.trim()) {
playerAction = "(Continue the scene, applying the updated direction.)";
}
// 2b. Dramatic arc (Phase I): resolve the act this turn is in (from pacing)
// up front — both the lore gate (step 3) and the secret gate (step 4c)
// read its opened-id set, and the act mood/goal colour the prompt below.
// Pure — the conductor that ADVANCES the arc runs post-narration in the
// handler. Null when the feature is off or no arc was supplied.
const arc = input.config.dramaticArc && input.arc ? input.arc : null;
const act = arc ? resolveAct(arc, input.state.pacing) : null;
// The act-eligible fact-id set: which secrets/lore the current act has opened.
// null = no restriction (wildcard / default arc / feature off).
const eligibleIds = arc && act ? actEligibleIds(arc, act.id) : null;
// 3. Select relevant lore: constants + keyword hits + (when the handler
// supplied embeddings) semantic matches, sorted by order and fit under the
// token budget — then filtered by the act gate so a tagged spoiler stays
// out of the prompt until its act opens it. Pure (no I/O) — the vectors are
// computed upstream.
const semantic = input.recentTextEmbedding
? {
queryEmbedding: input.recentTextEmbedding,
entryEmbeddings: input.loreEmbeddings ?? [],
threshold: input.config.loreSemanticThreshold,
topK: input.config.loreSemanticTopK,
}
: undefined;
const lore = selectLore(input.lore ?? [], input.recentText ?? "", {
maxTokens: input.config.loreBudgetTokens,
semantic,
actEligibleIds: eligibleIds,
});
// 4. Activate the NPC cast (Phase B+): keep the player persona always, but
// inject only the NPCs that are pinned, named, or semantically relevant —
// falling back to the whole cast if nothing matches. The query embedding is
// the same recent-text vector used for lore. Reuses the lore engine's
// machinery; pure (vectors computed upstream).
const loaded = input.characters ?? { player: null, cast: [] };
let activeCast = loaded.cast;
// NPCs known to the recent conversation but NOT on stage (scene-presence on):
// a thin off-scene reference list, never full cards — a plain name when merely
// mentioned/semantic, or a `name — link` line when an on-stage character's
// authored relation pulled them in. Empty when the feature is off (then the
// prompt is byte-identical).
let mentionable: OffSceneMention[] = [];
if (input.config.npcActivation && loaded.cast.length > 0) {
const castSemantic = input.recentTextEmbedding
? {
queryEmbedding: input.recentTextEmbedding,
castEmbeddings: input.castEmbeddings ?? [],
threshold: input.config.npcSemanticThreshold,
topK: input.config.npcSemanticTopK,
}
: undefined;
if (input.config.scenePresence) {
// Scene tiering: full cards only for who is on stage, a thin line for who is
// merely known this turn. Presence is the model's reading (`state.scene`,
// written by the conductor's piggyback at each beat), freshened each turn by
// the player's action (named NPCs) and location binding (a place's regulars).
// No never-empty-scene fallback.
const scene = selectScene(loaded.cast, input.recentText ?? "", {
present: input.state.scene.present,
location: input.state.scene.location,
actionText: playerAction,
semantic: castSemantic,
});
activeCast = scene.present;
// Build the off-scene list: plain mentions (named recently / semantic) plus
// relationship pulls (an on-stage character's non-neutral authored relation),
// keyed by name so a pull's link line wins over a bare mention of the same NPC.
const related = relatedOffScene(scene.present, loaded.cast, {
minDisposition: input.config.relationPullMinDisposition ?? 15,
});
const byKey = new Map<string, OffSceneMention>();
for (const c of scene.mentioned) byKey.set(c.name.trim().toLowerCase(), { name: c.name });
for (const r of related) {
byKey.set(r.card.name.trim().toLowerCase(), { name: r.card.name, link: r.link });
}
mentionable = [...byKey.values()];
} else {
activeCast = selectCast(loaded.cast, input.recentText ?? "", {
semantic: castSemantic,
});
}
}
const characters: LoadedCast = { player: loaded.player, cast: activeCast };
// 4b. Recall relevant past messages (Phase F): the vector-RAG complement to
// the bounded rolling summary. Uses the same recent-text query vector;
// pure (the chunk vectors are computed upstream by the handler). Recalls
// nothing without a query embedding or when disabled.
const store = input.memoryStore ?? [];
let recalled: MemoryChunk[] = [];
if (input.config.ragEnabled && input.recentTextEmbedding && store.length > 0) {
recalled = selectMemories(store, {
queryEmbedding: input.recentTextEmbedding,
chunkEmbeddings: input.memoryEmbeddings ?? [],
threshold: input.config.ragThreshold,
topK: input.config.ragTopK,
maxTokens: input.config.ragBudgetTokens,
});
// Drop anything still verbatim in the sent window — recall is for events
// that have scrolled out, not a second copy of what the model already sees.
// Stored chunks carry a "Player:"/"Narrator:" role label the live messages
// lack, so compare on the body.
if (input.windowMessages && input.windowMessages.length > 0) {
const inWindow = new Set(input.windowMessages.map((t) => t.trim()));
recalled = recalled.filter(
(c) => !inWindow.has(c.text.replace(/^(?:Player|Narrator):\s*/, "").trim()),
);
}
}
// 4c. Knowledge gating (the revelation system): sort each in-scene NPC's
// card-declared secrets into what the narrator may see this turn — guarded
// tells (content-free) and unlocked payloads — keeping every locked secret
// OUT of the prompt so it cannot leak. Pure (state + recent text). Skipped
// when off or when no active NPC declares secrets, leaving the prompt
// byte-identical; `candidates` then stays empty (no digest pass downstream).
// The act + its opened-id set were resolved at step 2b above.
let revelations:
| Record<string, { guarded: string[]; unlocked: string[] }>
| undefined;
let revelationCandidates: RevelationCandidate[] = [];
if (
input.config.knowledgeGating &&
activeCast.some((c) => (c.secrets?.length ?? 0) > 0)
) {
const revPlan = planRevelations(
activeCast,
input.state.relationships,
input.state.knowledge?.playerKnownFacts ?? [],
input.recentText ?? "",
eligibleIds,
);
revelations = revPlan.byNpc;
revelationCandidates = revPlan.candidates;
}
// 4d. First-mention introduction (the disclosure system): flag the in-play lore
// and NPCs the narration has not introduced yet, so the narrator grounds a
// place/faction/character on its first appearance. Pure (the playthrough's
// introduced set + the lore/cast already selected above). Skipped when off →
// empty sets/targets, leaving the prompt byte-identical and the handler's
// post-narration marker idle.
let undisclosedLore: Set<string> | undefined;
let undisclosedNpcs: Set<string> | undefined;
let disclosureTargets: DisclosureTarget[] = [];
if (input.config.firstMentionIntros) {
const disc = planDisclosure(lore, activeCast, input.state.disclosure?.revealed ?? []);
undisclosedLore = disc.undisclosedLore;
undisclosedNpcs = disc.undisclosedNpcs;
disclosureTargets = disc.targets;
}
// 4e. Narration length: scale the prose length INVERSELY with the scene's
// dramatic intensity (charged ⇒ short and clipped; calm ⇒ room to breathe).
// The intensity reading is the conductor's running tension when the arc is
// on; otherwise a neutral baseline, so the compact target still applies with
// pacing off. Live combat floors it up (a fight reads short and urgent),
// independent of the arc. Omitted (block absent) when the feature is off.
let narrationLength:
| { intensity: number; calmWords: number; intenseWords: number }
| undefined;
const calmWords = input.config.narrationCalmWords ?? 0;
if (input.config.narrationLength && calmWords > 0) {
let intensity = input.config.dramaticArc
? input.state.pacing.tension
: (input.config.narrationBaselineIntensity ?? 0);
const inCombat =
Boolean(input.rulesDef) && Object.keys(input.combatants ?? {}).length > 0;
if (inCombat) {
intensity = Math.max(intensity, input.config.narrationCombatIntensity ?? 0);
}
narrationLength = {
intensity,
calmWords,
intenseWords: input.config.narrationIntenseWords ?? 0,
};
}
// 5. Assemble the system prompt (steering + world + lore + format + language).
const systemPrompt = assembleSystemPrompt({
world: input.state.world,
// World clock (the chronos subsystem): the authored model + this save's live
// clock state for the `# Time & weather` block. Passed only when the feature
// is on AND a model is loaded, so off / no-model stays byte-identical.
chronos:
input.config.timeWeather && input.chronosModel
? { model: input.chronosModel, state: input.state.chronos }
: undefined,
director,
characters,
// Scene-presence tiering: NPCs known but not on stage → the thin
// `# Off-scene characters` block. Empty when the feature is off.
mentionable,
lore,
// Two-tier memory: the whole-story summary (updated at chapter closes) and
// the current chapter's rolling summary (updated every few messages).
storySummary: input.state.memory.storySummary,
chapterSummary: input.state.memory.summary,
// Past messages recalled from the RAG store this turn (Phase F).
recalledMemories: recalled,
// Relationship memory (the relationship pass): pass the stored record only
// when the feature is on, so off stays byte-identical (the block renders the
// ACTIVE cast above, defaulting a never-met NPC to a stranger).
relationships: input.config.relationshipMemory
? input.state.relationships
: undefined,
// Character psyche (Phase J): the live mood/intent for the cast block. Passed
// only when volition is on, so off stays byte-identical (an NPC with no
// record gets no line regardless).
psyche: input.config.volition ? input.state.psyche : undefined,
// Knowledge gating (the revelation system): only the gate-approved secrets.
revelations,
// First-mention introduction (the disclosure system): flag first appearances
// so the narrator grounds them. Undefined when off → block byte-identical.
undisclosedLore,
undisclosedNpcs,
// Narration length: the resolved intensity + word anchors (undefined = off).
narrationLength,
// Dramatic arc (Phase I): the current act's MOOD only, rendered into the
// direction block to colour the narration. Empty when the feature is off. The
// act's `goal` is intentionally NOT passed — it is director-level intent that
// tends to name concrete tells; the narrator would front-run them. The goal
// feeds only the conductor (see the conductor pass in the handler).
actMood: act?.mood ?? "",
// The act opens nothing yet (empty eligible set, not the wildcard) → the
// "establish, no shadow" phase: hold the narrator against front-running the
// mystery in prose or options. null (no restriction) / non-empty → false.
actEstablishing: eligibleIds !== null && eligibleIds.size === 0,
narrationStyle: input.config.narrationStyle,
enableChoices: input.config.enableChoices,
choiceCount: input.config.choiceCount,
languageInstruction: languageInstruction(input.config.responseLanguage),
// Structured mechanics (Phase G) — forwarded as-is; resolution is rolled
// upstream in the handler so this planner stays pure.
rulesDef: input.rulesDef,
sheet: input.sheet,
combatants: input.combatants,
resolution: input.resolution,
resolutionNote: input.resolutionNote,
adjudication: input.adjudication,
affects: input.affects,
// Character volition (Phase J): the social referee's per-NPC stances, rendered
// into the binding `# Character stance` block (only the resisting ones).
stances: input.stances,
gameOver: input.gameOver,
});
return {
systemPrompt,
playerAction,
director,
lore,
cast: activeCast,
mentioned: mentionable,
resolvedChoice,
recalled,
revelationCandidates,
disclosureTargets,
};
}