Add Ink integration notes
This commit is contained in:
@@ -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<string, string>` (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<void> { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
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<string, InkEngine>();
|
||||||
|
|
||||||
|
async function handleGameApi(socket, method, args): Promise<object> {
|
||||||
|
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`).
|
||||||
Reference in New Issue
Block a user