/**
* Defensive loading of a universe's dramatic arc from its package folder.
*
* universes/<universe>/arc.json # optional authored act structure (Phase I)
*
* A missing file, bad JSON, or an unusable shape yields `null` — the caller then
* falls back to the built-in `DEFAULT_ARC` (when the feature is on) or disables
* arc gating entirely. A turn never breaks on arc I/O. Mirrors `rules/load.ts`
* (path helpers are intentionally duplicated rather than cross-imported, keeping
* `arc` decoupled, per CLAUDE.md).
*/
import { readFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { Arc, normalizeArc } 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 ArcLoadOptions {
/** Override the universes root (e.g. from tuning). Empty = default. */
universesDir?: string;
}
function resolvePackageDir(universe: string, options?: ArcLoadOptions): string {
const base = options?.universesDir?.trim();
const root = base ? resolve(base) : defaultUniversesDir();
return join(root, universeSegment(universe));
}
/**
* Load a universe's arc from `universes/<universe>/arc.json`, or `null` if
* absent / invalid / empty. `null` means "no authored arc" — the caller decides
* whether to fall back to `DEFAULT_ARC` or turn arc gating off.
*/
export async function loadArc(
universe: string,
options?: ArcLoadOptions,
): Promise<Arc | null> {
const file = join(resolvePackageDir(universe, options), "arc.json");
try {
const raw = await readFile(file, "utf8");
return normalizeArc(JSON.parse(raw));
} catch {
// Missing file or bad JSON — no authored arc for this universe.
return null;
}
}