Add Zork engine integration work
This commit is contained in:
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
|
||||
Reference in New Issue
Block a user