Nicolas Rabault Claude Sonnet 4.6 commited on
Commit
57d1b94
·
1 Parent(s): b644621

docs(content): authoritative SCHEMA + playthrough integration test

Browse files
content/SCHEMA.md ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Story Content Schema (Reachy Tales)
2
+
3
+ This file documents the **actual** schema the `story-engine` reads.
4
+ It supersedes the legacy `STORIES_DOCUMENTATION.md` from the original AI_game project.
5
+
6
+ ## Layout
7
+
8
+ ```
9
+ content/
10
+ ├── narrator/
11
+ │ └── narrator.json
12
+ └── <story_id>/
13
+ ├── manifest.json
14
+ ├── map.json
15
+ ├── flags.json
16
+ ├── story_state_template.json
17
+ ├── characters/<id>.json
18
+ ├── scenes/<id>.json
19
+ └── storylines/chapter<n>/<node_id>.json
20
+ ```
21
+
22
+ ## File formats
23
+
24
+ (Brief schema description — kept minimal to avoid drift; engine code is the source of truth.)
25
+
26
+ ### narrator.json / characters/<id>.json
27
+ ```json
28
+ {
29
+ "name": "string",
30
+ "description": "string",
31
+ "introduction": "string",
32
+ "background": "string",
33
+ "goal": "string",
34
+ "voice": "string (OpenAI Realtime voice preset name, e.g. \"cedar\", \"ash\")",
35
+ "voice_tone": "string"
36
+ }
37
+ ```
38
+
39
+ ### manifest.json
40
+ ```json
41
+ {
42
+ "id": "string (matches folder name)",
43
+ "title": "string",
44
+ "description": "string",
45
+ "languages": ["en"],
46
+ "entry_point": "storylines/chapter1/<node_id>.json",
47
+ "narrator": { /* same shape as a character */ }
48
+ }
49
+ ```
50
+
51
+ ### scenes/<id>.json
52
+ ```json
53
+ {
54
+ "name": "string",
55
+ "description": "string",
56
+ "actions": [ { "id": "string", "description": "string", "effects": "string" } ],
57
+ "characters_present": ["character_id_or_name"]
58
+ }
59
+ ```
60
+
61
+ ### storylines/chapter<n>/<node_id>.json
62
+ ```json
63
+ {
64
+ "id": "string",
65
+ "scene": "scene_id",
66
+ "type": "dialogue | narration | character_interaction",
67
+ "speaker": "character_id_or_name | narrator | \"\"",
68
+ "text": "string",
69
+ "actions": [
70
+ {
71
+ "id": "string",
72
+ "description": "string",
73
+ "next": "node_id (optional, end-node if omitted)",
74
+ "effects": { "set_flags": { "flag_name": true } }
75
+ }
76
+ ]
77
+ }
78
+ ```
79
+
80
+ ### map.json
81
+ ```json
82
+ [
83
+ {
84
+ "from": "scene_id",
85
+ "to": "scene_id",
86
+ "description": "string",
87
+ "conditions": [
88
+ {
89
+ "type": "requires_flag",
90
+ "flag": "flag_name",
91
+ "condition_failed": "string (player-facing explanation)"
92
+ }
93
+ ]
94
+ }
95
+ ]
96
+ ```
97
+
98
+ ### flags.json
99
+ ```json
100
+ ["flag_name_1", "flag_name_2"]
101
+ ```
102
+
103
+ ## Notes
104
+ - All strings are English. Bilingual `{ "en": "...", "fr": "..." }` envelopes from the legacy schema are NOT supported.
105
+ - Image fields (`picture`, `cover_image`) are ignored by the engine.
packages/story-engine/tests/playthrough.test.ts ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect } from "vitest";
2
+ import { StoryEngine } from "../src/engine.js";
3
+ import { loadNarrator, loadStory } from "../src/loader.js";
4
+ import { readFileSync, readdirSync } from "node:fs";
5
+ import { resolve } from "node:path";
6
+ import type { LoadedContent } from "../src/types.js";
7
+
8
+ const CONTENT = resolve(__dirname, "../../../content");
9
+ function readJsonFile(p: string): unknown { return JSON.parse(readFileSync(p, "utf-8")); }
10
+ function readJsonDir(dir: string): Record<string, unknown> {
11
+ const out: Record<string, unknown> = {};
12
+ for (const f of readdirSync(dir)) if (f.endsWith(".json")) out[f.replace(/\.json$/, "")] = readJsonFile(resolve(dir, f));
13
+ return out;
14
+ }
15
+
16
+ function loadRealContent(): LoadedContent {
17
+ const narrator = loadNarrator(readJsonFile(resolve(CONTENT, "narrator/narrator.json")));
18
+ const pirates = loadStory({
19
+ manifest: readJsonFile(resolve(CONTENT, "story_pirates/manifest.json")),
20
+ map: readJsonFile(resolve(CONTENT, "story_pirates/map.json")),
21
+ flags: readJsonFile(resolve(CONTENT, "story_pirates/flags.json")),
22
+ characters: readJsonDir(resolve(CONTENT, "story_pirates/characters")),
23
+ scenes: readJsonDir(resolve(CONTENT, "story_pirates/scenes")),
24
+ storylines: readJsonDir(resolve(CONTENT, "story_pirates/storylines/chapter1")),
25
+ });
26
+ return { narrator, stories: { story_pirates: pirates } };
27
+ }
28
+
29
+ describe("story_pirates playthrough", () => {
30
+ it("loads cleanly and reports the expected starting state", () => {
31
+ const engine = new StoryEngine(loadRealContent());
32
+ const r = engine.enterStoryMode("story_pirates");
33
+ expect(r.events.find((e) => e.type === "blocked")).toBeUndefined();
34
+ expect(r.state.currentNodeId).toBe("meet_sparrow");
35
+ expect(r.state.currentSceneId).toBe("tavern");
36
+ });
37
+
38
+ it("happy-path traversal: respect Sparrow → ask treasure → ... reaches an end", () => {
39
+ const engine = new StoryEngine(loadRealContent());
40
+ engine.enterStoryMode("story_pirates");
41
+ // Walk the friendly branch as far as the data allows
42
+ const visited: string[] = [];
43
+ const walk = (actionId: string, maxSteps = 20): void => {
44
+ for (let step = 0; step < maxSteps; step++) {
45
+ const r = engine.advanceStoryline(actionId);
46
+ if (r.events.some((e) => e.type === "blocked")) break;
47
+ const node = engine.getCurrentNode();
48
+ if (!node || node.actions.length === 0) break;
49
+ visited.push(node.id);
50
+ actionId = node.actions[0]!.id;
51
+ }
52
+ };
53
+ walk("greet_respectfully");
54
+ expect(visited.length).toBeGreaterThan(0);
55
+ });
56
+ });