فهرست منبع

feat: add interactive fiction base models

Ma 7 ماه پیش
والد
کامیت
01a082923d

+ 119 - 0
packages/core/src/__tests__/interactive-fiction-models.test.ts

@@ -0,0 +1,119 @@
+import { describe, expect, it } from "vitest";
+import { InteractiveBranchTreeSchema } from "../models/interactive-fiction.js";
+
+describe("InteractiveBranchTreeSchema", () => {
+  it("accepts a minimal branch tree with a root node and active pointer", () => {
+    const tree = InteractiveBranchTreeSchema.parse({
+      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: [],
+    });
+
+    expect(tree.activeNodeId).toBe("root");
+    expect(tree.rootNodeId).toBe("root");
+    expect(tree.nodes).toHaveLength(1);
+  });
+
+  it("accepts child choices that point to dormant branch nodes sharing a snapshot", () => {
+    const tree = InteractiveBranchTreeSchema.parse({
+      version: 1,
+      activeNodeId: "root",
+      rootNodeId: "root",
+      nodes: [
+        {
+          nodeId: "root",
+          parentNodeId: null,
+          sourceChapterId: null,
+          sourceChapterNumber: 0,
+          branchDepth: 0,
+          branchLabel: "Main Route",
+          status: "awaiting-choice",
+          snapshotRef: {
+            chapterNumber: 12,
+          },
+          selectedChoiceId: null,
+          chapterIds: ["ch-0012"],
+          displayPath: "main",
+        },
+        {
+          nodeId: "n-a",
+          parentNodeId: "root",
+          sourceChapterId: "ch-0012",
+          sourceChapterNumber: 12,
+          branchDepth: 1,
+          branchLabel: "Take the deal",
+          status: "dormant",
+          snapshotRef: {
+            chapterNumber: 12,
+          },
+          selectedChoiceId: null,
+          chapterIds: [],
+          displayPath: "main.a",
+        },
+      ],
+      choices: [
+        {
+          choiceId: "c-a",
+          fromNodeId: "root",
+          toNodeId: "n-a",
+          label: "Take the deal",
+          intent: "Accept the deal and enter the archive under watch.",
+          immediateGoal: "Get inside without losing leverage.",
+          expectedCost: "Owe a favor immediately.",
+          expectedRisk: "Hidden surveillance and debt.",
+          hookPressure: "Anonymous patron hook advances.",
+          characterPressure: "Trust between allies strains.",
+          tone: "tense",
+          selected: false,
+        },
+      ],
+    });
+
+    expect(tree.nodes[1]?.snapshotRef.chapterNumber).toBe(12);
+    expect(tree.choices[0]?.toNodeId).toBe("n-a");
+  });
+
+  it("rejects invalid branch node status", () => {
+    expect(() => InteractiveBranchTreeSchema.parse({
+      version: 1,
+      activeNodeId: "root",
+      rootNodeId: "root",
+      nodes: [
+        {
+          nodeId: "root",
+          parentNodeId: null,
+          sourceChapterId: null,
+          sourceChapterNumber: 0,
+          branchDepth: 0,
+          branchLabel: "Main Route",
+          status: "queued",
+          snapshotRef: {
+            chapterNumber: 0,
+          },
+          selectedChoiceId: null,
+          chapterIds: [],
+          displayPath: "main",
+        },
+      ],
+      choices: [],
+    })).toThrow();
+  });
+});

+ 21 - 0
packages/core/src/__tests__/models.test.ts

@@ -4,6 +4,7 @@ import {
   PlatformSchema,
   PlatformSchema,
   GenreSchema,
   GenreSchema,
   BookStatusSchema,
   BookStatusSchema,
+  NarrativeModeSchema,
 } from "../models/book.js";
 } from "../models/book.js";
 import { ChapterMetaSchema, ChapterStatusSchema } from "../models/chapter.js";
 import { ChapterMetaSchema, ChapterStatusSchema } from "../models/chapter.js";
 import {
 import {
@@ -122,6 +123,16 @@ describe("BookConfigSchema", () => {
       BookConfigSchema.parse({ ...validBook, createdAt: "not-a-date" }),
       BookConfigSchema.parse({ ...validBook, createdAt: "not-a-date" }),
     ).toThrow();
     ).toThrow();
   });
   });
