Add Zork engine integration work

This commit is contained in:
2026-05-15 07:55:05 +02:00
parent b8fe8535aa
commit 6faee20268
19 changed files with 4113 additions and 21 deletions
+674
View File
@@ -0,0 +1,674 @@
# Z-Code Engine Integration Analysis
## Overview
This document analyses what would be required to add a **Z-Code engine server** (`src/server-zcode.ts`) to the multi-engine architecture described in `ink_inclusion.md`. The Z-Code server would allow classic and modern Inform-compiled `.z5`, `.z8`, and `.zblorb` story files to run in the same book-style UI, over the same unified Socket.IO protocol.
---
## 1. What Is the Z-Machine?
The Z-machine is a virtual machine created by Infocom in 1979 for running interactive fiction. Versions 18 exist; almost all modern Inform 6/7-compiled games target version 5 or 8. Key properties:
- **Text-based I/O** via a simple line-input / text-output model.
- **Multi-window layout**: a fixed "status bar" window (room name + score/moves) plus a scrolling story window.
- **Formatting**: bold, italic, fixed-pitch, colours (optional).
- **Sound** (v5+): simple beeps in older games; sampled audio via Blorb archives in modern ones.
- **Graphics** (v6 only): a rarely-used version with arbitrary bitmap graphics windows. Almost no modern games use v6.
- **UNDO**: built-in opcode; many games support multiple undo levels.
- **Save/restore**: binary save files (Quetzal format).
The Z-machine does **not** have a tag system like Ink. All structural and media information must be inferred from the Glk/GlkOte output stream.
---
## 2. Available JavaScript Z-Machine Interpreters
### 2.1 `ifvms.js` (Recommended)
- **Repo**: [github.com/curiousdannii/ifvms.js](https://github.com/curiousdannii/ifvms.js)
- **npm**: `npm install ifvms` (package name: `ifvms`)
- **License**: MIT
- **Versions supported**: Z-machine v18 (full), Glulx (not yet)
- **Architecture**: JIT disassembler/compiler targeting JS. Generates an AST from Z-machine bytecode, then emits JS. Uses the **GlkOte** I/O protocol (JSON update objects).
- **Node.js support**: Yes — used by Parchment, also ships a terminal CLI (`zvm story.z5`).
- **Active maintenance**: Yes; last commit 10 months ago fixing edge cases in the spec.
- **Used by**: Parchment (`iplayif.com`), Lectrote desktop interpreter.
### 2.2 `Bocfel` via `Emglken` (WebAssembly)
- **Repo**: [github.com/garglk/garglk](https://github.com/garglk/garglk) (Bocfel) + [curiousdannii/emglken](https://github.com/curiousdannii/emglken)
- **Architecture**: Bocfel is a C interpreter compiled to WebAssembly via Emscripten. More complete Z-machine support (all versions, including V6 graphics to some extent), but WASM adds ~2 MB overhead and Node.js integration is more complex.
- **Recommendation**: Overkill for this use case. `ifvms.js` is simpler to integrate in Node.js.
### 2.3 `Quixe`
- **Repo**: [github.com/erkyrath/quixe](https://github.com/erkyrath/quixe)
- Implements **Glulx** (the successor to Z-machine), not Z-machine. Not directly applicable unless you want to run Glulx games.
**Verdict**: Use `ifvms.js` for Z-machine. If Glulx support is wanted later, add Quixe as a fourth engine type.
---
## 3. The GlkOte Protocol — The Key to Integration
Both `ifvms.js` and the browser Parchment front-end communicate via the **GlkOte JSON protocol**. Understanding this is essential because it is the seam where we integrate with our own pipeline.
GlkOte sends structured JSON **content updates** from the interpreter to the display layer:
```json
{
"type": "update",
"windows": [
{ "id": 1, "type": "grid", "gridheight": 1, "gridwidth": 80 },
{ "id": 2, "type": "buffer", "rock": 201 }
],
"content": [
{
"id": 2,
"text": [
{ "content": [{ "style": "normal", "text": "You are in a dark room." }] },
{ "content": [{ "style": "em", "text": "Something moves." }] }
]
},
{
"id": 1,
"lines": [
{ "line": 0, "content": [{ "style": "normal", "text": "Dark Room Score: 12 Moves: 7" }] }
]
}
],
"input": [
{ "id": 2, "type": "line", "gen": 5 }
]
}
```
The interpreter **waits** after sending an update. The display sends back input events:
```json
{ "type": "line", "window": 2, "value": "go north", "gen": 5 }
```
This is already very close to our `TurnResult` shape. **Our Z-Code server translates GlkOte updates into `TurnResult` objects**, forwarding them to the client via Socket.IO.
---
## 4. Architecture: Z-Code Server
### 4.1 `src/engine/zcode-engine.ts`
**Effort: ~300 lines new — the hardest piece**
The engine runs `ifvms.js` in a Node.js worker thread (or the main thread with async I/O), intercepts GlkOte updates, and translates them:
```ts
import { ZMachine } from 'ifvms'; // ifvms npm package
class ZCodeEngine {
private vm: ZMachine;
private pendingResolve: ((input: GlkInput) => void) | null = null;
private statusLine: string = '';
async newGame(storyPath: string): Promise<TurnResult> { ... }
async sendCommand(text: string): Promise<TurnResult> { ... }
async sendCharInput(charCode: number): Promise<TurnResult> { ... }
async undo(): Promise<TurnResult> { ... }
private onGlkUpdate(update: GlkUpdate): TurnResult { ... } // The translator
}
```
The `ifvms.js` Glk layer is designed to be replaceable. Instead of plugging in the browser GlkOte display, we plug in a **custom Glk backend** that captures output into our `TurnResult` structure.
### 4.2 GlkOte Update → `TurnResult` Translation
This is the core of the Z-Code server. The translation rules:
| GlkOte concept | `TurnResult` mapping |
|---|---|
| Window 2 (buffer) text spans | `paragraphs[]` — one entry per `text[]` item |
| `style: "header"` or bold span at paragraph start | `tags: [{ key: 'chapter', value: text }]` |
| Window 1 (grid/status bar) line 0 | `gameState.statusLine`, parsed for room/score/moves |
| `input: [{ type: 'line' }]` | `inputMode: 'text'` |
| `input: [{ type: 'char' }]` | `inputMode: 'char'` (see §5.4) |
| No input (game waiting for timer/end) | `inputMode: 'end'` |
| Sound channel open (Blorb) | `tags: [{ key: 'sfx', value: soundId }]` |
| Background colour set | `tags: [{ key: 'background', value: cssColor }]` |
### 4.3 `src/server-zcode.ts`
**Effort: ~70 lines new**
Identical structure to `server-ink.ts`. `handleGameApi` creates a `ZCodeEngine` per socket session, delegates `newGame` / `saveGame` / `loadGame` to it, and also handles:
- `playerCommand``engine.sendCommand(text)` → emit `narrativeResponse`
- `chooseChoice``engine.sendCharInput(charCode)` for char-input mode (see §5.4)
- `undo` (new optional API method) → `engine.undo()`
---
## 5. What Works Easily
### 5.1 Text Output (Buffer Window)
**Effort: Low** — maps directly to `paragraphs[]`. The GlkOte buffer window content arrives as an array of styled text spans per paragraph, which translates cleanly into `{ text, tags }` pairs.
Inline styling (`style: 'em'`, `style: 'strong'`) maps to Markdown `_..._` and `**...**` in the text field, or to an inline HTML wrapper. SmartyPants is applied server-side.
### 5.2 Line Input (Standard Command Mode)
**Effort: None** — already matches the existing `playerCommand` event and `inputMode: 'text'`. The Z-machine's line input request (`input: [{ type: 'line' }]`) maps directly.
### 5.3 Save and Restore
**Effort: Low**`ifvms.js` handles Quetzal-format save files internally via its Glk Dialog layer. We intercept Glk file-open/write calls and redirect to Node.js `Buffer` objects stored in the session slot map (`Map<number, Buffer>`). No Quetzal parsing needed on our side.
### 5.4 Character Input (Menu Mode)
**Effort: Medium**
Some Z-machine games use `read_char` for yes/no prompts, menu selections, or "press any key" pauses. When GlkOte reports `input: [{ type: 'char' }]`, we set `inputMode: 'char'`.
The client already has a `'choice'` mode concept. For char input we can synthesise a minimal choice list:
- For yes/no prompts: `choices: [{ index: 89, text: 'Yes' }, { index: 78, text: 'No' }]`
- For "press any key": `choices: [{ index: 32, text: 'Continue' }]`
Detecting *which* char input a game expects requires heuristics (checking the game's current output context). A simpler fallback: always show a freetext input accepting single characters, passed as `engine.sendCharInput(text.charCodeAt(0))`.
### 5.5 UNDO
**Effort: Low**
The Z-machine `save_undo` / `restore_undo` opcodes are handled internally by `ifvms.js` when the game calls them. We can also expose an explicit `undo` API method in `handleGameApi` that the client's toolbar restart button can call. The game then emits a new turn result showing the result of undoing.
### 5.6 Sound Effects (V5+ with Blorb)
**Effort: Medium**
Blorb archives embed sounds alongside the story file. `ifvms.js` (via GlkOte's Blorb support) signals sound playback via `glk_schannel_play`. We intercept these Glk sound calls and translate them to `sfx[soundId]` tags in the turn result. The client's `audio-manager-module.js` then fetches and plays the audio. Sound files from the Blorb archive would need to be extracted to `public/sounds/` at server startup.
### 5.7 Text Styling (Bold, Italic, Fixed-Pitch)
**Effort: Low**
GlkOte styles (`em`, `strong`, `fixed`, `preformatted`) map to Markdown or HTML in the paragraph text field:
- `em``_text_`
- `strong` / `header``**text**`
- `fixed` / `preformatted` → `` `text` `` or `<code>text</code>`
The existing `text-processor-module.js` already handles Markdown inline formatting.
---
## 6. What Is Difficult or Unsupported
### 6.1 Status Bar / Split Windows
**Effort: Medium — biggest conceptual mismatch**
The Z-machine has a top "status bar" (window 1, type grid) showing room name and score/moves. Our UI currently has no equivalent space.
Options:
1. **Parse and emit as `gameState`**: Extract room name, score, and move count from the grid window content and send them as `gameState.statusLine` in `TurnResult`. The client `ui-controller-module.js` would display them in the existing toolbar area (room label, score). **This works for standard Inform games.**
2. **Ignore entirely**: Many games function fine without a visible status bar.
3. **Add a status bar element**: Add a `<div id="status-bar">` to `index.html` and update it from `gameState`. Medium CSS/JS effort.
The grid window parsing is fragile for non-standard layouts, but for Inform 6/7 games it is reliably structured as `Room Name Score: N Moves: M`.
### 6.2 Multiple Text Windows
**Effort: High — not practical for most games**
A few Z-machine v5+ games use multiple buffer windows (e.g. `Border Zone` with split-screen displays). Our UI has a single story column. Supporting arbitrary multi-window layout would require significant UI restructuring and is not recommended. These games represent a small minority.
### 6.3 V6 Graphics Windows
**Effort: Very High — not recommended**
Z-machine version 6 (`Shogun`, `Journey`, `Arthur`) uses arbitrary graphics windows with bitmap drawing commands. `ifvms.js` has limited V6 support. The GlkOte graphics API (pixel buffer, image blitting) has no mapping to our canvas-less UI. V6 games should be considered **out of scope**.
### 6.4 Colours
**Effort: LowMedium**
Z-machine games can set foreground/background colours (16 named colours + true colour in V5+). GlkOte reports these as CSS-compatible strings. We can translate `background-colour` changes to `background[#rrggbb]` tags, but mapping arbitrary text colours to our typography-focused style is aesthetically problematic. Recommended: honour background colour changes, ignore foreground colour (always use theme typography).
### 6.5 Fixed-Width / Pre-formatted Text
**Effort: Medium**
Some Z-machine games use fixed-pitch text for ASCII-art maps, inventory tables, or status displays (e.g. `Anchorhead`'s newspaper). These arrive in `style: 'fixed'` or `style: 'preformatted'` spans. The current book-layout renderer with KnuthPlass line breaking is incompatible with fixed-width layout. Options:
- Render fixed spans as `<pre>` elements, outside the book column layout.
- Tag the paragraph with `class[fixed]` and use monospace CSS.
Neither option will look as good as a native terminal display, but both are functional.
### 6.6 Timed Input
**Effort: High**
The Z-machine supports timed line input: the interpreter can interrupt an in-progress input request after N/10 seconds to fire a timer routine. This is used by games like `Border Zone` for real-time events. `ifvms.js` supports this, but over Socket.IO it would require sending a "timer tick" event from server to client and receiving it mid-input. This is complex and rarely needed. **Not recommended for initial implementation.**
### 6.7 Mouse Input (V5+, V6)
**Effort: High**
Mouse clicks are used by very few V5 games and extensively by V6 games. V5 mouse support maps to a grid window coordinate — not applicable in our single-column layout. **Out of scope.**
### 6.8 Hyperlinks (Extended Glk)
**Effort: Medium**
Some modern Glk-aware Z-machine games (compiled with extensions) use hyperlinks in the story text. GlkOte reports these as spans with `href` or `hyperlink` values. We could translate them to `[text](choice://N)` Markdown links that the client renders as clickable choices. Nice to have, but not essential.
### 6.9 Transcript / Recording
**Effort: Low**
The Z-machine's `script_on` / `script_off` opcodes open a transcript file via Glk. We can intercept these and append to a server-side text file per session. Straightforward but low priority.
---
## 7. The Paragraph Boundary Problem
**This is the most subtle technical challenge.**
The Z-machine does not have an explicit "paragraph" concept. Games print text character by character (or string by string) via `print` opcodes. GlkOte batches output between input requests into `content[].text[]` arrays, where each array element is one `glk_put_string` call. A single "paragraph" may be split across many such calls.
The rule we apply for translation:
- A `\n` newline within output → end current paragraph, start new.
- Two consecutive `\n` → additional visual space (paragraph break).
- No trailing `\n` → append to current paragraph buffer.
This is exactly what `text-buffer-module.js` already does on the client for the LLM narrative stream. Applying the same logic server-side before building `ParagraphResult[]` works correctly for standard Inform games.
Edge cases:
- Games that use `glk_put_char('\n')` to create specific whitespace (e.g. centred headings) may produce unexpected splits. These are rare.
- The status bar (window 1 grid) output does not follow this rule and must be handled separately (see §6.1).
---
## 8. Save/Restore Deep Dive
`ifvms.js` implements Glk file operations via a pluggable `Dialog` object. We replace the default browser-localStorage `Dialog` with a Node.js implementation that stores Quetzal save data in `Buffer` objects:
```ts
const customDialog = {
open: (usage, mode, rock, callback) => { /* return file reference */ },
read: (ref, callback) => callback(saveSlots.get(currentSlot)), // Buffer
write: (ref, data, callback) => { saveSlots.set(currentSlot, data); callback(); },
};
```
This is ~50 lines and gives us full save/restore with no changes to `ifvms.js`.
---
## 9. Effort Summary
| Area | Effort | Notes |
|---|---|---|
| `src/engine/zcode-engine.ts` — GlkOte translator | **~300 lines new** | Hardest piece |
| Custom Glk Dialog (save/restore) | **~50 lines new** | Replaces localStorage |
| `src/server-zcode.ts` — server entry | **~70 lines new** | Same pattern as ink server |
| Blorb sound extraction utility | **~60 lines new** | Run at startup |
| Status bar parsing + `gameState` | **~30 lines** | In `zcode-engine.ts` |
| Client: `story:char-input` mode handling | **~20 lines** | Extend `choice-display-module.js` |
| CSS: fixed-pitch paragraph style | **~15 lines** | `style.css` |
| **`ifvms.js` npm dependency** | `npm install ifvms` | |
**Total new Z-Code specific code: ~530 lines** across 4 new files. All client-side changes reuse the infrastructure built for the Ink engine (tag events, choice display, input mode switching).
---
## 10. Feature Support Matrix
| Z-Machine Feature | Support Level | Notes |
|---|---|---|
| Text output (buffer window) | ✅ Full | Core functionality |
| Line input | ✅ Full | Standard command prompt |
| Character input | ⚠️ Partial | Synthesised choice list or single-char text field |
| UNDO | ✅ Full | Via Z-machine internal opcode |
| Save / Restore | ✅ Full | Custom Glk Dialog with server-side Buffers |
| Status bar (room/score) | ⚠️ Partial | Parsed and shown in toolbar, not as a true grid window |
| Text styles (bold/italic/fixed) | ⚠️ Partial | Mapped to Markdown/CSS; fixed-width not book-formatted |
| Colours | ⚠️ Partial | Background colour only; foreground ignored |
| Sound effects (Blorb v5+) | ⚠️ Partial | Requires Blorb extraction at startup |
| Simple beeps (v3) | ❌ Ignored | No concept in our audio system |
| Multiple buffer windows | ❌ Not supported | UI has single story column |
| V6 graphics | ❌ Out of scope | No canvas rendering |
| Timed input | ❌ Not supported | Complex; rarely needed |
| Mouse input | ❌ Out of scope | No pointing concept in UI |
| Hyperlinks | ⚠️ Optional | Could map to clickable choice spans |
| Transcript | ⚠️ Optional | Server-side file append |
---
## 11. Recommended Implementation Order
1. `npm install ifvms`
2. Write `ZCodeEngine` with a minimal custom Glk backend, returning raw GlkOte updates.
3. Implement the GlkOte → `TurnResult` translator for buffer window only (ignoring status bar and styles).
4. Build `server-zcode.ts`; test with a simple `.z5` file (e.g. `Zork I`) via `playerCommand`.
5. Add Quetzal save/restore via custom Dialog.
6. Add status bar parsing → `gameState`.
7. Add Blorb sound extraction + `sfx` tag emission.
8. Add fixed-pitch / bold text style mapping.
9. Handle char input mode (yes/no, press-any-key).
10. Test with a range of Inform 6 and Inform 7 games.
---
---
## Section 2: LLM-Enhanced Zork Engine ("Zork Narrator")
### 2.1 Concept
This engine runs a specific Z-machine game — Zork I — inside `ifvms.js` as a headless backend, while an LLM acts as a narrative layer between the Z-machine and the player. The player never sees raw Z-machine output; they read a continuously re-voiced prose adaptation of it. Conversely, the player never types parser commands; they write in natural language, and the LLM translates their intent into the terse syntax Zork's parser understands.
The central premise is that **Zork's world state is authoritative** — only the Z-machine determines what actually exists, what has been taken, what doors are unlocked — while **everything the player reads and writes is mediated by an LLM** that maintains a consistent narrative voice, a distinct player character, and persistent memory across the session.
This engine is a separate npm server (`dev:zork`) that shares the same client UI over the same Socket.IO protocol. No client changes are required.
---
### 2.2 Architecture Overview
```
Player ──(free text)──► [Command Translator LLM]
┌──────────┴───────────┐
│ tool call │ Zork command
▼ ▼
[Session Manager] [Z-Machine (ifvms.js)]
(char, notes) │
raw Zork output
[Output Evaluator LLM]
┌─────────┴──────────┐
│ retry │ accept
▼ ▼
new command [Text Rewriter LLM]
prose output
Player
```
---
### 2.3 Component Inventory
| Component | File | Role |
|---|---|---|
| Z-machine subprocess | `src/engine/zork-llm-engine.ts``ZorkProcess` | Runs `zork1.bin` via `ifvms` CLI; captures text I/O |
| Session state | `ZorkSession` (in-memory) | Character description, notes, room history |
| LLM client | Inline in engine (axios + OpenRouter) | Four distinct prompt invocations per turn |
| Prompt files | `data/zork-prompts/*.yml` | YAML templates; easily refined without code changes |
| Engine | `ZorkLlmEngine` | Orchestrates the three-step loop |
| Server | `src/server-zork.ts` | Express + Socket.IO, same pattern as YAML server |
| Story file | `data/z-code/zork1.bin` | Provided by the operator; not in version control |
---
### 2.4 Session State
The engine maintains one `ZorkSession` object per connected socket:
```ts
interface ZorkSession {
characterDescription: string; // Generated at game start; LLM can update
notes: string[]; // Persistent notes the LLM adds/removes
roomHistory: Record<string, string[]>; // roomName → up to 5 recent player-facing outputs
currentRoom: string; // Latest known room name
running: boolean;
}
```
When saved, the session is serialised to JSON and stored in a server-side slot map (same pattern as the YAML engine). The Zork process state is saved by sending the `SAVE` command to the running Z-machine and capturing the resulting Quetzal binary, stored as Base64 inside the session JSON.
---
### 2.5 Prompt Files
All four prompts live in `data/zork-prompts/` as YAML files with two fields:
- `system` — the system message, used verbatim.
- `user_template` — the user message, with `{{variable}}` placeholders substituted at call time.
Available variables in every prompt:
| Variable | Contents |
|---|---|
| `{{characterDescription}}` | Current player character prose |
| `{{notes}}` | Numbered list of persistent notes, or "(none)" |
| `{{roomHistory}}` | Up to 5 most recent player-facing outputs for the current room |
| `{{currentRoom}}` | Current room name |
#### 2.5.1 `character-generation.yml`
Called once at the start of a new game, before any Z-machine output is processed.
- **Input**: none (no user message template needed; the system message is the full prompt).
- **Expected output**: A single block of vivid prose (300500 words) describing the player character — name, personality, history, voice, quirks, motivations.
- **Used in**: All subsequent prompts as `{{characterDescription}}`.
#### 2.5.2 `text-rewriter.yml`
Called only for the game's **opening text** (before the player has made any input) and for any Z-machine output produced when re-entering a previously visited room that has no recent history yet.
Additional template variables:
| Variable | Contents |
|---|---|
| `{{zorkOutput}}` | Raw Z-machine text |
- **Expected output**: Plain prose. No JSON. No Z-machine parser vocabulary. Written in second person (or first if the character's voice demands it), in the established narrative style.
#### 2.5.3 `command-translator.yml`
Called each time the player submits input. Translates free natural-language text into a Zork parser command — or decides that no game command is appropriate.
Additional template variables:
| Variable | Contents |
|---|---|
| `{{userInput}}` | Raw player input |
- **Expected output**: A JSON object with one of these shapes:
```jsonc
// Player input maps to a Zork command
{ "type": "command", "command": "open mailbox" }
// Player input has no in-game equivalent; reply narratively
{ "type": "reply", "text": "You pause and take a steadying breath..." }
// LLM wants to update session state; may also include a command
{
"type": "tools",
"tools": [
{ "name": "add_note", "args": { "note": "Player is afraid of the dark." } },
{ "name": "update_character", "args": { "description": "Updated character prose..." } },
{ "name": "remove_note", "args": { "index": 2 } }
],
"command": "examine lantern" // optional — also try this command
}
```
The `command-translator` prompt must include the full list of available tools with descriptions, so that the LLM knows what actions it can take independently of the Z-machine.
#### 2.5.4 `output-evaluator.yml`
Called after each Z-machine response. Decides whether to accept and rewrite the output, or to discard it and try a different command.
Additional template variables:
| Variable | Contents |
|---|---|
| `{{userIntent}}` | Original player input (natural language) |
| `{{commandTried}}` | The Zork command that was sent |
| `{{zorkOutput}}` | Raw Z-machine text |
| `{{attempt}}` | Current retry attempt number (1-based) |
| `{{maxAttempts}}` | Maximum allowed retry attempts |
- **Expected output**: A JSON object with one of two shapes:
```jsonc
// Accept: rewrite the output for the player
{
"decision": "accept",
"text": "The heavy lid of the mailbox swings open..."
}
// Retry: discard this output, try a different command instead
{
"decision": "retry",
"command": "open mailbox with hands"
}
```
When the evaluator returns `retry`, the engine sends the new command to the Z-machine and calls the evaluator again on the result. If the maximum number of retries is reached without acceptance, the engine falls back to rewriting the last Z-machine output regardless.
**The evaluator should return `retry` when:**
- The Z-machine says it does not understand the command ("I don't understand that" / "That's not a verb I recognise").
- The Z-machine says the action is not possible in a way that suggests a different phrasing would work ("You can't go that way" when the player clearly wants to go somewhere).
- The output is mechanically correct but logically inconsistent with the established narrative.
**The evaluator should return `accept` when:**
- The Z-machine performed an observable world-state change (picked up an object, moved to a new room, unlocked something).
- The action failed in a meaningful, story-relevant way ("The troll blocks your path").
- The maximum retry count has been reached (soft-forced accept on the last attempt).
---
### 2.6 The Game Loop in Detail
#### Start-Up
1. Spawn the Z-machine subprocess with `zork1.bin`.
2. Collect all output until the first `>` input prompt.
3. Call `character-generation` LLM → store result as `session.characterDescription`.
4. Call `text-rewriter` LLM with the Zork intro text → send rewritten output to client as `TurnResult`.
5. Set `inputMode: 'text'`; await player input.
#### Per-Turn Loop
```
user input
command-translator LLM
├─── type: 'reply' → send LLM text to client; await next input (no Zork involved)
├─── type: 'tools' → execute tool actions (update character / add or remove notes)
│ │ then, if a command is included, fall through to ↓
│ └─────────────────────────────────────────────────────────────────────────┐
│ │
└─── type: 'command' ──────────────────────────────────────────────────────────────┘
send command to Z-machine
raw Zork output
extract room name → update currentRoom → update roomHistory
output-evaluator LLM (attempt N of maxRetries)
├─── decision: 'retry' AND attempt < maxRetries
│ │
│ └──→ send new command to Z-machine (loop back)
└─── decision: 'accept' (or maxRetries exceeded)
store accepted text in roomHistory[currentRoom]
send TurnResult to client; await next input
```
#### Room History
Each accepted player-facing output is stored in `session.roomHistory[roomName]`. Only the five most recent entries per room are kept (older entries are dropped). This rolling history is injected into all subsequent LLM prompts via `{{roomHistory}}`, ensuring that descriptions added to a room on previous visits can be referenced again when the player returns.
Room names are extracted from Z-machine output using the status bar (window 1 in the GlkOte protocol), which Zork reliably populates with the current room name. As a fallback, the first line of each Z-machine response is used if it matches the pattern for a room title (capitalised, fewer than 60 characters, no trailing punctuation).
---
### 2.7 LLM Tool Actions
The `command-translator` prompt informs the LLM of three tool actions it can invoke instead of — or in addition to — a Zork command:
| Tool | Arguments | Effect |
|---|---|---|
| `update_character` | `description: string` | Replaces `session.characterDescription` with new prose |
| `add_note` | `note: string` | Appends a note to `session.notes` |
| `remove_note` | `index: number` | Removes the note at the given zero-based index |
These tools exist to handle player inputs that have no in-game equivalent but *do* have a character-state equivalent. Examples:
- Player writes: *"I decide I'm no longer scared of trolls after that encounter."* → LLM calls `update_character` to amend the character's personality note, then replies narratively.
- Player writes: *"Remember that the sword glows near enemies."* → LLM calls `add_note` with that fact so it appears in all future evaluator and rewriter prompts.
- Player writes: *"Forget about that note about the axe."* → LLM calls `remove_note` referencing the relevant index.
---
### 2.8 Configuration
All runtime configuration is provided via environment variables (`.env`):
| Variable | Default | Description |
|---|---|---|
| `OPENROUTER_API_KEY` | — | Required |
| `OPENROUTER_MODEL` | — | Required (e.g. `anthropic/claude-3-5-sonnet`) |
| `ZORK_STORY_FILE` | `./data/z-code/zork1.bin` | Path to the Z-machine story file |
| `ZORK_MAX_RETRIES` | `3` | Maximum retry attempts before forced accept |
| `ZORK_HISTORY_SIZE` | `5` | Number of past outputs stored per room |
| `PORT` | `3002` | HTTP port for the Zork server |
---
### 2.9 Save and Restore
Saving a game involves two steps:
1. Send the `SAVE` command to the running Z-machine subprocess; respond to its filename prompt with a temporary file path; read the resulting Quetzal binary and encode it as Base64.
2. Serialise `ZorkSession` (character, notes, room history, current room) to JSON. Embed the Base64 Quetzal data as `zorkSave`.
Loading reverses this: decode and write the Quetzal file to a temp path, start a fresh Z-machine process, send `RESTORE` and provide that path, then run `continueUntilPrompt()` to reach the restored state.
---
### 2.10 Effort Estimate
| Component | Effort |
|---|---|
| `ZorkProcess` class (subprocess management) | ~100 lines |
| `ZorkLlmEngine` class (session + loop logic) | ~280 lines |
| `src/server-zork.ts` | ~80 lines |
| 4 × YAML prompt files | ~200 lines total |
| `package.json` script additions | 4 lines |
| **Total** | **~660 lines** |
No client-side changes are required. The engine reuses `axios` and `js-yaml`, which are already in the project's dependency tree. The only new dependency is `ifvms`.
---
## 12. Recommended Test Games
| Game | Format | Tests |
|---|---|---|
| `Zork I` | z3 | Basic text, status bar, save/restore |
| `Anchorhead` | z8 | Fixed-width text, complex parser |
| `Lost Pig` | z8 | Modern Inform 6, clean output |
| `Counterfeit Monkey` | zblorb | Blorb, sounds, complex Inform 7 |
| `Anchorhead` (Glulx re-release) | — | Out of scope (Glulx, not Z-machine) |
| Any V6 game | z6 | Expect graceful failure / fallback |