989 lines
40 KiB
JavaScript
989 lines
40 KiB
JavaScript
"use strict";
|
||
/**
|
||
* Z-code LLM Engine
|
||
*
|
||
* Runs a 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):
|
||
* ZCODE_STORY_FILE - path to the .z5/.z8/.bin story file (default: ./data/z-code/zork1.bin)
|
||
* ZCODE_MAX_RETRIES - maximum command retry attempts per turn (default: 3)
|
||
* ZCODE_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.ZcodeLlmEngine = 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"));
|
||
const turn_result_1 = require("../interfaces/turn-result");
|
||
dotenv.config();
|
||
const DEBUG_ENABLED = /^(1|true|yes|on)$/i.test(process.env.ZCODE_DEBUG ?? '');
|
||
function debugLog(message, details) {
|
||
if (!DEBUG_ENABLED)
|
||
return;
|
||
if (typeof details === 'undefined') {
|
||
console.log(`[ZcodeLlm:debug] ${message}`);
|
||
return;
|
||
}
|
||
console.log(`[ZcodeLlm: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, zcodeOutput) {
|
||
const object = command.replace(/^READ\s+/i, '').trim().toLowerCase();
|
||
const label = object ? `the ${object}` : 'it';
|
||
const cleanedOutput = zcodeOutput
|
||
.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];
|
||
}
|
||
// ---------------------------------------------------------------------------
|
||
// ZcodeProcess – manages the ifvms zvm child process
|
||
// ---------------------------------------------------------------------------
|
||
class ZcodeProcess {
|
||
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 a Z-machine 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(`[ZcodeLlm] ${scope} failed: ${ax.message}`);
|
||
if (ax.response) {
|
||
console.error(`[ZcodeLlm] ${scope} status=${ax.response.status} data=`, ax.response.data);
|
||
if (ax.response.status === 404) {
|
||
console.error('[ZcodeLlm] Hint: OPENROUTER_MODEL is likely invalid or unavailable for your API key.');
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
console.error(`[ZcodeLlm] ${scope} failed:`, err);
|
||
}
|
||
// ---------------------------------------------------------------------------
|
||
// ZcodeLlmEngine
|
||
// ---------------------------------------------------------------------------
|
||
class ZcodeLlmEngine {
|
||
constructor(options = {}) {
|
||
this.zmachine = new ZcodeProcess();
|
||
this.session = null;
|
||
this.resolvedFallbackModel = null;
|
||
this.llmCallCounter = 0;
|
||
this.nextTurnId = 1;
|
||
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 = ZcodeLlmEngine.DEPRECATED_MODEL_REPLACEMENTS[model] ?? null;
|
||
if (replacement) {
|
||
this.model = replacement;
|
||
console.warn(`[ZcodeLlm] 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.ZCODE_MAX_RETRIES ?? '3', 10);
|
||
this.historySize = parseInt(process.env.ZCODE_HISTORY_SIZE ?? '5', 10);
|
||
this.storyPath = path.resolve(options.storyPath ?? process.env.ZCODE_STORY_FILE ?? './data/z-code/zork1.bin');
|
||
const promptDir = path.resolve(options.promptDir ?? './data/zcode-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(`[ZcodeLlm] 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.zmachine.isAlive();
|
||
}
|
||
/**
|
||
* Start a new game: launch the Z-machine story, generate the player character, rewrite the
|
||
* intro text, and return the first TurnResult for the client.
|
||
*/
|
||
async newGame() {
|
||
// Kill any existing game
|
||
if (this.zmachine.isAlive())
|
||
this.zmachine.kill();
|
||
this.nextTurnId = 1;
|
||
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.zmachine.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 Z-machine 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(), `zcode-save-${Date.now()}.qzl`);
|
||
try {
|
||
// Ask the Z-machine to save, supply the temp file path, and discard the output
|
||
await this.zmachine.sendLine('SAVE');
|
||
await this.zmachine.sendLine(tmpFile);
|
||
let zcodeSave = '';
|
||
if (fs.existsSync(tmpFile)) {
|
||
zcodeSave = fs.readFileSync(tmpFile).toString('base64');
|
||
}
|
||
return JSON.stringify({ session: this.session, zcodeSave });
|
||
}
|
||
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, zcodeSave } = JSON.parse(savedJson);
|
||
if (this.zmachine.isAlive())
|
||
this.zmachine.kill();
|
||
const tmpFile = path.join(os.tmpdir(), `zcode-restore-${Date.now()}.qzl`);
|
||
try {
|
||
fs.writeFileSync(tmpFile, Buffer.from(zcodeSave, 'base64'));
|
||
await this.zmachine.launch(this.storyPath);
|
||
await this.zmachine.sendLine('RESTORE');
|
||
const restoreOutput = await this.zmachine.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.zmachine.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(zcodeOutput) {
|
||
const cfg = this.prompts.textRewriter;
|
||
const vars = this.buildCommonVars();
|
||
vars['zcodeOutput'] = zcodeOutput;
|
||
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 zcodeOutput;
|
||
}
|
||
}
|
||
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 Z-machine parser
|
||
return { type: 'command', command: userInput.toUpperCase() };
|
||
}
|
||
}
|
||
async evaluateOutput(userIntent, commandTried, zcodeOutput, attempt) {
|
||
const cfg = this.prompts.outputEvaluator;
|
||
const vars = this.buildCommonVars();
|
||
vars['userIntent'] = userIntent;
|
||
vars['commandTried'] = commandTried;
|
||
vars['zcodeOutput'] = zcodeOutput;
|
||
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: zcodeOutput };
|
||
}
|
||
}
|
||
// ---- 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.zmachine.isAlive();
|
||
if (!alive && this.session)
|
||
this.session.running = false;
|
||
const paragraphs = (0, turn_result_1.textToParagraphs)(text);
|
||
return {
|
||
turnId: this.nextTurnId++,
|
||
paragraphs,
|
||
choices: [],
|
||
inputMode: alive ? 'text' : 'end',
|
||
gameState: { statusLine: this.session?.currentRoom },
|
||
};
|
||
}
|
||
}
|
||
exports.ZcodeLlmEngine = ZcodeLlmEngine;
|
||
ZcodeLlmEngine.DEPRECATED_MODEL_REPLACEMENTS = {
|
||
'anthropic/claude-3-opus-20240229': 'openai/gpt-5.5',
|
||
'openai/gpt-5.4-mini': 'openai/gpt-5.5',
|
||
};
|
||
//# sourceMappingURL=zcode-llm-engine.js.map
|