+
+  it("defaults narrativeMode to linear", () => {
+    const result = BookConfigSchema.parse(validBook);
+    expect(result.narrativeMode).toBe("linear");
+  });
+
+  it("accepts interactive-tree narrative mode", () => {
+    const result = BookConfigSchema.parse({ ...validBook, narrativeMode: "interactive-tree" });
+    expect(result.narrativeMode).toBe("interactive-tree");
+  });
 });
 });
 
 
 describe("PlatformSchema", () => {
 describe("PlatformSchema", () => {
@@ -179,6 +190,16 @@ describe("BookStatusSchema", () => {
   });
   });
 });
 });
 
 
+describe("NarrativeModeSchema", () => {
+  it.each(["linear", "interactive-tree"] as const)("accepts '%s'", (value) => {
+    expect(NarrativeModeSchema.parse(value)).toBe(value);
+  });
+
+  it("rejects unknown narrative mode", () => {
+    expect(() => NarrativeModeSchema.parse("interactive")).toThrow();
+  });
+});
+
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------
 // ChapterMeta
 // ChapterMeta
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------

+ 13 - 1
packages/core/src/index.ts

@@ -1,5 +1,5 @@
 // Models
 // Models
-export { type BookConfig, type Platform, type Genre, type BookStatus, type FanficMode, BookConfigSchema, PlatformSchema, GenreSchema, BookStatusSchema, FanficModeSchema } from "./models/book.js";
+export { type BookConfig, type Platform, type Genre, type BookStatus, type FanficMode, type NarrativeMode, BookConfigSchema, PlatformSchema, GenreSchema, BookStatusSchema, FanficModeSchema, NarrativeModeSchema } from "./models/book.js";
 export { type ChapterMeta, type ChapterStatus, ChapterMetaSchema, ChapterStatusSchema } from "./models/chapter.js";
 export { type ChapterMeta, type ChapterStatus, ChapterMetaSchema, ChapterStatusSchema } from "./models/chapter.js";
 export { type ProjectConfig, type LLMConfig, type NotifyChannel, type DetectionConfig, type QualityGates, type AgentLLMOverride, type InputGovernanceMode, ProjectConfigSchema, LLMConfigSchema, AgentLLMOverrideSchema, DetectionConfigSchema, QualityGatesSchema, InputGovernanceModeSchema } from "./models/project.js";
 export { type ProjectConfig, type LLMConfig, type NotifyChannel, type DetectionConfig, type QualityGates, type AgentLLMOverride, type InputGovernanceMode, ProjectConfigSchema, LLMConfigSchema, AgentLLMOverrideSchema, DetectionConfigSchema, QualityGatesSchema, InputGovernanceModeSchema } from "./models/project.js";
 export { type CurrentState, type ParticleLedger, type PendingHooks, type PendingHook, type LedgerEntry } from "./models/state.js";
 export { type CurrentState, type ParticleLedger, type PendingHooks, type PendingHook, type LedgerEntry } from "./models/state.js";
