/**
* Defensive loading of a universe's rules from its package folder.
*
* universes/<universe>/rules.json # optional structured-mechanics definition
*
* A missing file, bad JSON, or an unusable shape yields `null` — mechanics off,
* a turn never breaks on rules I/O. Mirrors `world/load.ts` (path helpers are
* intentionally duplicated rather than cross-imported, keeping `rules`
* decoupled from `world`, per CLAUDE.md).
*/
import { readFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { normalizeRules, RulesDefinition } 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 RulesLoadOptions {
/** Override the universes root (e.g. from tuning). Empty = default. */
universesDir?: string;
}
function resolvePackageDir(universe: string, options?: RulesLoadOptions): string {
const base = options?.universesDir?.trim();
const root = base ? resolve(base) : defaultUniversesDir();
return join(root, universeSegment(universe));
}
/**
* Load a universe's rules from `universes/<universe>/rules.json`, or `null` if
* absent / invalid / empty. `null` means mechanics are off for this universe.
*/
export async function loadRules(
universe: string,
options?: RulesLoadOptions,
): Promise<RulesDefinition | null> {
const file = join(resolvePackageDir(universe, options), "rules.json");
try {
const raw = await readFile(file, "utf8");
return normalizeRules(JSON.parse(raw));
} catch {
// Missing file or bad JSON — no rules for this universe.
return null;
}
}