/**
* The social referee — character volition (Phase J).
*
* The physical referee (`rules/adjudicate.ts`) rules on whether the player's
* action is *possible*; this one rules on whether each present NPC is *willing*.
* It is the missing brake on the instruction-tuned model's default: voicing every
* NPC as endlessly compliant. It runs BEFORE narration, like the physical referee,
* and its verdict BINDS the narrator (an honour clause + the `# Character stance`
* block), because a non-binding hint is overridden by the agreeable model.
*
* It reasons over the three layers of a character (see `docs/phase-j-volition.md`):
* - the stable TRAIT (the card's Big Five — Agreeableness is the resistance dial),
* - the fluctuating STATE (the psyche: mood / intent),
* - the RELATION to the player (familiarity + disposition).
* Plus the authored agenda (desires / needs / boundaries) and the scene.
*
* Batched: ONE structured call rules on the whole in-scene cast (`stances[]`, the
* `npc` an enum of those present), exactly like the relationship pass — cost is
* independent of cast size, NOT one call per NPC.
*
* Pure and unit-testable, like the rest: this file builds the schema, the prompt,
* the trigger gate, the pure application + the read-side block. The one impure
* boundary — the `respond({ structured })` call — lives in the handler. It is
* DECOUPLED from `characters`/`state` (per CLAUDE.md): the caller pre-renders each
* NPC's profile / standing / mood into strings and passes them in, so this module
* imports only zod and the shared whole-word matcher (a dependency-free helper).
*/
import { z } from "zod";
import { wholeWordMatch } from "../shared/text.js";
/**
* The bounded stances an NPC may take toward the player's action. Ordered from
* most yielding to most closed. `comply` is the ordinary, low-friction case (and
* is NOT rendered into the binding block — the agreeable default already covers
* it); everything else is a form of resistance the narrator must honour.
*/
export const STANCE_VALUES = [
"comply", // goes along with it
"hesitant", // wavers, needs convincing first
"negotiate", // open, but wants something in return (see `wants`)
"deflect", // dodges, changes the subject, won't engage the ask
"refuse", // declines outright
"hostile", // pushes back with antagonism
"withdraw", // disengages — shuts down, leaves, goes cold
] as const;
export type StanceVerdict = (typeof STANCE_VALUES)[number];
/** A short, narrator-facing gloss for each stance (used in the block). */
const STANCE_GLOSS: Record<StanceVerdict, string> = {
comply: "is willing to go along with it",
hesitant: "wavers and needs convincing before they will",
negotiate: "is open to it, but wants something in return",
deflect: "dodges — changes the subject and will not engage the ask",
refuse: "declines",
hostile: "pushes back, with antagonism",
withdraw: "disengages — goes cold, shuts down, or leaves",
};
/**
* The second, orthogonal axis (the initiative refinement): what an NPC does of
* its OWN will this turn, UNPROMPTED by the player. The stance above is reactive
* (how they meet the player's ask); this is proactive — the engine of agency the
* base narrator suppresses ("React only to the player's stated action"). `none`
* is the overwhelming default — characters do not constantly seize the scene —
* so it is NOT rendered into the binding block, exactly like `comply`; the other
* three are an unprompted move the narrator must stage. Note `leave` overlaps the
* `withdraw` stance, but the two differ in cause: `withdraw` is a reaction TO the
* player's action, `leave` is the character choosing to go for their own reasons
* (bored, finished, somewhere else to be) regardless of what the player did.
*/
export const INITIATIVE_VALUES = [
"none", // takes no unprompted action — stays and lets the exchange continue
"interject", // cuts in / steers the moment to their own concern, of their own accord
"pursue", // turns to their own goal in the scene rather than waiting on the player
"leave", // disengages and moves to go — bored, finished, or with elsewhere to be
] as const;
export type Initiative = (typeof INITIATIVE_VALUES)[number];
/** A short, narrator-facing gloss for each initiative (used in its block). */
const INITIATIVE_GLOSS: Record<Initiative, string> = {
none: "",
interject: "breaks in of their own accord — interrupts or steers the moment to their own concern",
pursue: "turns to their own goal here rather than waiting on the player",
leave: "moves to disengage and leave the scene of their own accord",
};
/** A single NPC the social referee weighs, with its pre-rendered context. */
export interface StanceNpc {
/** Display name (matches the cast block + the schema enum). */
name: string;
/**
* Pre-rendered persona: personality + the Big Five blurb + desires / needs /
* boundaries. Built by the caller (it owns `characters`); this module stays
* decoupled. The capability/trait anchor for the ruling.
*/
profile: string;
/** Pre-rendered standing toward the player (familiarity + disposition word). */
standing: string;
/** Pre-rendered current psyche (mood / intent); "" when unknown yet. */
mood: string;
}
/** The variable, per-turn inputs the social referee reasons over. */
export interface StanceContext {
/** World setting + tone — what is normal / possible in this fiction. */
worldTone: string;
/** The recent narration — what is actually true in the scene right now. */
scene: string;
/** The player's declared action this turn (free-form text or the chosen option). */
declaredAction: string;
/** The in-scene cast the referee rules on (the schema enum is built from these). */
npcs: StanceNpc[];
}
/** One parsed, validated stance for a named NPC. */
export interface Stance {
npc: string;
verdict: StanceVerdict;
/** The in-character justification the narrator turns into the beat. */
reason: string;
/** What would move them (negotiate/hesitant); "" otherwise. */
wants: string;
/** The unprompted move they make of their own will this turn; "none" = nothing. */
initiative: Initiative;
/** Why they take that initiative now, in character; "" when `none`. */
initiativeReason: string;
}
/**
* The trigger gate (Phase J refinement). Of the turn's ACTIVE cast, return only
* the NPCs the player's action actually ENGAGES — those PRESENT on stage (a name
* or alias appears in the scene just narrated) or NAMED in the player's action
* itself. An NPC merely activated by theme — its card was semantically close to
* the recent text, or it was the never-empty-scene fallback — but who is neither
* on stage nor addressed is dropped, so the social referee does not fire on (and
* cost a call for) characters who are not in play. This removes the phantom-stance
* case seen in live testing: the player alone in a field, yet the pass ruling on
* an off-stage NPC whose stance the narration had no one to honour.
*
* Pure; the caller supplies the scene + action text and each NPC's name+aliases.
* Generic over the card shape so `psyche/` stays decoupled from `characters`. The
* input order is preserved. An empty result means "skip the pass this turn".
*/
export function addressedNpcs<T extends { name: string; aliases?: string[] }>(
cast: T[],
scene: string,
action: string,
): T[] {
return cast.filter((npc) =>
[npc.name, ...(npc.aliases ?? [])].some(
(n) => wholeWordMatch(scene, n) || wholeWordMatch(action, n),
),
);
}
/**
* Psyche decay (Phase J follow-up). The fluctuating state layer is meant to be
* CURRENT, but the social pass only refreshes an NPC while it is in an active pair
* — so an off-scene character's mood otherwise freezes verbatim and resurfaces
* unchanged many turns later (a turn-5 grudge still colouring turn-30). Drop every
* entry the pass has not touched within `maxAgeTurns`, so a returning character
* reverts to their stable card persona instead of a stale mood. Pure;
* `maxAgeTurns <= 0` (or non-finite) disables decay (returns the record as-is).
* Generic over the value shape so `psyche/` stays decoupled from `state` — it
* needs only an `updatedTurn` on each record.
*/
export function prunePsyche<T extends { updatedTurn: number }>(
psyche: Record<string, T>,
currentTurn: number,
maxAgeTurns: number,
): Record<string, T> {
if (!Number.isFinite(maxAgeTurns) || maxAgeTurns <= 0) return psyche;
const out: Record<string, T> = {};
for (const [key, rec] of Object.entries(psyche)) {
if (currentTurn - rec.updatedTurn <= maxAgeTurns) out[key] = rec;
}
return out;
}
/**
* Build the Zod schema the social referee is forced into: one stance per active
* NPC, `npc` an enum over the in-scene names (so it can only rule on a character
* actually present). Fixed shape, every field always present (grammars are more
* reliable that way) — `wants` is "" unless the stance invites a counter-offer.
*/
export function buildStanceSchema(npcNames: string[]): z.ZodTypeAny {
const names = npcNames.filter((n) => n.trim().length > 0);
const npcEnum = names.length > 0 ? z.enum(names as [string, ...string[]]) : z.string();
const StanceObj = z.object({
npc: npcEnum,
stance: z.enum(STANCE_VALUES),
reason: z.string(),
wants: z.string(),
initiative: z.enum(INITIATIVE_VALUES),
initiativeReason: z.string(),
});
return z.object({ stances: z.array(StanceObj) });
}
/**
* Build the social referee's prompt: a CONSTANT, cache-friendly system prefix
* (role + the stance vocabulary + the self-interest bias + how the traits read),
* and a variable user tail (world tone, scene, declared action, and the roster of
* present characters with their profile / standing / mood). The tail sits AFTER
* the prefix so the prefix's KV cache survives turns (same rule as the other
* passes). No few-shot — only the general principle (the house rule; a worked
* example would bias the ruling).
*/
export function buildStancePrompt(ctx: StanceContext): { system: string; user: string } {
const system = [
"You are an impartial reader of CHARACTER WILL in a collaborative role-play. " +
"For each character present, you decide how they are disposed to respond to " +
"what the player just did — from their OWN personality, standing, and mood, " +
"not from a wish to be helpful. You never narrate, never write prose, and " +
"never speak for the player. Output only the structured stances.",
"",
"You are given the world's tone, the scene, the player's action, and a roster " +
"of the characters present — each with their persona (including Big Five " +
"traits and what they want / refuse), how they currently regard the player, " +
"and their present mood. For each listed character decide TWO things: their " +
"`stance` (how they react to what the player just did) and their `initiative` " +
"(whether they also make a move of their own this turn).",
"",
"The `stance` — how they meet the player's action:",
'- "comply": willing to go along with it. THIS IS THE ORDINARY CASE for a ' +
"reasonable, low-cost, in-character request — most interactions pass here. " +
"Do not manufacture conflict.",
'- "hesitant": wavers; would need convincing, reassurance, or a moment first.',
'- "negotiate": open, but wants something in return — set `wants` to what ' +
"would move them (a price, a favour, a guarantee).",
'- "deflect": dodges the ask, changes the subject, will not engage it head-on.',
'- "refuse": declines outright.',
'- "hostile": pushes back with antagonism — the ask provokes them.',
'- "withdraw": disengages entirely — goes cold, shuts down, or leaves.',
"",
"The `initiative` — what they do of their OWN will this turn, unprompted by the " +
"player. MOST of the time this is \"none\": characters do not constantly seize " +
"the scene, and you must NOT manufacture restlessness. Choose otherwise only " +
"when their personality, mood, or agenda genuinely drives it — they have been " +
"sidelined while the scene ran on, the exchange no longer serves what they " +
"want, their goal pulls them elsewhere, or they have simply lost patience:",
'- "none": they take no unprompted action — they stay and let the exchange ' +
"continue. THE ORDINARY CASE. A character who is engaged, deferential, or has " +
"every reason to stay takes none.",
'- "interject": they break in or steer the moment themselves — cut the player ' +
"off, change the subject to their own concern, press their own agenda.",
'- "pursue": they turn to their own goal in the scene rather than waiting on ' +
"the player — act on what they want or need here.",
'- "leave": they disengage and move to go — bored, finished, or with somewhere ' +
"they would rather be. This is their own choice, not a reaction to the ask.",
"Low extraversion or agreeableness, high neuroticism, a pressing desire or need, " +
"or a goal that lies elsewhere make initiative likelier; a strong bond to the " +
"player and an unfinished matter they care about hold them in place.",
"",
"How to judge — weigh these together, do not default to either yielding or " +
"obstruction:",
"- The ASK itself. An ordinary, costless, in-character request is met (comply). " +
"Resistance rises with its cost, intimacy, risk, or impropriety, and an ask " +
"that crosses a character's stated boundaries — or that is absurd, demeaning, " +
"or against their interests — is refused (or met with hostility / withdrawal) " +
"by almost anyone, regardless of how it is phrased.",
"- STANDING. A stranger or someone wary grants little and guards themselves; a " +
"warm, trusted, or devoted character grants far more and forgives more.",
"- TRAITS (Big Five). Low agreeableness → readier to refuse, negotiate hard, or " +
"put themselves first; high agreeableness → accommodating. High " +
"conscientiousness → keeps their word and resists improper or reckless asks; " +
"low → lax. Low openness → rejects the novel, strange, or absurd; high → " +
"game for it. High neuroticism → volatile, mood-driven, easily rattled; low " +
"→ steady. High extraversion → engages readily; low → reticent, withholding.",
"- MOOD. Their present state colours all of it — a fresh grievance hardens a " +
"yes into a no.",
"",
"`reason` is one or two short sentences, in the character's own terms — the " +
"honest why behind their stance, which the narrator turns into the beat. " +
"`wants` is filled only for negotiate/hesitant (what would move them); " +
"otherwise leave it empty. `initiativeReason` is one short sentence for any " +
"initiative other than none (why they make that move now); leave it empty " +
"for none.",
"",
"Output one stance per listed character, no more, no fewer.",
].join("\n");
const roster =
ctx.npcs.length > 0
? ctx.npcs
.map((npc) => {
const lines = [`## ${npc.name}`];
lines.push(`Persona: ${npc.profile.trim() || "(no details given)"}`);
if (npc.standing.trim()) lines.push(`Standing toward the player: ${npc.standing.trim()}`);
if (npc.mood.trim()) lines.push(`Current mood: ${npc.mood.trim()}`);
return lines.join("\n");
})
.join("\n\n")
: "(no characters present)";
const user = [
"# World tone",
ctx.worldTone.trim() || "(no setting given)",
"",
"# Scene",
ctx.scene.trim() || "(the scene is just beginning)",
"",
"# The player's action",
ctx.declaredAction.trim() || "(no explicit action)",
"",
"# Characters present",
roster,
"",
"---",
"",
"# Your task",
"Decide each listed character's stance toward the player's action, judged from " +
"their own will. Output only the structured object:",
].join("\n");
return { system, user };
}
/**
* Apply a parsed social-referee object into a clean {@link Stance}[] (pure).
* Keeps only entries naming an in-scene NPC, coerces an unknown stance to the
* safe non-interfering default (`comply`), de-duplicates by NPC (first wins).
* Defensive against a malformed object: junk entries are dropped, never thrown
* on (the narration still happens; next turn re-anchors).
*/
export function applyStance(parsed: unknown, npcNames: string[]): Stance[] {
const obj = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {};
const rawStances = Array.isArray(obj.stances) ? obj.stances : [];
const known = new Set(npcNames.filter((n) => n.trim().length > 0));
const valid = new Set<string>(STANCE_VALUES);
const validInit = new Set<string>(INITIATIVE_VALUES);
const out: Stance[] = [];
const seen = new Set<string>();
for (const raw of rawStances) {
if (!raw || typeof raw !== "object") continue;
const s = raw as Record<string, unknown>;
const npc = typeof s.npc === "string" ? s.npc : "";
if (!known.has(npc) || seen.has(npc)) continue;
seen.add(npc);
const verdict = (
typeof s.stance === "string" && valid.has(s.stance) ? s.stance : "comply"
) as StanceVerdict;
const reason = typeof s.reason === "string" ? s.reason.trim() : "";
const wants = typeof s.wants === "string" ? s.wants.trim() : "";
// The initiative axis: an unknown/absent value is the safe non-interfering
// default ("none"), exactly as an unknown stance coerces to "comply".
const initiative = (
typeof s.initiative === "string" && validInit.has(s.initiative) ? s.initiative : "none"
) as Initiative;
const initiativeReason = typeof s.initiativeReason === "string" ? s.initiativeReason.trim() : "";
out.push({ npc, verdict, reason, wants, initiative, initiativeReason });
}
return out;
}
/**
* The narrator's honour-clause for the social referee (Phase J), appended to the
* base instruction ONLY on a turn that carries a `# Character stance` block — so
* a turn where everyone simply complies (or the feature is off) stays
* byte-identical. Mirrors `ADJUDICATION_CLAUSE`.
*/
export const STANCE_CLAUSE =
"A `# Character stance` block, when present, is a binding reading of how each " +
"character WILLS to respond: honour it — do not have a character comply, soften, " +
"or grant what their stance refuses, and never let the player's insistence alone " +
"override it.";
/**
* The narrator's honour-clause for the initiative axis (the proactive-agency
* refinement), appended ONLY on a turn that carries a `# Character initiative`
* block — so a turn where no one takes the initiative (or the feature is off)
* stays byte-identical. Mirrors {@link STANCE_CLAUSE}: where the stance clause
* binds REACTION, this binds unprompted ACTION — it is what overrides the base
* narrator's "react only to the player's stated action" for the listed beats.
*/
export const INITIATIVE_CLAUSE =
"A `# Character initiative` block, when present, lists moves the characters make " +
"of their own will, unprompted by the player: honour it — stage the interruption, " +
"the pursuit of their own agenda, or the departure as a real beat this turn even " +
"though the player did not prompt it, and let a character who leaves actually be " +
"gone from the scene.";
/**
* Precedence clause reconciling the social referee with the dice (Phase J ⊕ G3),
* appended ONLY on a turn that carries BOTH a `# Action resolution` (a real dice
* roll) and a `# Character stance` block — so the two referees never contradict.
* The dice are supreme over the OUTCOME; a stance shapes only the MANNER. Without
* it, a binding refusal stance overrode a die-decided success (the live bug: a
* full-success negotiation the narrator played as a flat refusal). The structural
* guard in `reconcileStancesWithRoll` already drops the contested stance on a
* clean success; this clause covers what the guard cannot (an unnamed contest, a
* partial that must still cost). Mirrors `STANCE_CLAUSE` / `ADJUDICATION_CLAUSE`.
*/
export const STANCE_ROLL_PRECEDENCE_CLAUSE =
"When a `# Action resolution` (a dice roll) and a `# Character stance` both " +
"appear this turn, the dice decide the OUTCOME and a stance colours only the " +
"MANNER: on a success the contested character yields as the roll dictates — " +
"their stance shapes only how (grudgingly, exacting a small token), never " +
"whether — and a stance must never turn a successful roll into a refusal.";
/**
* Reconcile the social referee's stances with a dice roll that already resolved
* the player's action this turn (Phase J ⊕ Phase G3). When a contest is settled
* by the dice, the DICE are authoritative over the outcome — a stance must not
* separately veto it. On a clean FULL success against a specific character, drop
* that character's resisting stance entirely: the roll says the attempt "lands
* cleanly, as the player intended", so a binding `# Character stance` line telling
* the narrator NOT to grant it would directly contradict the roll (the live bug —
* a 12-vs-target negotiation the narrator then played as a refusal). A `partial`
* or a `miss` is LEFT in place — there the stance is consistent with the roll
* (the `wants` becomes the partial's cost; the refusal is the miss). Pure.
*
* `contested` is the set of character names the roll was against (the referee's
* `opposedBy`, mapped to display names by the caller); matching is
* case-insensitive. No roll, a non-`hit` tier, or an empty `contested` → the
* stances are returned unchanged.
*/
export function reconcileStancesWithRoll(
stances: Stance[],
roll: { rolled: boolean; tier: "miss" | "partial" | "hit" | null; contested: string[] },
): Stance[] {
if (!roll.rolled || roll.tier !== "hit" || roll.contested.length === 0) return stances;
const drop = new Set(
roll.contested.map((n) => n.trim().toLowerCase()).filter((n) => n.length > 0),
);
if (drop.size === 0) return stances;
return stances.filter((s) => !drop.has(s.npc.trim().toLowerCase()));
}
/**
* The `# Character stance` block: how the present characters are disposed to
* respond, so the narrator voices a refusal as a refusal. ONLY the resisting
* stances are rendered — a `comply` needs no instruction (the agreeable default
* already covers it), so a turn where everyone complies omits the block entirely
* and stays byte-identical. Returns null when there is nothing binding to say.
*/
export function stanceBlock(stances: Stance[]): string | null {
const resisting = stances.filter((s) => s.verdict !== "comply");
if (resisting.length === 0) return null;
const lines: string[] = [
"# Character stance",
"How each character below is disposed to respond to the player's action, from " +
"their own will and interest. Voice each true to this — do not have them " +
"simply comply, and never grant on the player's say-so what they would refuse:",
];
for (const s of resisting) {
let line = `- ${s.npc}: ${STANCE_GLOSS[s.verdict]}`;
if (s.reason) line += ` — ${s.reason}`;
if (s.wants && (s.verdict === "negotiate" || s.verdict === "hesitant")) {
line += ` (what would move them: ${s.wants})`;
}
lines.push(line);
}
return lines.join("\n");
}
/**
* The `# Character initiative` block: the unprompted moves the present characters
* make of their own will this turn (the proactive-agency refinement). ONLY the
* acting initiatives are rendered — a `none` needs no instruction (the base
* narrator already only reacts to the player), so a turn where no one acts omits
* the block entirely and stays byte-identical. Returns null when there is nothing
* to stage. Orthogonal to {@link stanceBlock}: a character may both resist the ask
* (stance) and make a move of their own (initiative); the two render separately.
*/
export function initiativeBlock(stances: Stance[]): string | null {
// Keep only entries with a real acting gloss: "none" maps to "" and an
// absent/invalid value (a Stance built without the axis) maps to undefined —
// both are falsy and dropped, so such turns stay byte-identical.
const acting = stances.filter((s) => Boolean(INITIATIVE_GLOSS[s.initiative]));
if (acting.length === 0) return null;
const lines: string[] = [
"# Character initiative",
"Unprompted moves the characters below make of their own will this turn — they " +
"are not merely reacting to the player. Stage each as a real beat of the " +
"scene, in character and at a natural moment; a character who moves to leave " +
"actually goes:",
];
for (const s of acting) {
let line = `- ${s.npc}: ${INITIATIVE_GLOSS[s.initiative]}`;
if (s.initiativeReason) line += ` — ${s.initiativeReason}`;
lines.push(line);
}
return lines.join("\n");
}