# 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`](../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` (branch `feat/ehance-js-lib`), file `js/reachy-mini.js`. - Reference JS conversation app: `https://huggingface.co/spaces/tfrere/reachy-mini-minimal-js-conversation-app` (raw files at `https://huggingface.co/spaces/tfrere/reachy-mini-minimal-js-conversation-app/raw/main/`). - 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/.gitignore` - Create: `/Users/nicolasrabault/Projects/reachy-tales/package.json` - Create: `/Users/nicolasrabault/Projects/reachy-tales/tsconfig.base.json` - Create: `/Users/nicolasrabault/Projects/reachy-tales/README.md` - [ ] **Step 1: Create directory and init git** ```bash 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`** ```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`** ```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) ```markdown # 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** ```bash 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.json` - Create: `packages/story-engine/tsconfig.json` - Create: `packages/story-engine/vitest.config.ts` - Create: `packages/story-engine/src/index.ts` - [ ] **Step 1: Create the package directory tree** ```bash mkdir -p packages/story-engine/src packages/story-engine/tests ``` - [ ] **Step 2: Write `packages/story-engine/package.json`** ```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`** ```json { "extends": "../../tsconfig.base.json", "include": ["src/**/*", "tests/**/*"] } ``` - [ ] **Step 4: Write `packages/story-engine/vitest.config.ts`** ```typescript 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) ```typescript export const ENGINE_VERSION = "0.1.0"; ``` - [ ] **Step 6: Install workspace deps** ```bash 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)** ```bash 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: ```json "test": "vitest run --passWithNoTests" ``` - [ ] **Step 8: Commit** ```bash 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.ts` - [ ] **Step 1: Write `types.ts`** — the full data model. No tests yet (types only); they become testable when we use them in the loader. ```typescript // ─── 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; } 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; scenes: Record; storylines: Record; map: MapEdge[]; flags: string[]; // declared flag names (flat array) } export interface LoadedContent { narrator: Narrator; // global menu narrator stories: Record; } // ─── 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; 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; events: TransitionEvent[]; } // ─── Tool definitions for OpenAI Realtime ───────────────────────────────── export interface ToolDef { name: string; description: string; parameters: { type: "object"; properties: Record; required: string[]; }; } ``` - [ ] **Step 2: Verify it type-checks** ```bash cd /Users/nicolasrabault/Projects/reachy-tales/packages/story-engine npx tsc --noEmit ``` Expected: no errors. - [ ] **Step 3: Commit** ```bash 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`: ```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`: ```typescript 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** ```bash cd /Users/nicolasrabault/Projects/reachy-tales npm test --workspace story-engine ``` Expected: FAIL — `loadNarrator` not exported. - [ ] **Step 4: Implement `loader.ts`** with just `loadNarrator` `packages/story-engine/src/loader.ts`: ```typescript 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** ```bash npm test --workspace story-engine ``` Expected: PASS. - [ ] **Step 6: Commit** ```bash 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.ts` - Modify: `packages/story-engine/tests/loader.test.ts` - Create: `packages/story-engine/tests/fixtures/story_test/characters/sparrow.json` - Create: `packages/story-engine/tests/fixtures/story_test/scenes/tavern.json` - Create: `packages/story-engine/tests/fixtures/story_test/storylines/chapter1/meet_sparrow.json` - [ ] **Step 1: Write fixtures** `packages/story-engine/tests/fixtures/story_test/characters/sparrow.json`: ```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`: ```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`: ```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`: ```typescript 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** ```bash npm test --workspace story-engine ``` Expected: FAIL — functions not exported. - [ ] **Step 4: Implement the three loaders** — append to `packages/story-engine/src/loader.ts`: ```typescript 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 }; } 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** ```bash npm test --workspace story-engine ``` Expected: PASS (4 tests total now). - [ ] **Step 6: Commit** ```bash 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.ts` - Modify: `packages/story-engine/tests/loader.test.ts` - Create: `packages/story-engine/tests/fixtures/story_test/manifest.json` - Create: `packages/story-engine/tests/fixtures/story_test/map.json` - Create: `packages/story-engine/tests/fixtures/story_test/flags.json` - Create: `packages/story-engine/tests/fixtures/story_test/storylines/chapter1/friendly_response.json` - Create: `packages/story-engine/tests/fixtures/story_test/storylines/chapter1/hostile_response.json` - Create: `packages/story-engine/tests/fixtures/story_test/scenes/harbor.json` - [ ] **Step 1: Write manifest, map, flags fixtures** `packages/story-engine/tests/fixtures/story_test/manifest.json`: ```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`: ```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`: ```json ["trust_sparrow", "found_treasure"] ``` `packages/story-engine/tests/fixtures/story_test/storylines/chapter1/friendly_response.json`: ```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`: ```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`: ```json { "name": "Port Royal Harbor", "description": "A bustling harbor smelling of salt and tar.", "actions": [], "characters_present": [] } ``` - [ ] **Step 2: Write failing tests for `loadStory` and `loadMapEdges`** — append: ```typescript 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 { const result: Record = {}; 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** ```bash 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`: ```typescript 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; // id → raw character JSON scenes: Record; // id → raw scene JSON storylines: Record; // 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 = {}; for (const [id, raw] of Object.entries(inputs.characters)) { characters[id] = loadCharacter(id, raw); } const scenes: Record = {}; for (const [id, raw] of Object.entries(inputs.scenes)) { scenes[id] = loadScene(id, raw); } const storylines: Record = {}; 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** ```bash npm test --workspace story-engine ``` Expected: PASS (all loader tests). - [ ] **Step 6: Commit** ```bash 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.ts` - Create: `packages/story-engine/src/engine.ts` - Create: `packages/story-engine/tests/engine-menu.test.ts` - [ ] **Step 1: Write `state.ts`** — initial-state factory ```typescript 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`: ```typescript 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 { const out: Record = {}; 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) ```bash npm test --workspace story-engine ``` - [ ] **Step 4: Implement `engine.ts`** — the menu-mode subset: ```typescript 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 { 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** ```bash npm test --workspace story-engine ``` - [ ] **Step 6: Commit** ```bash 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.ts` - Create: `packages/story-engine/tests/engine-lifecycle.test.ts` - [ ] **Step 1: Write failing tests** — `packages/story-engine/tests/engine-lifecycle.test.ts`: ```typescript 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 { const out: Record = {}; 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).to).toBe("Sparrow"); expect((sp as Extract).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 `enterStoryMode` and `exitStoryMode`** — append to `engine.ts`: ```typescript 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** ```bash npm test --workspace story-engine ``` - [ ] **Step 5: Commit** ```bash 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.ts` - Create: `packages/story-engine/tests/engine-storyline.test.ts` - [ ] **Step 1: Write failing tests** ```typescript 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 { const out: Record = {}; 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).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 to `engine.ts`: ```typescript 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** ```bash 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.ts` - Create: `packages/story-engine/tests/engine-navigation.test.ts` - [ ] **Step 1: Write failing tests** ```typescript 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).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 `docks` scene fixture so navigation tests work** `packages/story-engine/tests/fixtures/story_test/scenes/docks.json`: ```json { "name": "Old Docks", "description": "Splintered planks and shadowy crates.", "actions": [], "characters_present": [] } ``` - [ ] **Step 3: Run tests — FAIL** - [ ] **Step 4: Implement `navigateScene` and `getReachableScenes`** — add to `engine.ts`: ```typescript 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** ```bash 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.ts` - Create: `packages/story-engine/tests/engine-scene-actions.test.ts` - [ ] **Step 1: Write failing tests** ```typescript 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`: ```typescript 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** ```bash 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.ts` - Modify: `packages/story-engine/src/engine.ts` - Create: `packages/story-engine/tests/prompt-builder.test.ts` - [ ] **Step 1: Write failing tests** ```typescript 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`** ```typescript 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`** ```typescript 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** ```bash 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.ts` - Create: `packages/story-engine/tests/tool-defs.test.ts` - [ ] **Step 1: Write failing tests** ```typescript 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 to `engine.ts`: ```typescript 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** ```bash 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.ts` - Modify: `packages/story-engine/src/index.ts` - [ ] **Step 1: Add accessor to `engine.ts`** ```typescript getRecentTransitionLog(): TransitionEvent[] { return [...this.state.recentTransitions]; } getPromptContext(): { state: Readonly; 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.ts`** to export the public API ```typescript 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** ```bash npm test --workspace story-engine ``` - [ ] **Step 4: Commit** ```bash 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`** ```bash mkdir -p /Users/nicolasrabault/Projects/reachy-tales/scripts ``` ```javascript #!/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** ```bash chmod +x scripts/translate-content.mjs ``` - [ ] **Step 3: Run translation** (requires Anthropic API key) ```bash ANTHROPIC_API_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/*.json` and skim. Fix anything that lost flavor (especially Sparrow's pirate dialect, which often translates flat). - [ ] **Step 5: Add a `voice` field 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.json` narrator: `"voice": "ballad"` - `content/story_pirates/characters/sparrow.json`: `"voice": "ash"` - `content/story_pirates/characters/aria.json`: `"voice": "coral"` - [ ] **Step 6: Commit** ```bash 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.md` - Create: `packages/story-engine/tests/playthrough.test.ts` - [ ] **Step 1: Write `content/SCHEMA.md`** — authoritative schema doc ```markdown # 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/.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 ```json { "id": "string (matches folder name)", "title": "string", "description": "string", "languages": ["en"], "entry_point": "storylines/chapter1/.json", "narrator": { /* same shape as a character */ } } ``` ### scenes/.json ```json { "name": "string", "description": "string", "actions": [ { "id": "string", "description": "string", "effects": "string" } ], "characters_present": ["character_id_or_name"] } ``` ### storylines/chapter/.json ```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 ```json [ { "from": "scene_id", "to": "scene_id", "description": "string", "conditions": [ { "type": "requires_flag", "flag": "flag_name", "condition_failed": "string (player-facing explanation)" } ] } ] ``` ### flags.json ```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 { const out: Record = {}; 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) ```bash npm test --workspace story-engine ``` If it fails, the translated content is malformed somewhere — fix the JSON or the engine. - [ ] **Step 4: Commit** ```bash 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.json` - Create: `vite.config.ts` - Create: `index.html` - Create: `src/style.css` - Modify: root `package.json` to add Vite-side deps - [ ] **Step 1: Add Vite-side deps to root `package.json`** Modify root `package.json`: ```json { "dependencies": { "story-engine": "0.1.0" }, "devDependencies": { "typescript": "^5.6.3", "vite": "^5.4.10" } } ``` Run: ```bash cd /Users/nicolasrabault/Projects/reachy-tales npm install ``` - [ ] **Step 2: Write `tsconfig.app.json`** for the JS app side ```json { "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 ```typescript 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) ```html Reachy Tales

