playtest-session.ts 4.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124
  1. import type { ContentBundle } from "@nengine/core";
  2. import { arrowDirection } from "@nengine/render";
  3. /**
  4. * A pending save produced by an open editor model. `content` is the
  5. * stringified YAML to persist; `error` models a model that cannot serialize
  6. * (invalid edits never write — the playtest must fail loudly before boot).
  7. */
  8. export type PlaytestFlush = { path: string; content: string } | { path: string; error: string };
  9. /** Collects the dirty models currently open in the editor (empty = nothing to save). */
  10. export type PlaytestFlushCollector = () => PlaytestFlush[];
  11. export interface SaveAllResult {
  12. flushed: string[];
  13. failures: { path: string; error: string }[];
  14. }
  15. /** Persist every dirty model; a rejecting write or an un-serializable model is
  16. * recorded as a failure (never thrown), so a single bad file cannot half-save. */
  17. export async function saveAllFlushes(
  18. flushes: PlaytestFlush[],
  19. write: (path: string, content: string) => Promise<void>,
  20. ): Promise<SaveAllResult> {
  21. const flushed: string[] = [];
  22. const failures: { path: string; error: string }[] = [];
  23. for (const flush of flushes) {
  24. if ("error" in flush) {
  25. failures.push({ path: flush.path, error: flush.error });
  26. continue;
  27. }
  28. try {
  29. await write(flush.path, flush.content);
  30. flushed.push(flush.path);
  31. } catch (err) {
  32. failures.push({ path: flush.path, error: err instanceof Error ? err.message : String(err) });
  33. }
  34. }
  35. return { flushed, failures };
  36. }
  37. export type PlaytestPhase = "idle" | "saving" | "compiling" | "booting" | "playing" | "stopping";
  38. export interface PlaytestSessionDeps {
  39. collectFlushes: PlaytestFlushCollector;
  40. write: (path: string, content: string) => Promise<void>;
  41. compile: () => Promise<ContentBundle>;
  42. boot: (bundle: ContentBundle) => Promise<void>;
  43. teardown: () => Promise<void> | void;
  44. onPhase?: (phase: PlaytestPhase) => void;
  45. }
  46. export interface PlaytestStartResult {
  47. ok: boolean;
  48. saved: string[];
  49. error?: string;
  50. }
  51. export interface PlaytestSession {
  52. readonly phase: PlaytestPhase;
  53. start(): Promise<PlaytestStartResult>;
  54. stop(): Promise<void>;
  55. }
  56. /** The playtest lifecycle (E5): save-all → compile → boot → playing, with
  57. * stop() destroying the booted instance. Pure orchestration — every I/O side
  58. * is an injected dep, so the whole sequence is node-testable. */
  59. export function createPlaytestSession(deps: PlaytestSessionDeps): PlaytestSession {
  60. let phase: PlaytestPhase = "idle";
  61. function setPhase(next: PlaytestPhase): void {
  62. phase = next;
  63. deps.onPhase?.(next);
  64. }
  65. async function start(): Promise<PlaytestStartResult> {
  66. if (phase !== "idle") {
  67. return { ok: false, saved: [], error: "playtest already running" };
  68. }
  69. setPhase("saving");
  70. const result = await saveAllFlushes(deps.collectFlushes(), deps.write);
  71. if (result.failures.length > 0) {
  72. setPhase("idle");
  73. const lines = result.failures.map((f) => `- ${f.path}: ${f.error}`).join("\n");
  74. return { ok: false, saved: result.flushed, error: `save-all failed:\n${lines}` };
  75. }
  76. setPhase("compiling");
  77. let bundle: ContentBundle;
  78. try {
  79. bundle = await deps.compile();
  80. } catch (err) {
  81. setPhase("idle");
  82. return { ok: false, saved: result.flushed, error: err instanceof Error ? err.message : String(err) };
  83. }
  84. setPhase("booting");
  85. try {
  86. await deps.boot(bundle);
  87. } catch (err) {
  88. setPhase("idle");
  89. return { ok: false, saved: result.flushed, error: err instanceof Error ? err.message : String(err) };
  90. }
  91. setPhase("playing");
  92. return { ok: true, saved: result.flushed };
  93. }
  94. async function stop(): Promise<void> {
  95. if (phase === "idle") return;
  96. setPhase("stopping");
  97. try {
  98. await deps.teardown();
  99. } finally {
  100. setPhase("idle");
  101. }
  102. }
  103. return { get phase() { return phase; }, start, stop };
  104. }
  105. /** Map a keydown key to a raw `"key:Arrow*"` input string, or undefined for
  106. * non-arrow keys (the local-sync input mapping, D25). */
  107. export function keydownInput(key: string): string | undefined {
  108. const input = `key:${key}`;
  109. return arrowDirection(input) ? input : undefined;
  110. }