/**
* Defensive loading of a universe's world content from its package folder.
*
* A universe is ONE self-contained folder; this module reads its world
* definition and its lorebook:
*
* universes/
* <universe>/
* world.json # { name, setting, narration } — always-present identity
* lore.json # conditional "world info" entries (any shape)
* characters/ # cards (loaded by src/characters/)
*
* A missing file, bad JSON, or an unknown shape yields an empty result — a turn
* never breaks on world/lore I/O. Cards' embedded `character_book`s are merged
* in by the handler (it already loads the cards), keeping `selectLore` pure.
*
* The universes root defaults to `universes/` next to the plugin, overridable
* via the `universesDir` tuning knob (mirrors `state/store.ts` `savesDir`).
*/
import { readFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { LoreEntry, normalizeLorebook, normalizeWorld, WorldDefinition } from "./schema.js";
/** Default universes root: a `universes/` folder in the plugin working dir. */
function defaultUniversesDir(): string {
return resolve(process.cwd(), "universes");
}
/** Sanitize a universe id into a safe single path segment. */
function universeSegment(universe: string): string {
const safe = universe.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 100);
return safe || "default";
}
export interface WorldLoadOptions {
/** Override the universes root (e.g. from tuning). Empty = default. */
universesDir?: string;
}
function resolvePackageDir(universe: string, options?: WorldLoadOptions): string {
const base = options?.universesDir?.trim();
const root = base ? resolve(base) : defaultUniversesDir();
return join(root, universeSegment(universe));
}
/**
* Load a universe's world definition from `universes/<universe>/world.json`, or
* the empty default if absent/invalid. Always-present identity + narration.
*/
export async function loadWorldDef(
universe: string,
options?: WorldLoadOptions,
): Promise<WorldDefinition> {
const file = join(resolvePackageDir(universe, options), "world.json");
try {
const raw = await readFile(file, "utf8");
return normalizeWorld(JSON.parse(raw));
} catch {
// Missing file or bad JSON — empty world definition.
return normalizeWorld(undefined);
}
}
/**
* Load the lorebook for a universe from `universes/<universe>/lore.json`, or
* `[]` if absent/invalid. Reads defensively.
*/
export async function loadLore(
universe: string,
options?: WorldLoadOptions,
): Promise<LoreEntry[]> {
const file = join(resolvePackageDir(universe, options), "lore.json");
try {
const raw = await readFile(file, "utf8");
return normalizeLorebook(JSON.parse(raw));
} catch {
// Missing file or bad JSON — no lore for this universe.
return [];
}
}
/**
* Flatten the `character_book`s embedded in a set of loaded cards into lore
* entries. Each card's raw `character_book` (a parsed-but-ignored Phase A
* field) is run through `normalizeLorebook`. Pure helper — the handler passes
* the cards it already loaded.
*/
export function loreFromCharacterBooks(books: unknown[]): LoreEntry[] {
return books.flatMap((book) => normalizeLorebook(book));
}