From 44dc64f830421c16555d38f1a1763d59ce883302 Mon Sep 17 00:00:00 2001 From: Georg Tomitsch Date: Fri, 15 May 2026 08:11:35 +0200 Subject: [PATCH] Add Ink integration notes --- ink_inclusion.md | 373 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 373 insertions(+) create mode 100644 ink_inclusion.md diff --git a/ink_inclusion.md b/ink_inclusion.md new file mode 100644 index 0000000..1dc42c0 --- /dev/null +++ b/ink_inclusion.md @@ -0,0 +1,373 @@ +# Multi-Engine Architecture & Ink Engine Integration Analysis + +## Overview + +This document describes the redesign of the server architecture to support **three interchangeable game engine servers**, all serving the **identical client UI** over a shared Socket.IO protocol. It also covers the Ink engine specifically, the unified tag-driven event pipeline that all engines must implement, and the required changes to the YAML/LLM engine to conform to the same protocol. + +The three engines are: + +| Engine | Entry point | `npm run` command | Story format | +|---|---|---|---| +| **YAML + LLM** | `src/server.ts` | `dev:yaml` | `.yml` world model + OpenRouter LLM | +| **Ink** | `src/server-ink.ts` | `dev:ink` | Compiled `.ink.json` | +| **Z-Code** | `src/server-zcode.ts` | `dev:zcode` | `.z5` / `.z8` / `.zblorb` (see `zcode_inclusion.md`) | + +All three servers: +- Serve the same `public/` directory (static files, no change). +- Speak the same Socket.IO event protocol (see §3). +- Are selectable by starting a different npm script; no client code changes needed. + +--- + +## How the Prototype Worked + +The prototype (`prototype/game.js`) ran **entirely in the browser**: + +1. Fetched a compiled `.ink.json` file directly (e.g. `Herrenhaus.ink.json`). +2. Created an `inkjs.Story` instance from the JSON. +3. Called `story.Continue()` in a loop to collect paragraphs and `story.currentTags` for each. +4. Dispatched choices via `story.ChooseChoiceIndex(index)`. +5. Tags drove all media and layout: + - `CHAPTER: Title` → drop-cap heading + - `SEPARATOR` → ornamental divider + - `AUDIO: src` / `AUDIOLOOP: src` → sound effects / music + - `IMAGE: src` → inline image + - `CLASS: name` → custom CSS on a paragraph + - `CLEAR` / `RESTART` → wipe display + - Choice tags `ACTION: examine` → sorted into categorised choice columns +6. Save/load used `story.state.toJson()` / `story.state.LoadJson()` stored in `localStorage`. + +The current server/client architecture replaces all of this with a Socket.IO loop: the server processes commands and emits `narrativeResponse`; the client renders. + +--- + +## 1. The Unified Tag-Driven Event Pipeline + +### 1.1 Core Principle + +All text paragraph content emitted by any engine server contains **only prose text with inline Markdown and SmartyPants punctuation**. No media, layout, or structure information is embedded in the text strings. All such information is communicated exclusively via **structured tag objects** attached to paragraphs, choices, or the turn envelope. + +This means: +- The client's `markup-parser-module.js` no longer needs to detect and strip custom inline markup from text. +- `text-processor-module.js` receives clean text and applies only SmartyPants + basic Markdown (bold, italic, inline code). +- `audio-manager-module.js`, `ui-display-handler-module.js`, and any future media module listen for tag events dispatched alongside each paragraph. + +### 1.2 Tag Syntax (Authoring Convention) + +All engines author tags using the same `key[value]` bracket convention. For Ink this maps directly to native `# key[value]` tags. For YAML and Z-Code the server synthesises equivalent tag objects. + +| Tag | Scope | Meaning | +|---|---|---| +| `music[filename]` | paragraph or global | Start looping music track | +| `music[]` | paragraph | Stop music | +| `sfx[filename]` | paragraph | Play one-shot sound effect | +| `image[filename](caption)` | paragraph | Insert inline image | +| `chapter[Title]` | paragraph | Begin chapter: heading + drop-cap on next paragraph | +| `section` | paragraph | Ornamental section break (fleuron/divider) | +| `class[name]` | paragraph | Add CSS class to this paragraph element | +| `background[filename]` | paragraph | Change full-page background image | +| `clear` | paragraph | Wipe all story text from the display | +| `title[text]` | global (first turn) | Set document/story title | +| `author[text]` | global (first turn) | Set byline | +| `action[category]` | choice | Sort this choice into a named column | + +### 1.3 Parsed Tag Object Shape + +The server parses raw tags into structured objects before sending. The client never parses tag strings. + +```ts +interface StoryTag { + key: string; // e.g. "music", "chapter", "action" + value?: string; // e.g. "forest.mp3", "Part Two", "examine" + param?: string; // optional second param, e.g. image caption +} +``` + +### 1.4 YAML Engine Rewrite for Tag Support + +**Effort: Medium** + +The current `TextAdventureEngine` + `GameRunner` + LLM pipeline does not emit any tag objects. To conform to the unified protocol it must be extended: + +- **Room transitions**: When the player moves to a new room, the engine emits a `music[roomTrack]` tag if the room YAML defines a `music:` property, and a `background[image]` tag if it defines `background:`. +- **Chapter/section**: When a new room is entered for the first time, optionally emit a `chapter[roomName]` tag to trigger a drop-cap opening paragraph. +- **SFX**: Object interaction handlers in `game-engine.ts` can emit an `sfx[filename]` tag alongside the result text when the world YAML defines `sfx:` on an object's action. +- **YAML world-model additions** (`world-model.ts`): + - `Room`: add optional `music?: string`, `background?: string`, `entryTag?: string` fields. + - `GameObject`: add optional `sfx?: Record` (action → sound file) field. +- **`GameRunner.processCommand()`**: collect tags generated during the action, include them in the turn result alongside the narrative text. +- **LLM narrative text**: instruct the prompt to return only clean prose with Markdown. Strip any stray bracket-tag syntax from LLM output before sending. + +--- + +## 2. The Unified Socket.IO Protocol + +### 2.1 Session Lifecycle Events (unchanged) + +These work identically on all three servers: + +| Direction | Event | Payload | +|---|---|---| +| client → server | `gameApi` | `{ method, args }` → `respond(result)` | +| server → client | `gameIntroduction` | `{ title, author, inputMode }` | +| server → client | `gameSaved` | `{ slot }` | +| server → client | `gameLoaded` | `{ slot }` | +| server → client | `error` | `{ message }` | + +`gameApi` methods (all engines): `newGame`, `loadGame`, `saveGame`, `hasSaveGame`, `getSaveGames`, `isGameRunning`. + +**New `gameApi` method (Ink + Z-Code only):** + +| Method | Args | Description | +|---|---|---| +| `chooseChoice` | `[choiceIndex: number]` | Select a choice by index; server runs the next turn and emits `narrativeResponse` | + +### 2.2 The `narrativeResponse` Event (Extended) + +This is the core change. Previously `narrativeResponse` carried `{ text, gameState, suggestions }`. It is replaced with a richer `TurnResult` shape that all engines must produce: + +```ts +interface TurnResult { + paragraphs: ParagraphResult[]; // Ordered list of story paragraphs this turn + choices: ChoiceResult[]; // Available choices (empty in text-input mode) + inputMode: 'choice' | 'text' | 'end'; + globalTags?: StoryTag[]; // Only on first turn: title, author, etc. + gameState?: { // Optional, engines may omit unused fields + currentRoomId?: string; + score?: number; + moves?: number; + }; +} + +interface ParagraphResult { + text: string; // Clean prose: Markdown inline + SmartyPants only + tags: StoryTag[]; // Structural/media tags for this paragraph +} + +interface ChoiceResult { + index: number; // Engine-specific choice index + text: string; // Display text (SmartyPants applied) + tags: StoryTag[]; // Per-choice tags, e.g. action[examine] + category?: string; // Derived from action[...] tag for column grouping +} +``` + +The old `{ text }` field is removed. For backward compatibility during transition, the server may also emit a flattened `text` field containing all paragraph texts joined with newlines. + +### 2.3 `playerCommand` vs `chooseChoice` + +- **YAML/LLM engine**: always uses `playerCommand` (free text). `inputMode` is always `'text'` unless the game ends. +- **Ink engine**: uses `chooseChoice` when choices are available (`inputMode: 'choice'`), `playerCommand` if an external function expects text input (`inputMode: 'text'`). +- **Z-Code engine**: uses `playerCommand` for standard line input (`inputMode: 'text'`), character input requests translate to `chooseChoice` with a synthetic choice list (see `zcode_inclusion.md`). + +--- + +## 3. Three-Server NPM Configuration + +### 3.1 `package.json` additions + +```json +"scripts": { + "dev:yaml": "nodemon --watch src --ext ts,json --exec \"ts-node src/server.ts\"", + "dev:ink": "nodemon --watch src --ext ts,json --exec \"ts-node src/server-ink.ts\"", + "dev:zcode": "nodemon --watch src --ext ts,json --exec \"ts-node src/server-zcode.ts\"", + "start:yaml": "node dist/server.js", + "start:ink": "node dist/server-ink.js", + "start:zcode": "node dist/server-zcode.js" +} +``` + +Each server file contains only the engine-specific `handleGameApi` switch and session management. The static file serving, port-finding logic, and Socket.IO setup are extracted to a shared `src/server-base.ts` that all three import. + +### 3.2 Shared `server-base.ts` + +```ts +// src/server-base.ts +export function createServer(handleGameApi: GameApiHandler): http.Server { ... } +export function startServer(server: http.Server, port: number, range: number): Promise { ... } +``` + +Approximately 80 lines, extracted from the current `server.ts`. No functional change. + +--- + +## 4. Ink Engine Server + +### 4.1 `src/engine/ink-engine.ts` + +**Effort: ~200 lines new** + +- `npm install inkjs` (same library as in the prototype). +- Load `.ink.json` file at `newGame()` / on construction. +- `continueStory(): TurnResult`: + 1. Loop `story.canContinue`: call `story.Continue()`, parse `story.currentTags` into `StoryTag[]`, collect `ParagraphResult`. + 2. After loop: map `story.currentChoices` into `ChoiceResult[]`, derive `category` from `action[...]` tags. + 3. Set `inputMode`: `'choice'` if choices present, `'end'` if story finished, `'text'` otherwise (external function waiting). +- `chooseChoice(index)`: `story.ChooseChoiceIndex(index)` then `continueStory()`. +- `saveGame()`: `story.state.toJson()` → stored as string in session map. +- `loadGame(json)`: `story.state.LoadJson(json)` then `continueStory()` to reconstruct current state display. + +### 4.2 Tag Parser + +```ts +// src/utils/tag-parser.ts (~30 lines) +export function parseTag(raw: string): StoryTag | null { + // Matches: key[value](param) or key[value] or key + const m = raw.match(/^(\w+)(?:\[([^\]]*)\])?(?:\(([^)]*)\))?$/); + if (!m) return null; + return { key: m[1], value: m[2], param: m[3] }; +} +``` + +Used by all three engines. + +### 4.3 `src/server-ink.ts` + +**Effort: ~60 lines new** + +```ts +import { createServer, startServer } from './server-base'; +import { InkEngine } from './engine/ink-engine'; + +const sessions = new Map(); + +async function handleGameApi(socket, method, args): Promise { + switch (method) { + case 'newGame': { /* create InkEngine, continueStory(), emit narrativeResponse */ } + case 'chooseChoice': { /* session.chooseChoice(args[0]), emit narrativeResponse */ } + case 'saveGame': { /* session.saveGame() → slot map */ } + // ... isGameRunning, hasSaveGame, loadGame, getSaveGames + } +} +``` + +--- + +## 5. Client-Side Changes + +### 5.1 Tag Event Dispatch + +**Modified: `game-loop-module.js`** (or `socket-client-module.js`) + +On every `narrativeResponse`, after feeding text into the pipeline, iterate `paragraphs[i].tags` and dispatch a DOM `CustomEvent('story:tag', { detail: tag })` for each. Global tags dispatch once as `CustomEvent('story:global-tags', { detail: globalTags })`. + +### 5.2 `audio-manager-module.js` + +**Effort: ~20 lines added** + +Replace the current ad-hoc inline-markup scan with a listener: +```js +document.addEventListener('story:tag', ({ detail: tag }) => { + if (tag.key === 'music') tag.value ? this.playMusic(tag.value) : this.stopMusic(); + if (tag.key === 'sfx') this.playSfx(tag.value); +}); +``` + +### 5.3 `ui-display-handler-module.js` + +**Effort: ~50 lines added** + +```js +document.addEventListener('story:tag', ({ detail: tag }) => { + if (tag.key === 'chapter') this.beginChapter(tag.value); // emit heading, set drop-cap flag + if (tag.key === 'section') this.insertSectionBreak(); + if (tag.key === 'image') this.insertImage(tag.value, tag.param); + if (tag.key === 'background') this.setBackground(tag.value); + if (tag.key === 'clear') this.clearDisplay(); + if (tag.key === 'class') this.pendingClass = tag.value; // applied to next paragraph +}); +``` + +### 5.4 `markup-parser-module.js` + +**Effort: ~10 lines removed, ~10 lines changed** + +Remove all tag-detection regex from the text stream. The module now only applies SmartyPants and Markdown inline formatting to the paragraph `text` field. No structural parsing of text content. + +### 5.5 New: `choice-display-module.js` + +**Effort: ~150 lines new** + +Renders choice columns, exactly as in the prototype's `createChoiceContainer`. Registered in `module-registry.js`. + +- Listens for a `story:choices` event (dispatched by `game-loop-module.js` from the `TurnResult.choices` array). +- Groups choices by `category` field into named columns with localised headers. +- Registers keyboard shortcuts (A/B/C... or 1/2/3... depending on whether choices are categorised). +- On click/keypress: calls `socketClient.sendChoice(index)` → `gameApi { method: 'chooseChoice', args: [index] }`. + +### 5.6 `ui-input-handler-module.js` + +**Effort: ~25 lines modified** + +Extend `setInputAvailability()` to accept a mode: +```js +setMode(mode) { // 'text' | 'choice' | 'end' + this.inputArea.style.display = mode === 'text' ? '' : 'none'; + document.dispatchEvent(new CustomEvent('story:input-mode', { detail: mode })); +} +``` +`choice-display-module.js` listens on `story:input-mode` to show/hide choice columns. + +### 5.7 `game-loop-module.js` + +**Effort: ~40 lines modified** + +On `narrativeResponse`: +1. Iterate `paragraphs` → feed each `text` into `text-buffer` pipeline → dispatch `story:tag` for each tag array. +2. Dispatch `story:choices` with `choices` array. +3. Call `uiInputHandler.setMode(inputMode)`. +4. Update `gameState` from `result.gameState` if present. + +--- + +## 6. What Does NOT Need to Change + +| Component | Status | +|---|---| +| All TTS modules | Unchanged | +| `text-buffer-module.js`, `sentence-queue-module.js`, `animation-queue-module.js` | Unchanged | +| `paragraph-layout-module.js`, `layout-renderer-module.js` | Unchanged (drop-cap invoked by tag event) | +| `persistence-manager-module.js` | Unchanged | +| `options-ui-module.js` | Unchanged | +| `playback-coordinator-module.js` | Unchanged | +| `ui-controller-module.js` | Minor: reads `inputMode` from game state instead of deriving it | +| Socket.IO infrastructure, `module-registry.js`, `loader.js` | Unchanged (new module registered) | +| `public/index.html`, CSS | Unchanged (choice columns need ~30 lines new CSS) | + +--- + +## 7. Effort Summary + +| Area | Effort | +|---|---| +| `src/server-base.ts` — shared server setup | ~80 lines extracted | +| `src/server-ink.ts` — Ink server entry | ~60 lines new | +| `src/engine/ink-engine.ts` — Ink story runner | ~200 lines new | +| `src/utils/tag-parser.ts` — tag parser | ~30 lines new | +| `TurnResult` / `StoryTag` interfaces | ~30 lines new | +| YAML world model additions (`world-model.ts`, `game-engine.ts`, `game-runner.ts`) | ~80 lines modified | +| `game-loop-module.js` — turn result routing + tag dispatch | ~40 lines modified | +| `audio-manager-module.js` — tag event listener | ~20 lines added | +| `ui-display-handler-module.js` — tag event listener | ~50 lines added | +| `markup-parser-module.js` — remove inline tag parsing | ~10 lines removed | +| New `choice-display-module.js` | ~150 lines new | +| `ui-input-handler-module.js` — mode switching | ~25 lines modified | +| CSS — choice column layout | ~30 lines added | +| `package.json` — new npm scripts | ~8 lines | + +**Total: ~830 lines** across ~6 new files and ~8 modified files. + +--- + +## 8. Recommended Implementation Order + +1. Extract `server-base.ts`; verify YAML engine still works. +2. Define `TurnResult` + `StoryTag` interfaces. +3. Write `tag-parser.ts`; unit test it. +4. Rewrite `GameRunner` to produce `TurnResult`; update `server.ts` to emit it. +5. Update `game-loop-module.js` to consume `TurnResult` and dispatch tag events. +6. Update `audio-manager-module.js` and `ui-display-handler-module.js` to listen for tag events. +7. Remove tag parsing from `markup-parser-module.js`. +8. Build `choice-display-module.js` and `ui-input-handler` mode switching. +9. Build `InkEngine` and `server-ink.ts`; smoke-test with a compiled `.ink.json`. +10. Add Z-Code server (see `zcode_inclusion.md`).