Add Zork engine integration work
This commit is contained in:
@@ -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 1–8 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 v1–8 (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: Low–Medium**
|
||||
|
||||
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 Knuth–Plass 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 (300–500 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 |
|
||||
Reference in New Issue
Block a user