/**
* Persistence for playthrough state: one JSON file per (universe + save slot).
*
* A **universe** is the reusable content package (world / characters / lore,
* loaded from `universes/<universe>/`). A **save slot** is one playthrough on
* top of it — its own turn counter, rolling memory and `/mj` directives — and
* lives in a SEPARATE `saves/` folder so the universe package stays pure,
* shareable content (no runtime data mixed in). Keeping them separate lets a
* player run several independent stories on the same world: same package,
* different save files. An empty save slot maps to `<universe>.json`; a named
* one to `<universe>__<save>.json`.
*
* Loading is defensive — a missing or corrupt file yields a fresh state rather
* than throwing, so a player never loses a session to a parse error. Saving is
* atomic (write to a temp file, then rename) to avoid half-written files.
*/
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { createState, State } from "./schema.js";
/** Default saves directory: a `saves/` folder in the plugin working dir. */
function defaultSavesDir(): string {
return resolve(process.cwd(), "saves");
}
/** Sanitize one id segment into a safe file-name fragment. */
function sanitizeSegment(id: string): string {
return id.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 100);
}
/**
* State file name for a universe + optional save slot. An empty/blank save slot
* yields the legacy `<universe>.json`; a named slot yields
* `<universe>__<save>.json`. Pure — exported for unit tests.
*/
export function stateFileName(universe: string, save?: string): string {
const u = sanitizeSegment(universe) || "default";
const s = sanitizeSegment((save ?? "").trim());
return s ? `${u}__${s}.json` : `${u}.json`;
}
/**
* Inverse of {@link stateFileName}: parse a state file name back into its
* universe and (optional) save slot. Returns null for anything that is not a
* `.json` state file (e.g. a half-written `.json.tmp`). The save slot is the
* part after the first `__`; a file with no `__` is the universe's default
* story (empty save). Pure — used by the config save-slot dropdown and tested.
*/
export function parseStateFileName(
fileName: string,
): { universe: string; save: string } | null {
if (!fileName.toLowerCase().endsWith(".json")) return null;
const stem = fileName.slice(0, -5);
if (stem === "") return null;
const idx = stem.indexOf("__");
if (idx === -1) return { universe: stem, save: "" };
return { universe: stem.slice(0, idx), save: stem.slice(idx + 2) };
}
export interface StoreOptions {
/** Override the saves directory (e.g. from tuning). Empty = default. */
savesDir?: string;
/**
* Save slot — which playthrough of the universe to load/store. Empty = the
* default story (`<universe>.json`); a name picks a separate, independent
* story on the same content base (`<universe>__<save>.json`).
*/
save?: string;
}
function resolveDir(options?: StoreOptions): string {
const dir = options?.savesDir?.trim();
return dir ? resolve(dir) : defaultSavesDir();
}
/** Load a universe's state, or create a fresh one if absent/invalid. */
export async function loadState(
universe: string,
options?: StoreOptions,
): Promise<State> {
const file = join(resolveDir(options), stateFileName(universe, options?.save));
try {
const raw = await readFile(file, "utf8");
const parsed = State.safeParse(JSON.parse(raw));
if (parsed.success) {
// Keep the id in sync with the file the player actually selected.
return { ...parsed.data, universe };
}
} catch {
// Missing file or bad JSON — fall through to a fresh state.
}
return createState(universe);
}
/** Persist a universe's state atomically. Validated before writing. */
export async function saveState(
state: State,
options?: StoreOptions,
): Promise<void> {
const validated = State.parse(state);
const dir = resolveDir(options);
await mkdir(dir, { recursive: true });
const file = join(dir, stateFileName(validated.universe, options?.save));
const tmp = `${file}.tmp`;
await writeFile(tmp, JSON.stringify(validated, null, 2), "utf8");
await rename(tmp, file);
}