/**
* Knowledge gating — the pure gate (the revelation system).
*
* The problem this solves: an LLM narrator handed a secret in its prompt leaks
* it — it foreshadows, hints, "amounts to" it — so a guarded NPC dumps a thirty-
* year confidence on first meeting. Telling the model "don't say it yet" is not
* enough. The only robust obfuscation is to keep the payload OUT of the prompt
* until it is earned.
*
* This module is the PRE-narration half (pure, no model, instant): given the
* active cast's card-declared secrets, the player's standing with each, and what
* the player already knows, it sorts every secret into one of three tiers and
* returns only what the narrator may see this turn —
* - locked → nothing (the narrator cannot leak what it never receives);
* - guarded → the content-free `surface` tell, when a `topics` term is live in
* the recent scene (so the character reads as evasive, with nothing
* to spill);
* - unlocked → the `content` payload, because the gate is met OR the player
* already knows the fact.
*
* The POST-narration half — detecting which unlocked secret the scene actually
* disclosed, to grow the player's known-facts set — lives in `digest.ts` (a
* cheap structured pass). The two halves run on the turn's two clocks: this gate
* decides what the narrator may write THIS turn from last turn's state; the
* digest records what was learned so the gate opens further NEXT turn.
*
* Pure and unit-tested. Depends only on the central state types, the relationship
* read helpers, and the `Secret` card type — never on `@lmstudio/sdk`.
*/
import type { Secret } from "../characters/index.js";
import { familiarityRank, getRelationship, playerPairKey } from "../relationships/index.js";
import type { Relationship } from "../state/schema.js";
/** What the narrator may see about ONE in-scene NPC's secrets this turn. */
export interface NpcRevelations {
/** Content-free tells (the `surface` of guarded secrets) — show the evasion. */
guarded: string[];
/** Payloads (`content`) the character may now voice — gate met or already known. */
unlocked: string[];
}
/** A secret that is newly unlocked-but-not-yet-known — a target for the digest pass. */
export interface RevelationCandidate {
/** The holding NPC's display name. */
npcName: string;
/** The fact id (`Secret.id`) to mark known once the scene discloses it. */
id: string;
/** The fact's substance, so the digest pass can judge whether it was disclosed. */
content: string;
}
/** The gate's per-turn output: what to inject, and what the digest pass should watch. */
export interface RevelationPlan {
/** Per-NPC injectable secrets, keyed by the NPC's display name (matches the cast block). */
byNpc: Record<string, NpcRevelations>;
/** Unlocked-but-unknown secrets in play this turn, for the post-narration digest. */
candidates: RevelationCandidate[];
}
/** Case-insensitive substring test: is any of the secret's topics live in the scene? */
function topicLive(recentText: string, topics: string[]): boolean {
if (topics.length === 0) return false;
const hay = (recentText ?? "").toLowerCase();
if (!hay) return false;
return topics.some((t) => {
const needle = t.trim().toLowerCase();
return needle.length > 0 && hay.includes(needle);
});
}
/**
* Is a secret's gate open? The AND of three conditions — every prerequisite fact
* already known, familiarity at least `trust`, and disposition at least the
* secret's floor. An empty `trust` waives the familiarity check; the relationship
* record defaults a never-met pair to stranger/0, so trust/disposition gates
* simply stay shut until relationship memory has moved them.
*/
function gateMet(secret: Secret, rel: Relationship, known: Set<string>): boolean {
for (const req of secret.requires) {
if (!known.has(req)) return false;
}
if (secret.trust && familiarityRank(rel.familiarity) < familiarityRank(secret.trust)) {
return false;
}
if (rel.disposition < secret.disposition) return false;
return true;
}
/**
* Sort the active cast's secrets into the three tiers and return what the
* narrator may see this turn plus the digest-pass candidates. Pure: all inputs
* are values (the relationship record and known-facts set come from state; the
* recent text is the same window the lore/cast scanners use).
*
* An already-known fact stays `unlocked` (its content keeps flowing, so the
* character can keep referring to what the player has learned). A gate-met but
* still-unknown fact is BOTH unlocked (the narrator may finally voice it) and a
* candidate (so the digest can mark it known if the scene actually discloses it).
* Everything else is guarded (surface only, when its topic is live) or silent.
*/
export function planRevelations(
cast: { name: string; secrets?: Secret[] }[],
relationships: Record<string, Relationship>,
knownFacts: string[],
recentText: string,
/**
* Dramatic-arc act gate (Phase I): the secret ids the CURRENT act has opened
* (see `arc/actEligibleIds`). A secret whose id is not in this set is treated
* as fully locked this turn — no payload, no surface tell, no candidate — so an
* authored Act I with empty `reveals` keeps every secret out of the prompt and
* the world stays shadowless. `null`/absent = no act restriction (the wildcard
* `["*"]`, the default arc, or the feature off), leaving behaviour unchanged.
*/
actEligibleIds?: Set<string> | null,
): RevelationPlan {
const byNpc: Record<string, NpcRevelations> = {};
const candidates: RevelationCandidate[] = [];
const known = new Set(knownFacts);
for (const npc of cast) {
const secrets = npc.secrets ?? [];
if (secrets.length === 0) continue;
const rel = getRelationship(relationships, playerPairKey(npc.name));
const guarded: string[] = [];
const unlocked: string[] = [];
for (const secret of secrets) {
// Act gate first: a secret the arc has not yet opened is invisible this
// turn, however well earned — this is what holds Act I shadowless.
if (actEligibleIds && !actEligibleIds.has(secret.id)) continue;
const content = secret.content.trim();
if (known.has(secret.id)) {
if (content) unlocked.push(content);
continue;
}
if (gateMet(secret, rel, known)) {
if (content) unlocked.push(content);
candidates.push({ npcName: npc.name, id: secret.id, content });
continue;
}
const surface = secret.surface.trim();
if (surface && topicLive(recentText, secret.topics)) guarded.push(surface);
}
if (guarded.length > 0 || unlocked.length > 0) {
byNpc[npc.name] = { guarded, unlocked };
}
}
return { byNpc, candidates };
}