import type { ContentBundle } from "@nengine/core"; import { arrowDirection } from "@nengine/render"; /** * A pending save produced by an open editor model. `content` is the * stringified YAML to persist; `error` models a model that cannot serialize * (invalid edits never write — the playtest must fail loudly before boot). */ export type PlaytestFlush = { path: string; content: string } | { path: string; error: string }; /** Collects the dirty models currently open in the editor (empty = nothing to save). */ export type PlaytestFlushCollector = () => PlaytestFlush[]; export interface SaveAllResult { flushed: string[]; failures: { path: string; error: string }[]; } /** Persist every dirty model; a rejecting write or an un-serializable model is * recorded as a failure (never thrown), so a single bad file cannot half-save. */ export async function saveAllFlushes( flushes: PlaytestFlush[], write: (path: string, content: string) => Promise, ): Promise { const flushed: string[] = []; const failures: { path: string; error: string }[] = []; for (const flush of flushes) { if ("error" in flush) { failures.push({ path: flush.path, error: flush.error }); continue; } try { await write(flush.path, flush.content); flushed.push(flush.path); } catch (err) { failures.push({ path: flush.path, error: err instanceof Error ? err.message : String(err) }); } } return { flushed, failures }; } export type PlaytestPhase = "idle" | "saving" | "compiling" | "booting" | "playing" | "stopping"; export interface PlaytestSessionDeps { collectFlushes: PlaytestFlushCollector; write: (path: string, content: string) => Promise; compile: () => Promise; boot: (bundle: ContentBundle) => Promise; teardown: () => Promise | void; onPhase?: (phase: PlaytestPhase) => void; } export interface PlaytestStartResult { ok: boolean; saved: string[]; error?: string; } export interface PlaytestSession { readonly phase: PlaytestPhase; start(): Promise; stop(): Promise; } /** The playtest lifecycle (E5): save-all → compile → boot → playing, with * stop() destroying the booted instance. Pure orchestration — every I/O side * is an injected dep, so the whole sequence is node-testable. */ export function createPlaytestSession(deps: PlaytestSessionDeps): PlaytestSession { let phase: PlaytestPhase = "idle"; function setPhase(next: PlaytestPhase): void { phase = next; deps.onPhase?.(next); } async function start(): Promise { if (phase !== "idle") { return { ok: false, saved: [], error: "playtest already running" }; } setPhase("saving"); const result = await saveAllFlushes(deps.collectFlushes(), deps.write); if (result.failures.length > 0) { setPhase("idle"); const lines = result.failures.map((f) => `- ${f.path}: ${f.error}`).join("\n"); return { ok: false, saved: result.flushed, error: `save-all failed:\n${lines}` }; } setPhase("compiling"); let bundle: ContentBundle; try { bundle = await deps.compile(); } catch (err) { setPhase("idle"); return { ok: false, saved: result.flushed, error: err instanceof Error ? err.message : String(err) }; } setPhase("booting"); try { await deps.boot(bundle); } catch (err) { setPhase("idle"); return { ok: false, saved: result.flushed, error: err instanceof Error ? err.message : String(err) }; } setPhase("playing"); return { ok: true, saved: result.flushed }; } async function stop(): Promise { if (phase === "idle") return; setPhase("stopping"); try { await deps.teardown(); } finally { setPhase("idle"); } } return { get phase() { return phase; }, start, stop }; } /** Map a keydown key to a raw `"key:Arrow*"` input string, or undefined for * non-arrow keys (the local-sync input mapping, D25). */ export function keydownInput(key: string): string | undefined { const input = `key:${key}`; return arrowDirection(input) ? input : undefined; }