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