Spaces:
Running
Running
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 +105 -0
- packages/story-engine/tests/playthrough.test.ts +56 -0
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 |
+
});
|