Sign in

Settings
``` - [ ] **Step 5: Write a minimal `src/style.css`** — a working starting point; refine later ```css :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.ts` so dev server can boot** ```typescript import "./style.css"; console.log("Reachy Tales — boot"); ``` - [ ] **Step 7: Verify dev server starts** ```bash npm run dev # Open http://localhost:5173 — should show the orb UI; ctrl-C to stop. ``` - [ ] **Step 8: Verify build** ```bash npm run build ``` Expected: `dist/` created, no errors. - [ ] **Step 9: Commit** ```bash 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.ts` - Create: `src/antennas.ts` - Create: `src/head-wobbler.ts` - Create: `src/move-player.ts` - Create: `src/globals.d.ts` - Create: `src/robot-tools.ts` - [ ] **Step 1: Copy `openai-realtime.ts`** verbatim from the reference ```bash 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`** ```bash 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`** ```bash 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`** ```bash 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`** ```bash 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.ts` from the reference's `main.ts`** — the `HEAD_POSES`, `ROBOT_TOOLS` definitions and the dispatch handlers (search the reference `main.ts` for `const 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 file `src/robot-tools.ts`. ```typescript // 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): Promise { 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** ```bash 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** ```bash 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.ts` - [ ] **Step 1: Write `src/story-tools.ts`** — re-exports the engine's tool definitions in the shape expected by the OpenAI Realtime client (matches `RealtimeTool` from the copied `openai-realtime.ts`) ```typescript 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** ```bash 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** ```bash 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.ts` - [ ] **Step 1: Write `src/summary-builder.ts`** — calls the proxied `/openai/v1/chat/completions` to summarize recent transitions on character swap ```typescript 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 { 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** ```bash npm run build ``` - [ ] **Step 3: Commit** ```bash 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`** ```typescript 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 { 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 { if (this.realtime) { await this.realtime.close(); this.realtime = null; } } private async openSession(voice: string): Promise { 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): Promise { 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 { const blocked = result.events.find((e) => e.type === "blocked"); if (blocked) return `Cannot do that: ${(blocked as Extract).reason}`; const speakerChange = result.events.find((e) => e.type === "speakerChanged") as | Extract | 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 { 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 `OpenaiRealtimeClient` API surface**: The reference app exposes the client's API in its `openai-realtime.ts` file. The bridge here assumes methods `connect()`, `close()`, `updateSession({ instructions, tools })`, and constructor options for `inputAudioTrack`, `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 it `setSessionConfig` or `start`/`stop`). This is one of the items called out in §9 of the design spec. - [ ] **Step 2: Verify build** ```bash 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** ```bash 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` (add `test:app` script) 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`** ```typescript 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`** ```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" } ``` ```bash npm install ``` - [ ] **Step 3: Write the test** — `tests/story-bridge.test.ts` ```typescript 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 { const out: Record = {}; 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** ```bash 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** ```bash 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 ```bash 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 our `StoryBridge`. Only the parts that change: ```typescript 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@/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 = {}; 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 = {}; 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 = {}; 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) { 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 $(sel: string): T { const el = document.querySelector(sel); if (!el) throw new Error(`Missing element: ${sel}`); return el; } const circleBtn = $("#main-circle"); const circleCaption = $("#circle-caption"); const settingsHfId = $("#setting-hf-id"); const settingsApiKey = $("#setting-openai-key"); const settingsRealtimeModel = $("#setting-realtime-model"); const settingsSummaryModel = $("#setting-summary-model"); const settingsDefaultVoice = $("#setting-default-voice"); const settingsSave = $("#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 = { "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 reference `main.ts` is ~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** ```bash 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`: ```typescript import { ReachyMini } from "https://cdn.jsdelivr.net/gh/pollen-robotics/reachy_mini@/js/reachy-mini.js"; ``` - [ ] **Step 4: Verify build + dev server starts** ```bash npm run build npm run dev ``` Open http://localhost:5173 — the orb UI should render and respond to settings clicks. - [ ] **Step 5: Commit** ```bash 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: `Dockerfile` - Create: `nginx.conf` - [ ] **Step 1: Write `Dockerfile`** — multistage build ```dockerfile # ── 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 ```nginx 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** ```bash 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** ```bash 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.md` - [ ] **Step 1: Replace the minimal README with the HF Space-ready version** ```markdown --- 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.yml` - [ ] **Step 1: Write the workflow** ```yaml 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 username - [ ] **Step 3: Create the Space remote on Hugging Face manually** ```bash # 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** ```bash 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.yml` - [ ] **Step 1: Write the CI workflow** ```yaml 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** ```bash 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.md` - [ ] **Step 1: Write the checklist (lifted verbatim from spec §7.3)** ```markdown # 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** ```bash 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: 1. **`npm run test:all`** — every test green. 2. **`npm run build`** — Vite build succeeds with no errors. 3. **`docker build -t reachy-tales .`** — image builds. 4. **`docker run -p 7860:7860 reachy-tales`** — app loads at http://localhost:7860. 5. **Push to GitHub** → CI passes → Action mirrors to HF Space → Space comes online. 6. **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. |