Просмотр исходного кода

feat: add interactive branch tree persistence helpers

Ma 7 месяцев назад
Родитель
Сommit
57daeadd88
2 измененных файлов с 133 добавлено и 0 удалено
  1. 71 0
      packages/core/src/__tests__/state-manager.test.ts
  2. 62 0
      packages/core/src/state/manager.ts

+ 71 - 0
packages/core/src/__tests__/state-manager.test.ts

@@ -5,6 +5,7 @@ import { join } from "node:path";
 import { StateManager } from "../state/manager.js";
 import type { BookConfig } from "../models/book.js";
 import type { ChapterMeta } from "../models/chapter.js";
+import type { InteractiveBranchTree } from "../models/interactive-fiction.js";
 
 describe("StateManager", () => {
   let tempDir: string;
@@ -617,6 +618,76 @@ describe("StateManager", () => {
         join(tempDir, "books", "my-book", "story", "state"),
       );
     });
+
+    it("interactiveDir returns <bookDir>/story/interactive", () => {
+      expect(manager.interactiveDir("my-book")).toBe(
+        join(tempDir, "books", "my-book", "story", "interactive"),
+      );
+    });
+  });
+
+  describe("interactive branch tree persistence", () => {
+    const tree: InteractiveBranchTree = {
+      version: 1,
+      activeNodeId: "root",
+      rootNodeId: "root",
+      nodes: [
+        {
+          nodeId: "root",
+          parentNodeId: null,
+          sourceChapterId: null,
+          sourceChapterNumber: 0,
+          branchDepth: 0,
+          branchLabel: "Main Route",
+          status: "active",
+          snapshotRef: { chapterNumber: 0 },
+          selectedChoiceId: null,
+          chapterIds: [],
+          displayPath: "main",
+        },
+      ],
+      choices: [],
+    };
+
+    it("round-trips branch tree through save and load", async () => {
+      await manager.saveBranchTree("interactive-book", tree);
+      const loaded = await manager.loadBranchTree("interactive-book");
+      expect(loaded).toEqual(tree);
+    });
+
+    it("returns null when no branch tree exists", async () => {
+      await expect(manager.loadBranchTree("missing-branch-tree")).resolves.toBeNull();
+    });
+
+    it("creates a root branch tree when ensuring interactive storage", async () => {
+      await manager.saveBookConfig("interactive-book", {
+        id: "interactive-book",
+        title: "Interactive Book",
+        platform: "tomato",
+        genre: "other",
+        status: "outlining",
+        targetChapters: 50,
+        chapterWordCount: 2200,
+        narrativeMode: "interactive-tree",
+        createdAt: "2026-03-30T00:00:00Z",
+        updatedAt: "2026-03-30T00:00:00Z",
+      });
+
+      const created = await manager.ensureInteractiveTree("interactive-book");
+
+      expect(created.rootNodeId).toBe("root");
+      expect(created.activeNodeId).toBe("root");
+      expect(created.nodes).toEqual([
+        expect.objectContaining({
+          nodeId: "root",
+          status: "active",
+          branchDepth: 0,
+        }),
+      ]);
+      await expect(
+        readFile(join(manager.interactiveDir("interactive-book"), "branch-tree.json"), "utf-8"),
+      ).resolves.toContain("\"rootNodeId\": \"root\"");
+    });
   });
 
   // -------------------------------------------------------------------------

+ 62 - 0
packages/core/src/state/manager.ts

@@ -2,6 +2,10 @@ import { readFile, writeFile, mkdir, readdir, rm, stat, unlink, open } from "nod
 import { join } from "node:path";
 import type { BookConfig } from "../models/book.js";
 import type { ChapterMeta } from "../models/chapter.js";
+import {
+  InteractiveBranchTreeSchema,
+  type InteractiveBranchTree,
+} from "../models/interactive-fiction.js";
 import { bootstrapStructuredStateFromMarkdown, resolveDurableStoryProgress } from "./state-bootstrap.js";
 
 export class StateManager {
@@ -145,6 +149,10 @@ export class StateManager {
     return join(this.bookDir(bookId), "story", "state");
   }
 
+  interactiveDir(bookId: string): string {
+    return join(this.bookDir(bookId), "story", "interactive");
+  }
+
   async loadProjectConfig(): Promise<Record<string, unknown>> {
     const configPath = join(this.projectRoot, "inkos.json");
     const raw = await readFile(configPath, "utf-8");
@@ -268,6 +276,60 @@ export class StateManager {
     );
   }
 
+  async loadBranchTree(bookId: string): Promise<InteractiveBranchTree | null> {
+    const treePath = join(this.interactiveDir(bookId), "branch-tree.json");
+    try {
+      const raw = await readFile(treePath, "utf-8");
+      return InteractiveBranchTreeSchema.parse(JSON.parse(raw));
+    } catch {
+      return null;
+    }
+  }
+
+  async saveBranchTree(bookId: string, tree: InteractiveBranchTree): Promise<void> {
+    const interactiveDir = this.interactiveDir(bookId);
+    await mkdir(interactiveDir, { recursive: true });
+    await writeFile(
+      join(interactiveDir, "branch-tree.json"),
+      JSON.stringify(InteractiveBranchTreeSchema.parse(tree), null, 2),
+      "utf-8",
+    );
+  }
+
+  async ensureInteractiveTree(bookId: string): Promise<InteractiveBranchTree> {
+    const existing = await this.loadBranchTree(bookId);
+    if (existing) {
+      return existing;
+    }
+
+    const rootTree: InteractiveBranchTree = {
+      version: 1,
+      activeNodeId: "root",
+      rootNodeId: "root",
+      nodes: [
+        {
+          nodeId: "root",
+          parentNodeId: null,
+          sourceChapterId: null,
+          sourceChapterNumber: 0,
+          branchDepth: 0,
+          branchLabel: "Main Route",
+          status: "active",
+          snapshotRef: {
+            chapterNumber: 0,
+          },
+          selectedChoiceId: null,
+          chapterIds: [],
+          displayPath: "main",
+        },
+      ],
+      choices: [],
+    };
+
+    await this.saveBranchTree(bookId, rootTree);
+    return rootTree;
+  }
+
   async snapshotState(bookId: string, chapterNumber: number): Promise<void> {
     await this.snapshotStateAt(this.bookDir(bookId), chapterNumber);
   }