/**
* Character-card schema + tolerant normalization.
*
* Adopts the de-facto standard "character card" shape so existing cards work.
* A card can arrive in three shapes; `normalizeCard` flattens all of them into
* one internal {@link CharacterCard}:
*
* - **V2**: `{ spec: "chara_card_v2", data: { ...fields } }`
* - **V3**: `{ spec: "chara_card_v3", data: { ...fields } }`
* - **Bare**: a flat object with the fields directly (no `data` wrapper).
*
* A `data` wrapper (V2/V3) takes precedence over top-level fields, so it never
* matters whether a producer also left stray keys at the root.
*
* Pure and unit-testable (no I/O). Loading from disk lives in `load.ts`.
*/
import { z } from "zod";
/**
* A gated secret a character holds (knowledge gating — the revelation system).
*
* The point is robust obfuscation: a guarded NPC must not state a secret on first
* meeting, and an instruction ("don't reveal this yet") is not enough — a model
* with the payload in front of it leaks it. So the payload (`content`) is kept
* OUT of the narrator's prompt until the gate opens; only a content-free
* `surface` ("there is something here she guards") is shown while it is locked,
* which lets the character read as evasive without anything to spill.
*
* Three tiers the gate produces (see `src/knowledge/gate.ts`):
* - locked → nothing about it reaches the narrator;
* - guarded → only `surface` is injected (the tell), when a `topics` term is
* live in the recent scene;
* - unlocked → `content` is injected (the gate is met, or the fact is already
* known), and the character may finally voice it.
*
* The gate is the AND of: every `requires` fact already known to the player, the
* pair's familiarity at least `trust`, and disposition at least `disposition`.
* Gates chain through `requires` into a credible, multi-character progression.
*/
export const Secret = z.object({
/**
* Fact id. Unique within a card; MAY be shared across cards to mean "the same
* fact, known from another source" (revealing it anywhere marks it known once).
* Other secrets gate on this id via `requires`.
*/
id: z.string().min(1),
/**
* The content-free tell shown while the secret is merely guarded — names that
* a weight exists, never what it is. Safe to inject because it carries no
* payload. Optional: an empty surface simply shows no tell until unlocked.
*/
surface: z.string().default(""),
/** The payload — injected ONLY once unlocked. This is what must never leak early. */
content: z.string().default(""),
/** Fact ids the player must already know for the gate to open (the chain). */
requires: z.array(z.string()).default([]),
/**
* Minimum familiarity for the gate: ""|"stranger"|"acquaintance"|"known"|
* "close". Empty = no familiarity requirement. Interpreted by the gate against
* the relationship record (so it needs relationship memory on to bite).
*/
trust: z.string().default(""),
/** Minimum disposition (the pair's signed standing) for the gate. */
disposition: z.number().default(0),
/**
* Keywords that, when live in the recent scene, raise a still-locked secret to
* `guarded` (so the tell surfaces when the subject comes up, not before).
* Matched case-insensitively as substrings, so multi-word / accented French
* terms work. Empty = the secret never shows a guarded tell, only unlocks.
*/
topics: z.array(z.string()).default([]),
});
export type Secret = z.infer<typeof Secret>;
/**
* Big Five (OCEAN) personality traits — the stable trait layer of a character
* (Phase J). Compact, and a model the LLM understands deeply. **Agreeableness is
* the dial of the submissive-NPC complaint**: low agreeableness reads as blunt,
* self-interested, hard to sway. Each trait is a 0–100 percentile (neutral 50);
* the SOURCE OF TRUTH is numeric, but it is RENDERED qualitatively (see
* {@link traitWord}/{@link bigFiveBlurb}) the way `dispositionWord` renders a
* number — weak local models voice "disagreeable" far better than "agreeableness:
* 23". The trait is stable: no pass ever moves it (only the psyche state layer
* fluctuates). An absent `bigFive` on a card is `null` → nothing is rendered, so a
* card without it behaves exactly as before.
*/
export const BIG_FIVE_TRAITS = [
"openness",
"conscientiousness",
"extraversion",
"agreeableness",
"neuroticism",
] as const;
export type BigFiveTrait = (typeof BIG_FIVE_TRAITS)[number];
export const BigFive = z.object({
openness: z.number().min(0).max(100).default(50),
conscientiousness: z.number().min(0).max(100).default(50),
extraversion: z.number().min(0).max(100).default(50),
agreeableness: z.number().min(0).max(100).default(50),
neuroticism: z.number().min(0).max(100).default(50),
});
export type BigFive = z.infer<typeof BigFive>;
/**
* An authored, one-directional bond from this character to ANOTHER named
* character (scene-presence tiering). Powers the off-scene "referenceable" tier:
* when this character is on stage, a NON-NEUTRAL relation pulls the other into
* the `# Off-scene characters` block with a short link line — so the narrator
* knows who an on-stage character would plausibly bring up, without staging them.
*
* Distinct from the LEARNED player↔NPC relationship memory (`relationships/`):
* this is static, NPC↔NPC, and author-declared. `disposition` is the signed
* standing toward the other (roughly -100..100); only its MAGNITUDE drives the
* pull (a neutral acquaintance is background, not surfaced). `note` is the
* human-readable bond shown to the narrator.
*/
export const Relation = z.object({
/** The other character's name (should match a cast card's name or alias). */
name: z.string().min(1),
/** Signed standing toward them (~-100..100). Magnitude gates the off-scene pull. */
disposition: z.number().default(0),
/** Short bond descriptor shown as the link line, e.g. "an old grudge". */
note: z.string().default(""),
});
export type Relation = z.infer<typeof Relation>;
/** Normalized, internal character card. Only `name` is required. */
export const CharacterCard = z.object({
/** Persona / NPC name. */
name: z.string().min(1),
/**
* Extra names this character answers to. Used (alongside `name`) by Phase B+
* conditional activation to detect the NPC in the recent conversation. Sourced
* from `aliases` / `nicknames` / `nickname` (array or comma-separated string).
*/
aliases: z.array(z.string()).default([]),
/**
* Pin this NPC into every turn's "characters in scene" block, the cast
* equivalent of a lore entry's `constant`. Off by default, so by default an
* NPC is injected only when relevant (name/alias or semantic match).
*/
always_active: z.boolean().default(false),
/**
* Where this character is normally found — the place(s) they belong to (scene-
* presence tiering). When the scene's current location (read off the narration
* by the conductor) matches one of these, the character is staged as PRESENT
* even if unnamed this turn — so a place's regulars (the tavern keeper in her
* tavern) appear without anyone having to spell their name, bridging the
* content-language/play-language gap. Sourced from `location` | `locations`
* (array or comma-separated string). Empty = not location-bound (the default).
*/
locations: z.array(z.string()).default([]),
/**
* Sex / gender identity, free-form ("woman", "man", "nonbinary", "—", or a
* short phrase). Sourced from `gender` | `sex`; rendered as its own line in the
* character block so the game master never has to infer it from pronouns.
*/
gender: z.string().default(""),
/**
* Age or apparent age, free-form ("34", "elderly", "ageless"). Sourced from
* `age`; a number is coerced to a string so a card with `"age": 34` still parses.
*/
age: z.string().default(""),
/**
* Physical appearance — looks, build, dress, distinguishing features. Kept
* distinct from `description` (role/background) so an author can hand the model
* a clean visual to render. Sourced from
* `appearance` | `looks` | `physical` | `physical_description`.
*/
appearance: z.string().default(""),
/** Free-form description (role, background — appearance now has its own field). */
description: z.string().default(""),
/** Personality summary or trait list. */
personality: z.string().default(""),
/**
* Big Five (OCEAN) personality profile — the stable trait layer (Phase J),
* driving how strongly and in what way this character resists/complies (the
* social referee). `null` = the card declares none → nothing rendered, behaves
* exactly as before. Coexists with the free-text `personality` above.
*/
bigFive: BigFive.nullable().default(null),
/**
* What this character is pursuing — their agenda (Phase J). Gives them a reason
* to act for themselves rather than orbit the player. Free-form. Fed to the
* social referee and shown to the narrator. Empty = unchanged.
*/
desires: z.string().default(""),
/**
* Recurring drives — rest, money, safety, affection, a drink (Phase J): the
* "life of their own / rhythm". Free-form. Empty = unchanged.
*/
needs: z.string().default(""),
/**
* Lines this character refuses to cross regardless — the seed of "refuses even
* absurd propositions" (Phase J). Free-form. Read by the social referee to bias
* a refusal, and by the narrator so it never has them step over it. Empty =
* unchanged.
*/
boundaries: z.string().default(""),
/** Scene / world flavor this character brings. */
scenario: z.string().default(""),
/** Opening message — parsed and stored; used for greetings in A4 (not A3). */
first_mes: z.string().default(""),
/** Few-shot dialogue examples, blocks separated by `<START>`. */
mes_example: z.string().default(""),
/** Card-supplied system prompt — parsed; injection is optional in A3. */
system_prompt: z.string().default(""),
/** Card-supplied post-history instructions — parsed; optional in A3. */
post_history_instructions: z.string().default(""),
/**
* Embedded lorebook (SillyTavern "world info"). Retained as an opaque value
* here; Phase B's `world` module normalizes and merges it into the active
* lore set. Shape is validated there, not in the card schema.
*/
character_book: z.unknown().optional(),
/**
* Gated secrets this character holds (knowledge gating — the revelation
* system). Defaults to none, so a card without `secrets` is unchanged and a
* universe that declares none behaves exactly as before. Coerced tolerantly
* (see {@link toSecrets}) so one malformed secret never rejects the whole card.
*/
secrets: z.array(Secret).default([]),
/**
* Authored NPC↔NPC bonds (scene-presence tiering): who this character knows and
* how they regard them. A non-neutral relation surfaces the other character in
* the off-scene block while this one is on stage. Defaults to none, so a card
* without `relations` is unchanged. Coerced tolerantly (see {@link toRelations}).
*/
relations: z.array(Relation).default([]),
});
export type CharacterCard = z.infer<typeof CharacterCard>;
/** Coerce a value into a clean string[] (array, or comma-separated string). */
function toStringArray(raw: unknown): string[] {
if (Array.isArray(raw)) {
return raw.map((a) => String(a).trim()).filter((a) => a.length > 0);
}
if (typeof raw === "string") {
return raw
.split(",")
.map((a) => a.trim())
.filter((a) => a.length > 0);
}
return [];
}
/**
* Coerce a raw `secrets` value into a clean {@link Secret}[]: drop non-objects
* and any entry without a usable `id`, and tolerate `requires`/`topics` given as
* comma-strings. Defensive so one malformed secret can never reject the card —
* mirrors {@link toStringArray}. Returns [] for anything unusable.
*/
function toSecrets(raw: unknown): z.infer<typeof Secret>[] {
if (!Array.isArray(raw)) return [];
const out: z.infer<typeof Secret>[] = [];
for (const item of raw) {
if (!item || typeof item !== "object") continue;
const s = item as Record<string, unknown>;
const candidate = {
id: typeof s.id === "string" ? s.id.trim() : "",
surface: typeof s.surface === "string" ? s.surface : "",
content: typeof s.content === "string" ? s.content : "",
requires: toStringArray(s.requires),
trust: typeof s.trust === "string" ? s.trust.trim() : "",
disposition: Number.isFinite(Number(s.disposition)) ? Number(s.disposition) : 0,
topics: toStringArray(s.topics),
};
const parsed = Secret.safeParse(candidate);
if (parsed.success) out.push(parsed.data);
}
return out;
}
/**
* Coerce a raw `relations` value into a clean {@link Relation}[]: drop non-objects
* and any entry without a usable `name`, tolerate a bare string ("Bram" → a
* neutral acquaintance) and a few key aliases (`note`/`relation`/`description`,
* `disposition`/`standing`). Defensive so one malformed relation never rejects the
* card — mirrors {@link toSecrets}. Returns [] for anything unusable.
*/
function toRelations(raw: unknown): z.infer<typeof Relation>[] {
if (!Array.isArray(raw)) return [];
const out: z.infer<typeof Relation>[] = [];
for (const item of raw) {
let candidate: Record<string, unknown>;
if (typeof item === "string") {
candidate = { name: item.trim(), disposition: 0, note: "" };
} else if (item && typeof item === "object") {
const r = item as Record<string, unknown>;
const dispRaw = r.disposition ?? r.standing;
candidate = {
name: typeof r.name === "string" ? r.name.trim() : "",
disposition: Number.isFinite(Number(dispRaw)) ? Number(dispRaw) : 0,
note:
typeof r.note === "string"
? r.note
: typeof r.relation === "string"
? r.relation
: typeof r.description === "string"
? r.description
: "",
};
} else {
continue;
}
const parsed = Relation.safeParse(candidate);
if (parsed.success && parsed.data.name) out.push(parsed.data);
}
return out;
}
/**
* Coerce a value into clean free text: a string as-is, a number stringified, an
* array joined with "; " (so a `desires`/`needs`/`boundaries` given as a list
* still parses). Anything else → "". Mirrors {@link toStringArray}'s tolerance.
*/
function toText(raw: unknown): string {
if (typeof raw === "string") return raw.trim();
if (typeof raw === "number") return String(raw);
if (Array.isArray(raw)) {
return raw
.map((a) => (typeof a === "string" || typeof a === "number" ? String(a).trim() : ""))
.filter((a) => a.length > 0)
.join("; ");
}
return "";
}
/** Aliases accepted for each Big Five trait key (full name, prefix, O/C/E/A/N). */
const BIG_FIVE_ALIASES: Record<BigFiveTrait, string[]> = {
openness: ["openness", "open", "o"],
conscientiousness: ["conscientiousness", "conscientious", "c"],
extraversion: ["extraversion", "extroversion", "extravert", "extrovert", "e"],
agreeableness: ["agreeableness", "agreeable", "a"],
neuroticism: ["neuroticism", "neurotic", "n"],
};
/**
* Coerce a raw `bigFive` value into a {@link BigFive}, or `null` if it declares
* none (so the card renders nothing — byte-identical to no profile). Accepts an
* object keyed by full trait names, common variants, or single letters O/C/E/A/N,
* case-insensitively; values are clamped to 0–100, a missing trait defaults to 50.
* Returns null unless AT LEAST ONE recognized trait is present, so a stray empty
* object never fabricates a neutral profile out of nothing.
*/
function toBigFive(raw: unknown): z.infer<typeof BigFive> | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const obj = raw as Record<string, unknown>;
// Lower-cased lookup of the provided keys → numeric value.
const lc = new Map<string, number>();
for (const [k, v] of Object.entries(obj)) {
const n = Number(v);
if (Number.isFinite(n)) lc.set(k.trim().toLowerCase(), n);
}
const out: Record<string, number> = {};
let any = false;
for (const trait of BIG_FIVE_TRAITS) {
const hit = BIG_FIVE_ALIASES[trait].find((alias) => lc.has(alias));
if (hit !== undefined) {
out[trait] = Math.max(0, Math.min(100, lc.get(hit)!));
any = true;
}
}
if (!any) return null;
const parsed = BigFive.safeParse(out);
return parsed.success ? parsed.data : null;
}
/**
* Render one Big Five trait as a salient word, or "" when it is near neutral
* (41–59) so an average trait adds no noise. Bands: ≤20 very-low, 21–40 low,
* 60–79 high, ≥80 very-high; the extremes take a "very " prefix. Mirrors
* `dispositionWord` — a stored number → a model-facing word.
*/
export function traitWord(trait: BigFiveTrait, value: number): string {
const v = Math.max(0, Math.min(100, value));
// [low adjective, high adjective] per trait.
const words: Record<BigFiveTrait, [string, string]> = {
openness: ["conventional", "open-minded"],
conscientiousness: ["careless", "disciplined"],
extraversion: ["reserved", "outgoing"],
agreeableness: ["disagreeable", "agreeable"],
neuroticism: ["even-tempered", "high-strung"],
};
const [low, high] = words[trait];
if (v <= 20) return `very ${low}`;
if (v <= 40) return low;
if (v >= 80) return `very ${high}`;
if (v >= 60) return high;
return ""; // 41–59: average, dropped
}
/**
* Render a Big Five profile as a compact, signal-dense line — only the salient
* traits (in OCEAN order), e.g. "disagreeable, reserved, very conscientious".
* Returns "" when every trait is average (so the caller renders nothing) or the
* profile is null. The narrator- and referee-facing form of the numeric profile.
*/
export function bigFiveBlurb(bf: z.infer<typeof BigFive> | null | undefined): string {
if (!bf) return "";
const parts = BIG_FIVE_TRAITS.map((trait) => traitWord(trait, bf[trait])).filter(
(w) => w.length > 0,
);
return parts.join(", ");
}
/**
* Normalize an arbitrary parsed JSON value into a {@link CharacterCard}, or
* `null` if it cannot be made into a valid card (e.g. missing `name`, garbage).
* Unknown fields are ignored rather than rejected.
*/
export function normalizeCard(raw: unknown): CharacterCard | null {
if (!raw || typeof raw !== "object") return null;
const obj = raw as Record<string, unknown>;
// V2/V3 nest the fields under `data`; bare cards put them at the top level.
// Prefer the `data` wrapper when it is itself an object (covers both
// chara_card_v2 and ccv3, and shields us from stray root-level keys).
const source =
obj.data && typeof obj.data === "object" && !Array.isArray(obj.data)
? (obj.data as Record<string, unknown>)
: obj;
// Coerce the loosely-typed Phase B+ fields before validation so an oddly
// shaped `aliases` (e.g. a comma-string) doesn't reject the whole card.
const coerced: Record<string, unknown> = { ...source };
coerced.aliases = toStringArray(source.aliases ?? source.nicknames ?? source.nickname);
coerced.secrets = toSecrets(source.secrets);
coerced.relations = toRelations(source.relations ?? source.knows);
coerced.locations = toStringArray(source.locations ?? source.location);
if (typeof source.always_active !== "boolean") delete coerced.always_active;
// Phase J — stable trait layer: Big Five (accept a few key spellings), and the
// free-text agenda fields. A null/absent bigFive renders nothing; empty text
// fields leave the card byte-identical to before.
coerced.bigFive = toBigFive(
source.bigFive ?? source.big_five ?? source.ocean ?? source.personality_big_five,
);
coerced.desires = toText(source.desires ?? source.goals ?? source.agenda);
coerced.needs = toText(source.needs ?? source.drives);
coerced.boundaries = toText(source.boundaries ?? source.limits ?? source.wont);
// Identity / appearance fields: accept a few aliases and coerce a number to a
// string (e.g. `"age": 34`), dropping anything unusable so the card still
// parses (mirrors the tolerant `aliases` handling above).
const asText = (v: unknown): string | undefined =>
typeof v === "string" ? v : typeof v === "number" ? String(v) : undefined;
const gender = asText(source.gender ?? source.sex);
const age = asText(source.age);
const appearance = asText(
source.appearance ?? source.looks ?? source.physical ?? source.physical_description,
);
if (gender !== undefined) coerced.gender = gender;
else delete coerced.gender;
if (age !== undefined) coerced.age = age;
else delete coerced.age;
if (appearance !== undefined) coerced.appearance = appearance;
else delete coerced.appearance;
const parsed = CharacterCard.safeParse(coerced);
return parsed.success ? parsed.data : null;
}
/**
* Split a `mes_example` blob into individual example blocks on `<START>`
* markers, trimming and dropping empties. Pure helper for callers that want to
* inject few-shot examples (injection itself is optional in A3).
*/
export function splitExamples(mesExample: string): string[] {
return mesExample
.split(/<START>/i)
.map((block) => block.trim())
.filter((block) => block.length > 0);
}