/**
* Conditional NPC activation — Phase B+, the cast counterpart of `selectLore`.
*
* `selectCast` is **pure** (no I/O): given the universe's NPCs and the recent
* conversation text, it returns only the NPCs to inject this turn, so the model
* isn't handed (and tempted to voice) characters who aren't in play. An NPC
* activates when it is `always_active`, OR its name/aliases appear in the recent
* window (whole-word, case-insensitive), OR — when semantic vectors are supplied
* — its card embedding is similar enough to the recent text.
*
* Two deliberate choices from the locked Phase B+ design (Option B):
* - the **player persona is handled separately** (always injected by the
* planner); this function only ever decides the NPC cast.
* - **never an empty scene**: if nothing matches, the whole cast is returned
* as a fallback rather than dropping every NPC.
*
* The keyword + cosine machinery mirrors `world/scan.ts` but is duplicated here
* on purpose: `characters` stays decoupled from `world` (see CLAUDE.md). The
* embeddings themselves are produced by `embed.ts` (I/O) and passed in, keeping
* this pure.
*/
import { CharacterCard } from "./schema.js";
import { wholeWordMatch } from "../shared/text.js";
import { cosineSimilarity } from "../shared/vector.js";
export interface CastSemanticOptions {
/** Embedding of the recent text (the query). Null disables the semantic path. */
queryEmbedding: number[] | null;
/** Per-NPC embeddings, aligned by index to the `cast` array. */
castEmbeddings: (number[] | null)[];
/** Noise floor: an NPC must reach at least this cosine similarity to count. */
threshold: number;
/**
* Keep at most this many *semantic-only* activations per turn — the closest
* ones. `always_active` / name-match NPCs are forced in and don't count
* against this. Non-finite means "no cap".
*/
topK: number;
}
export interface SelectCastOptions {
/**
* Optional semantic matching. When provided with a query embedding, an NPC
* also activates if its card embedding's cosine similarity to the query meets
* the threshold — in addition to `always_active` / name matching.
*/
semantic?: CastSemanticOptions;
}
/**
* Choose which NPCs to put in scene this turn. Pure and deterministic.
*
* 1. Force in every `always_active` NPC and every NPC whose name/alias hits the
* recent text — these are unconditional and ignore the semantic cap.
* 2. Among the rest, with a vector at/above the floor, rank by cosine
* similarity and keep the closest `topK`.
* 3. If nothing was chosen at all, return the whole cast (never an empty scene).
*
* The returned NPCs keep the input order (the loader sorts by name).
*/
export function selectCast(
cast: CharacterCard[],
recentText: string,
options: SelectCastOptions = {},
): CharacterCard[] {
const text = recentText ?? "";
const sem = options.semantic;
const chosen = new Set<number>();
const semanticHits: { index: number; score: number }[] = [];
cast.forEach((npc, i) => {
if (npc.always_active) {
chosen.add(i);
return;
}
if ([npc.name, ...npc.aliases].some((n) => wholeWordMatch(text, n))) {
chosen.add(i);
return;
}
if (sem?.queryEmbedding) {
const vec = sem.castEmbeddings[i];
if (vec) {
const score = cosineSimilarity(sem.queryEmbedding, vec);
if (score >= sem.threshold) semanticHits.push({ index: i, score });
}
}
});
// Keep the closest `topK` semantic activations (non-finite cap = keep all).
const topK =
sem && Number.isFinite(sem.topK) ? Math.max(0, Math.floor(sem.topK)) : Infinity;
semanticHits
.sort((a, b) => b.score - a.score)
.slice(0, topK)
.forEach(({ index }) => chosen.add(index));
// Never strand the model with an empty scene: fall back to the full cast.
if (chosen.size === 0) return [...cast];
return cast.filter((_, i) => chosen.has(i));
}
export interface SelectSceneOptions {
/**
* The model's carried present roster (`state.scene.present`) — the display
* names the conductor judged physically on stage when it last read the scene.
* The authoritative source for staging, carried between beats. Matched
* case-insensitively to each card's name.
*/
present: string[];
/**
* The current scene location (`state.scene.location`), read off the narration
* by the conductor; "" = unknown. An NPC whose card `locations` matches is
* staged even when unnamed (a place's regulars). Case-insensitive, bidirectional
* substring ("the Drowned Lantern" ⇿ "Drowned Lantern").
*/
location: string;
/**
* The player's processed action this turn. An NPC named/aliased here (whole-
* word) is staged immediately — the player is addressing them — without waiting
* for the next conductor beat. Defaults to empty.
*/
actionText: string;
/**
* Optional semantic matching, exactly as in `selectCast` — but here it only
* feeds the MENTIONED tier (a thematically near NPC is referenceable, never
* auto-staged).
*/
semantic?: CastSemanticOptions;
}
export interface SceneCast {
/** NPCs on stage this turn — given their full card; the narrator may voice them. */
present: CharacterCard[];
/**
* NPCs known to the recent conversation but NOT on stage — surfaced as a thin
* off-scene line (name only). The narrator may reference them in passing but
* must not voice or stage them.
*/
mentioned: CharacterCard[];
}
/** Case-insensitive, bidirectional substring match of a card location vs the scene's. */
function inLocation(cardLocations: string[], sceneLocation: string): boolean {
const loc = sceneLocation.trim().toLowerCase();
if (!loc) return false;
return cardLocations.some((l) => {
const c = l.trim().toLowerCase();
return c !== "" && (loc.includes(c) || c.includes(loc));
});
}
/**
* Scene-presence tiering — the cast counterpart to "who is in the room now".
*
* Splits the cast into who is PRESENT (full card, voiceable) versus merely
* MENTIONED (a thin reference line), instead of `selectCast`'s flat "active set".
*
* Presence is the MODEL's reading, freshened deterministically each turn. An NPC
* is PRESENT when ANY of:
* - `always_active` (pinned by the author);
* - it is in the carried `present` roster (what the conductor last read off the
* scene — the authoritative source, persisted between beats);
* - its name/alias appears in the player's `actionText` (the player is
* addressing them now — immediate, no wait for the next beat);
* - its card `locations` match the scene's current `location` (a place's
* regulars appear without being named — the keeper in her tavern).
* Everyone else is MENTIONED when named further back in the wider `recentText`
* window, or — with vectors — semantically close (the same top-K cap as
* `selectCast`). An NPC matched by none is dropped from the turn entirely.
*
* Unlike `selectCast`, there is **no never-empty-scene fallback**: a quiet scene
* yields an empty `present`. Additions are deterministic and instant each turn;
* REMOVALS (someone left) are the model's call, landing at the next conductor
* beat — the accepted staleness of the +0-cost piggyback. Pure; input order
* preserved in both lists.
*/
export function selectScene(
cast: CharacterCard[],
recentText: string,
options: SelectSceneOptions,
): SceneCast {
const recent = recentText ?? "";
const action = options.actionText ?? "";
const location = options.location ?? "";
const sem = options.semantic;
const carried = new Set((options.present ?? []).map((n) => n.trim().toLowerCase()));
const present = new Set<number>();
const mentioned = new Set<number>();
const semanticHits: { index: number; score: number }[] = [];
cast.forEach((npc, i) => {
const names = [npc.name, ...npc.aliases];
if (
npc.always_active ||
carried.has(npc.name.trim().toLowerCase()) ||
names.some((n) => wholeWordMatch(action, n)) ||
inLocation(npc.locations, location)
) {
present.add(i);
return;
}
if (names.some((n) => wholeWordMatch(recent, n))) {
mentioned.add(i);
return;
}
if (sem?.queryEmbedding) {
const vec = sem.castEmbeddings[i];
if (vec) {
const score = cosineSimilarity(sem.queryEmbedding, vec);
if (score >= sem.threshold) semanticHits.push({ index: i, score });
}
}
});
// Keep the closest `topK` semantic-only activations as mentioned (non-finite
// cap = keep all) — a thematically near NPC is referenceable, not staged.
const topK =
sem && Number.isFinite(sem.topK) ? Math.max(0, Math.floor(sem.topK)) : Infinity;
semanticHits
.sort((a, b) => b.score - a.score)
.slice(0, topK)
.forEach(({ index }) => mentioned.add(index));
return {
present: cast.filter((_, i) => present.has(i)),
mentioned: cast.filter((_, i) => mentioned.has(i)),
};
}
export interface RelatedMention {
/** The off-scene NPC pulled in by an on-stage character's authored relation. */
card: CharacterCard;
/** A short link line — the on-stage character + the bond note — for the block. */
link: string;
}
/**
* Relationship-pulled off-scene mentions (scene-presence tiering): for each NPC
* ON STAGE, surface the characters it is non-neutrally bonded to (authored card
* `relations`) who are NOT themselves on stage — so the narrator knows who an
* on-stage character would plausibly bring up, with a one-line link, without
* staging them. This is the NPC↔NPC counterpart to a plain name/semantic mention.
*
* A relation pulls only when |disposition| ≥ `minDisposition` (a neutral
* acquaintance is background, not surfaced) and its name resolves to a real cast
* card (exact match on name or alias, case-insensitive). On-stage targets are
* never pulled (they already have a full card); each target is listed once (the
* first on-stage character to pull it wins the link). Pure; cast order preserved.
*/
export function relatedOffScene(
present: CharacterCard[],
cast: CharacterCard[],
options: { minDisposition: number },
): RelatedMention[] {
const floor = Math.max(0, options.minDisposition);
const key = (s: string) => s.trim().toLowerCase();
const presentKeys = new Set(present.map((c) => key(c.name)));
const resolve = (name: string): CharacterCard | undefined => {
const target = key(name);
return cast.find(
(c) => key(c.name) === target || c.aliases.some((a) => key(a) === target),
);
};
const out: RelatedMention[] = [];
const added = new Set<string>();
for (const p of present) {
for (const rel of p.relations) {
if (Math.abs(rel.disposition) < floor) continue;
const target = resolve(rel.name);
if (!target) continue;
const tk = key(target.name);
if (presentKeys.has(tk) || added.has(tk)) continue;
const note = rel.note.trim();
const link = note ? `${p.name}: ${note}` : `known to ${p.name}`;
out.push({ card: target, link });
added.add(tk);
}
}
return out;
}