/**
* Relationship memory — the pure core (no LM Studio dependency).
*
* The problem: NPCs re-derive their bond with the player from scratch every turn,
* so a stranger from the lore greets the player by name and a relationship never
* grows over a long arc. This module gives each character pair a small, persisted,
* *evolving* record — `familiarity` (do they know each other?), `disposition`
* (a signed standing that drifts), and a rolling shared-history `summary` (the
* relationship analogue of `memory.summary`).
*
* Two writers, by design, so the consistency-critical passes (the referee and the
* accountant) carry NO relationship load:
* - **familiarity** is bumped in CODE (here, pure) the turn an NPC shares a
* scene with the player — instant, deterministic, no model call;
* - **disposition + the shared-history summary** are produced by a dedicated,
* off-critical-path **relationship pass** that runs at end of turn (in
* parallel with the rolling-summary pass). This file builds that pass's
* schema, prompt, and the pure application of its parsed output — the
* `respond({ structured })` call itself lives in the handler, exactly as the
* accountant (`rules/extract.ts`) and the summary do.
*
* The read side — the `# Relationships` system-prompt block — is rendered here
* too and injected by `director/assemble.ts`, so it reaches the narrator (and the
* referee sees the same standing). Everything is pure and unit-tested; the module
* is decoupled from `world`/`memory`/`rules` (per CLAUDE.md), depending only on
* the central state schema.
*/
import { z } from "zod";
import { FAMILIARITY, Psyche, Relationship } from "../state/schema.js";
/** The pair key for the player's relationship with a named character. */
const PLAYER_KEY = "player";
/** Lowercased, trimmed character key — matches how `npcSheets` is keyed. */
function characterKey(name: string): string {
return name.trim().toLowerCase();
}
/** Stable pair key for the player↔NPC relationship (MVP scope). */
export function playerPairKey(npcName: string): string {
return `${PLAYER_KEY}|${characterKey(npcName)}`;
}
/** A fresh, default relationship (stranger / neutral / no history). */
function emptyRelationship(): Relationship {
return Relationship.parse({});
}
/** The record for a pair, or a default stranger record when none exists yet. */
export function getRelationship(
relationships: Record<string, Relationship>,
key: string,
): Relationship {
return relationships[key] ?? emptyRelationship();
}
/** Order index of a familiarity level, so it can only ever rise, never fall. */
export function familiarityRank(level: string): number {
const i = (FAMILIARITY as readonly string[]).indexOf(level);
return i < 0 ? 0 : i;
}
/** The higher (better-acquainted) of two familiarity levels. */
function maxFamiliarity(a: string, b: string): Relationship["familiarity"] {
return (familiarityRank(a) >= familiarityRank(b) ? a : b) as Relationship["familiarity"];
}
/**
* Bump familiarity for every NPC sharing the scene with the player this turn —
* pure, deterministic, no model. A first-time stranger becomes an acquaintance,
* so next turn the narrator no longer treats them as never-met (the visible bug).
* Higher levels (`known`, `close`) are left to the relationship pass; this only
* guarantees the floor and never lowers an existing level. Returns a new record
* (and whether anything changed, for the debug log).
*/
export function advanceFamiliarity(
relationships: Record<string, Relationship>,
castNames: string[],
turn: number,
): { relationships: Record<string, Relationship>; promoted: string[] } {
const next = { ...relationships };
const promoted: string[] = [];
for (const name of castNames) {
if (!name.trim()) continue;
const key = playerPairKey(name);
const cur = getRelationship(next, key);
if (cur.familiarity === "stranger") {
next[key] = { ...cur, familiarity: "acquaintance", updatedTurn: turn };
promoted.push(name);
}
}
return { relationships: next, promoted };
}
/** A short, model-facing word for a disposition value (the NPC's standing). */
export function dispositionWord(disposition: number): string {
if (disposition <= -50) return "hostile";
if (disposition <= -15) return "wary";
if (disposition < 15) return "neutral";
if (disposition < 50) return "warm";
return "devoted";
}
/**
* The `# Relationships` block: how the NPCs present currently regard the player,
* plus their shared history — so the narrator voices a stranger as a stranger and
* an old ally as an ally. Rendered for the ACTIVE cast (a never-met NPC still gets
* a line, so the name-gate fires). Returns null when the feature is off (the
* caller passes `undefined`), there is no player, or no cast — keeping a turn
* without it byte-identical.
*/
export function relationshipBlock(
playerName: string | undefined,
cast: { name: string }[],
relationships: Record<string, Relationship> | undefined,
): string | null {
if (relationships === undefined) return null; // feature off
if (!playerName || !playerName.trim()) return null;
if (cast.length === 0) return null;
const who = playerName.trim();
const lines: string[] = [
"# Relationships",
`How the characters present currently regard ${who}, and their shared ` +
`history. Voice each character true to this — their warmth or wariness, and ` +
`whether they even know ${who} — and never contradict it.`,
];
let any = false;
for (const npc of cast) {
if (!npc.name.trim()) continue;
const rel = getRelationship(relationships, playerPairKey(npc.name));
const standing = dispositionWord(rel.disposition);
let line: string;
if (rel.familiarity === "stranger") {
line =
`- ${npc.name}: has never met ${who} and does not know their name — ` +
`do not use it until ${who} gives it in the scene; regards this ` +
`newcomer as ${standing}.`;
} else {
line = `- ${npc.name}: ${rel.familiarity} with ${who}; ${standing} toward them.`;
}
lines.push(line);
if (rel.summary.trim()) {
lines.push(` History: ${rel.summary.trim()}`);
}
any = true;
}
return any ? lines.join("\n") : null;
}
// ---------------------------------------------------------------------------
// The relationship pass (disposition + shared-history summary) — schema, prompt,
// and the pure application of its parsed output. The model call is in the handler.
// ---------------------------------------------------------------------------
/** A pair active this window: the NPC's display name + its player-pair key. */
export interface ActivePair {
npcName: string;
key: string;
}
/** Build the ActivePair list for the current cast (player↔NPC, MVP scope). */
export function activePairs(castNames: string[]): ActivePair[] {
const seen = new Set<string>();
const pairs: ActivePair[] = [];
for (const name of castNames) {
const trimmed = name.trim();
if (!trimmed) continue;
const key = playerPairKey(trimmed);
if (seen.has(key)) continue;
seen.add(key);
pairs.push({ npcName: trimmed, key });
}
return pairs;
}
/**
* The Zod schema the (now SOCIAL) pass is forced into: one update per active
* pair, keyed by the NPC's name (the pair is the player and that NPC). Fixed
* shape (no optionals — grammars are more reliable on them). `pair` is an enum of
* the active NPC names so the model can only name a character actually in scene.
*
* Phase J folds the character PSYCHE (the fluctuating state layer) into this same
* pass (+0 model calls): besides the relationship fields, each update also carries
* the NPC's current `mood` / `intent` / `goalFocus`, applied into `State.psyche`
* by {@link applyPsycheExtraction} from the very same parsed object.
*/
export function buildRelationshipSchema(pairs: ActivePair[]): z.ZodTypeAny {
const names = pairs.map((p) => p.npcName);
const pairEnum =
names.length > 0 ? z.enum(names as [string, ...string[]]) : z.string();
const Update = z.object({
pair: pairEnum,
familiarity: z.enum(FAMILIARITY),
dispositionDelta: z.number().int(),
summary: z.string(),
// Psyche (Phase J — the fluctuating state layer), written into State.psyche.
mood: z.string(),
intent: z.string(),
goalFocus: z.string(),
});
return z.object({ updates: z.array(Update) });
}
/**
* Build the relationship pass's prompt. Like the rolling summary it recompresses
* recursively — the model folds the new events into each pair's EXISTING summary
* and rewrites it whole. The system text is a constant (cache-friendly); the user
* tail carries each active pair's current standing + the new narration to digest.
*/
export function buildRelationshipPrompt(
playerName: string,
pairs: { npcName: string; rel: Relationship }[],
delta: string[],
bridge: string[] = [],
): { system: string; user: string } {
const who = playerName.trim() || "the player";
const system = [
`You maintain the RELATIONSHIPS in an ongoing role-play: for each pair of ` +
`characters, how they regard each other and the history they share. You ` +
`track only ${who}'s relationships with the characters listed below. You ` +
`never narrate and never invent events — you only update standing from what ` +
`the narration actually shows.`,
"",
"For each listed character, output one update with:",
`- pair: the character's name (the relationship is between them and ${who}).`,
"- familiarity: how well they now know each other — one of: stranger, " +
"acquaintance, known, close. It only ever RISES with real interaction " +
"(meeting, time spent, confidences shared); never lower it for a quarrel " +
"(that is disposition). Keep the current level if nothing changed it.",
`- dispositionDelta: a signed whole number, how much this character's ` +
`standing toward ${who} MOVED this window (roughly -25..+25). Positive for ` +
`kindness, help, shared danger survived; negative for insult, betrayal, ` +
`threat, or harm. 0 when the standing did not really change — the common ` +
`case. Judge it from what happened, not from a single word.`,
`- summary: rewrite this pair's shared-history summary as a whole (do NOT ` +
`append), folding in the new events. Dense, past-tense, third-person, a few ` +
`sentences: who they are to each other, what passed between them, debts, ` +
`promises, frictions, and anything that should colour their next meeting. ` +
`Keep only what bears on THIS relationship.`,
"",
`Then read the character's INNER STATE right now — how these events left ` +
`THEM, in their own head (this is the character's psyche, not the bond):`,
`- mood: their CURRENT emotional state after these events — a short phrase ` +
`("guarded, nursing a grudge", "buoyant and reckless"). What they feel now, ` +
`present tense, not a history. Keep the prior mood if nothing shifted it.`,
`- intent: what they mean to do NEXT toward ${who} or the scene — their ` +
`short-term intention ("wants them gone", "testing whether to trust them", ` +
`"angling for a better cut"). Empty if they have none.`,
`- goalFocus: what this character is most fixated on pursuing right now — ` +
`their live focus, grounded in who they are and what just happened. This is ` +
`THEIR agenda, independent of ${who}; empty if nothing presses on them.`,
"",
"Output only the structured object — one update per listed character, no more.",
].join("\n");
const standingLines = pairs
.map((p) => {
const cur = p.rel;
let head =
`## ${p.npcName}\n` +
`Current familiarity: ${cur.familiarity}. ` +
`Current disposition toward ${who}: ${cur.disposition} ` +
`(${dispositionWord(cur.disposition)}).`;
// The trust scar (low-water mark): if this pair once sank into real hostility
// and has since climbed back, say so — the wound lingers, so the warmth can
// never be quite what it was. Keeps the rewritten summary from declaring a
// clean reconciliation the standing does not actually support.
if (cur.lowWaterMark <= -20 && cur.lowWaterMark < cur.disposition) {
head +=
` (Once fell to ${cur.lowWaterMark} — a grave breach. It has recovered ` +
`since, but the wound lingers: trust here can be rebuilt only part-way, ` +
`never as if it had not happened.)`;
}
const hist = cur.summary.trim()
? `\nShared history so far: ${cur.summary.trim()}`
: `\nShared history so far: (none yet).`;
return head + hist;
})
.join("\n\n");
const lead = bridge
.map((m) => m.trim())
.filter(Boolean)
.join("\n\n");
const recent =
delta
.map((m) => m.trim())
.filter(Boolean)
.join("\n\n") || "(no new events)";
const user =
`# Characters whose relationship with ${who} you are tracking\n\n` +
`${standingLines}\n\n` +
(lead
? `# Bridge — where the new events pick up\n\n` +
`*Already reflected above. Context only — do NOT re-digest it.*\n\n${lead}\n\n`
: "") +
`# New events to integrate\n\n` +
`*Game-master narration and the player's actions, in order.*\n\n${recent}\n\n` +
`---\n\n` +
`# Your task\n\n` +
`Update each listed character's relationship with ${who}, folding in the new ` +
`events. Output only the structured object:`;
return { system, user };
}
/** Options bounding how far the relationship pass may move a value in one go. */
export interface ApplyRelationshipOptions {
/** Symmetric clamp for the stored disposition (e.g. 100). */
dispositionMax: number;
/** Max absolute move per window, so one scene can't swing the bond wildly. */
deltaCap: number;
/**
* Trust-scar strength (the low-water mark): how many points of recovery ceiling
* are lost per point of historic low. `0` disables the scar (disposition can
* recover fully — the pre-scar behaviour). `1` is point-for-point: a pair that
* once sank to −70 can never climb back above +30 (100 − 70). Defaults to 0 so a
* caller that omits it is unchanged.
*/
scarFactor?: number;
}
/**
* Fold a parsed relationship-pass object into the stored relationships (pure).
* Each update is matched back to its pair by the NPC name; disposition moves by a
* capped, clamped delta; familiarity can only rise; a non-empty summary replaces
* the old one. Defensive against malformed input: junk entries and unknown names
* are skipped, never thrown on. Returns the new record + the names actually
* touched (for the debug log).
*/
export function applyRelationshipExtraction(
parsed: unknown,
relationships: Record<string, Relationship>,
pairs: ActivePair[],
turn: number,
options: ApplyRelationshipOptions,
): { relationships: Record<string, Relationship>; updated: string[] } {
const obj =
parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {};
const rawUpdates = Array.isArray(obj.updates) ? obj.updates : [];
const byName = new Map(pairs.map((p) => [p.npcName, p.key]));
const dispositionMax = Math.max(0, options.dispositionMax);
const deltaCap = Math.max(0, options.deltaCap);
const scarFactor = Math.max(0, options.scarFactor ?? 0);
const next = { ...relationships };
const updated: string[] = [];
for (const raw of rawUpdates) {
if (!raw || typeof raw !== "object") continue;
const u = raw as Record<string, unknown>;
const name = typeof u.pair === "string" ? u.pair : "";
const key = byName.get(name);
if (!key) continue; // not a tracked, in-scene pair
const cur = getRelationship(next, key);
// Disposition: capped move, then clamped to the symmetric bound.
const rawDelta = Number.isFinite(Number(u.dispositionDelta))
? Math.round(Number(u.dispositionDelta))
: 0;
const cappedDelta = Math.max(-deltaCap, Math.min(deltaCap, rawDelta));
const moved = Math.max(
-dispositionMax,
Math.min(dispositionMax, cur.disposition + cappedDelta),
);
// Trust scar (the low-water mark): a grave falling-out leaves a lasting wound,
// so warmth can be rebuilt only PART of the way back. The ceiling drops by
// `scarFactor` per point of the worst disposition ever reached coming into
// this turn — so the deeper the past low, the less the recovery. It caps the
// climb only: a negative move is never blocked (you can always sour further),
// and the cap never drags disposition below where it already sits (so a quiet
// turn can't yank it down). With scarFactor 0 the ceiling is `dispositionMax`
// → no effect, exactly the pre-scar behaviour. `curLow` is coerced so a record
// predating the field (undefined) reads as 0 rather than poisoning the maths.
const curLow = Number.isFinite(cur.lowWaterMark) ? cur.lowWaterMark : 0;
const ceiling = dispositionMax - scarFactor * Math.abs(curLow);
const disposition = Math.min(moved, Math.max(ceiling, cur.disposition));
// The mark only ratchets down — the damage is remembered for good.
const lowWaterMark = Math.min(curLow, disposition);
// Familiarity: only ever rises.
const proposed =
typeof u.familiarity === "string" &&
(FAMILIARITY as readonly string[]).includes(u.familiarity)
? u.familiarity
: cur.familiarity;
const familiarity = maxFamiliarity(cur.familiarity, proposed);
// Summary: a non-empty rewrite replaces the old; a blank leaves it untouched.
const summary =
typeof u.summary === "string" && u.summary.trim()
? u.summary.trim()
: cur.summary;
next[key] = { familiarity, disposition, lowWaterMark, summary, updatedTurn: turn };
updated.push(name);
}
return { relationships: next, updated };
}
/**
* Fold the SAME parsed social-pass object into the character psyche (Phase J) —
* the fluctuating state layer (mood / intent / goalFocus), keyed in `State.psyche`
* by the lowercased NPC name (matching `npcSheets`/the stance pass). Pure; runs
* off the same parsed output as {@link applyRelationshipExtraction} (no extra
* model call — the locked decision). A blank field leaves the prior value intact
* (so a quiet turn does not erase a standing mood); only an in-scene pair is
* touched; junk is dropped, never thrown on. Returns the new record + the names
* touched (for the debug log).
*/
export function applyPsycheExtraction(
parsed: unknown,
psyche: Record<string, Psyche>,
pairs: ActivePair[],
turn: number,
): { psyche: Record<string, Psyche>; updated: string[] } {
const obj =
parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {};
const rawUpdates = Array.isArray(obj.updates) ? obj.updates : [];
const known = new Set(pairs.map((p) => p.npcName));
const next = { ...psyche };
const updated: string[] = [];
for (const raw of rawUpdates) {
if (!raw || typeof raw !== "object") continue;
const u = raw as Record<string, unknown>;
const name = typeof u.pair === "string" ? u.pair : "";
if (!known.has(name)) continue; // not a tracked, in-scene character
const key = name.trim().toLowerCase();
const cur = next[key] ?? Psyche.parse({});
// A non-empty value replaces the prior; a blank leaves it untouched (so a
// quiet turn keeps a standing mood rather than wiping it).
const pick = (v: unknown, prev: string): string =>
typeof v === "string" && v.trim() ? v.trim() : prev;
const mood = pick(u.mood, cur.mood);
const intent = pick(u.intent, cur.intent);
const goalFocus = pick(u.goalFocus, cur.goalFocus);
// Skip writing a record that would carry nothing (all three empty) — keeps
// psyche absent for a character the model said nothing about, so the stance
// pass / cast block stay byte-identical for them.
if (!mood && !intent && !goalFocus) continue;
next[key] = { mood, intent, goalFocus, updatedTurn: turn };
updated.push(name);
}
return { psyche: next, updated };
}