/**
* Defensive loading of character cards from a universe's package folder.
*
* A universe is ONE self-contained folder; its cards live in a `characters/`
* subfolder:
*
* universes/
* <universe>/
* characters/
* player.json # the player persona (optional)
* aria.json # an NPC
* doss-coyle.json # an NPC
*
* Convention: `player.json` is the player persona; every other `*.json` is an
* NPC. A missing folder yields an empty cast, and any file that fails to read /
* parse / validate is skipped — a single bad card never breaks a turn.
*
* This is the I/O layer; it is kept out of the pure planner (see
* `director/plan.ts`). The universes root mirrors `world/load.ts`.
*/
import { readFile, readdir } from "node:fs/promises";
import { join, resolve } from "node:path";
import { CharacterCard, normalizeCard } 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 LoadOptions {
/** Override the universes root (e.g. from tuning). Empty = default. */
universesDir?: string;
}
/** The cast loaded for a universe: the player persona (if any) + the NPCs. */
export interface LoadedCast {
player: CharacterCard | null;
cast: CharacterCard[];
}
function resolveCharactersDir(universe: string, options?: LoadOptions): string {
const base = options?.universesDir?.trim();
const root = base ? resolve(base) : defaultUniversesDir();
return join(root, universeSegment(universe), "characters");
}
/**
* Load all cards for a universe. Reads every `*.json` in the folder, splits
* `player.json` from the NPCs, and skips anything that fails to parse. NPCs are
* returned in a stable, name-sorted order so the assembled prompt is
* deterministic.
*/
export async function loadCharacters(
universe: string,
options?: LoadOptions,
): Promise<LoadedCast> {
const dir = resolveCharactersDir(universe, options);
let entries: string[];
try {
entries = await readdir(dir);
} catch {
// No folder for this universe — simply no cards.
return { player: null, cast: [] };
}
let player: CharacterCard | null = null;
const cast: CharacterCard[] = [];
for (const entry of entries) {
if (!entry.toLowerCase().endsWith(".json")) continue;
let card: CharacterCard | null = null;
try {
const raw = await readFile(join(dir, entry), "utf8");
card = normalizeCard(JSON.parse(raw));
} catch {
card = null; // unreadable file or bad JSON — skip it
}
if (!card) continue;
if (entry.toLowerCase() === "player.json") player = card;
else cast.push(card);
}
cast.sort((a, b) => a.name.localeCompare(b.name));
return { player, cast };
}