@@ -7,6 +7,18 @@ export { type GenreProfile, type ParsedGenreProfile, GenreProfileSchema, parseGe
 export { type BookRules, type ParsedBookRules, BookRulesSchema, parseBookRules } from "./models/book-rules.js";
 export { type BookRules, type ParsedBookRules, BookRulesSchema, parseBookRules } from "./models/book-rules.js";
 export { type DetectionHistoryEntry, type DetectionStats } from "./models/detection.js";
 export { type DetectionHistoryEntry, type DetectionStats } from "./models/detection.js";
 export { type StyleProfile } from "./models/style-profile.js";
 export { type StyleProfile } from "./models/style-profile.js";
+export {
+  type InteractiveSnapshotRef,
+  type InteractiveBranchNodeStatus,
+  type InteractiveBranchNode,
+  type InteractiveChoice,
+  type InteractiveBranchTree,
+  InteractiveSnapshotRefSchema,
+  InteractiveBranchNodeStatusSchema,
+  InteractiveBranchNodeSchema,
+  InteractiveChoiceSchema,
+  InteractiveBranchTreeSchema,
+} from "./models/interactive-fiction.js";
 export { type LengthCountingMode, type LengthNormalizeMode, type LengthSpec, type LengthTelemetry, type LengthWarning, LengthCountingModeSchema, LengthNormalizeModeSchema, LengthSpecSchema, LengthTelemetrySchema, LengthWarningSchema } from "./models/length-governance.js";
 export { type LengthCountingMode, type LengthNormalizeMode, type LengthSpec, type LengthTelemetry, type LengthWarning, LengthCountingModeSchema, LengthNormalizeModeSchema, LengthSpecSchema, LengthTelemetrySchema, LengthWarningSchema } from "./models/length-governance.js";
 export {
 export {
   type RuntimeStateLanguage,
   type RuntimeStateLanguage,

+ 7 - 1
packages/core/src/models/book.ts

@@ -19,6 +19,9 @@ export type BookStatus = z.infer<typeof BookStatusSchema>;
 export const FanficModeSchema = z.enum(["canon", "au", "ooc", "cp"]);
 export const FanficModeSchema = z.enum(["canon", "au", "ooc", "cp"]);
 export type FanficMode = z.infer<typeof FanficModeSchema>;
 export type FanficMode = z.infer<typeof FanficModeSchema>;
 
 
+export const NarrativeModeSchema = z.enum(["linear", "interactive-tree"]);
+export type NarrativeMode = z.infer<typeof NarrativeModeSchema>;
+
 export const BookConfigSchema = z.object({
 export const BookConfigSchema = z.object({
   id: z.string().min(1),
   id: z.string().min(1),
   title: z.string().min(1),
   title: z.string().min(1),
@@ -28,10 +31,13 @@ export const BookConfigSchema = z.object({
   targetChapters: z.number().int().min(1).default(200),
   targetChapters: z.number().int().min(1).default(200),
   chapterWordCount: z.number().int().min(1000).default(3000),
   chapterWordCount: z.number().int().min(1000).default(3000),
   language: z.enum(["zh", "en"]).optional(),
   language: z.enum(["zh", "en"]).optional(),
+  narrativeMode: NarrativeModeSchema.default("linear"),
   createdAt: z.string().datetime(),
   createdAt: z.string().datetime(),
   updatedAt: z.string().datetime(),
   updatedAt: z.string().datetime(),
   parentBookId: z.string().optional(),
   parentBookId: z.string().optional(),
   fanficMode: FanficModeSchema.optional(),
   fanficMode: FanficModeSchema.optional(),
 });
 });
 
 
-export type BookConfig = z.infer<typeof BookConfigSchema>;
+export type BookConfig = Omit<z.infer<typeof BookConfigSchema>, "narrativeMode"> & {
+  narrativeMode?: NarrativeMode;
+};

+ 54 - 0
packages/core/src/models/interactive-fiction.ts

@@ -0,0 +1,54 @@
+import { z } from "zod";
+
+export const InteractiveSnapshotRefSchema = z.object({
+  chapterNumber: z.number().int().min(0),
+});
+export type InteractiveSnapshotRef = z.infer<typeof InteractiveSnapshotRefSchema>;
+
+export const InteractiveBranchNodeStatusSchema = z.enum([
+  "active",
+  "awaiting-choice",
+  "dormant",
+  "completed",
+]);
+export type InteractiveBranchNodeStatus = z.infer<typeof InteractiveBranchNodeStatusSchema>;
+
+export const InteractiveBranchNodeSchema = z.object({
+  nodeId: z.string().min(1),
+  parentNodeId: z.string().min(1).nullable(),
+  sourceChapterId: z.string().min(1).nullable(),
+  sourceChapterNumber: z.number().int().min(0),
+  branchDepth: z.number().int().min(0),
+  branchLabel: z.string().min(1),
+  status: InteractiveBranchNodeStatusSchema,
+  snapshotRef: InteractiveSnapshotRefSchema,
+  selectedChoiceId: z.string().min(1).nullable(),
+  chapterIds: z.array(z.string().min(1)).default([]),
+  displayPath: z.string().min(1),
+});
+export type InteractiveBranchNode = z.infer<typeof InteractiveBranchNodeSchema>;
+
+export const InteractiveChoiceSchema = z.object({
+  choiceId: z.string().min(1),
+  fromNodeId: z.string().min(1),
+  toNodeId: z.string().min(1),
+  label: z.string().min(1),
+  intent: z.string().min(1),
+  immediateGoal: z.string().min(1),
+  expectedCost: z.string().min(1),
+  expectedRisk: z.string().min(1),
+  hookPressure: z.string().min(1),
+  characterPressure: z.string().min(1),
+  tone: z.string().min(1),
+  selected: z.boolean(),
+});
+export type InteractiveChoice = z.infer<typeof InteractiveChoiceSchema>;
+
+export const InteractiveBranchTreeSchema = z.object({
+  version: z.literal(1),
+  activeNodeId: z.string().min(1),
+  rootNodeId: z.string().min(1),
+  nodes: z.array(InteractiveBranchNodeSchema).min(1),
+  choices: z.array(InteractiveChoiceSchema).default([]),
+});
+export type InteractiveBranchTree = z.infer<typeof InteractiveBranchTreeSchema>;