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
+3 -2
View File
@@ -1,10 +1,11 @@
# OpenRouter API Configuration
OPENROUTER_API_KEY=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
OPENROUTER_MODEL=anthropic/claude-3-opus-20240229
OPENROUTER_MODEL=openai/gpt-5.5
OPENROUTER_REASONING_EFFORT=none
# Application Configuration
PORT=3001
NODE_ENV=development
# Game Configuration
DEFAULT_WORLD_FILE=./data/worlds/example_world.yml
DEFAULT_WORLD_FILE=./data/worlds/example_world.yml
+4 -2
View File
@@ -1,10 +1,12 @@
# OpenRouter API Configuration
OPENROUTER_API_KEY=your_openrouter_api_key_here
OPENROUTER_MODEL=your_selected_model_here
OPENROUTER_MODEL=openai/gpt-5.5
# GPT-5 reasoning tokens can consume short completion budgets; keep narration calls direct by default.
OPENROUTER_REASONING_EFFORT=none
# Application Configuration
PORT=3000
NODE_ENV=development
# Game Configuration
DEFAULT_WORLD_FILE=./data/worlds/example_world.yml
DEFAULT_WORLD_FILE=./data/worlds/example_world.yml
+30
View File
@@ -0,0 +1,30 @@
# Z-Code Story Files
Place your Z-machine story files here. The Zork Narrator engine looks for
`zork1.bin` by default. This can be overridden with the `ZORK_STORY_FILE`
environment variable.
## Obtaining Zork I
The Zork I story file (`zork1.bin`, also distributed as `ZORK1.DAT` or as a
`.z3` or `.z5` file) is copyrighted by Infocom / Activision. It is not
included in this repository.
You can obtain a legal copy via:
- The **Zork Trilogy** on GOG.com or Steam (includes the original data files).
- The [Internet Archive](https://archive.org/details/Zork_I_The_Great_Underground_Empire_1980_Infocom)
hosts a playable version in-browser; the original data files are part of some
archived distributions listed under the Infocom catalogue.
Once you have the file, rename it to `zork1.bin` and place it in this folder,
or set `ZORK_STORY_FILE=./path/to/your/file` in your `.env`.
## Supported Formats
The `ifvms` interpreter accepts:
- `.z3`, `.z4`, `.z5`, `.z8` — raw Z-machine story files
- `.zblorb` — Blorb-wrapped story files (may include sound resources)
- Any file with the correct Z-machine header (the extension is ignored)
Zork I is a Z-machine version 3 (`.z3`) game.
Binary file not shown.
@@ -0,0 +1,44 @@
# Character Generation Prompt
# Called once at game start to create a unique player character.
# No user_template is needed — the system message IS the full prompt.
# Expected output: 300-500 words of vivid character description prose. No JSON.
system: |
You are creating the canonical player-character profile for:
Zork I: The Great Underground Empire.
Hard requirements:
- Always write in second person and refer to the protagonist as "you".
- Never call the protagonist "he", "she", "they", or by a third-person noun.
- The character is from an Earth-like 1980s setting blended with Zork lore.
- The character is NOT an American treasure hunter.
- Tone: vivid, concrete, grounded, literary, and emotionally specific.
- Give the character one primary sensitive sense and make it easy for later
narration to use that sense.
Generate a complete persona that includes:
- Random full name.
- Gender, nationality, race, age.
- Skin color, eye color, hair color, body size, body build.
- Personal style, hairstyle.
- Tattoos (optional), piercings (optional), scars (optional).
- Distinctive standout trait (at least one clearly unusual detail).
- One dominant sense (sight, hearing, smell, taste, touch) that is most sensitive.
- Exactly three sentences of backstory.
- Personality, likes, dislikes, hopes, fears, worldview.
- Clothing and accessories worn on body, including underlayers where relevant.
- Do NOT list bags, tools, or equipment.
- Seed one or two concrete memory hooks that can later be triggered by places,
smells, sounds, architecture, darkness, weather, or treasure.
Output format (strict):
- First line must start exactly with: Welcome to the game
- On that same line include the full official title: Zork I: The Great Underground Empire
- Second line must start exactly with: You are
- Continue with the full persona in flowing prose.
- Do not output any extra headings, metadata, bullet points, or explanations.
Ensure the generated profile is specific enough to support memory continuity,
body-description requests, mood shifts, and character-consistent narration later.
user_template: ""
+112
View File
@@ -0,0 +1,112 @@
# Command Translator Prompt
# Called for every player input. Converts free natural-language text into a
# Zork parser command, or decides to reply directly / execute session tools.
# Expected output: a JSON object (see schema below).
system: |
You are the command-intent router for a literary Zork I engine.
Hard rules:
- Keep player-character continuity in second person ("you").
- If user asks for personal life/body/memory detail not present in context,
reply directly from the established character profile instead of sending a
parser command to Zork.
- If the player changes or adds stable identity, personality, mood, memory,
clothing, body, or backstory facts, use update_character or add_note so future
narration remembers it.
- If newly invented personal possessions are implied, add them to virtual inventory.
Choose one response mode:
MODE A — command
Use for one parser action.
JSON:
{ "type": "command", "command": "OPEN MAILBOX" }
MODE B — commands
Use when the user asks for multiple sequential actions in one input.
Example: "Take and read the pamphlet" -> TAKE PAMPHLET, READ PAMPHLET.
JSON:
{ "type": "commands", "commands": ["TAKE PAMPHLET", "READ PAMPHLET"] }
MODE C — reply
Use when no meaningful parser action exists.
Give a brief in-world response and guide back to actionable input only if the
player seems blocked. For body, clothing, identity, mood, memory, or "who am I"
questions, answer in second-person prose from the character profile.
JSON:
{ "type": "reply", "text": "..." }
MODE D — tools
Use tools when memory/state should be persisted, optionally with command(s).
JSON shape:
{
"type": "tools",
"tools": [ ... ],
"command": "OPTIONAL_SINGLE_COMMAND",
"commands": ["OPTIONAL", "MULTI", "COMMANDS"]
}
Available tools:
- update_character
args: { "description": string }
- add_note
args: { "note": string }
- remove_note
args: { "index": number }
- add_inventory_item
args: { "item": string }
- remove_inventory_item
args: { "item": string }
Tool usage policy:
- Use update_character for stable identity/body/personality updates.
- Use add_note for world facts, personal memories, unresolved goals, promises.
- Use add_inventory_item when narration introduces an on-person personal item
(even if Zork parser does not track it).
- Use remove_inventory_item when item is consumed/lost/discarded in story logic.
Command policy:
- Use terse Zork-style imperatives, uppercase preferred.
- Split compound natural language requests into ordered commands when needed.
- Avoid impossible commands when a helpful reply is better.
- Do not translate "who am I", "describe me", "look at myself", or body/clothing
inspection into parser commands; answer as the narrator using MODE C unless
the input also contains a concrete world action.
- When the player asks what a leaflet/pamphlet/paper says, use READ LEAFLET.
- When the player asks to take and read something from the mailbox, use
TAKE LEAFLET followed by READ LEAFLET, not TAKE MAILBOX or READ MAILBOX.
- When the player asks to look inside the mailbox, use LOOK IN MAILBOX.
- If the player complains that readable text was not shown, route to READ LEAFLET
when the recent context includes a leaflet/pamphlet/paper.
Output only valid JSON in exactly one mode.
user_template: |
Player character:
{{characterDescription}}
Narrator's notes (index 0, 1, 2…):
{{notes}}
Character-side virtual inventory:
{{virtualInventory}}
Narrator simulation state:
{{narratorState}}
Current location: {{currentRoom}}
What the player has seen here recently:
{{roomHistory}}
Most recent narrative paragraphs across scenes (up to 10, newest last):
{{recentNarrative}}
Recent raw parser transcript for factual anchoring:
{{rawTranscript}}
Player's input:
"{{userInput}}"
Respond with the appropriate JSON now.
+76
View File
@@ -0,0 +1,76 @@
# Output Evaluator Prompt
# Called after each Z-machine response. Decides whether to accept the output
# and rewrite it for the player, or to discard it and retry with a new command.
# Expected output: a JSON object (see schema below).
system: |
You are the quality gate between parser output and literary narration.
Decide whether to accept parser output or retry with a better command.
Retry when:
- parser error / unknown verb / malformed command,
- a clearer command likely achieves user intent,
- and attempt is not the final one.
Accept when:
- any meaningful world response occurred (including meaningful failure),
- or this is the final attempt.
If accepting, output vivid prose that:
- always refers to protagonist as "you" (never he/she/they),
- preserves parser facts,
- preserves written/readable text exactly when the command reads an object,
- uses the narrator simulation state for time/weather continuity,
- uses atmosphere and sensory detail, especially the character's sensitive sense,
- may include required preparatory body movement if it does not change game state,
- may include fitting internal monologue, direct speech, or a triggered memory,
- aligns with established character, notes, virtual inventory, and recent narrative.
Keep output concrete and scene-rooted.
Do not recommend commands, list possible next actions, or end with "If you want...".
Do not say the parser failed to provide text when the raw Z-machine response contains
the text being read.
Output JSON only:
- Accept:
{ "decision": "accept", "text": "..." }
- Retry:
{ "decision": "retry", "command": "..." }
user_template: |
Player character:
{{characterDescription}}
Narrator's notes:
{{notes}}
Character-side virtual inventory:
{{virtualInventory}}
Narrator simulation state:
{{narratorState}}
Current location: {{currentRoom}}
What the player has seen here recently:
{{roomHistory}}
Most recent narrative paragraphs across scenes (up to 10, newest last):
{{recentNarrative}}
Recent raw parser transcript for factual anchoring:
{{rawTranscript}}
---
Original player intent: "{{userIntent}}"
Command tried: {{commandTried}}
Attempt: {{attempt}} of {{maxAttempts}}
Raw Z-machine response:
---
{{zorkOutput}}
---
Decide now: accept and rewrite, or retry with a new command?
Respond with the appropriate JSON.
+77
View File
@@ -0,0 +1,77 @@
# Text Rewriter Prompt
# Called for the game's opening text, and for re-entry into rooms that have
# no prior player-facing history yet.
# Expected output: polished prose. No JSON.
system: |
You are the narrative layer for Zork I: The Great Underground Empire.
Rewrite raw Z-machine output into immersive prose while preserving game facts.
Core stance:
- Always narrate the player-character in second person: "you".
- Never refer to the player-character as he, she, they, or by third-person labels.
- Keep canon game facts intact (objects, exits, outcomes, failures, state changes).
- Do not invent gameplay-critical facts that contradict Zork output.
Style and simulation goals:
- Use atmospheric detail: light/shadow, sound, smell, airflow, temperature.
- Use the supplied narrator simulation state for day/night and weather continuity;
let it influence outside scenes and thresholds, and mention it only when it
naturally changes the felt scene.
- Make physical actions visceral when movement/exertion occurs.
- Let the character's personality, sensitive sense, hopes, fears, and worldview
color word choice, interpretation, internal monologue, and occasional direct
speech.
- Occasionally weave memory flashes from established backstory/notes when context fits.
- If describing the body, describe only what "you" can perceive directly and your
immediate thoughts about those details.
- Add incidental preparatory body movement when it would be required to perform
an action, as long as it does not change Zork's authoritative game state.
- Use Zork lore as texture, rumor, architecture, old names, or cultural memory,
but never as a new solvable fact unless the raw parser output establishes it.
Continuity policy:
- Use character profile, notes, virtual inventory, room history, and recent narrative
context to keep prose consistent.
- If prior context introduced non-Zork personal possessions, they can appear in prose
as personal details but must not be treated as parser-available game objects unless
present in Zork output.
Output constraints:
- Return prose only. No JSON, no labels, no headings.
- Prefer short paragraphs (2-5 sentences each).
- Preserve parser intent while replacing parser phrasing with natural narration.
- Do not recommend commands, list possible actions, or end with "If you want...".
- Do not apologize or mention missing information unless the raw Z-machine output
explicitly says that information is unavailable.
- When raw output contains written text from a sign, leaflet, book, label, inscription,
or other readable object, preserve the exact wording verbatim inside the prose.
user_template: |
The player character:
{{characterDescription}}
Narrator's notes about the story so far:
{{notes}}
Character-side virtual inventory (can exist even if Zork does not track it):
{{virtualInventory}}
Narrator simulation state:
{{narratorState}}
What the player has seen in this location before (most recent last):
{{roomHistory}}
Most recent narrative paragraphs across scenes (up to 10, newest last):
{{recentNarrative}}
Recent raw parser transcript for factual anchoring:
{{rawTranscript}}
Raw Z-machine output to rewrite:
---
{{zorkOutput}}
---
Rewrite the above as prose for the player now.
+90
View File
@@ -0,0 +1,90 @@
/**
* Zork LLM Engine
*
* Runs Zork I (or any Z-machine story file) as a headless subprocess via the
* `ifvms` CLI, and wraps every I/O exchange with OpenRouter LLM calls that
* translate free natural-language player input into parser commands and
* re-voice the Z-machine's raw output as polished narrative prose.
*
* Configuration (environment variables):
* ZORK_STORY_FILE path to the .z5/.z8/.bin story file (default: ./data/z-code/zork1.bin)
* ZORK_MAX_RETRIES maximum command retry attempts per turn (default: 3)
* ZORK_HISTORY_SIZE player-facing outputs stored per room (default: 5)
* OPENROUTER_API_KEY, OPENROUTER_MODEL required
*/
export interface ZorkSession {
characterDescription: string;
notes: string[];
recentParagraphs: string[];
rawTranscript: string[];
turnCount: number;
timeOfDay: string;
weather: string;
virtualInventory: string[];
/** roomName → last N player-facing output strings */
roomHistory: Record<string, string[]>;
currentRoom: string;
running: boolean;
}
/** Subset of the unified TurnResult protocol understood by the client. */
export interface ZorkTurnResult {
paragraphs: Array<{
text: string;
tags: unknown[];
}>;
choices: unknown[];
inputMode: 'text' | 'end';
gameState?: {
statusLine?: string;
};
}
export declare class ZorkLlmEngine {
private zork;
private session;
private prompts;
private llm;
private model;
private resolvedFallbackModel;
private llmCallCounter;
private maxRetries;
private historySize;
private storyPath;
private static readonly DEPRECATED_MODEL_REPLACEMENTS;
constructor();
private createCompletion;
private resolveFallbackModel;
isRunning(): boolean;
/**
* Start a new game: launch Zork, generate the player character, rewrite the
* intro text, and return the first TurnResult for the client.
*/
newGame(): Promise<ZorkTurnResult>;
/**
* Process player free-text input. Returns the next TurnResult.
*/
processInput(userInput: string): Promise<ZorkTurnResult>;
private runCommandPlan;
/**
* Save the current game state. Returns a JSON string suitable for storing
* in the socket's save-game slot map.
*/
saveGame(): Promise<string>;
/**
* Load a previously saved game. Returns the first TurnResult after restore.
*/
loadGame(savedJson: string): Promise<ZorkTurnResult>;
private runSingleCommandLoop;
private generateCharacter;
private rewriteText;
private translateCommand;
private evaluateOutput;
private executeTool;
private appendRecentParagraph;
private extractCommands;
private appendRawTranscript;
private advanceNarratorState;
private getDeterministicCommandPlan;
private appendRoomHistory;
private buildCommonVars;
private buildTurnResult;
}
+984
View File
@@ -0,0 +1,984 @@
"use strict";
/**
* Zork LLM Engine
*
* Runs Zork I (or any Z-machine story file) as a headless subprocess via the
* `ifvms` CLI, and wraps every I/O exchange with OpenRouter LLM calls that
* translate free natural-language player input into parser commands and
* re-voice the Z-machine's raw output as polished narrative prose.
*
* Configuration (environment variables):
* ZORK_STORY_FILE path to the .z5/.z8/.bin story file (default: ./data/z-code/zork1.bin)
* ZORK_MAX_RETRIES maximum command retry attempts per turn (default: 3)
* ZORK_HISTORY_SIZE player-facing outputs stored per room (default: 5)
* OPENROUTER_API_KEY, OPENROUTER_MODEL required
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.ZorkLlmEngine = void 0;
const child_process_1 = require("child_process");
const fs = __importStar(require("fs"));
const path = __importStar(require("path"));
const os = __importStar(require("os"));
const yaml = __importStar(require("js-yaml"));
const axios_1 = __importDefault(require("axios"));
const dotenv = __importStar(require("dotenv"));
dotenv.config();
const DEBUG_ENABLED = /^(1|true|yes|on)$/i.test(process.env.ZORK_DEBUG ?? '');
function debugLog(message, details) {
if (!DEBUG_ENABLED)
return;
if (typeof details === 'undefined') {
console.log(`[ZorkLlm:debug] ${message}`);
return;
}
console.log(`[ZorkLlm:debug] ${message}`, details);
}
function compactText(text, maxLength = 12000) {
if (text.length <= maxLength)
return text;
return `${text.slice(0, maxLength)}\n...[truncated ${text.length - maxLength} chars]`;
}
function getAssistantContent(data) {
const content = data?.choices?.[0]?.message?.content;
if (typeof content === 'string')
return content;
if (Array.isArray(content)) {
return content
.map((part) => {
if (typeof part === 'string')
return part;
if (typeof part?.text === 'string')
return part.text;
if (typeof part?.content === 'string')
return part.content;
return '';
})
.join('')
.trim();
}
throw new Error(`LLM response did not contain assistant text: ${compactText(JSON.stringify(data))}`);
}
function withReasoningDefaults(payload, model) {
if (payload.reasoning || !/\bgpt-5/i.test(model))
return payload;
return {
...payload,
reasoning: {
effort: process.env.OPENROUTER_REASONING_EFFORT ?? 'none',
exclude: true,
},
};
}
// ---------------------------------------------------------------------------
// Utility: strip ANSI escape sequences
// ---------------------------------------------------------------------------
function stripAnsi(s) {
// eslint-disable-next-line no-control-regex
return s.replace(/\x1B\[[0-9;]*[mGKHFJA-Z]/g, '');
}
// ---------------------------------------------------------------------------
// Utility: extract the current room name from Z-machine output
// ---------------------------------------------------------------------------
function extractRoomName(output) {
const lines = output
.split('\n')
.map(l => l.trim())
.filter(l => l.length > 0);
if (lines.length === 0)
return null;
const first = lines[0];
// Room name heuristics: short, starts with capital, no sentence-ending punctuation
if (first.length < 65 &&
/^[A-Z]/.test(first) &&
!/[.!?]$/.test(first) &&
!/^(You |I |It |There |The [a-z])/.test(first)) {
return first;
}
return null;
}
function isReadCommand(command) {
return /^READ\b/i.test(command.trim());
}
function isParserComplaint(output) {
const text = output.toLowerCase();
return [
"i don't know the word",
"i don't understand",
"that's not a verb",
"you can't see any",
"you don't have",
"you aren't carrying",
"what do you want to",
"what do you want to read",
"what do you want to take",
"which do you mean",
"there is no",
].some(fragment => text.includes(fragment));
}
function formatExactReadOutput(command, zorkOutput) {
const object = command.replace(/^READ\s+/i, '').trim().toLowerCase();
const label = object ? `the ${object}` : 'it';
const cleanedOutput = zorkOutput
.split('\n')
.filter((line, index) => index !== 0 || line.trim().toUpperCase() !== command.trim().toUpperCase())
.join('\n')
.trim();
return `You read ${label}.\n\n${cleanedOutput}`;
}
function pickInitialWeather() {
const options = [
'cool, unsettled air under a low grey sky',
'a dry bright afternoon with thin wind moving through the grass',
'misty weather with damp earth-smell clinging to everything outside',
'a mild overcast day, quiet enough that small sounds carry',
];
return options[Math.floor(Math.random() * options.length)];
}
function timeOfDayForTurn(turnCount) {
const phases = [
'late morning',
'early afternoon',
'late afternoon',
'dusk',
'early evening',
'night',
'deep night',
'pre-dawn',
'morning',
];
return phases[Math.floor(turnCount / 12) % phases.length];
}
function evolveWeather(previous, turnCount) {
if (turnCount > 0 && turnCount % 9 !== 0)
return previous;
const transitions = [
'the air has cooled and carries a faint mineral dampness',
'the wind has shifted, restless but not yet stormy',
'the light has thinned behind a veil of cloud',
'the weather holds steady, quiet and watchful',
'a trace of moisture gathers in the air',
];
return transitions[Math.floor(turnCount / 9) % transitions.length];
}
// ---------------------------------------------------------------------------
// ZorkProcess manages the ifvms zvm child process
// ---------------------------------------------------------------------------
class ZorkProcess {
constructor() {
this.proc = null;
this.outputBuffer = '';
this.pendingResolve = null;
this.debounceTimer = null;
}
/** Start the Z-machine with the given story file, return the opening text. */
async launch(storyPath) {
const zvm = this.locateZvm();
this.proc = (0, child_process_1.spawn)(zvm, [storyPath], {
stdio: ['pipe', 'pipe', 'pipe'],
shell: true,
cwd: process.cwd(),
});
this.proc.stdout.on('data', (chunk) => {
this.outputBuffer += stripAnsi(chunk.toString());
this.scheduleResolve();
});
this.proc.stderr.on('data', (chunk) => {
// Log but don't throw ifvms may emit warnings on stderr
console.warn('[zvm]', chunk.toString().trim());
});
this.proc.on('exit', () => {
// If the process exits while we are waiting for output, resolve immediately
if (this.pendingResolve) {
const resolver = this.pendingResolve;
this.pendingResolve = null;
resolver(this.outputBuffer.trim());
this.outputBuffer = '';
}
this.proc = null;
});
return this.waitForPrompt();
}
/** Send a line of input and return all output until the next prompt. */
async sendLine(text) {
if (!this.proc)
throw new Error('Z-machine process is not running');
this.outputBuffer = '';
this.proc.stdin.write(text + '\n');
return this.waitForPrompt();
}
isAlive() {
return this.proc !== null && !this.proc.killed;
}
kill() {
if (this.proc) {
this.proc.kill();
this.proc = null;
}
}
// ---- private ----
waitForPrompt() {
return new Promise((resolve) => {
// Wrap to allow debounce timer to cancel a previous waiter safely
const wrapped = (text) => resolve(text);
this.pendingResolve = wrapped;
// Safety timeout: if no prompt detected after 15 s, resolve with what we have
const safety = setTimeout(() => {
if (this.pendingResolve === wrapped) {
this.pendingResolve = null;
const text = this.outputBuffer.trim();
this.outputBuffer = '';
resolve(text);
}
}, 15000);
// Ensure the safety timeout does not keep Node alive indefinitely
if (safety.unref)
safety.unref();
// Override so debounce also cancels the safety timer
this.pendingResolve = (text) => {
clearTimeout(safety);
resolve(text);
};
// Data may already be buffered
this.scheduleResolve();
});
}
/** Debounced check: resolve when the buffer ends with Zork's '>' prompt. */
scheduleResolve() {
if (!/\n>\s*$/.test(this.outputBuffer))
return;
if (this.debounceTimer)
clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(() => {
this.debounceTimer = null;
if (!this.pendingResolve)
return;
const text = this.outputBuffer.replace(/\n>\s*$/, '').trim();
this.outputBuffer = '';
const resolver = this.pendingResolve;
this.pendingResolve = null;
resolver(text);
}, 80);
}
locateZvm() {
const binDir = path.join(process.cwd(), 'node_modules', '.bin');
const candidates = process.platform === 'win32'
? ['zvm.cmd', 'zvm.ps1', 'zvm']
: ['zvm'];
for (const name of candidates) {
const full = path.join(binDir, name);
if (fs.existsSync(full))
return full;
}
// Fall through to shell PATH lookup (works if ifvms is installed globally)
return 'zvm';
}
}
// ---------------------------------------------------------------------------
// Prompt loader
// ---------------------------------------------------------------------------
function loadPrompts(promptDir) {
function load(filename) {
const filePath = path.join(promptDir, filename);
if (!fs.existsSync(filePath)) {
throw new Error(`Prompt file not found: ${filePath}`);
}
return yaml.load(fs.readFileSync(filePath, 'utf8'));
}
return {
characterGeneration: load('character-generation.yml'),
textRewriter: load('text-rewriter.yml'),
commandTranslator: load('command-translator.yml'),
outputEvaluator: load('output-evaluator.yml'),
};
}
function renderTemplate(template, vars) {
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? '');
}
function logLlmError(scope, err) {
if (axios_1.default.isAxiosError(err)) {
const ax = err;
console.error(`[ZorkLlm] ${scope} failed: ${ax.message}`);
if (ax.response) {
console.error(`[ZorkLlm] ${scope} status=${ax.response.status} data=`, ax.response.data);
if (ax.response.status === 404) {
console.error('[ZorkLlm] Hint: OPENROUTER_MODEL is likely invalid or unavailable for your API key.');
}
}
return;
}
console.error(`[ZorkLlm] ${scope} failed:`, err);
}
// ---------------------------------------------------------------------------
// ZorkLlmEngine
// ---------------------------------------------------------------------------
class ZorkLlmEngine {
constructor() {
this.zork = new ZorkProcess();
this.session = null;
this.resolvedFallbackModel = null;
this.llmCallCounter = 0;
const apiKey = process.env.OPENROUTER_API_KEY;
const model = process.env.OPENROUTER_MODEL;
if (!apiKey || !model) {
throw new Error('Missing required environment variables: OPENROUTER_API_KEY and OPENROUTER_MODEL');
}
const replacement = ZorkLlmEngine.DEPRECATED_MODEL_REPLACEMENTS[model] ?? null;
if (replacement) {
this.model = replacement;
console.warn(`[ZorkLlm] Replacing deprecated model '${model}' with '${replacement}'.`);
}
else {
this.model = model;
}
debugLog('active LLM model configured', {
requestedModel: model,
activeModel: this.model,
});
this.maxRetries = parseInt(process.env.ZORK_MAX_RETRIES ?? '3', 10);
this.historySize = parseInt(process.env.ZORK_HISTORY_SIZE ?? '5', 10);
this.storyPath = path.resolve(process.env.ZORK_STORY_FILE ?? './data/z-code/zork1.bin');
const promptDir = path.resolve('./data/zork-prompts');
this.prompts = loadPrompts(promptDir);
this.llm = axios_1.default.create({
baseURL: 'https://openrouter.ai/api/v1',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
});
}
async createCompletion(payload) {
const withConfiguredModel = {
...withReasoningDefaults(payload, this.model),
model: this.model,
};
const callId = ++this.llmCallCounter;
debugLog(`LLM call #${callId} request`, {
model: this.model,
payload: compactText(JSON.stringify(withConfiguredModel, null, 2)),
});
try {
const response = await this.llm.post('/chat/completions', withConfiguredModel);
debugLog(`LLM call #${callId} response`, {
model: this.model,
status: response.status,
data: compactText(JSON.stringify(response.data, null, 2)),
});
return response;
}
catch (err) {
if (axios_1.default.isAxiosError(err) && err.response?.status === 404) {
const fallbackModel = await this.resolveFallbackModel();
this.model = fallbackModel;
console.warn(`[ZorkLlm] Switching active model to '${fallbackModel}'.`);
const withFallbackModel = {
...withReasoningDefaults(payload, fallbackModel),
model: fallbackModel,
};
debugLog(`LLM call #${callId} fallback request`, {
model: fallbackModel,
payload: compactText(JSON.stringify(withFallbackModel, null, 2)),
});
const fallbackResponse = await this.llm.post('/chat/completions', withFallbackModel);
debugLog(`LLM call #${callId} fallback response`, {
model: fallbackModel,
status: fallbackResponse.status,
data: compactText(JSON.stringify(fallbackResponse.data, null, 2)),
});
return fallbackResponse;
}
debugLog(`LLM call #${callId} error`, {
message: err instanceof Error ? err.message : String(err),
});
throw err;
}
}
async resolveFallbackModel() {
if (this.resolvedFallbackModel)
return this.resolvedFallbackModel;
const preferred = [
process.env.OPENROUTER_FALLBACK_MODEL,
'openai/gpt-5.5',
'openai/gpt-5.4',
'openai/gpt-5.4-mini',
'openai/gpt-5.4-nano',
'openai/gpt-5.3-chat',
'~anthropic/claude-sonnet-latest',
'~anthropic/claude-opus-latest',
'anthropic/claude-sonnet-4.6',
'anthropic/claude-sonnet-4',
'openai/gpt-4o-mini',
].filter((v) => Boolean(v && v.trim()));
try {
const response = await this.llm.get('/models');
const ids = new Set(Array.isArray(response.data?.data)
? response.data.data
.map((m) => (typeof m?.id === 'string' ? m.id : null))
.filter((id) => Boolean(id))
: []);
debugLog('OpenRouter model list fetched for fallback resolution', {
preferred,
availableCount: ids.size,
});
for (const candidate of preferred) {
if (ids.has(candidate)) {
this.resolvedFallbackModel = candidate;
return candidate;
}
}
const firstAvailable = response.data?.data?.[0]?.id;
if (typeof firstAvailable === 'string' && firstAvailable.length > 0) {
this.resolvedFallbackModel = firstAvailable;
return firstAvailable;
}
}
catch (err) {
logLlmError('resolveFallbackModel', err);
}
this.resolvedFallbackModel = 'openai/gpt-4o-mini';
return this.resolvedFallbackModel;
}
// ---- Public API -----------------------------------------------------------
isRunning() {
return this.session?.running === true && this.zork.isAlive();
}
/**
* Start a new game: launch Zork, generate the player character, rewrite the
* intro text, and return the first TurnResult for the client.
*/
async newGame() {
// Kill any existing game
if (this.zork.isAlive())
this.zork.kill();
if (!fs.existsSync(this.storyPath)) {
throw new Error(`Story file not found: ${this.storyPath}\n` +
'Place zork1.bin in ./data/z-code/ (see README in that folder).');
}
debugLog('launching Z-machine', { storyPath: this.storyPath });
const rawIntro = await this.zork.launch(this.storyPath);
debugLog('Z-machine intro output', compactText(rawIntro));
// Generate the player character before showing any text
const characterDescription = await this.generateCharacter();
this.session = {
characterDescription,
notes: [],
roomHistory: {},
currentRoom: extractRoomName(rawIntro) ?? 'Unknown Location',
recentParagraphs: [],
rawTranscript: [`[intro]\n${rawIntro}`],
turnCount: 0,
timeOfDay: timeOfDayForTurn(0),
weather: pickInitialWeather(),
virtualInventory: [],
running: true,
};
// Rewrite the opening text with the character's narrative voice
debugLog('session initialized', {
currentRoom: this.session.currentRoom,
characterDescription,
timeOfDay: this.session.timeOfDay,
weather: this.session.weather,
});
const introText = await this.rewriteText(rawIntro);
this.appendRecentParagraph(introText);
this.appendRoomHistory(this.session.currentRoom, introText);
return this.buildTurnResult(introText);
}
/**
* Process player free-text input. Returns the next TurnResult.
*/
async processInput(userInput) {
if (!this.session?.running) {
throw new Error('No active game session');
}
debugLog('processInput start', {
userInput,
currentRoom: this.session.currentRoom,
turnCount: this.session.turnCount,
timeOfDay: this.session.timeOfDay,
weather: this.session.weather,
notes: this.session.notes,
virtualInventory: this.session.virtualInventory,
});
this.advanceNarratorState();
const deterministicCommands = this.getDeterministicCommandPlan(userInput);
if (deterministicCommands.length > 0) {
debugLog('deterministic command plan selected', {
userInput,
commands: deterministicCommands,
});
return this.runCommandPlan(userInput, deterministicCommands);
}
const cmdResponse = await this.translateCommand(userInput);
debugLog('command translator parsed response', cmdResponse);
// Execute any tool calls first
if (cmdResponse.type === 'tools') {
for (const tool of cmdResponse.tools) {
this.executeTool(tool);
}
// If the translator also supplied a Zork command, continue to game loop
if (!cmdResponse.command && !cmdResponse.commands?.length) {
// Pure tool action — generate a brief acknowledgement via the rewriter
const ack = await this.rewriteText(`(The narrator pauses. ${userInput})`);
this.appendRecentParagraph(ack);
return this.buildTurnResult(ack);
}
}
if (cmdResponse.type === 'reply') {
this.appendRecentParagraph(cmdResponse.text);
return this.buildTurnResult(cmdResponse.text);
}
const commands = this.extractCommands(cmdResponse);
if (commands.length === 0) {
const fallback = await this.rewriteText("You hesitate, uncertain what action to take.");
this.appendRecentParagraph(fallback);
return this.buildTurnResult(fallback);
}
return this.runCommandPlan(userInput, commands);
}
async runCommandPlan(userInput, commands) {
const texts = [];
for (const command of commands) {
const text = await this.runSingleCommandLoop(userInput, command);
texts.push(text);
if (!this.isRunning())
break;
}
const combined = texts.join('\n\n');
return this.buildTurnResult(combined);
}
/**
* Save the current game state. Returns a JSON string suitable for storing
* in the socket's save-game slot map.
*/
async saveGame() {
if (!this.session)
throw new Error('No active session to save');
const tmpFile = path.join(os.tmpdir(), `zork-save-${Date.now()}.qzl`);
try {
// Ask Zork to save, supply the temp file path, and discard the output
await this.zork.sendLine('SAVE');
await this.zork.sendLine(tmpFile);
let zorkSave = '';
if (fs.existsSync(tmpFile)) {
zorkSave = fs.readFileSync(tmpFile).toString('base64');
}
return JSON.stringify({ session: this.session, zorkSave });
}
finally {
if (fs.existsSync(tmpFile))
fs.unlinkSync(tmpFile);
}
}
/**
* Load a previously saved game. Returns the first TurnResult after restore.
*/
async loadGame(savedJson) {
var _a, _b, _c, _d, _e, _f;
const { session, zorkSave } = JSON.parse(savedJson);
if (this.zork.isAlive())
this.zork.kill();
const tmpFile = path.join(os.tmpdir(), `zork-restore-${Date.now()}.qzl`);
try {
fs.writeFileSync(tmpFile, Buffer.from(zorkSave, 'base64'));
await this.zork.launch(this.storyPath);
await this.zork.sendLine('RESTORE');
const restoreOutput = await this.zork.sendLine(tmpFile);
this.session = { ...session, running: true };
(_a = this.session).rawTranscript ?? (_a.rawTranscript = []);
(_b = this.session).recentParagraphs ?? (_b.recentParagraphs = []);
(_c = this.session).virtualInventory ?? (_c.virtualInventory = []);
(_d = this.session).turnCount ?? (_d.turnCount = 0);
(_e = this.session).timeOfDay ?? (_e.timeOfDay = timeOfDayForTurn(this.session.turnCount));
(_f = this.session).weather ?? (_f.weather = pickInitialWeather());
const text = await this.rewriteText(restoreOutput);
this.appendRecentParagraph(text);
return this.buildTurnResult(text);
}
finally {
if (fs.existsSync(tmpFile))
fs.unlinkSync(tmpFile);
}
}
// ---- Core game loop -------------------------------------------------------
async runSingleCommandLoop(userIntent, firstCommand) {
let command = firstCommand;
let lastOutput = '';
for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
debugLog('sending Z-machine command', {
userIntent,
command,
attempt,
maxRetries: this.maxRetries,
});
const rawOutput = await this.zork.sendLine(command);
lastOutput = rawOutput;
this.appendRawTranscript(command, rawOutput);
debugLog('received Z-machine output', {
command,
attempt,
output: compactText(rawOutput),
});
const newRoom = extractRoomName(rawOutput);
if (newRoom) {
this.session.currentRoom = newRoom;
debugLog('current room updated', newRoom);
}
if (isReadCommand(command) && !isParserComplaint(rawOutput)) {
const exactText = formatExactReadOutput(command, rawOutput);
debugLog('accepted exact READ output without LLM paraphrase', {
command,
text: compactText(exactText),
});
this.appendRecentParagraph(exactText);
this.appendRoomHistory(this.session.currentRoom, exactText);
return exactText;
}
const evalResponse = await this.evaluateOutput(userIntent, command, rawOutput, attempt);
debugLog('output evaluator decision', evalResponse);
if (evalResponse.decision === 'accept') {
this.appendRecentParagraph(evalResponse.text);
this.appendRoomHistory(this.session.currentRoom, evalResponse.text);
return evalResponse.text;
}
// Retry with the LLM-suggested command
if (attempt < this.maxRetries) {
debugLog('retrying with evaluator command', {
previousCommand: command,
nextCommand: evalResponse.command,
});
command = evalResponse.command;
}
}
// Max retries exceeded — force a rewrite of the last output
const fallbackText = await this.rewriteText(lastOutput);
this.appendRecentParagraph(fallbackText);
this.appendRoomHistory(this.session.currentRoom, fallbackText);
return fallbackText;
}
// ---- LLM calls ------------------------------------------------------------
async generateCharacter() {
const cfg = this.prompts.characterGeneration;
try {
const response = await this.createCompletion({
messages: [
{ role: 'system', content: cfg.system },
{ role: 'user', content: 'Create the player character now.' },
],
temperature: 0.9,
max_tokens: 600,
});
return getAssistantContent(response.data).trim();
}
catch (err) {
logLlmError('generateCharacter', err);
return 'You are a wary but curious explorer, driven more by persistence than bravery. You have come to the old house seeking answers, carrying a notebook of unfinished questions and a habit of checking every corner twice.';
}
}
async rewriteText(zorkOutput) {
const cfg = this.prompts.textRewriter;
const vars = this.buildCommonVars();
vars['zorkOutput'] = zorkOutput;
try {
const response = await this.createCompletion({
messages: [
{ role: 'system', content: cfg.system },
{ role: 'user', content: renderTemplate(cfg.user_template, vars) },
],
temperature: 0.75,
max_tokens: 800,
});
return getAssistantContent(response.data).trim();
}
catch (err) {
logLlmError('rewriteText', err);
return zorkOutput;
}
}
async translateCommand(userInput) {
const cfg = this.prompts.commandTranslator;
const vars = this.buildCommonVars();
vars['userInput'] = userInput;
try {
const response = await this.createCompletion({
messages: [
{ role: 'system', content: cfg.system },
{ role: 'user', content: renderTemplate(cfg.user_template, vars) },
],
temperature: 0.2,
max_tokens: 300,
response_format: { type: 'json_object' },
});
const parsed = JSON.parse(getAssistantContent(response.data));
return parsed;
}
catch (err) {
logLlmError('translateCommand', err);
// Fallback: pass input directly to Zork parser
return { type: 'command', command: userInput.toUpperCase() };
}
}
async evaluateOutput(userIntent, commandTried, zorkOutput, attempt) {
const cfg = this.prompts.outputEvaluator;
const vars = this.buildCommonVars();
vars['userIntent'] = userIntent;
vars['commandTried'] = commandTried;
vars['zorkOutput'] = zorkOutput;
vars['attempt'] = String(attempt);
vars['maxAttempts'] = String(this.maxRetries);
try {
const response = await this.createCompletion({
messages: [
{ role: 'system', content: cfg.system },
{ role: 'user', content: renderTemplate(cfg.user_template, vars) },
],
temperature: 0.3,
max_tokens: 500,
response_format: { type: 'json_object' },
});
return JSON.parse(getAssistantContent(response.data));
}
catch (err) {
logLlmError('evaluateOutput', err);
// Fallback: accept the raw output as-is
return { decision: 'accept', text: zorkOutput };
}
}
// ---- Session helpers -------------------------------------------------------
executeTool(tool) {
if (!this.session)
return;
debugLog('executing tool call', tool);
switch (tool.name) {
case 'update_character':
if (typeof tool.args['description'] === 'string') {
this.session.characterDescription = tool.args['description'];
debugLog('tool updated character', this.session.characterDescription);
}
break;
case 'add_note':
if (typeof tool.args['note'] === 'string') {
this.session.notes.push(tool.args['note']);
debugLog('tool added note', {
note: tool.args['note'],
notes: this.session.notes,
});
}
break;
case 'remove_note': {
const idx = Number(tool.args['index']);
if (Number.isInteger(idx) &&
idx >= 0 &&
idx < this.session.notes.length) {
this.session.notes.splice(idx, 1);
debugLog('tool removed note', {
index: idx,
notes: this.session.notes,
});
}
break;
}
case 'add_inventory_item': {
const item = String(tool.args['item'] ?? '').trim();
if (!item)
break;
const exists = this.session.virtualInventory.some((it) => it.toLowerCase() === item.toLowerCase());
if (!exists)
this.session.virtualInventory.push(item);
debugLog('tool added inventory item', {
item,
virtualInventory: this.session.virtualInventory,
});
break;
}
case 'remove_inventory_item': {
const item = String(tool.args['item'] ?? '').trim();
if (!item)
break;
this.session.virtualInventory = this.session.virtualInventory.filter((it) => it.toLowerCase() !== item.toLowerCase());
debugLog('tool removed inventory item', {
item,
virtualInventory: this.session.virtualInventory,
});
break;
}
}
}
appendRecentParagraph(text) {
if (!this.session)
return;
const trimmed = text.trim();
if (!trimmed)
return;
this.session.recentParagraphs.push(trimmed);
if (this.session.recentParagraphs.length > 10) {
this.session.recentParagraphs.splice(0, this.session.recentParagraphs.length - 10);
}
}
extractCommands(cmdResponse) {
const list = [];
if (cmdResponse.type === 'command') {
list.push(cmdResponse.command);
}
else if (cmdResponse.type === 'commands') {
list.push(...cmdResponse.commands);
}
else if (cmdResponse.type === 'tools') {
if (cmdResponse.command)
list.push(cmdResponse.command);
if (Array.isArray(cmdResponse.commands))
list.push(...cmdResponse.commands);
}
return list
.map((c) => String(c).trim())
.filter(Boolean)
.map((c) => c.toUpperCase());
}
appendRawTranscript(command, output) {
if (!this.session)
return;
this.session.rawTranscript.push([`> ${command}`, output.trim()].filter(Boolean).join('\n'));
if (this.session.rawTranscript.length > 12) {
this.session.rawTranscript.splice(0, this.session.rawTranscript.length - 12);
}
}
advanceNarratorState() {
if (!this.session)
return;
this.session.turnCount += 1;
this.session.timeOfDay = timeOfDayForTurn(this.session.turnCount);
this.session.weather = evolveWeather(this.session.weather, this.session.turnCount);
debugLog('narrator state advanced', {
turnCount: this.session.turnCount,
timeOfDay: this.session.timeOfDay,
weather: this.session.weather,
});
}
getDeterministicCommandPlan(userInput) {
const normalized = userInput.toLowerCase();
const context = [
this.session?.currentRoom ?? '',
this.session?.recentParagraphs.join('\n') ?? '',
Object.values(this.session?.roomHistory ?? {}).flat().join('\n'),
].join('\n').toLowerCase();
const mentionsLeaflet = /\b(leaflet|pamphlet|brochure|paper|it|this)\b/.test(normalized);
const contextHasLeaflet = /\b(leaflet|pamphlet|brochure)\b/.test(context);
const mentionsMailbox = /\bmail\s*box|mailbox\b/.test(normalized);
const asksToRead = /\bread\b/.test(normalized) ||
/\bwhat (does|did|do).*say\b/.test(normalized) ||
/\btell me what it says\b/.test(normalized) ||
/\byou did not tell me\b/.test(normalized);
const asksToTake = /\b(take|get|grab|pick up|pluck)\b/.test(normalized);
const asksToOpen = /\bopen\b/.test(normalized);
const asksToLookIn = /\blook (in|inside|into)\b/.test(normalized) || /\binside\b/.test(normalized);
if (mentionsMailbox && asksToOpen && asksToLookIn) {
return ['OPEN MAILBOX', 'LOOK IN MAILBOX'];
}
if (mentionsMailbox && asksToOpen) {
return ['OPEN MAILBOX'];
}
if (asksToRead && (mentionsLeaflet || mentionsMailbox || contextHasLeaflet)) {
if (asksToTake || mentionsMailbox) {
return ['TAKE LEAFLET', 'READ LEAFLET'];
}
return ['READ LEAFLET'];
}
if (asksToTake && (mentionsLeaflet || (mentionsMailbox && contextHasLeaflet))) {
return ['TAKE LEAFLET'];
}
return [];
}
appendRoomHistory(room, text) {
if (!this.session)
return;
const history = this.session.roomHistory[room] ?? [];
history.push(text);
if (history.length > this.historySize) {
history.splice(0, history.length - this.historySize);
}
this.session.roomHistory[room] = history;
}
buildCommonVars() {
const s = this.session;
const notes = s.notes.length > 0
? s.notes.map((n, i) => `${i + 1}. ${n}`).join('\n')
: '(none)';
const virtualInventory = s.virtualInventory.length > 0
? s.virtualInventory.map((n, i) => `${i + 1}. ${n}`).join('\n')
: '(none)';
const recentNarrative = s.recentParagraphs.length > 0
? s.recentParagraphs.join('\n\n---\n\n')
: '(none)';
const rawTranscript = s.rawTranscript.length > 0
? s.rawTranscript.join('\n\n---\n\n')
: '(none)';
const history = (s.roomHistory[s.currentRoom] ?? []).join('\n\n---\n\n');
return {
characterDescription: s.characterDescription,
notes,
virtualInventory,
recentNarrative,
rawTranscript,
roomHistory: history || '(no prior visits)',
currentRoom: s.currentRoom,
narratorState: [
`Turn count: ${s.turnCount}`,
`Time of day: ${s.timeOfDay}`,
`Outside weather drift: ${s.weather}`,
].join('\n'),
};
}
buildTurnResult(text) {
const alive = this.zork.isAlive();
if (!alive && this.session)
this.session.running = false;
return {
paragraphs: [{ text, tags: [] }],
choices: [],
inputMode: alive ? 'text' : 'end',
gameState: { statusLine: this.session?.currentRoom },
};
}
}
exports.ZorkLlmEngine = ZorkLlmEngine;
ZorkLlmEngine.DEPRECATED_MODEL_REPLACEMENTS = {
'anthropic/claude-3-opus-20240229': 'openai/gpt-5.5',
'openai/gpt-5.4-mini': 'openai/gpt-5.5',
};
//# sourceMappingURL=zork-llm-engine.js.map
File diff suppressed because one or more lines are too long
+16
View File
@@ -0,0 +1,16 @@
/**
* Zork LLM Server
*
* Starts an Express + Socket.IO server that runs Zork I through the
* ZorkLlmEngine and serves the same shared client UI as the YAML engine.
*
* Usage:
* npm run dev:zork (development, with file watching)
* npm run start:zork (production, from compiled dist/)
*
* Environment variables:
* PORT HTTP port (default: 3002)
* ZORK_STORY_FILE path to the story file (default: ./data/z-code/zork1.bin)
* OPENROUTER_API_KEY, OPENROUTER_MODEL required
*/
export {};
+343
View File
@@ -0,0 +1,343 @@
"use strict";
/**
* Zork LLM Server
*
* Starts an Express + Socket.IO server that runs Zork I through the
* ZorkLlmEngine and serves the same shared client UI as the YAML engine.
*
* Usage:
* npm run dev:zork (development, with file watching)
* npm run start:zork (production, from compiled dist/)
*
* Environment variables:
* PORT HTTP port (default: 3002)
* ZORK_STORY_FILE path to the story file (default: ./data/z-code/zork1.bin)
* OPENROUTER_API_KEY, OPENROUTER_MODEL required
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const path_1 = __importDefault(require("path"));
const http_1 = __importDefault(require("http"));
const express_1 = __importDefault(require("express"));
const socket_io_1 = require("socket.io");
const dotenv = __importStar(require("dotenv"));
const fs_1 = require("fs");
const zork_llm_engine_1 = require("./engine/zork-llm-engine");
dotenv.config();
const app = (0, express_1.default)();
const server = http_1.default.createServer(app);
const io = new socket_io_1.Server(server);
const DEFAULT_PORT = 3002;
const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : DEFAULT_PORT;
const PORT_RANGE = 10;
const DEBUG_ENABLED = /^(1|true|yes|on)$/i.test(process.env.ZORK_DEBUG ?? '');
function debugLog(message, details) {
if (!DEBUG_ENABLED)
return;
if (typeof details === 'undefined') {
console.log(`[zork:debug] ${message}`);
return;
}
console.log(`[zork:debug] ${message}`, details);
}
// Serve the same shared client UI
app.use(express_1.default.static(path_1.default.join(__dirname, '../public'), {
etag: false,
lastModified: false,
setHeaders: (res) => {
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
},
}));
// One engine instance per connected socket
const sessions = new Map();
// Save-game slot maps: socketId → Map<slotNumber, serialisedJson>
const saveSlots = new Map();
function toLegacyNarrative(turn) {
const text = (turn.paragraphs ?? [])
.map((p) => String(p?.text ?? '').trim())
.filter(Boolean)
.join('\n\n');
return {
text,
gameState: {
currentRoomId: turn.gameState?.statusLine,
statusLine: turn.gameState?.statusLine,
},
};
}
function normalizeSaveSlot(slot) {
const n = Number(slot);
return Number.isInteger(n) && n > 0 ? n : 1;
}
function getOrCreateEngine(socketId) {
let engine = sessions.get(socketId);
if (!engine) {
engine = new zork_llm_engine_1.ZorkLlmEngine();
sessions.set(socketId, engine);
}
return engine;
}
function getSlots(socketId) {
let slots = saveSlots.get(socketId);
if (!slots) {
slots = new Map();
saveSlots.set(socketId, slots);
}
return slots;
}
async function handleGameApi(socket, method, args) {
const slots = getSlots(socket.id);
debugLog(`gameApi request from ${socket.id}: ${method}`, { args });
switch (method) {
case 'newGame':
case 'newGame()': {
const engine = getOrCreateEngine(socket.id);
const turn = await engine.newGame();
socket.emit('narrativeResponse', toLegacyNarrative(turn));
return {
success: true,
result: true,
running: true,
canLoad: slots.size > 0,
};
}
case 'loadGame':
case 'loadGame()': {
const slot = normalizeSaveSlot(args[0]);
if (!slots.has(slot)) {
return { success: false, error: 'missing_save', result: false };
}
const engine = getOrCreateEngine(socket.id);
const turn = await engine.loadGame(slots.get(slot));
socket.emit('narrativeResponse', toLegacyNarrative(turn));
socket.emit('gameLoaded', { slot });
return { success: true, result: true, running: true, slot };
}
case 'saveGame':
case 'saveGame()': {
const engine = sessions.get(socket.id);
if (!engine?.isRunning()) {
return { success: false, error: 'game_not_running', result: false };
}
const slot = normalizeSaveSlot(args[0]);
const savedJson = await engine.saveGame();
slots.set(slot, savedJson);
socket.emit('gameSaved', { slot });
return { success: true, result: true, slot };
}
case 'hasSaveGame':
case 'hasSaveGame()': {
const slot = normalizeSaveSlot(args[0]);
return { success: true, result: slots.has(slot), slot };
}
case 'getSaveGames':
case 'getSaveGames()':
return {
success: true,
result: Array.from(slots.keys()).sort((a, b) => a - b),
};
case 'isGameRunning':
case 'isGameRunning()':
return {
success: true,
result: sessions.get(socket.id)?.isRunning() ?? false,
};
default:
return { success: false, error: `unknown_method:${method}` };
}
}
function checkRuntimeConfiguration() {
const storyPath = path_1.default.resolve(process.env.ZORK_STORY_FILE ?? './data/z-code/zork1.bin');
const promptDir = path_1.default.resolve('./data/zork-prompts');
const promptFiles = [
'character-generation.yml',
'text-rewriter.yml',
'command-translator.yml',
'output-evaluator.yml',
];
const missingPrompts = promptFiles
.map((file) => path_1.default.join(promptDir, file))
.filter((filePath) => !(0, fs_1.existsSync)(filePath));
if (!process.env.OPENROUTER_API_KEY) {
console.error('[zork] Missing OPENROUTER_API_KEY in environment.');
}
if (!process.env.OPENROUTER_MODEL) {
console.error('[zork] Missing OPENROUTER_MODEL in environment.');
}
if (!(0, fs_1.existsSync)(storyPath)) {
console.error(`[zork] Story file missing: ${storyPath}`);
console.error('[zork] Place zork1.bin in ./data/z-code/ or set ZORK_STORY_FILE.');
}
if (missingPrompts.length > 0) {
console.error('[zork] Missing prompt files:');
for (const filePath of missingPrompts) {
console.error(` - ${filePath}`);
}
}
debugLog('runtime configuration', {
storyPath,
promptDir,
debug: DEBUG_ENABLED,
hasApiKey: Boolean(process.env.OPENROUTER_API_KEY),
model: process.env.OPENROUTER_MODEL ?? null,
});
}
io.on('connection', (socket) => {
console.log(`[zork] Client connected: ${socket.id}`);
socket.on('gameApi', async (request, respond) => {
try {
const result = await handleGameApi(socket, String(request?.method ?? ''), Array.isArray(request?.args) ? request.args : []);
debugLog(`gameApi response to ${socket.id}`, result);
if (typeof respond === 'function')
respond(result);
}
catch (error) {
console.error('[zork] gameApi error:', error);
if (typeof respond === 'function') {
respond({
success: false,
error: error instanceof Error ? error.message : String(error),
});
}
}
});
socket.on('playerCommand', async (data) => {
const engine = sessions.get(socket.id);
if (!engine?.isRunning()) {
socket.emit('error', {
message: 'No active game. Start or load a game first.',
});
return;
}
const input = String(data?.command ?? '').trim();
if (!input)
return;
debugLog(`playerCommand from ${socket.id}: ${input}`);
try {
const turn = await engine.processInput(input);
debugLog(`narrativeResponse to ${socket.id}`, {
inputMode: turn.inputMode,
paragraphs: turn.paragraphs.length,
statusLine: turn.gameState?.statusLine,
});
socket.emit('narrativeResponse', toLegacyNarrative(turn));
}
catch (error) {
console.error('[zork] playerCommand error:', error);
socket.emit('error', {
message: error instanceof Error ? error.message : 'An error occurred.',
});
}
});
socket.on('disconnect', () => {
console.log(`[zork] Client disconnected: ${socket.id}`);
sessions.delete(socket.id);
saveSlots.delete(socket.id);
});
});
// ---------------------------------------------------------------------------
// Startup helpers
// ---------------------------------------------------------------------------
function ensureDirectories() {
const dirs = [
path_1.default.join(__dirname, '../public'),
path_1.default.join(__dirname, '../public/js'),
path_1.default.join(__dirname, '../public/css'),
path_1.default.join(__dirname, '../public/images'),
path_1.default.join(__dirname, '../public/music'),
path_1.default.join(__dirname, '../public/sounds'),
path_1.default.join(__dirname, '../public/fonts'),
path_1.default.join(__dirname, '../data/z-code'),
path_1.default.join(__dirname, '../data/zork-prompts'),
];
for (const dir of dirs) {
if (!(0, fs_1.existsSync)(dir))
(0, fs_1.mkdirSync)(dir, { recursive: true });
}
}
function ensureKokoroJs() {
const src = path_1.default.join(__dirname, '../node_modules/kokoro-js/dist/index.js');
const dst = path_1.default.join(__dirname, '../public/js/kokoro-js.js');
if ((0, fs_1.existsSync)(src) && !(0, fs_1.existsSync)(dst))
(0, fs_1.copyFileSync)(src, dst);
}
async function startServer(initialPort, range) {
ensureDirectories();
try {
ensureKokoroJs();
}
catch { /* optional */ }
checkRuntimeConfiguration();
let port = initialPort;
while (port < initialPort + range) {
try {
await new Promise((resolve, reject) => {
server.listen(port, () => {
console.log(`[zork] Zork Narrator server running on http://localhost:${port}`);
resolve();
});
server.on('error', (err) => {
if (err.code === 'EADDRINUSE') {
console.log(`Port ${port} in use, trying ${port + 1}`);
server.close();
port++;
reject();
}
else {
reject(err);
}
});
});
return;
}
catch {
if (port >= initialPort + range - 1) {
throw new Error(`Failed to start server on ports ${initialPort}${initialPort + range - 1}`);
}
}
}
}
if (require.main === module) {
startServer(PORT, PORT_RANGE).catch((err) => {
console.error('[zork] Failed to start:', err);
process.exit(1);
});
}
//# sourceMappingURL=server-zork.js.map
+1
View File
File diff suppressed because one or more lines are too long
+129 -17
View File
@@ -14,6 +14,7 @@
"dotenv": "^16.4.7",
"express": "^5.1.0",
"hyphenopoly": "^6.0.0",
"ifvms": "^1.1.6",
"js-yaml": "^4.1.0",
"kokoro-js": "^1.2.0",
"openai": "^4.91.0",
@@ -32,6 +33,9 @@
"ts-jest": "^29.3.1",
"ts-node": "^10.9.2",
"typescript": "^5.8.2"
},
"engines": {
"node": ">=18.17"
}
},
"node_modules/@ampproject/remapping": {
@@ -2314,7 +2318,6 @@
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz",
"integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"type-fest": "^0.21.3"
@@ -2330,7 +2333,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -2340,7 +2342,6 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
@@ -2707,7 +2708,6 @@
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -3031,6 +3031,15 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/dedent": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.3.tgz",
@@ -3192,7 +3201,6 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/encodeurl": {
@@ -3913,7 +3921,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
@@ -4088,7 +4095,6 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
@@ -4154,6 +4160,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/glkote-term": {
"version": "0.4.4",
"resolved": "https://registry.npmjs.org/glkote-term/-/glkote-term-0.4.4.tgz",
"integrity": "sha512-5l2t4QC9Pr4DgMz/OBGojgaAZJ3p0yf+e8pIYuz63kT0gBaHqsAuASYWQVqSkj60v6nUxKYJRzE0GQucf9PDxg==",
"license": "MIT",
"dependencies": {
"ansi-escapes": "^4.0.0"
}
},
"node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
@@ -4343,6 +4358,89 @@
"node": ">=0.10.0"
}
},
"node_modules/ifvms": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/ifvms/-/ifvms-1.1.6.tgz",
"integrity": "sha512-4OPV23gHu/YsyqcUuV4oqVBkicz6KsFdwKyMQkaUeN6nvv4maGcYA5qgjDse/iEdvsqSijLHRbx5VuM0zuXEMQ==",
"license": "MIT",
"dependencies": {
"glkote-term": "^0.4.0",
"mute-stream": "0.0.8",
"yargs": "^15.0.1"
},
"bin": {
"zvm": "bin/zvm.js"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/ifvms/node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/ifvms/node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/ifvms/node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/ifvms/node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/ifvms/node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -4494,7 +4592,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -5396,7 +5493,6 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
@@ -5628,6 +5724,12 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/mute-stream": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz",
"integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==",
"license": "ISC"
},
"node_modules/natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@@ -5968,7 +6070,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
@@ -5981,7 +6082,6 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
@@ -5997,7 +6097,6 @@
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -6048,7 +6147,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -6358,12 +6456,17 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/resolve": {
"version": "1.22.10",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
@@ -6542,6 +6645,12 @@
"node": ">= 18"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -6983,7 +7092,6 @@
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
@@ -6998,7 +7106,6 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
@@ -7319,7 +7426,6 @@
"version": "0.21.3",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz",
"integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==",
"dev": true,
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=10"
@@ -7501,6 +7607,12 @@
"node": ">= 8"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+9
View File
@@ -17,6 +17,14 @@
"dev:web": "nodemon --watch src --ext ts,json --exec \"ts-node src/index.ts\"",
"predev:cli": "npm run check:node",
"dev:cli": "nodemon --watch src --ext ts,json --exec \"ts-node src/index.ts --cli\"",
"predev:zork": "npm run check:node",
"dev:zork": "nodemon --watch src --watch data/zork-prompts --ext ts,json,yml --exec \"ts-node src/server-zork.ts\"",
"dev:zork:debug": "powershell -NoProfile -Command \"$env:ZORK_DEBUG='1'; npm run dev:zork\"",
"dev:zork:inspect": "powershell -NoProfile -Command \"$env:ZORK_DEBUG='1'; nodemon --watch src --watch data/zork-prompts --ext ts,json,yml --exec \\\"node --inspect=127.0.0.1:9229 -r ts-node/register src/server-zork.ts\\\"\"",
"prestart:zork": "npm run check:node && npm run build",
"start:zork": "node dist/server-zork.js",
"start:zork:debug": "powershell -NoProfile -Command \"$env:ZORK_DEBUG='1'; npm run start:zork\"",
"start:zork:inspect": "powershell -NoProfile -Command \"$env:ZORK_DEBUG='1'; node --inspect=127.0.0.1:9229 dist/server-zork.js\"",
"pretest-server": "npm run check:node",
"test-server": "ts-node src/test-server.ts",
"build": "tsc",
@@ -51,6 +59,7 @@
"dotenv": "^16.4.7",
"express": "^5.1.0",
"hyphenopoly": "^6.0.0",
"ifvms": "^1.1.6",
"js-yaml": "^4.1.0",
"kokoro-js": "^1.2.0",
"openai": "^4.91.0",
File diff suppressed because it is too large Load Diff
+362
View File
@@ -0,0 +1,362 @@
/**
* Zork LLM Server
*
* Starts an Express + Socket.IO server that runs Zork I through the
* ZorkLlmEngine and serves the same shared client UI as the YAML engine.
*
* Usage:
* npm run dev:zork (development, with file watching)
* npm run start:zork (production, from compiled dist/)
*
* Environment variables:
* PORT HTTP port (default: 3002)
* ZORK_STORY_FILE path to the story file (default: ./data/z-code/zork1.bin)
* OPENROUTER_API_KEY, OPENROUTER_MODEL required
*/
import path from 'path';
import http from 'http';
import express from 'express';
import { Server as SocketIOServer } from 'socket.io';
import * as dotenv from 'dotenv';
import { existsSync, mkdirSync, copyFileSync } from 'fs';
import { ZorkLlmEngine, ZorkTurnResult } from './engine/zork-llm-engine';
dotenv.config();
const app = express();
const server = http.createServer(app);
const io = new SocketIOServer(server);
const DEFAULT_PORT = 3002;
const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : DEFAULT_PORT;
const PORT_RANGE = 10;
const DEBUG_ENABLED = /^(1|true|yes|on)$/i.test(process.env.ZORK_DEBUG ?? '');
function debugLog(message: string, details?: unknown): void {
if (!DEBUG_ENABLED) return;
if (typeof details === 'undefined') {
console.log(`[zork:debug] ${message}`);
return;
}
console.log(`[zork:debug] ${message}`, details);
}
// Serve the same shared client UI
app.use(
express.static(path.join(__dirname, '../public'), {
etag: false,
lastModified: false,
setHeaders: (res) => {
res.setHeader(
'Cache-Control',
'no-store, no-cache, must-revalidate, proxy-revalidate',
);
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
},
}),
);
// One engine instance per connected socket
const sessions = new Map<string, ZorkLlmEngine>();
// Save-game slot maps: socketId → Map<slotNumber, serialisedJson>
const saveSlots = new Map<string, Map<number, string>>();
function toLegacyNarrative(turn: ZorkTurnResult): {
text: string;
gameState: { currentRoomId?: string; statusLine?: string };
} {
const text = (turn.paragraphs ?? [])
.map((p) => String(p?.text ?? '').trim())
.filter(Boolean)
.join('\n\n');
return {
text,
gameState: {
currentRoomId: turn.gameState?.statusLine,
statusLine: turn.gameState?.statusLine,
},
};
}
function normalizeSaveSlot(slot: unknown): number {
const n = Number(slot);
return Number.isInteger(n) && n > 0 ? n : 1;
}
function getOrCreateEngine(socketId: string): ZorkLlmEngine {
let engine = sessions.get(socketId);
if (!engine) {
engine = new ZorkLlmEngine();
sessions.set(socketId, engine);
}
return engine;
}
function getSlots(socketId: string): Map<number, string> {
let slots = saveSlots.get(socketId);
if (!slots) {
slots = new Map();
saveSlots.set(socketId, slots);
}
return slots;
}
async function handleGameApi(
socket: ReturnType<SocketIOServer['sockets']['sockets']['get']> & {
id: string;
},
method: string,
args: unknown[],
): Promise<object> {
const slots = getSlots(socket.id);
debugLog(`gameApi request from ${socket.id}: ${method}`, { args });
switch (method) {
case 'newGame':
case 'newGame()': {
const engine = getOrCreateEngine(socket.id);
const turn = await engine.newGame();
socket.emit('narrativeResponse', toLegacyNarrative(turn));
return {
success: true,
result: true,
running: true,
canLoad: slots.size > 0,
};
}
case 'loadGame':
case 'loadGame()': {
const slot = normalizeSaveSlot(args[0]);
if (!slots.has(slot)) {
return { success: false, error: 'missing_save', result: false };
}
const engine = getOrCreateEngine(socket.id);
const turn = await engine.loadGame(slots.get(slot)!);
socket.emit('narrativeResponse', toLegacyNarrative(turn));
socket.emit('gameLoaded', { slot });
return { success: true, result: true, running: true, slot };
}
case 'saveGame':
case 'saveGame()': {
const engine = sessions.get(socket.id);
if (!engine?.isRunning()) {
return { success: false, error: 'game_not_running', result: false };
}
const slot = normalizeSaveSlot(args[0]);
const savedJson = await engine.saveGame();
slots.set(slot, savedJson);
socket.emit('gameSaved', { slot });
return { success: true, result: true, slot };
}
case 'hasSaveGame':
case 'hasSaveGame()': {
const slot = normalizeSaveSlot(args[0]);
return { success: true, result: slots.has(slot), slot };
}
case 'getSaveGames':
case 'getSaveGames()':
return {
success: true,
result: Array.from(slots.keys()).sort((a, b) => a - b),
};
case 'isGameRunning':
case 'isGameRunning()':
return {
success: true,
result: sessions.get(socket.id)?.isRunning() ?? false,
};
default:
return { success: false, error: `unknown_method:${method}` };
}
}
function checkRuntimeConfiguration(): void {
const storyPath = path.resolve(
process.env.ZORK_STORY_FILE ?? './data/z-code/zork1.bin',
);
const promptDir = path.resolve('./data/zork-prompts');
const promptFiles = [
'character-generation.yml',
'text-rewriter.yml',
'command-translator.yml',
'output-evaluator.yml',
];
const missingPrompts = promptFiles
.map((file) => path.join(promptDir, file))
.filter((filePath) => !existsSync(filePath));
if (!process.env.OPENROUTER_API_KEY) {
console.error('[zork] Missing OPENROUTER_API_KEY in environment.');
}
if (!process.env.OPENROUTER_MODEL) {
console.error('[zork] Missing OPENROUTER_MODEL in environment.');
}
if (!existsSync(storyPath)) {
console.error(`[zork] Story file missing: ${storyPath}`);
console.error('[zork] Place zork1.bin in ./data/z-code/ or set ZORK_STORY_FILE.');
}
if (missingPrompts.length > 0) {
console.error('[zork] Missing prompt files:');
for (const filePath of missingPrompts) {
console.error(` - ${filePath}`);
}
}
debugLog('runtime configuration', {
storyPath,
promptDir,
debug: DEBUG_ENABLED,
hasApiKey: Boolean(process.env.OPENROUTER_API_KEY),
model: process.env.OPENROUTER_MODEL ?? null,
});
}
io.on('connection', (socket) => {
console.log(`[zork] Client connected: ${socket.id}`);
socket.on(
'gameApi',
async (
request: { method?: string; args?: unknown[] },
respond: (result: object) => void,
) => {
try {
const result = await handleGameApi(
socket as Parameters<typeof handleGameApi>[0],
String(request?.method ?? ''),
Array.isArray(request?.args) ? request.args : [],
);
debugLog(`gameApi response to ${socket.id}`, result);
if (typeof respond === 'function') respond(result);
} catch (error) {
console.error('[zork] gameApi error:', error);
if (typeof respond === 'function') {
respond({
success: false,
error: error instanceof Error ? error.message : String(error),
});
}
}
},
);
socket.on(
'playerCommand',
async (data: { command?: string }) => {
const engine = sessions.get(socket.id);
if (!engine?.isRunning()) {
socket.emit('error', {
message: 'No active game. Start or load a game first.',
});
return;
}
const input = String(data?.command ?? '').trim();
if (!input) return;
debugLog(`playerCommand from ${socket.id}: ${input}`);
try {
const turn: ZorkTurnResult = await engine.processInput(input);
debugLog(`narrativeResponse to ${socket.id}`, {
inputMode: turn.inputMode,
paragraphs: turn.paragraphs.length,
statusLine: turn.gameState?.statusLine,
});
socket.emit('narrativeResponse', toLegacyNarrative(turn));
} catch (error) {
console.error('[zork] playerCommand error:', error);
socket.emit('error', {
message:
error instanceof Error ? error.message : 'An error occurred.',
});
}
},
);
socket.on('disconnect', () => {
console.log(`[zork] Client disconnected: ${socket.id}`);
sessions.delete(socket.id);
saveSlots.delete(socket.id);
});
});
// ---------------------------------------------------------------------------
// Startup helpers
// ---------------------------------------------------------------------------
function ensureDirectories(): void {
const dirs = [
path.join(__dirname, '../public'),
path.join(__dirname, '../public/js'),
path.join(__dirname, '../public/css'),
path.join(__dirname, '../public/images'),
path.join(__dirname, '../public/music'),
path.join(__dirname, '../public/sounds'),
path.join(__dirname, '../public/fonts'),
path.join(__dirname, '../data/z-code'),
path.join(__dirname, '../data/zork-prompts'),
];
for (const dir of dirs) {
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
}
}
function ensureKokoroJs(): void {
const src = path.join(__dirname, '../node_modules/kokoro-js/dist/index.js');
const dst = path.join(__dirname, '../public/js/kokoro-js.js');
if (existsSync(src) && !existsSync(dst)) copyFileSync(src, dst);
}
async function startServer(initialPort: number, range: number): Promise<void> {
ensureDirectories();
try { ensureKokoroJs(); } catch { /* optional */ }
checkRuntimeConfiguration();
let port = initialPort;
while (port < initialPort + range) {
try {
await new Promise<void>((resolve, reject) => {
server.listen(port, () => {
console.log(
`[zork] Zork Narrator server running on http://localhost:${port}`,
);
resolve();
});
server.on('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EADDRINUSE') {
console.log(`Port ${port} in use, trying ${port + 1}`);
server.close();
port++;
reject();
} else {
reject(err);
}
});
});
return;
} catch {
if (port >= initialPort + range - 1) {
throw new Error(
`Failed to start server on ports ${initialPort}${initialPort + range - 1}`,
);
}
}
}
}
if (require.main === module) {
startServer(PORT, PORT_RANGE).catch((err) => {
console.error('[zork] Failed to start:', err);
process.exit(1);
});
}
+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 |