Spaces:
Running
Reachy Tales Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Reboot the AI_game story project as a Reachy Mini JS app with a clean two-package layout β story-engine library + HF Space β shipping the translated story_pirates end-to-end in English with embodied audio narration.
Architecture: Pure TypeScript story-engine library (no DOM / robot / LLM deps) lives at packages/story-engine/. JS HF Space app at repo root copies plumbing from tfrere/reachy-mini-minimal-js-conversation-app and wires the engine into OpenAI Realtime via a new src/story-bridge.ts module. State-in-instructions pattern with regenerated tool defs per transition. Voice swap on character change via OpenAI Realtime session restart with a gpt-4o-mini-generated context summary.
Tech Stack: TypeScript, npm workspaces, Vite 5, Vitest, OpenAI Realtime API + chat-completions, Reachy Mini JS SDK (jsDelivr CDN), Docker + nginx, Hugging Face Spaces (static-Docker).
Source spec: docs/superpowers/specs/2026-04-29-reachy-tales-design.md (in this AI_game/ directory).
Reference materials to fetch as needed:
- Reachy Mini JS SDK:
https://github.com/pollen-robotics/reachy_mini(branchfeat/ehance-js-lib), filejs/reachy-mini.js. - Reference JS conversation app:
https://huggingface.co/spaces/tfrere/reachy-mini-minimal-js-conversation-app(raw files athttps://huggingface.co/spaces/tfrere/reachy-mini-minimal-js-conversation-app/raw/main/<path>). - Original story content:
/Users/nicolasrabault/Projects/AI_game/Stories/story_pirates/and/Users/nicolasrabault/Projects/AI_game/Stories/narrator/.
Repo location target: /Users/nicolasrabault/Projects/reachy-tales/ (create at start of plan).
Phase 1 β Repo bootstrap
Task 1: Create the repo and workspace skeleton
Files:
Create:
/Users/nicolasrabault/Projects/reachy-tales/.gitignoreCreate:
/Users/nicolasrabault/Projects/reachy-tales/package.jsonCreate:
/Users/nicolasrabault/Projects/reachy-tales/tsconfig.base.jsonCreate:
/Users/nicolasrabault/Projects/reachy-tales/README.mdStep 1: Create directory and init git
mkdir -p /Users/nicolasrabault/Projects/reachy-tales
cd /Users/nicolasrabault/Projects/reachy-tales
git init
- Step 2: Write
.gitignore
node_modules/
dist/
*.log
.DS_Store
.env
.env.local
.vite/
coverage/
- Step 3: Write root
package.json
{
"name": "reachy-tales",
"private": true,
"version": "0.1.0",
"type": "module",
"workspaces": [
"packages/*"
],
"scripts": {
"dev": "vite",
"build": "tsc -p tsconfig.app.json --noEmit && vite build",
"preview": "vite preview",
"test": "npm run test --workspaces --if-present"
},
"devDependencies": {
"typescript": "^5.6.3",
"vite": "^5.4.10"
}
}
- Step 4: Write
tsconfig.base.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true
}
}
- Step 5: Write a minimal root
README.md(HF Space frontmatter comes later when we deploy)
# Reachy Tales
Voice-driven story adventures, narrated by your Reachy Mini.
See [design spec](docs/superpowers/specs/2026-04-29-reachy-tales-design.md).
- Step 6: Initial commit
git add .gitignore package.json tsconfig.base.json README.md
git commit -m "chore: bootstrap reachy-tales workspace"
Task 2: Scaffold the story-engine package
Files:
Create:
packages/story-engine/package.jsonCreate:
packages/story-engine/tsconfig.jsonCreate:
packages/story-engine/vitest.config.tsCreate:
packages/story-engine/src/index.tsStep 1: Create the package directory tree
mkdir -p packages/story-engine/src packages/story-engine/tests
- Step 2: Write
packages/story-engine/package.json
{
"name": "story-engine",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"test": "vitest run",
"test:watch": "vitest"
},
"devDependencies": {
"vitest": "^2.1.0"
}
}
- Step 3: Write
packages/story-engine/tsconfig.json
{
"extends": "../../tsconfig.base.json",
"include": ["src/**/*", "tests/**/*"]
}
- Step 4: Write
packages/story-engine/vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["tests/**/*.test.ts"],
environment: "node",
},
});
- Step 5: Write a placeholder
packages/story-engine/src/index.ts(kept minimal so npm install + initial test works)
export const ENGINE_VERSION = "0.1.0";
- Step 6: Install workspace deps
cd /Users/nicolasrabault/Projects/reachy-tales
npm install
Expected: node_modules/ created, package-lock.json written, story-engine symlinked from node_modules/story-engine.
- Step 7: Verify Vitest runs (with no tests)
npm test --workspace story-engine
Expected: "No test files found" β exits 0 with --passWithNoTests? If exits non-zero, add --passWithNoTests to the script. Update package.json test script if needed:
"test": "vitest run --passWithNoTests"
- Step 8: Commit
git add package-lock.json packages/
git commit -m "chore: scaffold story-engine package"
Phase 2 β Engine library
Task 3: Engine types
Files:
Create:
packages/story-engine/src/types.tsStep 1: Write
types.tsβ the full data model. No tests yet (types only); they become testable when we use them in the loader.
// βββ Loaded data (immutable after load) ββββββββββββββββββββββββββββββββββ
export interface Narrator {
id: "narrator"; // sentinel id for the global narrator
name: string;
description: string;
introduction: string;
background: string;
goal: string;
voice: string; // OpenAI Realtime voice preset name
voiceTone: string;
}
export interface Character {
id: string; // derived from filename stem (e.g. "sparrow")
name: string;
description: string;
introduction: string;
background: string;
goal: string;
voice: string; // OpenAI Realtime voice preset name
voiceTone: string;
}
export interface SceneAction {
id: string;
description: string;
effects: string; // narrative description, no flag effects
}
export interface Scene {
id: string; // derived from filename stem
name: string;
description: string;
actions: SceneAction[]; // atmospheric actions
charactersPresent: string[]; // character ids
}
export interface StorylineActionEffects {
setFlags?: Record<string, boolean>;
}
export interface StorylineAction {
id: string;
description: string;
next?: string; // next storyline node id
effects?: StorylineActionEffects;
}
export type StorylineType = "dialogue" | "narration" | "character_interaction";
export interface StorylineNode {
id: string;
scene: string; // scene id
type: StorylineType;
speaker: string; // character id, "narrator", or empty string
text: string;
actions: StorylineAction[];
}
export interface FlagCondition {
type: "requires_flag";
flag: string;
conditionFailed: string; // explanation if blocked
}
export interface MapEdge {
from: string; // scene id
to: string; // scene id
description: string;
conditions?: FlagCondition[];
}
export interface Story {
id: string;
title: string;
description: string;
languages: string[];
entryPoint: string; // path relative to story root, e.g. "storylines/chapter1/meet_sparrow.json"
narrator: Character | Narrator; // story-specific or global
characters: Record<string, Character>;
scenes: Record<string, Scene>;
storylines: Record<string, StorylineNode>;
map: MapEdge[];
flags: string[]; // declared flag names (flat array)
}
export interface LoadedContent {
narrator: Narrator; // global menu narrator
stories: Record<string, Story>;
}
// βββ Mutable state ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
export type AppMode = "menu" | "story";
export interface GameState {
mode: AppMode;
storyId?: string;
currentSceneId?: string;
currentNodeId?: string;
currentSpeaker?: string; // character id, or "narrator"
flags: Record<string, boolean>;
discoveredScenes: string[];
metCharacters: string[];
recentTransitions: TransitionEvent[]; // bounded log for summary builder
}
// βββ Transition events ββββββββββββββββββββββββββββββββββββββββββββββββββββ
export type TransitionEvent =
| { type: "speakerChanged"; from?: string; to: string;
newCharacter: Character | Narrator }
| { type: "sceneChanged"; from?: string; to: string }
| { type: "storyEntered"; storyId: string }
| { type: "storyExited" }
| { type: "flagSet"; name: string; value: boolean }
| { type: "blocked"; reason: string };
export interface TransitionResult {
state: Readonly<GameState>;
events: TransitionEvent[];
}
// βββ Tool definitions for OpenAI Realtime βββββββββββββββββββββββββββββββββ
export interface ToolDef {
name: string;
description: string;
parameters: {
type: "object";
properties: Record<string, unknown>;
required: string[];
};
}
- Step 2: Verify it type-checks
cd /Users/nicolasrabault/Projects/reachy-tales/packages/story-engine
npx tsc --noEmit
Expected: no errors.
- Step 3: Commit
git add packages/story-engine/src/types.ts
git commit -m "feat(engine): define core types for story data and game state"
Task 4: Engine loader β narrator only (test-first)
Files:
- Create:
packages/story-engine/src/loader.ts - Create:
packages/story-engine/tests/loader.test.ts - Create:
packages/story-engine/tests/fixtures/narrator/narrator.json
The loader will eventually load from a Vite-bundled content/ directory via dynamic JSON imports, but for testability we make it accept arbitrary JSON objects. We'll test against fixture JSON written here, then use the same loader in the app with bundled content.
- Step 1: Write test fixture β a minimal narrator JSON
packages/story-engine/tests/fixtures/narrator/narrator.json:
{
"name": "The Narrator",
"description": "A cheerful storyteller",
"introduction": "Welcome adventurer!",
"background": "An entity who guides players through stories.",
"goal": "Help the player pick a story.",
"voice": "cedar",
"voice_tone": "Warm, enthusiastic, brief."
}
- Step 2: Write the failing test
packages/story-engine/tests/loader.test.ts:
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { loadNarrator } from "../src/loader.js";
const narratorJson = JSON.parse(
readFileSync(resolve(__dirname, "fixtures/narrator/narrator.json"), "utf-8"),
);
describe("loadNarrator", () => {
it("normalizes a narrator JSON into the Narrator type", () => {
const narrator = loadNarrator(narratorJson);
expect(narrator.id).toBe("narrator");
expect(narrator.name).toBe("The Narrator");
expect(narrator.voice).toBe("cedar");
expect(narrator.voiceTone).toBe("Warm, enthusiastic, brief.");
});
});
- Step 3: Run the test to confirm it fails
cd /Users/nicolasrabault/Projects/reachy-tales
npm test --workspace story-engine
Expected: FAIL β loadNarrator not exported.
- Step 4: Implement
loader.tswith justloadNarrator
packages/story-engine/src/loader.ts:
import type { Narrator } from "./types.js";
interface RawNarrator {
name: string;
description: string;
introduction: string;
background: string;
goal: string;
voice: string;
voice_tone: string;
// legacy fields ignored: picture, tools, etc.
}
export function loadNarrator(raw: unknown): Narrator {
const r = raw as RawNarrator;
if (!r || typeof r !== "object") {
throw new Error("Narrator JSON must be an object");
}
for (const field of ["name", "description", "introduction", "background", "goal", "voice", "voice_tone"] as const) {
if (typeof r[field] !== "string" || !r[field]) {
throw new Error(`Narrator missing required string field: ${field}`);
}
}
return {
id: "narrator",
name: r.name,
description: r.description,
introduction: r.introduction,
background: r.background,
goal: r.goal,
voice: r.voice,
voiceTone: r.voice_tone,
};
}
- Step 5: Run the test, confirm it passes
npm test --workspace story-engine
Expected: PASS.
- Step 6: Commit
git add packages/story-engine/
git commit -m "feat(engine): loader for narrator JSON"
Task 5: Engine loader β character + scene + storyline node
Files:
Modify:
packages/story-engine/src/loader.tsModify:
packages/story-engine/tests/loader.test.tsCreate:
packages/story-engine/tests/fixtures/story_test/characters/sparrow.jsonCreate:
packages/story-engine/tests/fixtures/story_test/scenes/tavern.jsonCreate:
packages/story-engine/tests/fixtures/story_test/storylines/chapter1/meet_sparrow.jsonStep 1: Write fixtures
packages/story-engine/tests/fixtures/story_test/characters/sparrow.json:
{
"name": "Sparrow",
"description": "A ruthless pirate captain.",
"introduction": "Captain Sparrow at your service.",
"background": "Notorious pirate of the seven seas.",
"goal": "Amass wealth through piracy.",
"voice": "ash",
"voice_tone": "Deep, raspy, threatening but conversational."
}
packages/story-engine/tests/fixtures/story_test/scenes/tavern.json:
{
"name": "The Rusty Anchor Tavern",
"description": "A dimly lit tavern, thick with pipe smoke.",
"actions": [
{
"id": "examine_memorabilia",
"description": "examine the nautical memorabilia",
"effects": "the user examines old maps and rusted cutlasses on the walls."
}
],
"characters_present": ["Sparrow"],
"picture": "tavern.png"
}
packages/story-engine/tests/fixtures/story_test/storylines/chapter1/meet_sparrow.json:
{
"id": "meet_sparrow",
"scene": "tavern",
"type": "dialogue",
"speaker": "Sparrow",
"text": "Captain Sparrow at your service. Friend or foe?",
"actions": [
{
"id": "greet_respectfully",
"next": "friendly_response",
"effects": { "set_flags": { "trust_sparrow": true } },
"description": "Show respect to the captain"
},
{
"id": "mock_sparrow",
"next": "hostile_response",
"effects": { "set_flags": { "trust_sparrow": false } },
"description": "Mock the so-called captain"
}
]
}
- Step 2: Add failing tests for each loader
Append to packages/story-engine/tests/loader.test.ts:
import { loadCharacter, loadScene, loadStorylineNode } from "../src/loader.js";
const sparrowJson = JSON.parse(readFileSync(resolve(__dirname, "fixtures/story_test/characters/sparrow.json"), "utf-8"));
const tavernJson = JSON.parse(readFileSync(resolve(__dirname, "fixtures/story_test/scenes/tavern.json"), "utf-8"));
const meetSparrowJson = JSON.parse(readFileSync(resolve(__dirname, "fixtures/story_test/storylines/chapter1/meet_sparrow.json"), "utf-8"));
describe("loadCharacter", () => {
it("normalizes a character JSON, deriving id from filename", () => {
const c = loadCharacter("sparrow", sparrowJson);
expect(c.id).toBe("sparrow");
expect(c.name).toBe("Sparrow");
expect(c.voice).toBe("ash");
expect(c.voiceTone).toBe("Deep, raspy, threatening but conversational.");
});
});
describe("loadScene", () => {
it("normalizes a scene JSON, deriving id from filename", () => {
const s = loadScene("tavern", tavernJson);
expect(s.id).toBe("tavern");
expect(s.name).toBe("The Rusty Anchor Tavern");
expect(s.actions).toHaveLength(1);
expect(s.actions[0]!.id).toBe("examine_memorabilia");
expect(s.charactersPresent).toEqual(["Sparrow"]);
});
});
describe("loadStorylineNode", () => {
it("normalizes a storyline node, mapping set_flags β setFlags", () => {
const n = loadStorylineNode(meetSparrowJson);
expect(n.id).toBe("meet_sparrow");
expect(n.speaker).toBe("Sparrow");
expect(n.actions).toHaveLength(2);
expect(n.actions[0]!.id).toBe("greet_respectfully");
expect(n.actions[0]!.next).toBe("friendly_response");
expect(n.actions[0]!.effects?.setFlags).toEqual({ trust_sparrow: true });
});
});
- Step 3: Run tests to confirm they fail
npm test --workspace story-engine
Expected: FAIL β functions not exported.
- Step 4: Implement the three loaders β append to
packages/story-engine/src/loader.ts:
import type { Character, Scene, SceneAction, StorylineAction, StorylineNode, StorylineType } from "./types.js";
interface RawCharacter {
name: string;
description: string;
introduction: string;
background: string;
goal: string;
voice: string;
voice_tone: string;
}
export function loadCharacter(id: string, raw: unknown): Character {
const r = raw as RawCharacter;
if (!r || typeof r !== "object") throw new Error(`Character ${id}: not an object`);
for (const field of ["name", "description", "introduction", "background", "goal", "voice", "voice_tone"] as const) {
if (typeof r[field] !== "string" || !r[field]) {
throw new Error(`Character ${id} missing required string field: ${field}`);
}
}
return {
id,
name: r.name,
description: r.description,
introduction: r.introduction,
background: r.background,
goal: r.goal,
voice: r.voice,
voiceTone: r.voice_tone,
};
}
interface RawSceneAction {
id: string;
description: string;
effects: string;
}
interface RawScene {
name: string;
description: string;
actions: RawSceneAction[];
characters_present: string[];
}
export function loadScene(id: string, raw: unknown): Scene {
const r = raw as RawScene;
if (!r || typeof r !== "object") throw new Error(`Scene ${id}: not an object`);
if (typeof r.name !== "string" || typeof r.description !== "string") {
throw new Error(`Scene ${id} missing name/description`);
}
if (!Array.isArray(r.actions)) throw new Error(`Scene ${id}: actions must be an array`);
if (!Array.isArray(r.characters_present)) throw new Error(`Scene ${id}: characters_present must be an array`);
const actions: SceneAction[] = r.actions.map((a, i) => {
if (typeof a.id !== "string" || typeof a.description !== "string" || typeof a.effects !== "string") {
throw new Error(`Scene ${id} action[${i}]: missing id/description/effects`);
}
return { id: a.id, description: a.description, effects: a.effects };
});
return {
id,
name: r.name,
description: r.description,
actions,
charactersPresent: r.characters_present,
};
}
interface RawStorylineAction {
id: string;
description: string;
next?: string;
effects?: { set_flags?: Record<string, boolean> };
}
interface RawStorylineNode {
id: string;
scene: string;
type: string;
speaker: string;
text: string;
actions: RawStorylineAction[];
}
export function loadStorylineNode(raw: unknown): StorylineNode {
const r = raw as RawStorylineNode;
if (!r || typeof r !== "object") throw new Error("Storyline node: not an object");
for (const field of ["id", "scene", "type", "speaker", "text"] as const) {
if (typeof r[field] !== "string") throw new Error(`Storyline ${r.id ?? "?"} missing field: ${field}`);
}
if (!["dialogue", "narration", "character_interaction"].includes(r.type)) {
throw new Error(`Storyline ${r.id}: unknown type "${r.type}"`);
}
if (!Array.isArray(r.actions)) throw new Error(`Storyline ${r.id}: actions must be an array`);
const actions: StorylineAction[] = r.actions.map((a, i) => {
if (typeof a.id !== "string" || typeof a.description !== "string") {
throw new Error(`Storyline ${r.id} action[${i}]: missing id/description`);
}
return {
id: a.id,
description: a.description,
next: a.next,
effects: a.effects?.set_flags ? { setFlags: a.effects.set_flags } : undefined,
};
});
return {
id: r.id,
scene: r.scene,
type: r.type as StorylineType,
speaker: r.speaker,
text: r.text,
actions,
};
}
- Step 5: Run tests, confirm they pass
npm test --workspace story-engine
Expected: PASS (4 tests total now).
- Step 6: Commit
git add packages/story-engine/
git commit -m "feat(engine): loaders for character, scene, storyline node"
Task 6: Engine loader β map edges, flags, story manifest, full story
Files:
Modify:
packages/story-engine/src/loader.tsModify:
packages/story-engine/tests/loader.test.tsCreate:
packages/story-engine/tests/fixtures/story_test/manifest.jsonCreate:
packages/story-engine/tests/fixtures/story_test/map.jsonCreate:
packages/story-engine/tests/fixtures/story_test/flags.jsonCreate:
packages/story-engine/tests/fixtures/story_test/storylines/chapter1/friendly_response.jsonCreate:
packages/story-engine/tests/fixtures/story_test/storylines/chapter1/hostile_response.jsonCreate:
packages/story-engine/tests/fixtures/story_test/scenes/harbor.jsonStep 1: Write manifest, map, flags fixtures
packages/story-engine/tests/fixtures/story_test/manifest.json:
{
"id": "story_test",
"title": "Test Story",
"description": "A short test story.",
"languages": ["en"],
"entry_point": "storylines/chapter1/meet_sparrow.json",
"narrator": {
"name": "Test Narrator",
"description": "story-specific narrator",
"introduction": "Ahoy!",
"background": "Pirate storyteller.",
"goal": "Tell the pirate tale.",
"voice": "ballad",
"voice_tone": "Salty and brief."
}
}
packages/story-engine/tests/fixtures/story_test/map.json:
[
{
"from": "tavern",
"to": "harbor",
"description": "Head to the harbor"
},
{
"from": "harbor",
"to": "tavern",
"description": "Return to the tavern"
},
{
"from": "tavern",
"to": "docks",
"description": "Sneak to the docks",
"conditions": [
{
"type": "requires_flag",
"flag": "trust_sparrow",
"condition_failed": "You don't trust him enough to follow yet."
}
]
}
]
packages/story-engine/tests/fixtures/story_test/flags.json:
["trust_sparrow", "found_treasure"]
packages/story-engine/tests/fixtures/story_test/storylines/chapter1/friendly_response.json:
{
"id": "friendly_response",
"scene": "tavern",
"type": "dialogue",
"speaker": "Sparrow",
"text": "Aye, that's the spirit!",
"actions": []
}
packages/story-engine/tests/fixtures/story_test/storylines/chapter1/hostile_response.json:
{
"id": "hostile_response",
"scene": "tavern",
"type": "dialogue",
"speaker": "Sparrow",
"text": "Bold of you, scallywag!",
"actions": []
}
packages/story-engine/tests/fixtures/story_test/scenes/harbor.json:
{
"name": "Port Royal Harbor",
"description": "A bustling harbor smelling of salt and tar.",
"actions": [],
"characters_present": []
}
- Step 2: Write failing tests for
loadStoryandloadMapEdgesβ append:
import { loadStory, loadMapEdges, loadFlags } from "../src/loader.js";
import { readdirSync } from "node:fs";
const STORY_FIXTURE_DIR = resolve(__dirname, "fixtures/story_test");
function readJsonFile(path: string): unknown {
return JSON.parse(readFileSync(path, "utf-8"));
}
function readJsonDir(dir: string): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const file of readdirSync(dir)) {
if (!file.endsWith(".json")) continue;
const id = file.replace(/\.json$/, "");
result[id] = readJsonFile(resolve(dir, file));
}
return result;
}
describe("loadFlags", () => {
it("loads a flat array of flag names", () => {
const flags = loadFlags(readJsonFile(resolve(STORY_FIXTURE_DIR, "flags.json")));
expect(flags).toEqual(["trust_sparrow", "found_treasure"]);
});
});
describe("loadMapEdges", () => {
it("normalizes map edges, mapping condition_failed β conditionFailed", () => {
const edges = loadMapEdges(readJsonFile(resolve(STORY_FIXTURE_DIR, "map.json")));
expect(edges).toHaveLength(3);
expect(edges[2]!.conditions).toEqual([
{ type: "requires_flag", flag: "trust_sparrow", conditionFailed: "You don't trust him enough to follow yet." },
]);
});
});
describe("loadStory", () => {
it("loads a complete story from raw inputs", () => {
const story = loadStory({
manifest: readJsonFile(resolve(STORY_FIXTURE_DIR, "manifest.json")),
map: readJsonFile(resolve(STORY_FIXTURE_DIR, "map.json")),
flags: readJsonFile(resolve(STORY_FIXTURE_DIR, "flags.json")),
characters: readJsonDir(resolve(STORY_FIXTURE_DIR, "characters")),
scenes: readJsonDir(resolve(STORY_FIXTURE_DIR, "scenes")),
storylines: readJsonDir(resolve(STORY_FIXTURE_DIR, "storylines/chapter1")),
});
expect(story.id).toBe("story_test");
expect(story.title).toBe("Test Story");
expect(story.entryPoint).toBe("storylines/chapter1/meet_sparrow.json");
expect(Object.keys(story.characters)).toContain("sparrow");
expect(Object.keys(story.scenes)).toEqual(expect.arrayContaining(["tavern", "harbor"]));
expect(Object.keys(story.storylines)).toContain("meet_sparrow");
expect(story.map).toHaveLength(3);
expect(story.flags).toEqual(["trust_sparrow", "found_treasure"]);
expect(story.narrator.voice).toBe("ballad");
});
it("validates that storyline.scene refers to a real scene", () => {
expect(() =>
loadStory({
manifest: readJsonFile(resolve(STORY_FIXTURE_DIR, "manifest.json")),
map: [],
flags: [],
characters: {},
scenes: { tavern: readJsonFile(resolve(STORY_FIXTURE_DIR, "scenes/tavern.json")) },
storylines: { bad_node: { id: "bad_node", scene: "nonexistent", type: "dialogue", speaker: "x", text: "y", actions: [] } },
})
).toThrow(/scene "nonexistent"/);
});
it("validates that action.next refers to a real storyline node", () => {
expect(() =>
loadStory({
manifest: readJsonFile(resolve(STORY_FIXTURE_DIR, "manifest.json")),
map: [],
flags: [],
characters: {},
scenes: { tavern: readJsonFile(resolve(STORY_FIXTURE_DIR, "scenes/tavern.json")) },
storylines: {
a: { id: "a", scene: "tavern", type: "dialogue", speaker: "x", text: "y",
actions: [{ id: "go", description: "go", next: "missing" }] },
},
})
).toThrow(/storyline "missing"/);
});
});
- Step 3: Run tests β expected to fail
npm test --workspace story-engine
Expected: FAIL β loadStory, loadMapEdges, loadFlags not exported.
- Step 4: Implement the new loaders β append to
packages/story-engine/src/loader.ts:
import type { MapEdge, FlagCondition, Story } from "./types.js";
interface RawCondition {
type: string;
flag: string;
condition_failed: string;
}
interface RawMapEdge {
from: string;
to: string;
description: string;
conditions?: RawCondition[];
}
export function loadMapEdges(raw: unknown): MapEdge[] {
if (!Array.isArray(raw)) throw new Error("map.json must be an array");
return raw.map((e: RawMapEdge, i) => {
if (typeof e.from !== "string" || typeof e.to !== "string" || typeof e.description !== "string") {
throw new Error(`Map edge[${i}]: missing from/to/description`);
}
const conditions: FlagCondition[] | undefined = e.conditions?.map((c, j) => {
if (c.type !== "requires_flag" || typeof c.flag !== "string" || typeof c.condition_failed !== "string") {
throw new Error(`Map edge[${i}] condition[${j}]: invalid`);
}
return { type: "requires_flag", flag: c.flag, conditionFailed: c.condition_failed };
});
return { from: e.from, to: e.to, description: e.description, conditions };
});
}
export function loadFlags(raw: unknown): string[] {
if (!Array.isArray(raw)) throw new Error("flags.json must be an array of strings");
for (const f of raw) {
if (typeof f !== "string") throw new Error("flags.json: every entry must be a string");
}
return raw as string[];
}
interface RawManifest {
id: string;
title: string;
description: string;
languages: string[];
entry_point: string;
narrator: RawCharacter;
}
export interface StoryRawInputs {
manifest: unknown;
map: unknown;
flags: unknown;
characters: Record<string, unknown>; // id β raw character JSON
scenes: Record<string, unknown>; // id β raw scene JSON
storylines: Record<string, unknown>; // id β raw storyline node JSON
}
export function loadStory(inputs: StoryRawInputs): Story {
const m = inputs.manifest as RawManifest;
if (!m || typeof m !== "object") throw new Error("manifest.json: not an object");
for (const field of ["id", "title", "description", "entry_point"] as const) {
if (typeof m[field] !== "string" || !m[field]) {
throw new Error(`manifest missing required field: ${field}`);
}
}
if (!Array.isArray(m.languages)) throw new Error("manifest.languages must be an array");
// Story-specific narrator: load as Character (same shape) but flag the id as "narrator"
const rawNarr = m.narrator;
const narrator: Character = loadCharacter("narrator", rawNarr);
const characters: Record<string, Character> = {};
for (const [id, raw] of Object.entries(inputs.characters)) {
characters[id] = loadCharacter(id, raw);
}
const scenes: Record<string, import("./types.js").Scene> = {};
for (const [id, raw] of Object.entries(inputs.scenes)) {
scenes[id] = loadScene(id, raw);
}
const storylines: Record<string, StorylineNode> = {};
for (const [_, raw] of Object.entries(inputs.storylines)) {
const node = loadStorylineNode(raw);
storylines[node.id] = node;
}
const map = loadMapEdges(inputs.map);
const flags = loadFlags(inputs.flags);
// Reference integrity checks
for (const node of Object.values(storylines)) {
if (!scenes[node.scene]) {
throw new Error(`Storyline "${node.id}" references unknown scene "${node.scene}"`);
}
for (const action of node.actions) {
if (action.next !== undefined && !storylines[action.next]) {
throw new Error(`Storyline "${node.id}" action "${action.id}" references unknown storyline "${action.next}"`);
}
}
}
for (const edge of map) {
if (!scenes[edge.from]) throw new Error(`Map edge: from-scene "${edge.from}" not found`);
if (!scenes[edge.to]) throw new Error(`Map edge: to-scene "${edge.to}" not found`);
}
for (const scene of Object.values(scenes)) {
for (const cp of scene.charactersPresent) {
// characters_present can use either id or display name in legacy data; we accept either
const knownIds = new Set(Object.keys(characters));
const knownNames = new Set(Object.values(characters).map((c) => c.name));
if (!knownIds.has(cp) && !knownNames.has(cp)) {
throw new Error(`Scene "${scene.id}": characters_present references unknown character "${cp}"`);
}
}
}
return {
id: m.id,
title: m.title,
description: m.description,
languages: m.languages,
entryPoint: m.entry_point,
narrator,
characters,
scenes,
storylines,
map,
flags,
};
}
- Step 5: Run tests, confirm pass
npm test --workspace story-engine
Expected: PASS (all loader tests).
- Step 6: Commit
git add packages/story-engine/
git commit -m "feat(engine): loadStory with reference-integrity validation"
Task 7: Engine state initialization & menu mode
Files:
Create:
packages/story-engine/src/state.tsCreate:
packages/story-engine/src/engine.tsCreate:
packages/story-engine/tests/engine-menu.test.tsStep 1: Write
state.tsβ initial-state factory
import type { GameState, Narrator } from "./types.js";
export function createInitialState(narrator: Narrator): GameState {
return {
mode: "menu",
currentSpeaker: narrator.id,
flags: {},
discoveredScenes: [],
metCharacters: [],
recentTransitions: [],
};
}
export const RECENT_TRANSITIONS_LIMIT = 20;
export function appendTransition(
events: GameState["recentTransitions"],
newEvents: GameState["recentTransitions"],
): GameState["recentTransitions"] {
const combined = [...events, ...newEvents];
if (combined.length > RECENT_TRANSITIONS_LIMIT) {
return combined.slice(combined.length - RECENT_TRANSITIONS_LIMIT);
}
return combined;
}
- Step 2: Write failing test β
packages/story-engine/tests/engine-menu.test.ts:
import { describe, it, expect } from "vitest";
import { readFileSync, readdirSync } from "node:fs";
import { resolve } from "node:path";
import { StoryEngine } from "../src/engine.js";
import type { LoadedContent } from "../src/types.js";
import { loadNarrator, loadStory } from "../src/loader.js";
function readJsonFile(p: string): unknown { return JSON.parse(readFileSync(p, "utf-8")); }
function readJsonDir(dir: string): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const f of readdirSync(dir)) {
if (f.endsWith(".json")) out[f.replace(/\.json$/, "")] = readJsonFile(resolve(dir, f));
}
return out;
}
const FIX = resolve(__dirname, "fixtures");
function loadTestContent(): LoadedContent {
const narrator = loadNarrator(readJsonFile(resolve(FIX, "narrator/narrator.json")));
const story = loadStory({
manifest: readJsonFile(resolve(FIX, "story_test/manifest.json")),
map: readJsonFile(resolve(FIX, "story_test/map.json")),
flags: readJsonFile(resolve(FIX, "story_test/flags.json")),
characters: readJsonDir(resolve(FIX, "story_test/characters")),
scenes: readJsonDir(resolve(FIX, "story_test/scenes")),
storylines: readJsonDir(resolve(FIX, "story_test/storylines/chapter1")),
});
return { narrator, stories: { story_test: story } };
}
describe("StoryEngine β menu mode", () => {
it("starts in menu mode with the narrator as the current speaker", () => {
const engine = new StoryEngine(loadTestContent());
const state = engine.getState();
expect(state.mode).toBe("menu");
expect(state.currentSpeaker).toBe("narrator");
expect(state.storyId).toBeUndefined();
expect(state.flags).toEqual({});
});
it("lists available stories", () => {
const engine = new StoryEngine(loadTestContent());
const stories = engine.getAvailableStories();
expect(stories).toEqual([{ id: "story_test", title: "Test Story", description: "A short test story." }]);
});
it("returns the menu narrator as current character", () => {
const engine = new StoryEngine(loadTestContent());
const c = engine.getCurrentCharacter();
expect(c).not.toBeNull();
expect(c!.id).toBe("narrator");
});
it("reset() returns to fresh menu state", () => {
const engine = new StoryEngine(loadTestContent());
const fresh = engine.reset();
expect(fresh.mode).toBe("menu");
expect(fresh.flags).toEqual({});
});
});
- Step 3: Run tests β FAIL (StoryEngine not implemented)
npm test --workspace story-engine
- Step 4: Implement
engine.tsβ the menu-mode subset:
import type {
Character, GameState, LoadedContent, Narrator, Scene, StorylineAction, StorylineNode,
} from "./types.js";
import { createInitialState } from "./state.js";
export class StoryEngine {
private content: LoadedContent;
private state: GameState;
constructor(content: LoadedContent) {
this.content = content;
this.state = createInitialState(content.narrator);
}
reset(): GameState {
this.state = createInitialState(this.content.narrator);
return this.state;
}
getState(): Readonly<GameState> {
return this.state;
}
getAvailableStories(): Array<{ id: string; title: string; description: string }> {
return Object.values(this.content.stories).map((s) => ({
id: s.id, title: s.title, description: s.description,
}));
}
getCurrentCharacter(): Character | Narrator | null {
if (this.state.mode === "menu") return this.content.narrator;
if (!this.state.storyId) return null;
const story = this.content.stories[this.state.storyId];
if (!story) return null;
if (this.state.currentSpeaker === "narrator") return story.narrator;
if (!this.state.currentSpeaker) return null;
return story.characters[this.state.currentSpeaker] ?? null;
}
getCurrentNode(): StorylineNode | null {
if (this.state.mode !== "story" || !this.state.storyId || !this.state.currentNodeId) return null;
return this.content.stories[this.state.storyId]?.storylines[this.state.currentNodeId] ?? null;
}
getCurrentScene(): Scene | null {
if (this.state.mode !== "story" || !this.state.storyId || !this.state.currentSceneId) return null;
return this.content.stories[this.state.storyId]?.scenes[this.state.currentSceneId] ?? null;
}
}
- Step 5: Run tests β PASS
npm test --workspace story-engine
- Step 6: Commit
git add packages/story-engine/
git commit -m "feat(engine): StoryEngine class β menu-mode operations"
Task 8: Engine β enterStoryMode / exitStoryMode
Files:
Modify:
packages/story-engine/src/engine.tsCreate:
packages/story-engine/tests/engine-lifecycle.test.tsStep 1: Write failing tests β
packages/story-engine/tests/engine-lifecycle.test.ts:
import { describe, it, expect } from "vitest";
import { StoryEngine } from "../src/engine.js";
// reuse the fixture loader from engine-menu test
import { readFileSync, readdirSync } from "node:fs";
import { resolve } from "node:path";
import type { LoadedContent } from "../src/types.js";
import { loadNarrator, loadStory } from "../src/loader.js";
function readJsonFile(p: string): unknown { return JSON.parse(readFileSync(p, "utf-8")); }
function readJsonDir(dir: string): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const f of readdirSync(dir)) if (f.endsWith(".json")) out[f.replace(/\.json$/, "")] = readJsonFile(resolve(dir, f));
return out;
}
const FIX = resolve(__dirname, "fixtures");
function loadTestContent(): LoadedContent {
const narrator = loadNarrator(readJsonFile(resolve(FIX, "narrator/narrator.json")));
const story = loadStory({
manifest: readJsonFile(resolve(FIX, "story_test/manifest.json")),
map: readJsonFile(resolve(FIX, "story_test/map.json")),
flags: readJsonFile(resolve(FIX, "story_test/flags.json")),
characters: readJsonDir(resolve(FIX, "story_test/characters")),
scenes: readJsonDir(resolve(FIX, "story_test/scenes")),
storylines: readJsonDir(resolve(FIX, "story_test/storylines/chapter1")),
});
return { narrator, stories: { story_test: story } };
}
describe("enterStoryMode", () => {
it("transitions from menu to story mode with correct initial state", () => {
const engine = new StoryEngine(loadTestContent());
const result = engine.enterStoryMode("story_test");
expect(result.state.mode).toBe("story");
expect(result.state.storyId).toBe("story_test");
expect(result.state.currentSceneId).toBe("tavern");
expect(result.state.currentNodeId).toBe("meet_sparrow");
expect(result.state.currentSpeaker).toBe("Sparrow");
});
it("emits storyEntered + sceneChanged + speakerChanged events", () => {
const engine = new StoryEngine(loadTestContent());
const { events } = engine.enterStoryMode("story_test");
const types = events.map((e) => e.type);
expect(types).toContain("storyEntered");
expect(types).toContain("sceneChanged");
expect(types).toContain("speakerChanged");
const sp = events.find((e) => e.type === "speakerChanged");
expect(sp).toBeDefined();
expect((sp as Extract<typeof sp, { type: "speakerChanged" }>).to).toBe("Sparrow");
expect((sp as Extract<typeof sp, { type: "speakerChanged" }>).newCharacter.voice).toBe("ash");
});
it("emits a blocked event for unknown story id", () => {
const engine = new StoryEngine(loadTestContent());
const result = engine.enterStoryMode("nope");
expect(result.events.find((e) => e.type === "blocked")).toBeDefined();
expect(result.state.mode).toBe("menu");
});
});
describe("exitStoryMode", () => {
it("returns to menu state and emits storyExited + speakerChanged", () => {
const engine = new StoryEngine(loadTestContent());
engine.enterStoryMode("story_test");
const result = engine.exitStoryMode();
expect(result.state.mode).toBe("menu");
expect(result.state.storyId).toBeUndefined();
expect(result.state.currentSpeaker).toBe("narrator");
const types = result.events.map((e) => e.type);
expect(types).toContain("storyExited");
expect(types).toContain("speakerChanged");
});
});
Step 2: Run β FAIL
Step 3: Implement
enterStoryModeandexitStoryModeβ append toengine.ts:
import type { TransitionEvent, TransitionResult } from "./types.js";
import { appendTransition } from "./state.js";
// Add to StoryEngine class:
enterStoryMode(storyId: string): TransitionResult {
const story = this.content.stories[storyId];
if (!story) {
return this.blockedResult(`Unknown story: ${storyId}`);
}
// Find the entry-point node
const entryNode = this.findEntryNode(story);
if (!entryNode) {
return this.blockedResult(`Entry node not resolvable for story ${storyId}`);
}
const events: TransitionEvent[] = [
{ type: "storyEntered", storyId },
{ type: "sceneChanged", from: undefined, to: entryNode.scene },
];
// Speaker change: from narrator to entry-node speaker
const newCharacter = this.resolveCharacter(story, entryNode.speaker);
if (newCharacter) {
events.push({
type: "speakerChanged",
from: this.state.currentSpeaker,
to: entryNode.speaker,
newCharacter,
});
}
this.state = {
...this.state,
mode: "story",
storyId,
currentSceneId: entryNode.scene,
currentNodeId: entryNode.id,
currentSpeaker: entryNode.speaker,
discoveredScenes: [entryNode.scene],
metCharacters: newCharacter && newCharacter.id !== "narrator" ? [newCharacter.id] : [],
recentTransitions: appendTransition(this.state.recentTransitions, events),
};
return { state: this.state, events };
}
exitStoryMode(): TransitionResult {
if (this.state.mode !== "story") {
return { state: this.state, events: [] };
}
const events: TransitionEvent[] = [
{ type: "storyExited" },
{
type: "speakerChanged",
from: this.state.currentSpeaker,
to: "narrator",
newCharacter: this.content.narrator,
},
];
this.state = {
...this.state,
mode: "menu",
storyId: undefined,
currentSceneId: undefined,
currentNodeId: undefined,
currentSpeaker: "narrator",
flags: {},
discoveredScenes: [],
metCharacters: [],
recentTransitions: appendTransition(this.state.recentTransitions, events),
};
return { state: this.state, events };
}
// βββ private helpers ββββββββββββββββββββββββββββββββββββββββββββββββββ
private blockedResult(reason: string): TransitionResult {
const events: TransitionEvent[] = [{ type: "blocked", reason }];
this.state = {
...this.state,
recentTransitions: appendTransition(this.state.recentTransitions, events),
};
return { state: this.state, events };
}
private findEntryNode(story: import("./types.js").Story): StorylineNode | null {
// entryPoint is path like "storylines/chapter1/meet_sparrow.json"
const stem = story.entryPoint.split("/").pop()?.replace(/\.json$/, "");
if (!stem) return null;
return story.storylines[stem] ?? null;
}
private resolveCharacter(
story: import("./types.js").Story,
speakerId: string,
): Character | Narrator | null {
if (!speakerId || speakerId === "narrator") return story.narrator;
// speakerId in legacy data may be display name (e.g. "Sparrow"); normalize
const byId = story.characters[speakerId];
if (byId) return byId;
const byName = Object.values(story.characters).find((c) => c.name === speakerId);
return byName ?? null;
}
- Step 4: Run tests β PASS
npm test --workspace story-engine
- Step 5: Commit
git add packages/story-engine/
git commit -m "feat(engine): enterStoryMode and exitStoryMode with events"
Task 9: Engine β advanceStoryline
Files:
Modify:
packages/story-engine/src/engine.tsCreate:
packages/story-engine/tests/engine-storyline.test.tsStep 1: Write failing tests
import { describe, it, expect } from "vitest";
import { StoryEngine } from "../src/engine.js";
// reuse helper at top of file (copy from engine-lifecycle.test.ts)
import { readFileSync, readdirSync } from "node:fs";
import { resolve } from "node:path";
import type { LoadedContent } from "../src/types.js";
import { loadNarrator, loadStory } from "../src/loader.js";
function readJsonFile(p: string): unknown { return JSON.parse(readFileSync(p, "utf-8")); }
function readJsonDir(dir: string): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const f of readdirSync(dir)) if (f.endsWith(".json")) out[f.replace(/\.json$/, "")] = readJsonFile(resolve(dir, f));
return out;
}
const FIX = resolve(__dirname, "fixtures");
function loadTestContent(): LoadedContent {
const narrator = loadNarrator(readJsonFile(resolve(FIX, "narrator/narrator.json")));
const story = loadStory({
manifest: readJsonFile(resolve(FIX, "story_test/manifest.json")),
map: readJsonFile(resolve(FIX, "story_test/map.json")),
flags: readJsonFile(resolve(FIX, "story_test/flags.json")),
characters: readJsonDir(resolve(FIX, "story_test/characters")),
scenes: readJsonDir(resolve(FIX, "story_test/scenes")),
storylines: readJsonDir(resolve(FIX, "story_test/storylines/chapter1")),
});
return { narrator, stories: { story_test: story } };
}
describe("advanceStoryline", () => {
it("advances to the next node, sets flags, emits flagSet events", () => {
const engine = new StoryEngine(loadTestContent());
engine.enterStoryMode("story_test");
const result = engine.advanceStoryline("greet_respectfully");
expect(result.state.currentNodeId).toBe("friendly_response");
expect(result.state.flags["trust_sparrow"]).toBe(true);
const flagEvents = result.events.filter((e) => e.type === "flagSet");
expect(flagEvents).toHaveLength(1);
expect((flagEvents[0] as Extract<typeof flagEvents[0], { type: "flagSet" }>).name).toBe("trust_sparrow");
});
it("does NOT emit speakerChanged when the next node has the same speaker", () => {
const engine = new StoryEngine(loadTestContent());
engine.enterStoryMode("story_test");
const result = engine.advanceStoryline("greet_respectfully");
expect(result.events.find((e) => e.type === "speakerChanged")).toBeUndefined();
});
it("returns blocked event when actionId not in current node", () => {
const engine = new StoryEngine(loadTestContent());
engine.enterStoryMode("story_test");
const result = engine.advanceStoryline("nonexistent_action");
expect(result.events.find((e) => e.type === "blocked")).toBeDefined();
expect(result.state.currentNodeId).toBe("meet_sparrow");
});
it("returns blocked event when not in story mode", () => {
const engine = new StoryEngine(loadTestContent());
const result = engine.advanceStoryline("anything");
expect(result.events.find((e) => e.type === "blocked")).toBeDefined();
});
});
Step 2: Run β FAIL
Step 3: Implement
advanceStorylineβ add toengine.ts:
advanceStoryline(actionId: string): TransitionResult {
if (this.state.mode !== "story" || !this.state.storyId || !this.state.currentNodeId) {
return this.blockedResult("Not in story mode");
}
const story = this.content.stories[this.state.storyId];
const currentNode = story?.storylines[this.state.currentNodeId];
if (!story || !currentNode) {
return this.blockedResult("Current state has no valid node");
}
const action = currentNode.actions.find((a) => a.id === actionId);
if (!action) {
return this.blockedResult(`Action "${actionId}" not available at current node`);
}
if (!action.next) {
return this.blockedResult(`Action "${actionId}" has no next node`);
}
const nextNode = story.storylines[action.next];
if (!nextNode) {
return this.blockedResult(`Action "${actionId}" points to unknown node "${action.next}"`);
}
const events: TransitionEvent[] = [];
const newFlags = { ...this.state.flags };
if (action.effects?.setFlags) {
for (const [name, value] of Object.entries(action.effects.setFlags)) {
newFlags[name] = value;
events.push({ type: "flagSet", name, value });
}
}
let newSceneId = this.state.currentSceneId!;
if (nextNode.scene !== this.state.currentSceneId) {
events.push({ type: "sceneChanged", from: this.state.currentSceneId, to: nextNode.scene });
newSceneId = nextNode.scene;
}
let newSpeaker = this.state.currentSpeaker;
if (nextNode.speaker && nextNode.speaker !== this.state.currentSpeaker) {
const newCharacter = this.resolveCharacter(story, nextNode.speaker);
if (newCharacter) {
events.push({
type: "speakerChanged",
from: this.state.currentSpeaker,
to: nextNode.speaker,
newCharacter,
});
}
newSpeaker = nextNode.speaker;
}
const newDiscovered = this.state.discoveredScenes.includes(newSceneId)
? this.state.discoveredScenes
: [...this.state.discoveredScenes, newSceneId];
const newMet = newSpeaker && newSpeaker !== "narrator" && !this.state.metCharacters.includes(newSpeaker)
? [...this.state.metCharacters, newSpeaker]
: this.state.metCharacters;
this.state = {
...this.state,
currentNodeId: nextNode.id,
currentSceneId: newSceneId,
currentSpeaker: newSpeaker,
flags: newFlags,
discoveredScenes: newDiscovered,
metCharacters: newMet,
recentTransitions: appendTransition(this.state.recentTransitions, events),
};
return { state: this.state, events };
}
Step 4: Run tests β PASS
Step 5: Commit
git add packages/story-engine/
git commit -m "feat(engine): advanceStoryline with flag effects + speaker/scene transitions"
Task 10: Engine β navigateScene with flag-gated edges
Files:
Modify:
packages/story-engine/src/engine.tsCreate:
packages/story-engine/tests/engine-navigation.test.tsStep 1: Write failing tests
import { describe, it, expect } from "vitest";
import { StoryEngine } from "../src/engine.js";
// (copy fixture loader helper from prior test files at top)
// ... loadTestContent ...
describe("navigateScene", () => {
it("succeeds for an unconditional edge", () => {
const engine = new StoryEngine(loadTestContent());
engine.enterStoryMode("story_test");
const result = engine.navigateScene("harbor");
expect(result.state.currentSceneId).toBe("harbor");
const sc = result.events.find((e) => e.type === "sceneChanged");
expect(sc).toBeDefined();
});
it("blocks an edge with unmet condition_failed flag", () => {
const engine = new StoryEngine(loadTestContent());
engine.enterStoryMode("story_test");
// trust_sparrow is false at this point
const result = engine.navigateScene("docks");
expect(result.state.currentSceneId).toBe("tavern");
const blocked = result.events.find((e) => e.type === "blocked");
expect(blocked).toBeDefined();
expect((blocked as Extract<typeof blocked, { type: "blocked" }>).reason)
.toMatch(/don't trust him/);
});
it("succeeds once the gating flag is set", () => {
const engine = new StoryEngine(loadTestContent());
engine.enterStoryMode("story_test");
engine.advanceStoryline("greet_respectfully"); // sets trust_sparrow=true
// navigate from tavern to docks
const result = engine.navigateScene("docks");
// docks isn't a real scene in our fixture map; we need to add it OR test this with harbor.
// Adjust: harbor edge is unconditional and tavernβharbor exists, so use that case.
// For a cleaner test, modify map fixture to include docks as a scene file as well.
expect(result.events.find((e) => e.type === "blocked")).toBeUndefined();
});
it("blocks an edge that doesn't exist in the map", () => {
const engine = new StoryEngine(loadTestContent());
engine.enterStoryMode("story_test");
const result = engine.navigateScene("nonexistent_scene");
expect(result.events.find((e) => e.type === "blocked")).toBeDefined();
});
});
describe("getReachableScenes", () => {
it("returns scenes reachable from current scene with availability flags", () => {
const engine = new StoryEngine(loadTestContent());
engine.enterStoryMode("story_test");
const reachable = engine.getReachableScenes();
const harborEntry = reachable.find((r) => r.scene.id === "harbor");
expect(harborEntry).toBeDefined();
expect(harborEntry!.available).toBe(true);
const docksEntry = reachable.find((r) => r.scene.id === "docks");
if (docksEntry) {
expect(docksEntry.available).toBe(false);
expect(docksEntry.blockedReason).toBeDefined();
}
});
});
- Step 2: Add a
docksscene fixture so navigation tests work
packages/story-engine/tests/fixtures/story_test/scenes/docks.json:
{
"name": "Old Docks",
"description": "Splintered planks and shadowy crates.",
"actions": [],
"characters_present": []
}
Step 3: Run tests β FAIL
Step 4: Implement
navigateSceneandgetReachableScenesβ add toengine.ts:
import type { MapEdge, Scene } from "./types.js";
navigateScene(sceneId: string): TransitionResult {
if (this.state.mode !== "story" || !this.state.storyId || !this.state.currentSceneId) {
return this.blockedResult("Not in story mode");
}
const story = this.content.stories[this.state.storyId];
if (!story) return this.blockedResult("No active story");
const edge = story.map.find((e) => e.from === this.state.currentSceneId && e.to === sceneId);
if (!edge) return this.blockedResult(`No path from "${this.state.currentSceneId}" to "${sceneId}"`);
const blockedCondition = (edge.conditions ?? []).find((c) => !this.state.flags[c.flag]);
if (blockedCondition) return this.blockedResult(blockedCondition.conditionFailed);
const events: TransitionEvent[] = [
{ type: "sceneChanged", from: this.state.currentSceneId, to: sceneId },
];
const newDiscovered = this.state.discoveredScenes.includes(sceneId)
? this.state.discoveredScenes
: [...this.state.discoveredScenes, sceneId];
this.state = {
...this.state,
currentSceneId: sceneId,
discoveredScenes: newDiscovered,
recentTransitions: appendTransition(this.state.recentTransitions, events),
};
return { state: this.state, events };
}
getReachableScenes(): Array<{ scene: Scene; description: string; available: boolean; blockedReason?: string }> {
if (this.state.mode !== "story" || !this.state.storyId || !this.state.currentSceneId) return [];
const story = this.content.stories[this.state.storyId];
if (!story) return [];
const fromHere = story.map.filter((e) => e.from === this.state.currentSceneId);
return fromHere.flatMap((edge) => {
const scene = story.scenes[edge.to];
if (!scene) return [];
const blocked = (edge.conditions ?? []).find((c) => !this.state.flags[c.flag]);
return [{
scene,
description: edge.description,
available: !blocked,
blockedReason: blocked?.conditionFailed,
}];
});
}
Step 5: Run tests β PASS
Step 6: Commit
git add packages/story-engine/
git commit -m "feat(engine): navigateScene and getReachableScenes with flag gating"
Task 11: Engine β performSceneAction & remaining queries
Files:
Modify:
packages/story-engine/src/engine.tsCreate:
packages/story-engine/tests/engine-scene-actions.test.tsStep 1: Write failing tests
import { describe, it, expect } from "vitest";
import { StoryEngine } from "../src/engine.js";
// ... fixture loader as above ...
describe("performSceneAction", () => {
it("returns the atmospheric description and does NOT change story state", () => {
const engine = new StoryEngine(loadTestContent());
engine.enterStoryMode("story_test");
const before = engine.getState();
const result = engine.performSceneAction("examine_memorabilia");
expect(result.state.currentNodeId).toBe(before.currentNodeId);
expect(result.state.currentSceneId).toBe(before.currentSceneId);
expect(result.events.find((e) => e.type === "blocked")).toBeUndefined();
});
it("returns blocked for unknown scene action id", () => {
const engine = new StoryEngine(loadTestContent());
engine.enterStoryMode("story_test");
const result = engine.performSceneAction("nope");
expect(result.events.find((e) => e.type === "blocked")).toBeDefined();
});
});
describe("getAvailableActions / getAvailableSceneActions", () => {
it("returns the storyline actions for the current node", () => {
const engine = new StoryEngine(loadTestContent());
engine.enterStoryMode("story_test");
const actions = engine.getAvailableActions();
expect(actions.map((a) => a.id)).toEqual(["greet_respectfully", "mock_sparrow"]);
});
it("returns the scene actions for the current scene", () => {
const engine = new StoryEngine(loadTestContent());
engine.enterStoryMode("story_test");
const sceneActions = engine.getAvailableSceneActions();
expect(sceneActions.map((a) => a.id)).toEqual(["examine_memorabilia"]);
});
});
Step 2: Run β FAIL
Step 3: Implement β add to
engine.ts:
performSceneAction(actionId: string): TransitionResult {
if (this.state.mode !== "story" || !this.state.storyId || !this.state.currentSceneId) {
return this.blockedResult("Not in story mode");
}
const story = this.content.stories[this.state.storyId];
const scene = story?.scenes[this.state.currentSceneId];
const action = scene?.actions.find((a) => a.id === actionId);
if (!action) return this.blockedResult(`Scene action "${actionId}" not available`);
// Atmospheric β no state mutation, no events
return { state: this.state, events: [] };
}
getAvailableActions(): StorylineAction[] {
const node = this.getCurrentNode();
return node ? node.actions : [];
}
getAvailableSceneActions(): import("./types.js").SceneAction[] {
const scene = this.getCurrentScene();
return scene ? scene.actions : [];
}
Step 4: Run β PASS
Step 5: Commit
git add packages/story-engine/
git commit -m "feat(engine): performSceneAction + read queries for actions"
Task 12: Engine β prompt builder
Files:
Create:
packages/story-engine/src/prompt-builder.tsModify:
packages/story-engine/src/engine.tsCreate:
packages/story-engine/tests/prompt-builder.test.tsStep 1: Write failing tests
import { describe, it, expect } from "vitest";
import { StoryEngine } from "../src/engine.js";
// ... fixture loader ...
describe("buildSystemPrompt β menu mode", () => {
it("includes narrator persona, intro, and the list of available stories", () => {
const engine = new StoryEngine(loadTestContent());
const prompt = engine.buildSystemPrompt();
expect(prompt).toContain("The Narrator");
expect(prompt).toContain("Welcome adventurer!");
expect(prompt).toMatch(/Test Story/);
expect(prompt).toContain("select_story");
});
});
describe("buildSystemPrompt β story mode", () => {
it("includes character persona, scene description, scripted line, available actions, and the LLM rules", () => {
const engine = new StoryEngine(loadTestContent());
engine.enterStoryMode("story_test");
const prompt = engine.buildSystemPrompt();
expect(prompt).toContain("Sparrow"); // character name
expect(prompt).toContain("Notorious pirate"); // background
expect(prompt).toContain("Rusty Anchor Tavern"); // scene name
expect(prompt).toContain("Friend or foe?"); // scripted line
expect(prompt).toContain("greet_respectfully"); // action id
expect(prompt).toContain("mock_sparrow"); // action id
expect(prompt).toMatch(/never advance the plot/i); // rules block
});
it("renders set flags as plain English", () => {
const engine = new StoryEngine(loadTestContent());
engine.enterStoryMode("story_test");
engine.advanceStoryline("greet_respectfully");
const prompt = engine.buildSystemPrompt();
expect(prompt).toContain("trust_sparrow"); // mentioned somewhere in flag list
});
});
describe("formatOptionsRecap", () => {
it("renders the available actions as a short spoken-style sentence", () => {
const engine = new StoryEngine(loadTestContent());
engine.enterStoryMode("story_test");
const recap = engine.formatOptionsRecap();
expect(recap).toMatch(/show respect/i);
expect(recap).toMatch(/mock/i);
});
});
Step 2: Run β FAIL
Step 3: Implement
prompt-builder.ts
import type { Character, GameState, LoadedContent, Narrator, Scene, StorylineAction, StorylineNode } from "./types.js";
const COMMON_RULES = `# Conversation rules
- You are in a voice conversation. Keep replies short, warm, spoken (1-2 sentences). No formatting, no asterisks, no emojis.
- Speak naturally as if to a real person.
`;
const MENU_RULES = `# Menu rules
- The user is choosing a story. Briefly describe options and help them decide.
- When the user clearly picks a story, call \`select_story\` with its id.
`;
const STORY_RULES = `# Story rules
- You voice a single character. Stay in character.
- Speak the SCRIPTED LINE below in your character's voice β paraphrase if needed but never invent new plot.
- React to the user with full awareness of the scene, your character, and what just happened.
- NEVER advance the plot without calling \`advance_storyline\` with one of the AVAILABLE ACTION ids.
- If the user clearly picks one of the available actions, call \`advance_storyline\` immediately.
- If the user just chats, respond in character without a tool call.
- If the user is lost or asks "what can I do?", call \`request_options\`.
- If the user wants to go somewhere new in the world, call \`navigate_scene\`.
- If the user explicitly does an atmospheric scene action, call \`perform_scene_action\`.
- If the user wants to stop / go back to menu, call \`exit_story\`.
`;
export function buildMenuPrompt(content: LoadedContent): string {
const n = content.narrator;
const stories = Object.values(content.stories)
.map((s) => `- id: "${s.id}" β ${s.title}: ${s.description}`)
.join("\n");
return [
COMMON_RULES,
`# You are\nName: ${n.name}\nDescription: ${n.description}`,
`# Voice tone\n${n.voiceTone}`,
`# Background\n${n.background}`,
`# Goal\n${n.goal}`,
`# Greeting\nIf the user says hello first, respond with: "${n.introduction}"`,
`# Available stories\n${stories}`,
MENU_RULES,
].join("\n\n");
}
export function buildStoryPrompt(args: {
state: GameState;
character: Character | Narrator;
scene: Scene;
node: StorylineNode;
availableActions: StorylineAction[];
reachableScenes: Array<{ id: string; description: string; available: boolean; blockedReason?: string }>;
sceneActions: Array<{ id: string; description: string }>;
setFlagNames: string[];
}): string {
const { character: c, scene, node, availableActions, reachableScenes, sceneActions, setFlagNames } = args;
const actionsList = availableActions.length === 0
? "(none β this is an end node)"
: availableActions.map((a) => `- id: "${a.id}" β ${a.description}`).join("\n");
const scenesList = reachableScenes.length === 0
? "(none)"
: reachableScenes.map((r) =>
`- id: "${r.id}" β ${r.description}` + (r.available ? "" : ` (BLOCKED: ${r.blockedReason})`),
).join("\n");
const sceneActionsList = sceneActions.length === 0
? "(none)"
: sceneActions.map((a) => `- id: "${a.id}" β ${a.description}`).join("\n");
const flagsList = setFlagNames.length === 0
? "(no flags set yet)"
: setFlagNames.map((f) => `- ${f}`).join("\n");
return [
COMMON_RULES,
`# You are\nName: ${c.name}\nDescription: ${c.description}`,
`# Voice tone\n${c.voiceTone}`,
`# Background\n${c.background}`,
`# Goal\n${c.goal}`,
`# Current scene\n${scene.name}\n${scene.description}`,
`# Scripted line to speak now (paraphrase in character)\n"${node.text}"`,
`# Available actions (the user must clearly pick one before you call advance_storyline)\n${actionsList}`,
`# Reachable scenes (for navigate_scene)\n${scenesList}`,
`# Atmospheric scene actions (for perform_scene_action)\n${sceneActionsList}`,
`# Currently set story flags\n${flagsList}`,
STORY_RULES,
].join("\n\n");
}
- Step 4: Wire into
engine.ts
import { buildMenuPrompt, buildStoryPrompt } from "./prompt-builder.js";
buildSystemPrompt(): string {
if (this.state.mode === "menu") {
return buildMenuPrompt(this.content);
}
const story = this.state.storyId ? this.content.stories[this.state.storyId] : undefined;
const character = this.getCurrentCharacter();
const scene = this.getCurrentScene();
const node = this.getCurrentNode();
if (!story || !character || !scene || !node) return buildMenuPrompt(this.content);
return buildStoryPrompt({
state: this.state,
character,
scene,
node,
availableActions: this.getAvailableActions(),
reachableScenes: this.getReachableScenes().map((r) => ({
id: r.scene.id, description: r.description, available: r.available, blockedReason: r.blockedReason,
})),
sceneActions: this.getAvailableSceneActions().map((a) => ({ id: a.id, description: a.description })),
setFlagNames: Object.entries(this.state.flags).filter(([, v]) => v).map(([k]) => k),
});
}
formatOptionsRecap(): string {
const actions = this.getAvailableActions();
if (actions.length === 0) return "There's nothing more to do here right now.";
if (actions.length === 1) return `You could ${actions[0]!.description}.`;
const phrases = actions.map((a) => a.description);
const last = phrases.pop();
return `You could ${phrases.join(", ")}, or ${last}.`;
}
Step 5: Run tests β PASS
Step 6: Commit
git add packages/story-engine/
git commit -m "feat(engine): system prompt builder + options recap"
Task 13: Engine β dynamic tool definitions
Files:
Modify:
packages/story-engine/src/engine.tsCreate:
packages/story-engine/tests/tool-defs.test.tsStep 1: Write failing tests
import { describe, it, expect } from "vitest";
import { StoryEngine } from "../src/engine.js";
// ... fixture loader ...
describe("buildToolDefinitions β menu mode", () => {
it("exposes select_story with current story ids in the enum", () => {
const engine = new StoryEngine(loadTestContent());
const tools = engine.buildToolDefinitions();
const selectStory = tools.find((t) => t.name === "select_story");
expect(selectStory).toBeDefined();
expect((selectStory!.parameters.properties.storyId as { enum: string[] }).enum)
.toEqual(["story_test"]);
});
});
describe("buildToolDefinitions β story mode", () => {
it("exposes advance_storyline with current node action ids in the enum", () => {
const engine = new StoryEngine(loadTestContent());
engine.enterStoryMode("story_test");
const tools = engine.buildToolDefinitions();
const advance = tools.find((t) => t.name === "advance_storyline");
expect((advance!.parameters.properties.actionId as { enum: string[] }).enum)
.toEqual(["greet_respectfully", "mock_sparrow"]);
});
it("exposes navigate_scene with currently reachable scene ids", () => {
const engine = new StoryEngine(loadTestContent());
engine.enterStoryMode("story_test");
const tools = engine.buildToolDefinitions();
const nav = tools.find((t) => t.name === "navigate_scene");
const enumIds = (nav!.parameters.properties.sceneId as { enum: string[] }).enum;
expect(enumIds).toEqual(expect.arrayContaining(["harbor"]));
});
it("exposes perform_scene_action with current scene action ids", () => {
const engine = new StoryEngine(loadTestContent());
engine.enterStoryMode("story_test");
const tools = engine.buildToolDefinitions();
const sa = tools.find((t) => t.name === "perform_scene_action");
expect((sa!.parameters.properties.actionId as { enum: string[] }).enum)
.toEqual(["examine_memorabilia"]);
});
it("always exposes request_options and exit_story in story mode", () => {
const engine = new StoryEngine(loadTestContent());
engine.enterStoryMode("story_test");
const tools = engine.buildToolDefinitions();
expect(tools.find((t) => t.name === "request_options")).toBeDefined();
expect(tools.find((t) => t.name === "exit_story")).toBeDefined();
});
});
Step 2: Run β FAIL
Step 3: Implement
buildToolDefinitionsβ add toengine.ts:
import type { ToolDef } from "./types.js";
buildToolDefinitions(): ToolDef[] {
if (this.state.mode === "menu") {
const storyIds = Object.keys(this.content.stories);
return [{
name: "select_story",
description: "Confirm the user has chosen a story; transitions into story mode.",
parameters: {
type: "object",
properties: {
storyId: { type: "string", enum: storyIds, description: "Id of the chosen story." },
},
required: ["storyId"],
},
}];
}
const node = this.getCurrentNode();
const actionIds = node ? node.actions.map((a) => a.id) : [];
const reachable = this.getReachableScenes().map((r) => r.scene.id);
const sceneActionIds = this.getAvailableSceneActions().map((a) => a.id);
const tools: ToolDef[] = [];
if (actionIds.length > 0) {
tools.push({
name: "advance_storyline",
description: "Advance the plot by picking one of the listed available actions. Only call when user intent matches one.",
parameters: {
type: "object",
properties: {
actionId: { type: "string", enum: actionIds, description: "Id of the chosen storyline action." },
},
required: ["actionId"],
},
});
}
if (reachable.length > 0) {
tools.push({
name: "navigate_scene",
description: "Move to a different scene the user wants to visit.",
parameters: {
type: "object",
properties: {
sceneId: { type: "string", enum: reachable, description: "Id of the scene to move to." },
},
required: ["sceneId"],
},
});
}
if (sceneActionIds.length > 0) {
tools.push({
name: "perform_scene_action",
description: "Perform an atmospheric scene action (no plot effect β just narration).",
parameters: {
type: "object",
properties: {
actionId: { type: "string", enum: sceneActionIds, description: "Id of the scene action." },
},
required: ["actionId"],
},
});
}
tools.push({
name: "request_options",
description: "Use when the user asks what they can do or seems lost.",
parameters: { type: "object", properties: {}, required: [] },
});
tools.push({
name: "exit_story",
description: "Use when the user wants to stop the story and return to the menu.",
parameters: { type: "object", properties: {}, required: [] },
});
return tools;
}
Step 4: Run β PASS
Step 5: Commit
git add packages/story-engine/
git commit -m "feat(engine): dynamic tool-definition builder"
Task 14: Engine β recent-transitions log accessor + index export
Files:
Modify:
packages/story-engine/src/engine.tsModify:
packages/story-engine/src/index.tsStep 1: Add accessor to
engine.ts
getRecentTransitionLog(): TransitionEvent[] {
return [...this.state.recentTransitions];
}
getPromptContext(): {
state: Readonly<GameState>;
character: Character | Narrator | null;
scene: Scene | null;
node: StorylineNode | null;
} {
return {
state: this.state,
character: this.getCurrentCharacter(),
scene: this.getCurrentScene(),
node: this.getCurrentNode(),
};
}
- Step 2: Update
src/index.tsto export the public API
export { StoryEngine } from "./engine.js";
export {
loadNarrator,
loadCharacter,
loadScene,
loadStorylineNode,
loadMapEdges,
loadFlags,
loadStory,
type StoryRawInputs,
} from "./loader.js";
export {
buildMenuPrompt,
buildStoryPrompt,
} from "./prompt-builder.js";
export type {
Narrator, Character, Scene, SceneAction, StorylineNode, StorylineAction,
StorylineActionEffects, MapEdge, FlagCondition, Story, LoadedContent,
GameState, AppMode, TransitionEvent, TransitionResult, ToolDef, StorylineType,
} from "./types.js";
- Step 3: Verify everything still builds and tests pass
npm test --workspace story-engine
- Step 4: Commit
git add packages/story-engine/
git commit -m "feat(engine): expose public API and getRecentTransitionLog"
Phase 3 β Content translation
Task 15: Translation tooling β write a Node script
Files:
- Create:
scripts/translate-content.mjs
This script reads the original French JSON files from /Users/nicolasrabault/Projects/AI_game/Stories/, calls the Anthropic Claude API to translate string fields while preserving JSON structure, and writes English files to content/. Run once to seed the content; manual review after.
- Step 1: Create
scripts/translate-content.mjs
mkdir -p /Users/nicolasrabault/Projects/reachy-tales/scripts
#!/usr/bin/env node
// Translate AI_game/Stories/{narrator,story_pirates} from French to English using Anthropic Claude.
// Run: ANTHROPIC_API_KEY=sk-ant-... node scripts/translate-content.mjs
import { readFileSync, writeFileSync, readdirSync, mkdirSync, existsSync, statSync } from "node:fs";
import { resolve, relative, dirname, join } from "node:path";
const SRC_ROOT = "/Users/nicolasrabault/Projects/AI_game/Stories";
const DST_ROOT = resolve(import.meta.dirname, "../content");
const STORIES_TO_COPY = ["narrator", "story_pirates"];
const FIELDS_TO_TRANSLATE_NESTED = new Set([
"name", "description", "introduction", "background", "goal",
"voice_tone", "voiceTone", "text", "title", "from", "to",
"condition_failed", "effects",
]);
// Some fields should pass through unchanged (ids, voice names, file refs).
const FIELDS_TO_PRESERVE = new Set([
"id", "languages", "entry_point", "voice", "type",
"speaker", "next", "scene", "characters_present",
"set_flags", "flag", "picture", "cover_image", "tools",
]);
const apiKey = process.env.ANTHROPIC_API_KEY;
if (!apiKey) { console.error("Missing ANTHROPIC_API_KEY"); process.exit(1); }
async function translateBatch(strings) {
const numbered = strings.map((s, i) => `[${i}] ${s}`).join("\n---\n");
const res = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"x-api-key": apiKey,
"anthropic-version": "2023-06-01",
"content-type": "application/json",
},
body: JSON.stringify({
model: "claude-sonnet-4-6",
max_tokens: 8000,
messages: [{
role: "user",
content: `Translate the following French text segments to English. Each segment is preceded by [N]. Output the same numbering. Preserve tone, register, and any special formatting markers (asterisks, ellipses). Strip any French-only directives like "LANGUE : PARLER EXCLUSIVEMENT EN FRANΓAIS" β replace with English equivalent (e.g. "LANGUAGE: SPEAK EXCLUSIVELY IN ENGLISH").\n\n${numbered}`,
}],
}),
});
if (!res.ok) throw new Error(`API ${res.status}: ${await res.text()}`);
const data = await res.json();
const out = data.content[0].text;
const segments = out.split(/\[(\d+)\]\s*/).filter(Boolean);
const result = new Array(strings.length).fill("");
for (let i = 0; i < segments.length; i += 2) {
const idx = parseInt(segments[i], 10);
if (!isNaN(idx)) result[idx] = (segments[i + 1] ?? "").trim().replace(/\n---$/, "").trim();
}
return result;
}
function collectStrings(node, path, out) {
if (typeof node === "string") {
if (FIELDS_TO_TRANSLATE_NESTED.has(path[path.length - 1])) {
out.push({ path: [...path], value: node });
}
} else if (Array.isArray(node)) {
node.forEach((item, i) => collectStrings(item, [...path, String(i)], out));
} else if (node && typeof node === "object") {
for (const [k, v] of Object.entries(node)) {
if (FIELDS_TO_PRESERVE.has(k)) continue;
collectStrings(v, [...path, k], out);
}
}
}
function applyTranslations(node, translations) {
if (typeof node === "string") return node;
if (Array.isArray(node)) return node.map((x) => applyTranslations(x, translations));
if (node && typeof node === "object") {
const out = {};
for (const [k, v] of Object.entries(node)) out[k] = applyTranslations(v, translations);
return out;
}
return node;
}
function setAtPath(obj, path, value) {
let cur = obj;
for (let i = 0; i < path.length - 1; i++) {
cur = Array.isArray(cur) ? cur[parseInt(path[i], 10)] : cur[path[i]];
}
const last = path[path.length - 1];
if (Array.isArray(cur)) cur[parseInt(last, 10)] = value;
else cur[last] = value;
}
async function translateFile(srcPath, dstPath) {
const raw = JSON.parse(readFileSync(srcPath, "utf-8"));
const strings = [];
collectStrings(raw, [], strings);
if (strings.length === 0) {
mkdirSync(dirname(dstPath), { recursive: true });
writeFileSync(dstPath, JSON.stringify(raw, null, 2) + "\n");
return;
}
console.log(` ${strings.length} strings to translate`);
// Batch in chunks of 30 to keep prompts manageable
const out = [];
for (let i = 0; i < strings.length; i += 30) {
const chunk = strings.slice(i, i + 30).map((s) => s.value);
const translated = await translateBatch(chunk);
out.push(...translated);
}
const result = JSON.parse(JSON.stringify(raw));
out.forEach((value, idx) => setAtPath(result, strings[idx].path, value));
mkdirSync(dirname(dstPath), { recursive: true });
writeFileSync(dstPath, JSON.stringify(result, null, 2) + "\n");
}
function copyImage(srcPath, dstPath) {
// Per spec, images are dropped from v1. Skip.
}
async function walkAndTranslate(srcDir, dstDir) {
for (const entry of readdirSync(srcDir)) {
const src = join(srcDir, entry);
const dst = join(dstDir, entry);
const stat = statSync(src);
if (stat.isDirectory()) {
await walkAndTranslate(src, dst);
} else if (entry.endsWith(".json")) {
console.log(`Translating ${relative(SRC_ROOT, src)}`);
await translateFile(src, dst);
}
}
}
for (const story of STORIES_TO_COPY) {
await walkAndTranslate(join(SRC_ROOT, story), join(DST_ROOT, story));
}
console.log("Done.");
- Step 2: Make it executable
chmod +x scripts/translate-content.mjs
- Step 3: Run translation (requires Anthropic API key)
ANTHROPIC_API_KEY=<your-key> node scripts/translate-content.mjs
Expected: content/narrator/narrator.json and content/story_pirates/... populated with English versions.
Step 4: Manual review pass β open
content/story_pirates/storylines/chapter1/*.jsonand skim. Fix anything that lost flavor (especially Sparrow's pirate dialect, which often translates flat).Step 5: Add a
voicefield to characters β the legacy data has"voice": "onyx"for everyone. Update per the design spec voice mapping:content/narrator/narrator.json:"voice": "cedar"content/story_pirates/manifest.jsonnarrator:"voice": "ballad"content/story_pirates/characters/sparrow.json:"voice": "ash"content/story_pirates/characters/aria.json:"voice": "coral"
Step 6: Commit
git add scripts/ content/
git commit -m "feat(content): translated story_pirates and narrator to English"
Task 16: Schema doc + sanity test against translated content
Files:
Create:
content/SCHEMA.mdCreate:
packages/story-engine/tests/playthrough.test.tsStep 1: Write
content/SCHEMA.mdβ authoritative schema doc
# Story Content Schema (Reachy Tales)
This file documents the **actual** schema the `story-engine` reads.
It supersedes the legacy `STORIES_DOCUMENTATION.md` from the original AI_game project.
## Layout
content/ βββ narrator/ β βββ narrator.json βββ / βββ manifest.json βββ map.json βββ flags.json βββ story_state_template.json βββ characters/.json βββ scenes/.json βββ storylines/chapter/.json
## File formats
(Brief schema description β kept minimal to avoid drift; engine code is the source of truth.)
### narrator.json / characters/<id>.json
```json
{
"name": "string",
"description": "string",
"introduction": "string",
"background": "string",
"goal": "string",
"voice": "string (OpenAI Realtime voice preset name, e.g. \"cedar\", \"ash\")",
"voice_tone": "string"
}
manifest.json
{
"id": "string (matches folder name)",
"title": "string",
"description": "string",
"languages": ["en"],
"entry_point": "storylines/chapter1/<node_id>.json",
"narrator": { /* same shape as a character */ }
}
scenes/.json
{
"name": "string",
"description": "string",
"actions": [ { "id": "string", "description": "string", "effects": "string" } ],
"characters_present": ["character_id_or_name"]
}
storylines/chapter/.json
{
"id": "string",
"scene": "scene_id",
"type": "dialogue | narration | character_interaction",
"speaker": "character_id_or_name | narrator | \"\"",
"text": "string",
"actions": [
{
"id": "string",
"description": "string",
"next": "node_id (optional, end-node if omitted)",
"effects": { "set_flags": { "flag_name": true } }
}
]
}
map.json
[
{
"from": "scene_id",
"to": "scene_id",
"description": "string",
"conditions": [
{
"type": "requires_flag",
"flag": "flag_name",
"condition_failed": "string (player-facing explanation)"
}
]
}
]
flags.json
["flag_name_1", "flag_name_2"]
Notes
- All strings are English. Bilingual
{ "en": "...", "fr": "..." }envelopes from the legacy schema are NOT supported. - Image fields (
picture,cover_image) are ignored by the engine.
- [ ] **Step 2: Write `playthrough.test.ts`** β exercises the full pirate story end-to-end
```typescript
import { describe, it, expect } from "vitest";
import { StoryEngine } from "../src/engine.js";
import { loadNarrator, loadStory } from "../src/loader.js";
import { readFileSync, readdirSync } from "node:fs";
import { resolve } from "node:path";
import type { LoadedContent } from "../src/types.js";
const CONTENT = resolve(__dirname, "../../../content");
function readJsonFile(p: string): unknown { return JSON.parse(readFileSync(p, "utf-8")); }
function readJsonDir(dir: string): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const f of readdirSync(dir)) if (f.endsWith(".json")) out[f.replace(/\.json$/, "")] = readJsonFile(resolve(dir, f));
return out;
}
function loadRealContent(): LoadedContent {
const narrator = loadNarrator(readJsonFile(resolve(CONTENT, "narrator/narrator.json")));
const pirates = loadStory({
manifest: readJsonFile(resolve(CONTENT, "story_pirates/manifest.json")),
map: readJsonFile(resolve(CONTENT, "story_pirates/map.json")),
flags: readJsonFile(resolve(CONTENT, "story_pirates/flags.json")),
characters: readJsonDir(resolve(CONTENT, "story_pirates/characters")),
scenes: readJsonDir(resolve(CONTENT, "story_pirates/scenes")),
storylines: readJsonDir(resolve(CONTENT, "story_pirates/storylines/chapter1")),
});
return { narrator, stories: { story_pirates: pirates } };
}
describe("story_pirates playthrough", () => {
it("loads cleanly and reports the expected starting state", () => {
const engine = new StoryEngine(loadRealContent());
const r = engine.enterStoryMode("story_pirates");
expect(r.events.find((e) => e.type === "blocked")).toBeUndefined();
expect(r.state.currentNodeId).toBe("meet_sparrow");
expect(r.state.currentSceneId).toBe("tavern");
});
it("happy-path traversal: respect Sparrow β ask treasure β ... reaches an end", () => {
const engine = new StoryEngine(loadRealContent());
engine.enterStoryMode("story_pirates");
// Walk the friendly branch as far as the data allows
const visited: string[] = [];
const walk = (actionId: string, maxSteps = 20): void => {
for (let step = 0; step < maxSteps; step++) {
const r = engine.advanceStoryline(actionId);
if (r.events.some((e) => e.type === "blocked")) break;
const node = engine.getCurrentNode();
if (!node || node.actions.length === 0) break;
visited.push(node.id);
actionId = node.actions[0]!.id;
}
};
walk("greet_respectfully");
expect(visited.length).toBeGreaterThan(0);
});
});
- Step 3: Run β should PASS (real content is loaded successfully)
npm test --workspace story-engine
If it fails, the translated content is malformed somewhere β fix the JSON or the engine.
- Step 4: Commit
git add content/SCHEMA.md packages/story-engine/tests/playthrough.test.ts
git commit -m "docs(content): authoritative SCHEMA + playthrough integration test"
Phase 4 β JS app scaffolding
Task 17: Vite app scaffolding + minimal index.html
Files:
Create:
tsconfig.app.jsonCreate:
vite.config.tsCreate:
index.htmlCreate:
src/style.cssModify: root
package.jsonto add Vite-side depsStep 1: Add Vite-side deps to root
package.json
Modify root package.json:
{
"dependencies": {
"story-engine": "0.1.0"
},
"devDependencies": {
"typescript": "^5.6.3",
"vite": "^5.4.10"
}
}
Run:
cd /Users/nicolasrabault/Projects/reachy-tales
npm install
- Step 2: Write
tsconfig.app.jsonfor the JS app side
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": []
},
"include": ["src/**/*", "index.html"]
}
- Step 3: Write
vite.config.tsβ including the OpenAI proxy rules used in dev
import { defineConfig } from "vite";
export default defineConfig({
server: {
port: 5173,
proxy: {
"/openai/v1/realtime": {
target: "https://api.openai.com",
changeOrigin: true,
secure: true,
rewrite: (p) => p.replace(/^\/openai/, ""),
ws: true,
},
"/openai/v1/chat/completions": {
target: "https://api.openai.com",
changeOrigin: true,
secure: true,
rewrite: (p) => p.replace(/^\/openai/, ""),
},
},
},
build: {
outDir: "dist",
target: "es2022",
},
});
- Step 4: Write
index.htmlβ the single-orb UI (adapted from the reference app)
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Reachy Tales</title>
<link rel="stylesheet" href="/src/style.css" />
</head>
<body>
<main class="orb-wrap">
<button id="main-circle" class="circle state-signed-out" aria-label="Main action">
<span class="circle-inner"></span>
</button>
<p id="circle-caption">Sign in</p>
<div id="tool-toast" hidden>
<span class="tool-toast-text"></span>
</div>
<aside id="robot-picker" hidden>
<h2>Choose a Reachy</h2>
<ul id="robot-list"></ul>
</aside>
<div id="hf-user" hidden>
<img id="hf-avatar" alt="" />
<span id="hf-user-name"></span>
</div>
<button id="mic-btn" hidden aria-label="Mute mic">π€</button>
<button id="stop-btn" hidden aria-label="Stop">βΉ</button>
<details id="settings">
<summary>Settings</summary>
<label>HF OAuth Client ID <input id="setting-hf-id" /></label>
<label>OpenAI API Key <input id="setting-openai-key" type="password" /></label>
<label>Realtime model <input id="setting-realtime-model" placeholder="gpt-realtime" /></label>
<label>Summary model <input id="setting-summary-model" placeholder="gpt-4o-mini" /></label>
<label>Default narrator voice <input id="setting-default-voice" placeholder="cedar" /></label>
<button id="settings-save">Save</button>
</details>
</main>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
- Step 5: Write a minimal
src/style.cssβ a working starting point; refine later
:root {
--bg: #0d1117;
--fg: #e6edf3;
--orb-base: #2d3748;
--orb-listening: #38b2ac;
--orb-speaking-user: #ed8936;
--orb-speaking-ai: #9f7aea;
}
* { box-sizing: border-box; }
body {
margin: 0; padding: 0;
background: var(--bg); color: var(--fg);
font-family: system-ui, -apple-system, sans-serif;
display: flex; align-items: center; justify-content: center;
min-height: 100vh;
}
.orb-wrap { display: flex; flex-direction: column; align-items: center; gap: 1.5rem; }
.circle {
width: 240px; height: 240px; border-radius: 50%;
border: none; background: var(--orb-base);
cursor: pointer; transition: background 0.3s, transform 0.2s;
box-shadow: 0 0 60px rgba(0,0,0,0.5);
}
.circle:disabled { cursor: not-allowed; opacity: 0.6; }
.circle.state-listening { background: var(--orb-listening); animation: pulse 2s infinite; }
.circle.state-user-speaking { background: var(--orb-speaking-user); }
.circle.state-ai-speaking { background: var(--orb-speaking-ai); animation: pulse 1s infinite; }
.circle.state-swapping-character, .circle.state-swapping-story { animation: spin 1.2s linear infinite; }
@keyframes pulse { 0%,100% { transform: scale(1); } 50% { transform: scale(1.05); } }
@keyframes spin { 0% { transform: rotate(0); } 100% { transform: rotate(360deg); } }
#circle-caption { font-size: 1.1rem; opacity: 0.9; }
#tool-toast {
position: fixed; bottom: 2rem; left: 50%; transform: translateX(-50%);
background: rgba(0,0,0,0.8); padding: 0.5rem 1rem; border-radius: 4px;
}
#settings { position: fixed; top: 1rem; right: 1rem; }
#settings label { display: block; margin: 0.5rem 0; }
#settings input { width: 280px; }
#hf-user { position: fixed; top: 1rem; left: 1rem; display: flex; gap: 0.5rem; align-items: center; }
#hf-avatar { width: 32px; height: 32px; border-radius: 50%; }
#mic-btn, #stop-btn { font-size: 1.5rem; padding: 0.5rem 1rem; }
- Step 6: Write a stub
src/main.tsso dev server can boot
import "./style.css";
console.log("Reachy Tales β boot");
- Step 7: Verify dev server starts
npm run dev
# Open http://localhost:5173 β should show the orb UI; ctrl-C to stop.
- Step 8: Verify build
npm run build
Expected: dist/ created, no errors.
- Step 9: Commit
git add tsconfig.app.json vite.config.ts index.html src/ package-lock.json package.json
git commit -m "feat(app): Vite scaffolding + orb UI shell"
Task 18: Copy plumbing files from the reference app
Files:
Create:
src/openai-realtime.tsCreate:
src/antennas.tsCreate:
src/head-wobbler.tsCreate:
src/move-player.tsCreate:
src/globals.d.tsCreate:
src/robot-tools.tsStep 1: Copy
openai-realtime.tsverbatim from the reference
curl -sL "https://huggingface.co/spaces/tfrere/reachy-mini-minimal-js-conversation-app/raw/main/src/openai-realtime.ts" \
> /Users/nicolasrabault/Projects/reachy-tales/src/openai-realtime.ts
- Step 2: Copy
antennas.ts
curl -sL "https://huggingface.co/spaces/tfrere/reachy-mini-minimal-js-conversation-app/raw/main/src/antennas.ts" \
> /Users/nicolasrabault/Projects/reachy-tales/src/antennas.ts
- Step 3: Copy
head-wobbler.ts
curl -sL "https://huggingface.co/spaces/tfrere/reachy-mini-minimal-js-conversation-app/raw/main/src/head-wobbler.ts" \
> /Users/nicolasrabault/Projects/reachy-tales/src/head-wobbler.ts
- Step 4: Copy
move-player.ts
curl -sL "https://huggingface.co/spaces/tfrere/reachy-mini-minimal-js-conversation-app/raw/main/src/move-player.ts" \
> /Users/nicolasrabault/Projects/reachy-tales/src/move-player.ts
- Step 5: Copy
globals.d.ts
curl -sL "https://huggingface.co/spaces/tfrere/reachy-mini-minimal-js-conversation-app/raw/main/src/globals.d.ts" \
> /Users/nicolasrabault/Projects/reachy-tales/src/globals.d.ts
- Step 6: Extract
robot-tools.tsfrom the reference'smain.tsβ theHEAD_POSES,ROBOT_TOOLSdefinitions and the dispatch handlers (search the referencemain.tsforconst HEAD_POSES,const ROBOT_TOOLS, and the tool-execution code paths starting around line 60-115 and the dispatch around the realtime client setup). Copy those pieces into a new filesrc/robot-tools.ts.
// src/robot-tools.ts β extracted from the reference app's main.ts
import {
MovePlayer,
MOVE_CATALOG,
MOVE_IDS,
type MoveId,
} from "./move-player.js";
import type { ReachyMiniInstance } from "./globals.d.ts";
import type { RealtimeTool } from "./openai-realtime.js";
const HEAD_POSES = {
center: { roll: 0, pitch: 0, yaw: 0 },
up: { roll: 0, pitch: -18, yaw: 0 },
down: { roll: 0, pitch: 18, yaw: 0 },
left: { roll: 0, pitch: 0, yaw: 25 },
right: { roll: 0, pitch: 0, yaw: -25 },
tilt_left: { roll: -15, pitch: 0, yaw: 0 },
tilt_right: { roll: 15, pitch: 0, yaw: 0 },
} as const;
export const ROBOT_TOOLS: RealtimeTool[] = [
{
name: "move_head",
description:
"Point the robot's head in a named direction. Use to accompany speech with a tiny gesture.",
parameters: {
type: "object",
properties: {
direction: { type: "string", enum: Object.keys(HEAD_POSES),
description: "Named head pose to assume." },
},
required: ["direction"],
},
},
{
name: "play_move",
description:
"Trigger a short pre-recorded body-language move (1-4s) from the Reachy library.\n" +
MOVE_CATALOG.map((m) => ` - ${m.id} | ${m.kind} | ${m.description}`).join("\n"),
parameters: {
type: "object",
properties: {
name: { type: "string", enum: [...MOVE_IDS],
description: "Catalog id." },
},
required: ["name"],
},
},
];
export class RobotToolDispatcher {
private movePlayer: MovePlayer;
constructor(robot: ReachyMiniInstance) {
this.movePlayer = new MovePlayer(robot);
}
async handle(robot: ReachyMiniInstance, name: string, args: Record<string, unknown>): Promise<string> {
if (name === "move_head") {
const dir = args.direction as keyof typeof HEAD_POSES;
const pose = HEAD_POSES[dir];
if (!pose) return `Unknown direction: ${args.direction}`;
robot.setHeadPose(pose.roll, pose.pitch, pose.yaw);
return `Head moved to ${dir}.`;
}
if (name === "play_move") {
const moveId = args.name as MoveId;
await this.movePlayer.play(moveId);
return `Played move: ${moveId}.`;
}
return `Unknown robot tool: ${name}`;
}
}
- Step 7: Verify build still works
npm run build
Expected: build succeeds. If TypeScript errors arise from the copied files (e.g. import paths use .ts vs .js), normalize them β Vite + TypeScript "bundler" resolution accepts .ts extensions in imports.
- Step 8: Commit
git add src/
git commit -m "feat(app): copy plumbing (openai-realtime, antennas, head-wobbler, move-player, robot-tools) from reference app"
Task 19: Story tools definitions for the LLM
Files:
Create:
src/story-tools.tsStep 1: Write
src/story-tools.tsβ re-exports the engine's tool definitions in the shape expected by the OpenAI Realtime client (matchesRealtimeToolfrom the copiedopenai-realtime.ts)
import type { ToolDef } from "story-engine";
import type { RealtimeTool } from "./openai-realtime.js";
/**
* Convert engine ToolDef[] to the RealtimeTool[] shape used by openai-realtime.ts.
* The shapes are structurally compatible; this is a typed pass-through.
*/
export function toRealtimeTools(toolDefs: ToolDef[]): RealtimeTool[] {
return toolDefs as RealtimeTool[];
}
export const STORY_TOOL_NAMES = [
"select_story",
"advance_storyline",
"navigate_scene",
"perform_scene_action",
"request_options",
"exit_story",
] as const;
export type StoryToolName = (typeof STORY_TOOL_NAMES)[number];
export function isStoryTool(name: string): name is StoryToolName {
return (STORY_TOOL_NAMES as readonly string[]).includes(name);
}
- Step 2: Verify build
npm run build
If RealtimeTool's shape isn't structurally identical, adjust toRealtimeTools to re-shape. Most likely both use the same { name, description, parameters: { type, properties, required } } form.
- Step 3: Commit
git add src/story-tools.ts
git commit -m "feat(app): story-tools.ts β bridge engine ToolDef β RealtimeTool"
Task 20: Summary builder
Files:
Create:
src/summary-builder.tsStep 1: Write
src/summary-builder.tsβ calls the proxied/openai/v1/chat/completionsto summarize recent transitions on character swap
import type { TransitionEvent } from "story-engine";
export interface SummaryBuilderOptions {
apiKey: string;
model?: string; // defaults to gpt-4o-mini
baseUrl?: string; // defaults to /openai/v1/chat/completions
}
export class SummaryBuilder {
private apiKey: string;
private model: string;
private baseUrl: string;
constructor(opts: SummaryBuilderOptions) {
this.apiKey = opts.apiKey;
this.model = opts.model ?? "gpt-4o-mini";
this.baseUrl = opts.baseUrl ?? "/openai/v1/chat/completions";
}
async summarize(events: TransitionEvent[]): Promise<string> {
if (events.length === 0) return "(The story is just beginning.)";
const log = events
.map((e) => {
switch (e.type) {
case "storyEntered": return `Story "${e.storyId}" began.`;
case "storyExited": return `User exited the story.`;
case "sceneChanged": return `Scene changed: ${e.from ?? "?"} β ${e.to}.`;
case "speakerChanged": return `Now speaking: ${e.to} (was ${e.from ?? "narrator"}).`;
case "flagSet": return `Flag set: ${e.name} = ${e.value}.`;
case "blocked": return `Blocked: ${e.reason}.`;
}
})
.join("\n");
const prompt = `Given these recent story events, write a 2-3 sentence summary suitable for handing off to a new character voice. Capture what just happened and what the user was doing. Plain spoken English, no markdown.\n\nEvents:\n${log}`;
const res = await fetch(this.baseUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.apiKey}`,
},
body: JSON.stringify({
model: this.model,
messages: [{ role: "user", content: prompt }],
max_tokens: 200,
temperature: 0.4,
}),
});
if (!res.ok) {
throw new Error(`Summary API ${res.status}: ${await res.text()}`);
}
const data = await res.json();
return data.choices[0]?.message?.content?.trim() ?? "(No summary available.)";
}
}
- Step 2: Verify build
npm run build
- Step 3: Commit
git add src/summary-builder.ts
git commit -m "feat(app): summary-builder for character-swap context handoff"
Task 21: Story bridge β engine β Realtime adapter
Files:
- Create:
src/story-bridge.ts
The bridge holds the StoryEngine, the active OpenaiRealtimeClient, and the RobotToolDispatcher. It dispatches tool calls and orchestrates voice-swap session restarts.
- Step 1: Write
src/story-bridge.ts
import { StoryEngine, type TransitionResult, type LoadedContent } from "story-engine";
import { OpenaiRealtimeClient } from "./openai-realtime.js";
import { SummaryBuilder } from "./summary-builder.js";
import { ROBOT_TOOLS, RobotToolDispatcher } from "./robot-tools.js";
import { toRealtimeTools, isStoryTool } from "./story-tools.js";
import type { ReachyMiniInstance } from "./globals.d.ts";
export interface StoryBridgeOptions {
content: LoadedContent;
robot: ReachyMiniInstance;
apiKey: string;
realtimeModel: string; // e.g. "gpt-realtime"
summaryModel: string; // e.g. "gpt-4o-mini"
defaultVoice: string; // fallback if a character has no voice
/** Provider for the input audio track (the robot's mic). */
getRobotMicTrack: () => MediaStreamTrack | null;
/** Called when the OpenAI output track is ready, so caller can route it to the robot. */
onOpenAiOutputTrack: (track: MediaStreamTrack) => void;
/** Called whenever the app should react visually (state machine transitions). */
onAppStateChange: (state: BridgeAppState) => void;
}
export type BridgeAppState =
| "starting"
| "listening"
| "user-speaking"
| "processing"
| "ai-speaking"
| "swapping-character"
| "swapping-story"
| "error";
export class StoryBridge {
private engine: StoryEngine;
private realtime: OpenaiRealtimeClient | null = null;
private summary: SummaryBuilder;
private robotDispatcher: RobotToolDispatcher;
private currentVoice: string | null = null;
private opts: StoryBridgeOptions;
constructor(opts: StoryBridgeOptions) {
this.opts = opts;
this.engine = new StoryEngine(opts.content);
this.summary = new SummaryBuilder({ apiKey: opts.apiKey, model: opts.summaryModel });
this.robotDispatcher = new RobotToolDispatcher(opts.robot);
}
async start(): Promise<void> {
this.opts.onAppStateChange("starting");
const narrator = this.engine.getCurrentCharacter();
const voice = narrator?.voice ?? this.opts.defaultVoice;
await this.openSession(voice);
this.opts.onAppStateChange("listening");
}
async stop(): Promise<void> {
if (this.realtime) {
await this.realtime.close();
this.realtime = null;
}
}
private async openSession(voice: string): Promise<void> {
const tools = [...toRealtimeTools(this.engine.buildToolDefinitions()), ...ROBOT_TOOLS];
const instructions = this.engine.buildSystemPrompt();
this.realtime = new OpenaiRealtimeClient({
apiKey: this.opts.apiKey,
model: this.opts.realtimeModel,
voice,
instructions,
tools,
inputAudioTrack: this.opts.getRobotMicTrack(),
onOutputTrack: this.opts.onOpenAiOutputTrack,
onToolCall: (name, args) => this.handleToolCall(name, args),
onStateChange: (s) => this.translateRealtimeState(s),
});
await this.realtime.connect();
this.currentVoice = voice;
}
private translateRealtimeState(rtState: string): void {
// Map openai-realtime states to BridgeAppState
switch (rtState) {
case "listening":
case "user-speaking":
case "processing":
case "ai-speaking":
this.opts.onAppStateChange(rtState as BridgeAppState);
break;
case "error":
this.opts.onAppStateChange("error");
break;
}
}
/** Called by openai-realtime.ts when the LLM emits a tool call. */
async handleToolCall(name: string, args: Record<string, unknown>): Promise<string> {
if (!isStoryTool(name)) {
return this.robotDispatcher.handle(this.opts.robot, name, args);
}
let result: TransitionResult;
switch (name) {
case "select_story":
result = this.engine.enterStoryMode(args.storyId as string);
return this.applyTransition(result, "swapping-story");
case "advance_storyline":
result = this.engine.advanceStoryline(args.actionId as string);
return this.applyTransition(result);
case "navigate_scene":
result = this.engine.navigateScene(args.sceneId as string);
return this.applyTransition(result);
case "perform_scene_action":
result = this.engine.performSceneAction(args.actionId as string);
return this.applyTransition(result);
case "request_options":
return this.engine.formatOptionsRecap();
case "exit_story":
result = this.engine.exitStoryMode();
return this.applyTransition(result, "swapping-story");
default:
return `Unknown tool: ${name}`;
}
}
private async applyTransition(
result: TransitionResult,
transientState: "swapping-character" | "swapping-story" = "swapping-character",
): Promise<string> {
const blocked = result.events.find((e) => e.type === "blocked");
if (blocked) return `Cannot do that: ${(blocked as Extract<typeof blocked, { type: "blocked" }>).reason}`;
const speakerChange = result.events.find((e) => e.type === "speakerChanged") as
| Extract<TransitionResult["events"][number], { type: "speakerChanged" }>
| undefined;
if (speakerChange) {
const newVoice = speakerChange.newCharacter.voice ?? this.opts.defaultVoice;
if (newVoice !== this.currentVoice) {
this.opts.onAppStateChange(transientState);
await this.swapSession(newVoice);
this.opts.onAppStateChange("ai-speaking");
return ""; // new session takes over
}
}
// Same voice β just push updated instructions + tool defs to the live session
if (this.realtime) {
await this.realtime.updateSession({
instructions: this.engine.buildSystemPrompt(),
tools: [...toRealtimeTools(this.engine.buildToolDefinitions()), ...ROBOT_TOOLS],
});
}
return this.engine.getCurrentNode()?.text ?? "";
}
private async swapSession(newVoice: string): Promise<void> {
let summaryText = "(no summary)";
try {
summaryText = await this.summary.summarize(this.engine.getRecentTransitionLog());
} catch (e) {
console.warn("Summary failed, continuing with no summary:", e);
}
if (this.realtime) {
await this.realtime.close();
this.realtime = null;
}
const tools = [...toRealtimeTools(this.engine.buildToolDefinitions()), ...ROBOT_TOOLS];
const instructions = this.engine.buildSystemPrompt() +
`\n\n# RECENT STORY CONTEXT\n${summaryText}\n\nSpeak the next scripted line now, in character.`;
this.realtime = new OpenaiRealtimeClient({
apiKey: this.opts.apiKey,
model: this.opts.realtimeModel,
voice: newVoice,
instructions,
tools,
inputAudioTrack: this.opts.getRobotMicTrack(),
onOutputTrack: this.opts.onOpenAiOutputTrack,
onToolCall: (name, args) => this.handleToolCall(name, args),
onStateChange: (s) => this.translateRealtimeState(s),
});
await this.realtime.connect();
this.currentVoice = newVoice;
}
}
Note on
OpenaiRealtimeClientAPI surface: The reference app exposes the client's API in itsopenai-realtime.tsfile. The bridge here assumes methodsconnect(),close(),updateSession({ instructions, tools }), and constructor options forinputAudioTrack,onOutputTrack,onToolCall,onStateChange. Verify these match the actual reference file after copying in Task 18 β adapt the bridge if names differ (e.g. the reference may call itsetSessionConfigorstart/stop). This is one of the items called out in Β§9 of the design spec.
- Step 2: Verify build
npm run build
If type errors flag missing methods on OpenaiRealtimeClient, read the actual src/openai-realtime.ts and adapt the bridge calls to match. Don't fake methods on the client β make the bridge use what the client actually exposes.
- Step 3: Commit
git add src/story-bridge.ts
git commit -m "feat(app): story-bridge orchestrating engine + realtime + voice swap"
Task 22: Bridge transition unit tests with a fake OpenAI client
Files:
- Create:
tests/story-bridge.test.ts(at repo root, with a small Vitest config for the app side) - Create:
vitest.config.app.ts(root) - Modify: root
package.json(addtest:appscript)
The bridge has logic worth testing without OpenAI / robot infrastructure. Use a recording fake for the realtime client.
- Step 1: Write
vitest.config.app.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["tests/**/*.test.ts"],
environment: "node",
},
resolve: {
alias: {
"story-engine": new URL("./packages/story-engine/src/index.ts", import.meta.url).pathname,
},
},
});
- Step 2: Add a script to root
package.json
"scripts": {
"...": "...",
"test:app": "vitest run --config vitest.config.app.ts",
"test:all": "npm run test --workspaces --if-present && npm run test:app"
},
"devDependencies": {
"vitest": "^2.1.0"
}
npm install
- Step 3: Write the test β
tests/story-bridge.test.ts
import { describe, it, expect, vi } from "vitest";
import { StoryBridge } from "../src/story-bridge";
import { loadNarrator, loadStory } from "story-engine";
import type { LoadedContent } from "story-engine";
import { readFileSync, readdirSync } from "node:fs";
import { resolve } from "node:path";
// Avoid actually instantiating the real OpenAI client + robot.
// Use module-mocking to swap them out.
vi.mock("../src/openai-realtime", () => {
return {
OpenaiRealtimeClient: class {
static instances: any[] = [];
opts: any;
constructor(opts: any) {
this.opts = opts;
(this.constructor as any).instances.push(this);
}
async connect() { /* noop */ }
async close() { /* noop */ }
async updateSession(_opts: any) { /* noop */ }
},
};
});
vi.mock("../src/summary-builder", () => {
return {
SummaryBuilder: class {
async summarize() { return "Stub summary."; }
},
};
});
vi.mock("../src/robot-tools", () => {
return {
ROBOT_TOOLS: [],
RobotToolDispatcher: class {
async handle() { return "ok"; }
},
};
});
const FIX = resolve(__dirname, "../packages/story-engine/tests/fixtures");
function readJsonFile(p: string): unknown { return JSON.parse(readFileSync(p, "utf-8")); }
function readJsonDir(dir: string): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const f of readdirSync(dir)) if (f.endsWith(".json")) out[f.replace(/\.json$/, "")] = readJsonFile(resolve(dir, f));
return out;
}
function loadFixtureContent(): LoadedContent {
const narrator = loadNarrator(readJsonFile(resolve(FIX, "narrator/narrator.json")));
const story = loadStory({
manifest: readJsonFile(resolve(FIX, "story_test/manifest.json")),
map: readJsonFile(resolve(FIX, "story_test/map.json")),
flags: readJsonFile(resolve(FIX, "story_test/flags.json")),
characters: readJsonDir(resolve(FIX, "story_test/characters")),
scenes: readJsonDir(resolve(FIX, "story_test/scenes")),
storylines: readJsonDir(resolve(FIX, "story_test/storylines/chapter1")),
});
return { narrator, stories: { story_test: story } };
}
function makeFakeRobot(): any {
return { setHeadPose: vi.fn(), setAntennas: vi.fn(), playSound: vi.fn() };
}
describe("StoryBridge", () => {
it("starts a session in menu mode with the narrator's voice", async () => {
const stateChanges: string[] = [];
const bridge = new StoryBridge({
content: loadFixtureContent(),
robot: makeFakeRobot(),
apiKey: "test-key",
realtimeModel: "gpt-realtime",
summaryModel: "gpt-4o-mini",
defaultVoice: "cedar",
getRobotMicTrack: () => null,
onOpenAiOutputTrack: () => {},
onAppStateChange: (s) => { stateChanges.push(s); },
});
await bridge.start();
const { OpenaiRealtimeClient } = await import("../src/openai-realtime");
const inst = (OpenaiRealtimeClient as any).instances[0];
expect(inst.opts.voice).toBe("cedar");
expect(stateChanges).toContain("starting");
expect(stateChanges).toContain("listening");
});
it("on select_story tool call β transitions to story and swaps voice to character", async () => {
const bridge = new StoryBridge({
content: loadFixtureContent(),
robot: makeFakeRobot(),
apiKey: "test-key",
realtimeModel: "gpt-realtime",
summaryModel: "gpt-4o-mini",
defaultVoice: "cedar",
getRobotMicTrack: () => null,
onOpenAiOutputTrack: () => {},
onAppStateChange: () => {},
});
await bridge.start();
await bridge.handleToolCall("select_story", { storyId: "story_test" });
const { OpenaiRealtimeClient } = await import("../src/openai-realtime");
const inst = (OpenaiRealtimeClient as any).instances[1]; // second client = post-swap
expect(inst.opts.voice).toBe("ash"); // Sparrow's voice
});
it("on advance_storyline β no voice swap when speaker unchanged", async () => {
const bridge = new StoryBridge({
content: loadFixtureContent(),
robot: makeFakeRobot(),
apiKey: "test-key",
realtimeModel: "gpt-realtime",
summaryModel: "gpt-4o-mini",
defaultVoice: "cedar",
getRobotMicTrack: () => null,
onOpenAiOutputTrack: () => {},
onAppStateChange: () => {},
});
await bridge.start();
await bridge.handleToolCall("select_story", { storyId: "story_test" });
const { OpenaiRealtimeClient } = await import("../src/openai-realtime");
const countBefore = (OpenaiRealtimeClient as any).instances.length;
await bridge.handleToolCall("advance_storyline", { actionId: "greet_respectfully" });
const countAfter = (OpenaiRealtimeClient as any).instances.length;
expect(countAfter).toBe(countBefore); // no new session opened
});
});
- Step 4: Run app tests
npm run test:app
If the test file imports fail (e.g. circular workspace resolution), adjust vitest.config.app.ts aliases until it works.
- Step 5: Commit
git add tests/ vitest.config.app.ts package.json package-lock.json
git commit -m "test(app): bridge transition unit tests with fake realtime client"
Phase 5 β JS app orchestration (main.ts wiring)
Task 23: Settings + OAuth + robot connection (port from reference app)
Files:
- Modify:
src/main.ts
This task ports the OAuth + robot-connection + settings logic from the reference app's main.ts. We'll keep our story-aware additions for the next task.
- Step 1: Read the reference
main.tsβ focus on lines that handle:- Settings load/save (localStorage keys:
reachyMini.hf.clientId,reachyMini.openai.apiKey,reachyMini.openai.model,reachyMini.openai.voice) - HF OAuth:
robot.authenticate(),robot.login(), OAuth callback handling - Robot connection:
robot.connect(), robot picker,robot.startSession(robotId) - State machine + orb DOM updates
- Settings load/save (localStorage keys:
curl -sL "https://huggingface.co/spaces/tfrere/reachy-mini-minimal-js-conversation-app/raw/main/src/main.ts" \
> /tmp/reference-main.ts
wc -l /tmp/reference-main.ts
- Step 2: Write
src/main.tsβ adapt the reference, replacing the simple instructions/tools wiring with ourStoryBridge. Only the parts that change:
import "./style.css";
import { ReachyMini } from "/* CDN */";
// import resolved at runtime β see reference app for exact import path / ESM module URL.
// Use the JS SDK loaded from jsDelivr per AGENTS.md:
// import { ReachyMini } from "https://cdn.jsdelivr.net/gh/pollen-robotics/reachy_mini@<TAG>/js/reachy-mini.js";
// At plan time, look up the latest tag with:
// git ls-remote --tags --refs --sort=-v:refname https://github.com/pollen-robotics/reachy_mini.git | head -1
import { HeadWobbler } from "./head-wobbler.js";
import { AntennasOscillator } from "./antennas.js";
import { StoryBridge, type BridgeAppState } from "./story-bridge.js";
import { loadNarrator, loadStory } from "story-engine";
import type { LoadedContent } from "story-engine";
// βββ Content loading (Vite bundles content/ as static assets) ββββββββββββ
// Vite supports `import.meta.glob` for batch-importing JSON.
const narratorJson = (await import("../content/narrator/narrator.json")).default;
const piratesManifest = (await import("../content/story_pirates/manifest.json")).default;
const piratesMap = (await import("../content/story_pirates/map.json")).default;
const piratesFlags = (await import("../content/story_pirates/flags.json")).default;
const piratesCharacters: Record<string, any> = {};
const characterModules = import.meta.glob("../content/story_pirates/characters/*.json", { eager: true });
for (const [path, mod] of Object.entries(characterModules)) {
const id = path.split("/").pop()!.replace(/\.json$/, "");
piratesCharacters[id] = (mod as any).default;
}
const piratesScenes: Record<string, any> = {};
const sceneModules = import.meta.glob("../content/story_pirates/scenes/*.json", { eager: true });
for (const [path, mod] of Object.entries(sceneModules)) {
const id = path.split("/").pop()!.replace(/\.json$/, "");
piratesScenes[id] = (mod as any).default;
}
const piratesStorylines: Record<string, any> = {};
const storylineModules = import.meta.glob("../content/story_pirates/storylines/chapter1/*.json", { eager: true });
for (const [path, mod] of Object.entries(storylineModules)) {
const id = path.split("/").pop()!.replace(/\.json$/, "");
piratesStorylines[id] = (mod as any).default;
}
const content: LoadedContent = {
narrator: loadNarrator(narratorJson),
stories: {
story_pirates: loadStory({
manifest: piratesManifest,
map: piratesMap,
flags: piratesFlags,
characters: piratesCharacters,
scenes: piratesScenes,
storylines: piratesStorylines,
}),
},
};
// βββ Settings persistence (matching the reference app keys) βββββββββββββ
const STORAGE_KEYS = {
hfClientId: "reachyMini.hf.clientId",
apiKey: "reachyMini.openai.apiKey",
realtimeModel: "reachyTales.openai.realtimeModel",
summaryModel: "reachyTales.openai.summaryModel",
defaultVoice: "reachyTales.openai.defaultVoice",
} as const;
function loadSettings() {
return {
hfClientId: localStorage.getItem(STORAGE_KEYS.hfClientId) ?? "",
apiKey: localStorage.getItem(STORAGE_KEYS.apiKey) ?? "",
realtimeModel: localStorage.getItem(STORAGE_KEYS.realtimeModel) ?? "gpt-realtime",
summaryModel: localStorage.getItem(STORAGE_KEYS.summaryModel) ?? "gpt-4o-mini",
defaultVoice: localStorage.getItem(STORAGE_KEYS.defaultVoice) ?? "cedar",
};
}
function saveSettings(s: ReturnType<typeof loadSettings>) {
localStorage.setItem(STORAGE_KEYS.hfClientId, s.hfClientId);
localStorage.setItem(STORAGE_KEYS.apiKey, s.apiKey);
localStorage.setItem(STORAGE_KEYS.realtimeModel, s.realtimeModel);
localStorage.setItem(STORAGE_KEYS.summaryModel, s.summaryModel);
localStorage.setItem(STORAGE_KEYS.defaultVoice, s.defaultVoice);
}
// βββ DOM refs ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
function $<T extends HTMLElement>(sel: string): T {
const el = document.querySelector<T>(sel);
if (!el) throw new Error(`Missing element: ${sel}`);
return el;
}
const circleBtn = $<HTMLButtonElement>("#main-circle");
const circleCaption = $<HTMLParagraphElement>("#circle-caption");
const settingsHfId = $<HTMLInputElement>("#setting-hf-id");
const settingsApiKey = $<HTMLInputElement>("#setting-openai-key");
const settingsRealtimeModel = $<HTMLInputElement>("#setting-realtime-model");
const settingsSummaryModel = $<HTMLInputElement>("#setting-summary-model");
const settingsDefaultVoice = $<HTMLInputElement>("#setting-default-voice");
const settingsSave = $<HTMLButtonElement>("#settings-save");
// Populate settings UI
const initialSettings = loadSettings();
settingsHfId.value = initialSettings.hfClientId;
settingsApiKey.value = initialSettings.apiKey;
settingsRealtimeModel.value = initialSettings.realtimeModel;
settingsSummaryModel.value = initialSettings.summaryModel;
settingsDefaultVoice.value = initialSettings.defaultVoice;
settingsSave.addEventListener("click", () => {
saveSettings({
hfClientId: settingsHfId.value, apiKey: settingsApiKey.value,
realtimeModel: settingsRealtimeModel.value, summaryModel: settingsSummaryModel.value,
defaultVoice: settingsDefaultVoice.value,
});
alert("Settings saved.");
});
// βββ App state βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
type AppState =
| "signed-out" | "authenticated" | "connecting" | "connected"
| "starting" | "listening" | "user-speaking" | "processing" | "ai-speaking"
| "swapping-character" | "swapping-story" | "error";
const STATE_CAPTIONS: Record<AppState, string> = {
"signed-out": "Sign in",
authenticated: "Tap to start",
connecting: "Connectingβ¦",
connected: "Choose a Reachy",
starting: "Startingβ¦",
listening: "",
"user-speaking": "",
processing: "",
"ai-speaking": "",
"swapping-character": "Changing voiceβ¦",
"swapping-story": "Loading storyβ¦",
error: "Tap to retry",
};
let appState: AppState = "signed-out";
function setAppState(s: AppState) {
appState = s;
circleBtn.className = `circle state-${s}`;
circleCaption.textContent = STATE_CAPTIONS[s];
}
// βββ Robot connection β borrowed from the reference app βββββββββββββββββββ
const robot = new ReachyMini({ appName: "reachy-tales" });
let bridge: StoryBridge | null = null;
// On startup: check authentication
if (await robot.authenticate()) {
setAppState("authenticated");
}
circleBtn.addEventListener("click", async () => {
if (appState === "signed-out") {
if (!initialSettings.hfClientId) {
alert("Set your HF OAuth Client ID in settings first.");
return;
}
robot.login(initialSettings.hfClientId);
return;
}
if (appState === "authenticated") {
setAppState("connecting");
await robot.connect();
setAppState("connected");
// robotsChanged listener handles the picker
return;
}
if (appState === "connected") {
// robot picker not implemented in this minimal port; auto-pick the first robot.
return;
}
if (appState === "error") {
location.reload();
return;
}
});
robot.addEventListener("robotsChanged", async (e: any) => {
const robots = e.detail.robots;
if (robots.length === 0) return;
const robotId = robots[0].id;
setAppState("starting");
await robot.startSession(robotId);
// Set up body motion
new AntennasOscillator({ onAntennas: (r, l) => robot.setAntennas(r, l) }).start();
// Build and start the story bridge
const settings = loadSettings();
if (!settings.apiKey) {
alert("Set your OpenAI API key in settings first.");
setAppState("error");
return;
}
bridge = new StoryBridge({
content,
robot,
apiKey: settings.apiKey,
realtimeModel: settings.realtimeModel,
summaryModel: settings.summaryModel,
defaultVoice: settings.defaultVoice,
getRobotMicTrack: () => {
// The reference app pulls the robot's mic from robot._pc.getReceivers().
// Reuse that pattern here.
const pc = (robot as any)._pc as RTCPeerConnection;
const receiver = pc.getReceivers().find((r) => r.track?.kind === "audio");
return receiver?.track ?? null;
},
onOpenAiOutputTrack: (track) => {
const pc = (robot as any)._pc as RTCPeerConnection;
const audioSender = pc.getSenders().find((s) => s.track?.kind === "audio");
audioSender?.replaceTrack(track);
// Wire head-wobbler to the OpenAI output track for speech-driven sway
const wobbler = new HeadWobbler({
onPose: (roll, pitch, yaw) => robot.setHeadPose(roll, pitch, yaw),
});
wobbler.attachTrack(track);
},
onAppStateChange: (s: BridgeAppState) => {
setAppState(s as AppState);
},
});
await bridge.start();
});
Important: This is a sketch β the actual SDK URL needs to be hardcoded with the latest
v*tag (per AGENTS.md), and many edge cases (mic mute, stop button, error paths, robot picker UI, OAuth callback timing) need to be ported faithfully from the reference app. The referencemain.tsis ~2 kloc; the engineer should read it line by line, port the bones of it, and replace the simple instructions/tools wiring with the bridge as shown above.
- Step 3: Resolve the latest Reachy Mini SDK tag and hardcode it
git ls-remote --tags --refs --sort=-v:refname https://github.com/pollen-robotics/reachy_mini.git | head -1 | awk -F/ '{print $NF}'
Substitute the resulting tag in the import URL of src/main.ts:
import { ReachyMini } from "https://cdn.jsdelivr.net/gh/pollen-robotics/reachy_mini@<TAG>/js/reachy-mini.js";
- Step 4: Verify build + dev server starts
npm run build
npm run dev
Open http://localhost:5173 β the orb UI should render and respond to settings clicks.
- Step 5: Commit
git add src/main.ts
git commit -m "feat(app): main.ts wiring β content load, settings, robot connect, story bridge"
Phase 6 β Deployment
Task 24: Dockerfile + nginx
Files:
Create:
DockerfileCreate:
nginx.confStep 1: Write
Dockerfileβ multistage build
# ββ Builder βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json tsconfig.base.json tsconfig.app.json vite.config.ts ./
COPY packages ./packages
RUN npm ci
COPY index.html ./
COPY src ./src
COPY content ./content
RUN npm run build
# ββ Runtime βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
FROM nginx:alpine AS runtime
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 7860
CMD ["nginx", "-g", "daemon off;"]
- Step 2: Write
nginx.confβ extends the reference app's
server {
listen 7860;
server_name _;
root /usr/share/nginx/html;
# Static SPA β fall back to index.html for client-side routing
location / {
try_files $uri $uri/ /index.html;
}
# OpenAI Realtime WebRTC negotiation (browser β /openai/v1/realtime β api.openai.com)
location /openai/v1/realtime {
proxy_pass https://api.openai.com/v1/realtime;
proxy_http_version 1.1;
proxy_set_header Host api.openai.com;
proxy_set_header Authorization $http_authorization;
proxy_set_header Connection "";
proxy_buffering off;
proxy_request_buffering off;
proxy_read_timeout 600;
proxy_send_timeout 600;
}
# Summary LLM (chat completions)
location /openai/v1/chat/completions {
proxy_pass https://api.openai.com/v1/chat/completions;
proxy_http_version 1.1;
proxy_set_header Host api.openai.com;
proxy_set_header Authorization $http_authorization;
proxy_buffering off;
proxy_read_timeout 60;
}
}
- Step 3: Local build test
docker build -t reachy-tales-test .
docker run -p 7860:7860 reachy-tales-test
# Open http://localhost:7860 β should serve the orb UI.
# Ctrl-C to stop.
- Step 4: Commit
git add Dockerfile nginx.conf
git commit -m "feat(deploy): Dockerfile + nginx with OpenAI proxy routes"
Task 25: HF Space frontmatter + README
Files:
Modify:
README.mdStep 1: Replace the minimal README with the HF Space-ready version
---
title: Reachy Tales
emoji: π΄ββ οΈ
colorFrom: indigo
colorTo: purple
sdk: docker
app_port: 7860
pinned: false
hf_oauth: true
hf_oauth_expiration_minutes: 480
short_description: Voice-driven story adventures, narrated by your Reachy Mini.
tags:
- reachy_mini
- reachy_mini_js_app
---
# Reachy Tales
Voice-driven story adventures for Reachy Mini. The robot becomes the narrator and characters; the user replies in natural language; story state is driven by a clean, testable engine.
## Quick start
1. Open the Space, sign in with Hugging Face.
2. Paste an OpenAI API key with Realtime API access into the settings panel.
3. Pick your robot.
4. Tap the orb. The narrator greets you and offers stories.
## Architecture
- **`packages/story-engine/`** β pure TypeScript library: data loading, scene graph, storyline DAG, flag rules, prompt builder. No DOM / robot / LLM dependencies. Independently testable.
- **`src/`** β JS HF Space app. Wires the engine into the OpenAI Realtime API (via WebRTC over the robot peer) and into the Reachy Mini JS SDK. Body motion (idle breathing, listening cues, speech-driven head sway, dance/emotion library) is ported from the [reference conversation app](https://huggingface.co/spaces/tfrere/reachy-mini-minimal-js-conversation-app).
- **`content/`** β story data (English).
See [design spec](docs/superpowers/specs/2026-04-29-reachy-tales-design.md).
## Local dev
```bash
npm install
npm test --workspace story-engine
npm run dev # http://localhost:5173
Local dev requires:
- A Reachy Mini accessible (Lite via USB or Wireless on network)
- An OpenAI API key with Realtime + chat-completions access
- A registered HF OAuth app for
localhost:5173(see settings panel for the client-id field)
Deployment
Push to the HF Space remote β auto-builds the Docker image and rolls out (~1-2 min). For dual-source-of-truth (GitHub + HF Space), see .github/workflows/mirror-to-hf.yml.
- [ ] **Step 2: Commit**
```bash
git add README.md
git commit -m "docs: HF Space frontmatter + project README"
Task 26: GitHub β HF Space mirror Action
Files:
Create:
.github/workflows/mirror-to-hf.ymlStep 1: Write the workflow
name: Mirror to Hugging Face Space
on:
push:
branches: [main]
workflow_dispatch:
jobs:
mirror:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Push to Hugging Face Space
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
HF_USERNAME: ${{ secrets.HF_USERNAME }}
HF_SPACE: reachy-tales
run: |
git config user.email "github-actions@users.noreply.github.com"
git config user.name "GitHub Actions"
git remote add hf "https://${HF_USERNAME}:${HF_TOKEN}@huggingface.co/spaces/${HF_USERNAME}/${HF_SPACE}"
git push hf main --force
- Step 2: Document required secrets in the workflow file (already added inline as env)
In the GitHub repo settings β Secrets, add:
HF_TOKENβ a write-scoped HF access token (https://huggingface.co/settings/tokens)HF_USERNAMEβ your HF usernameStep 3: Create the Space remote on Hugging Face manually
# One-time, on your laptop:
hf auth login # if not authenticated
hf repos create reachy-tales --repo-type space --space-sdk docker
This creates the empty Space β the GitHub Action's force-push then populates it.
- Step 4: Commit
git add .github/workflows/mirror-to-hf.yml
git commit -m "ci: GitHubβHF Space mirror workflow"
Task 27: CI for tests + build
Files:
Create:
.github/workflows/ci.ymlStep 1: Write the CI workflow
name: CI
on:
pull_request:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm test --workspace story-engine
- run: npm run test:app
- run: npm run build
- Step 2: Commit
git add .github/workflows/ci.yml
git commit -m "ci: tests + build on PR and main"
Task 28: Manual playtest checklist
Files:
Create:
docs/playtest.mdStep 1: Write the checklist (lifted verbatim from spec Β§7.3)
# v1 Playtest Checklist
Walk through this before declaring v1 done. Each tick assumes physical robot + OpenAI key in settings.
## Boot & menu
- [ ] HF Space loads in under 5s
- [ ] OAuth login completes
- [ ] Robot picker shows my robot
- [ ] Tap orb β narrator greets me, lists pirate story
- [ ] "Tell me about the pirate story" β narrator describes it
- [ ] "Let's play it" β triggers `select_story` β swap to Sparrow's voice
## Voice swap
- [ ] Voice swap takes < 3s with brief "Changing voiceβ¦" caption
- [ ] New character (Sparrow) opens with paraphrased meet_sparrow line
- [ ] After swap, Sparrow voice clearly different from narrator voice
## Gameplay
- [ ] "I show him respect" advances to friendly_response
- [ ] "Screw you" advances to hostile_response
- [ ] "What can I do?" β narrator/character recites options
- [ ] "I'm bored, exit" β returns to menu, narrator's voice resumes
## Scene navigation
- [ ] Trying to go to docks before trust_sparrow β character explains it's blocked
- [ ] After trust_sparrow set β "Let's go to the docks" navigates
## Scene actions
- [ ] "I look at the memorabilia" β atmospheric description spoken
- [ ] Atmospheric actions don't change story state (next utterance still at same node)
## Body motion
- [ ] Robot breathes when idle
- [ ] Antennas freeze when I speak, blend back when I stop
- [ ] Head wobbles in sync with speech
- [ ] LLM occasionally calls `play_move` for emotional beats
## Robustness
- [ ] WiFi blip < 5s recovers without losing session
- [ ] Pause for 30s β app stays in listening state, no crash
- [ ] Refresh page mid-session β restarts at menu (v1 = no persist)
- Step 2: Commit
git add docs/playtest.md
git commit -m "docs: manual playtest checklist for v1"
Self-review note (informational)
After completing all tasks above, run a final review:
npm run test:allβ every test green.npm run buildβ Vite build succeeds with no errors.docker build -t reachy-tales .β image builds.docker run -p 7860:7860 reachy-talesβ app loads at http://localhost:7860.- Push to GitHub β CI passes β Action mirrors to HF Space β Space comes online.
- Walk the playtest checklist with a real Reachy Mini.
Phase summary
| Phase | Tasks | What's working at the end |
|---|---|---|
| 1 β Bootstrap | 1-2 | npm workspaces resolve; vitest runs (no tests). |
| 2 β Engine | 3-14 | All engine features implemented + tested with fixtures. |
| 3 β Content | 15-16 | English content present; engine playthrough test passes against real content. |
| 4 β App scaffold | 17-22 | Vite app boots, plumbing copied, bridge exists with unit tests. |
| 5 β Orchestration | 23 | Full app wires together β settings, OAuth, robot, bridge, story flow. |
| 6 β Deployment | 24-28 | Docker image, nginx proxy, HF frontmatter, GitHub mirror, CI, playtest doc. |