"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