/**
* Lore-entry schema + tolerant normalization (the "world info" of the world).
*
* Adopts the de-facto SillyTavern "world info / lorebook" shape so existing
* books work. Lore can arrive in several container shapes; `normalizeLorebook`
* flattens all of them into one internal `LoreEntry[]`:
*
* - **array**: `{ entries: [ {...}, {...} ] }`
* - **object map**: `{ entries: { "0": {...}, "1": {...} } }` (SillyTavern)
* - **bare array**: `[ {...}, {...} ]` at the top level
* - **character_book**: a card's `data.character_book` (same inner shapes),
* optionally still wrapped as `{ character_book: { entries: ... } }`.
*
* Per-entry, only `content` is required; everything else has a sensible default
* and a set of accepted aliases. Unknown fields are ignored, and any entry that
* can't be made valid is dropped (never throws) — mirroring the defensive
* loading of `state/store.ts` and `characters/schema.ts`.
*
* Pure and unit-testable (no I/O). Loading from disk lives in `load.ts`;
* relevance selection in `scan.ts`.
*/
import { z } from "zod";
/** Normalized, internal lore entry. Only `content` is required. */
export const LoreEntry = z.object({
/**
* Stable identifier used by the disclosure system to remember whether this
* entry has been introduced in the narration yet (see `src/disclosure/`). Most
* books omit it — a deterministic content hash is the fallback, so an entry
* needs no edits; set it (or rely on a SillyTavern `uid`) only to keep the
* disclosure state stable across edits to `content`. Identity/debug only —
* never injected into the prompt.
*/
id: z.string().default(""),
/** Trigger words; an entry fires when any appears in the scanned window. */
keys: z.array(z.string()).default([]),
/** The text injected into the prompt when the entry is active. */
content: z.string().min(1),
/** Always active, regardless of keywords. */
constant: z.boolean().default(false),
/** Insertion priority — lower is inserted first (and survives the budget). */
order: z.number().default(100),
/** Disabled entries are never selected. */
enabled: z.boolean().default(true),
/**
* Dramatic-arc gate id (Phase I). When set, this entry is a *spoiler* tied to
* a fact the arc opens by act: it is suppressed from the prompt until that id
* is act-eligible (see `arc/actEligibleIds`), exactly like a card `secret`.
* Empty = ambient lore, always eligible (the default — every existing book is
* unchanged). Reuses the SAME id vocabulary as `arc.reveals`/card secrets, so a
* `reveal: "white-room"` entry opens precisely when the white-room secret does.
*/
reveal: z.string().default(""),
/** Optional label, for debug output only. */
comment: z.string().default(""),
});
export type LoreEntry = z.infer<typeof LoreEntry>;
/** Coerce a `keys`/`key` value (array, comma-string, or absent) into string[]. */
function toKeys(raw: unknown): string[] {
if (Array.isArray(raw)) {
return raw.map((k) => String(k).trim()).filter((k) => k.length > 0);
}
if (typeof raw === "string") {
return raw
.split(",")
.map((k) => k.trim())
.filter((k) => k.length > 0);
}
return [];
}
/**
* Normalize an arbitrary parsed JSON value into a {@link LoreEntry}, or `null`
* if it has no usable `content`. Accepts the common field aliases:
* - keys: `keys` | `key` (array or comma-separated string)
* - content: `content` | `entry`
* - order: `order` | `insertion_order`
* - enabled: `enabled` | inverse of `disable`
* - comment: `comment` | `name`
*/
export function normalizeEntry(raw: unknown): LoreEntry | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const o = raw as Record<string, unknown>;
const contentRaw =
typeof o.content === "string" && o.content.trim()
? o.content
: typeof o.entry === "string" && o.entry.trim()
? o.entry
: null;
if (!contentRaw) return null;
// Stable id (disclosure system): accept `id` | `uid` (SillyTavern), coercing a
// number to a string so a numeric `uid` survives. Absent → "" (content hash).
const idRaw = o.id ?? o.uid;
const id =
typeof idRaw === "string"
? idRaw.trim()
: typeof idRaw === "number"
? String(idRaw)
: undefined;
const orderRaw = o.order ?? o.insertion_order;
const order = typeof orderRaw === "number" ? orderRaw : undefined;
let enabled: boolean | undefined;
if (typeof o.enabled === "boolean") enabled = o.enabled;
else if (typeof o.disable === "boolean") enabled = !o.disable;
const constant = typeof o.constant === "boolean" ? o.constant : undefined;
// Arc gate id (Phase I). Accept `reveal` | `gate` (alias); ignore non-strings.
const revealRaw = o.reveal ?? o.gate;
const reveal = typeof revealRaw === "string" ? revealRaw.trim() : undefined;
const comment =
typeof o.comment === "string"
? o.comment
: typeof o.name === "string"
? o.name
: undefined;
const parsed = LoreEntry.safeParse({
...(id !== undefined ? { id } : {}),
keys: toKeys(o.keys ?? o.key),
content: contentRaw,
...(constant !== undefined ? { constant } : {}),
...(order !== undefined ? { order } : {}),
...(enabled !== undefined ? { enabled } : {}),
...(reveal !== undefined ? { reveal } : {}),
...(comment !== undefined ? { comment } : {}),
});
return parsed.success ? parsed.data : null;
}
/** Pull a container's entries (array or object-map) into a flat raw list. */
function entriesToArray(entries: unknown): unknown[] {
if (Array.isArray(entries)) return entries;
if (entries && typeof entries === "object") {
return Object.values(entries as Record<string, unknown>);
}
return [];
}
/**
* Normalize any lorebook container shape into a flat `LoreEntry[]`. Handles a
* bare array, `{ entries: [...] }`, `{ entries: { "0": ... } }`, and a card's
* `character_book` (optionally wrapped). Garbage in → `[]` out, never a throw.
*/
export function normalizeLorebook(raw: unknown): LoreEntry[] {
if (!raw) return [];
if (Array.isArray(raw)) {
return raw
.map(normalizeEntry)
.filter((e): e is LoreEntry => e !== null);
}
if (typeof raw === "object") {
const o = raw as Record<string, unknown>;
// A card's character_book is itself a lorebook container — unwrap it.
if (o.character_book && typeof o.character_book === "object") {
return normalizeLorebook(o.character_book);
}
if (o.entries !== undefined) {
return entriesToArray(o.entries)
.map(normalizeEntry)
.filter((e): e is LoreEntry => e !== null);
}
}
return [];
}
/**
* Built-in narration style, used when a universe's `world.json` leaves
* `narration` empty (or has no file). Keeps the game-master tone sensible
* out of the box without forcing every universe to specify one.
*/
export const DEFAULT_NARRATION = "Immersive second-person narration, present tense.";
/**
* A universe's world definition — its always-present identity and framing,
* loaded from `universes/<universe>/world.json`. This is the static counterpart
* to the conditional lorebook: `name` + `setting` fill the `# World` block every
* turn, and `narration` is the baseline game-master style. Distinct from lore,
* which is triggered on demand.
*/
export const WorldDefinition = z.object({
/** Display name of the setting, e.g. "Saltmere". */
name: z.string().default(""),
/** Short premise / atmosphere, always injected into the `# World` block. */
setting: z.string().default(""),
/** Baseline narration style for the game master (empty → DEFAULT_NARRATION). */
narration: z.string().default(""),
});
export type WorldDefinition = z.infer<typeof WorldDefinition>;
/**
* Normalize an arbitrary parsed JSON value into a {@link WorldDefinition}.
* Tolerant of field aliases (`setting` | `description` | `premise`,
* `narration` | `narrationStyle` | `style`) and of junk — anything unusable
* yields the empty default, never a throw (mirrors the lore loader).
*/
export function normalizeWorld(raw: unknown): WorldDefinition {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return WorldDefinition.parse({});
}
const o = raw as Record<string, unknown>;
const str = (...vals: unknown[]): string | undefined => {
for (const v of vals) if (typeof v === "string" && v.trim()) return v;
return undefined;
};
const parsed = WorldDefinition.safeParse({
name: str(o.name, o.title) ?? "",
setting: str(o.setting, o.description, o.premise) ?? "",
narration: str(o.narration, o.narrationStyle, o.style) ?? "",
});
return parsed.success ? parsed.data : WorldDefinition.parse({});
}