/**
* Knowledge gating — the revelation pass (the post-narration half).
*
* This is the digest the gate (`gate.ts`) cannot do in pure code: reading the
* scene just narrated and judging which UNLOCKED secret the character actually
* disclosed to the player — as opposed to hinted at, deflected, or merely had
* available. What it confirms is added to the player's known-facts set, which
* (a) keeps that fact's content flowing next turn and (b) opens any secret that
* gated on it (`Secret.requires`) — the credible, multi-character progression.
*
* A structured `respond({ structured })` pass like the accountant and the
* relationship pass: cheap (the output is just a list of ids), off the player's
* critical path (it runs after the reply has streamed), and run only when there
* was at least one unlocked-but-unknown secret in play this turn. Feeding the
* model each candidate's `content` here is safe — this pass never narrates, so
* there is nothing to leak; it only reads the finished scene and reports.
*
* Pure and unit-testable, like the rest: this file builds the schema, the prompt,
* and the pure application of a parsed object. The one impure boundary — the
* `respond` call — lives in the handler. No `@lmstudio/sdk` import.
*/
import { z } from "zod";
import type { RevelationCandidate } from "./gate.js";
/**
* The schema the revelation pass is forced into: the ids the narration disclosed,
* constrained to an enum of the candidate ids so the model can only name a secret
* actually in play (a plain string when there are none — never an empty enum).
*/
export function buildRevelationSchema(candidateIds: string[]): z.ZodTypeAny {
const idEnum =
candidateIds.length > 0 ? z.enum(candidateIds as [string, ...string[]]) : z.string();
return z.object({ revealed: z.array(idEnum) });
}
/**
* Build the revelation pass's prompt: a role + judging rules system message and a
* user tail listing the facts that were available this turn (id + substance),
* then the player's action and the resulting narration to judge. The pass decides
* which of those facts the scene actually conveyed to the player.
*/
export function buildRevelationPrompt(
playerName: string,
candidates: RevelationCandidate[],
playerAction: string,
narration: string,
): { system: string; user: string } {
const who = playerName.trim() || "the player";
const system = [
`You track what ${who} has learned in an ongoing role-play. A character was ` +
`willing this turn to share one or more guarded facts, listed below. You read ` +
`the scene that was just narrated and report which of those facts its ` +
`SUBSTANCE was actually disclosed to ${who} — facts ${who} now genuinely knows.`,
"",
"Count a fact as revealed ONLY when the narration tells, shows, or confirms its " +
"substance to " + who + ". Do NOT count:",
"- a hint, an allusion, or the character talking around it without saying it;",
"- a deflection, a refusal, or the character staying guarded;",
`- ${who} merely asking about it or asserting it themselves;`,
"- a fact that simply did not come up this turn.",
"",
"When in doubt, do not count it — a fact is learned once, and only on real " +
"disclosure. If nothing was disclosed, return an empty list. Output only the " +
"structured object.",
].join("\n");
const factLines = candidates
.map((c) => `- ${c.id} (${c.npcName}): ${c.content}`)
.join("\n");
const user = [
"# Facts the character was willing to share this turn",
factLines || "(none)",
"",
`# ${who}'s action this turn`,
playerAction.trim() || "(no explicit action)",
"",
"# Resulting narration (judge this scene only)",
narration.trim() || "(no narration)",
"",
"---",
"",
`# Your task`,
`Report the ids whose substance the narration actually disclosed to ${who} ` +
`this turn. Output only the structured object:`,
].join("\n");
return { system, user };
}
/**
* Fold a parsed revelation object into the player's known-facts set (pure). Only
* ids that were actually in play this turn (`candidateIds`) and not already known
* are added; junk and unknown ids are skipped, never thrown on. Returns the new
* set and the ids learned this turn (for the debug log).
*/
export function applyRevelation(
parsed: unknown,
knownFacts: string[],
candidateIds: string[],
): { playerKnownFacts: string[]; learned: string[] } {
const obj = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {};
const rawRevealed = Array.isArray(obj.revealed) ? obj.revealed : [];
const inPlay = new Set(candidateIds);
const known = new Set(knownFacts);
const learned: string[] = [];
for (const r of rawRevealed) {
if (typeof r !== "string") continue;
if (!inPlay.has(r) || known.has(r)) continue;
known.add(r);
learned.push(r);
}
return { playerKnownFacts: [...known], learned };
}