1161 lines
37 KiB
TypeScript
1161 lines
37 KiB
TypeScript
/**
|
||
* 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
|
||
*/
|
||
|
||
import { spawn, ChildProcess } from 'child_process';
|
||
import * as fs from 'fs';
|
||
import * as path from 'path';
|
||
import * as os from 'os';
|
||
import * as yaml from 'js-yaml';
|
||
import axios, { AxiosError, AxiosInstance } from 'axios';
|
||
import * as dotenv from 'dotenv';
|
||
import {
|
||
textToParagraphs,
|
||
TurnResult,
|
||
} from '../interfaces/turn-result';
|
||
|
||
dotenv.config();
|
||
|
||
const DEBUG_ENABLED = /^(1|true|yes|on)$/i.test(process.env.ZCODE_DEBUG ?? '');
|
||
|
||
function debugLog(message: string, details?: unknown): void {
|
||
if (!DEBUG_ENABLED) return;
|
||
if (typeof details === 'undefined') {
|
||
console.log(`[ZcodeLlm:debug] ${message}`);
|
||
return;
|
||
}
|
||
console.log(`[ZcodeLlm:debug] ${message}`, details);
|
||
}
|
||
|
||
function compactText(text: string, maxLength = 12_000): string {
|
||
if (text.length <= maxLength) return text;
|
||
return `${text.slice(0, maxLength)}\n...[truncated ${text.length - maxLength} chars]`;
|
||
}
|
||
|
||
function getAssistantContent(data: any): string {
|
||
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: Record<string, unknown>,
|
||
model: string,
|
||
): Record<string, unknown> {
|
||
if (payload.reasoning || !/\bgpt-5/i.test(model)) return payload;
|
||
return {
|
||
...payload,
|
||
reasoning: {
|
||
effort: process.env.OPENROUTER_REASONING_EFFORT ?? 'none',
|
||
exclude: true,
|
||
},
|
||
};
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Types
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export interface ZcodeSession {
|
||
characterDescription: string;
|
||
notes: string[];
|
||
recentParagraphs: string[];
|
||
rawTranscript: string[];
|
||
turnCount: number;
|
||
timeOfDay: string;
|
||
weather: string;
|
||
virtualInventory: string[];
|
||
/** roomName -> last N player-facing output strings */
|
||
roomHistory: Record<string, string[]>;
|
||
currentRoom: string;
|
||
running: boolean;
|
||
}
|
||
|
||
export type ZcodeTurnResult = TurnResult;
|
||
|
||
interface PromptConfig {
|
||
system: string;
|
||
user_template: string;
|
||
}
|
||
|
||
interface ZcodePrompts {
|
||
characterGeneration: PromptConfig;
|
||
textRewriter: PromptConfig;
|
||
commandTranslator: PromptConfig;
|
||
outputEvaluator: PromptConfig;
|
||
}
|
||
|
||
// LLM response shapes ---------------------------------------------------------
|
||
|
||
type CommandResponse =
|
||
| { type: 'command'; command: string }
|
||
| { type: 'commands'; commands: string[] }
|
||
| { type: 'reply'; text: string }
|
||
| { type: 'tools'; tools: ToolCall[]; command?: string; commands?: string[] };
|
||
|
||
interface ToolCall {
|
||
name:
|
||
| 'update_character'
|
||
| 'add_note'
|
||
| 'remove_note'
|
||
| 'add_inventory_item'
|
||
| 'remove_inventory_item';
|
||
args: Record<string, unknown>;
|
||
}
|
||
|
||
type EvaluatorResponse =
|
||
| { decision: 'accept'; text: string }
|
||
| { decision: 'retry'; command: string };
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Utility: strip ANSI escape sequences
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function stripAnsi(s: string): string {
|
||
// 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: string): string | null {
|
||
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: string): boolean {
|
||
return /^READ\b/i.test(command.trim());
|
||
}
|
||
|
||
function isParserComplaint(output: string): boolean {
|
||
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: string, zcodeOutput: string): string {
|
||
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(): string {
|
||
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: number): string {
|
||
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: string, turnCount: number): string {
|
||
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 {
|
||
private proc: ChildProcess | null = null;
|
||
private outputBuffer = '';
|
||
private pendingResolve: ((text: string) => void) | null = null;
|
||
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||
|
||
/** Start the Z-machine with the given story file, return the opening text. */
|
||
async launch(storyPath: string): Promise<string> {
|
||
const zvm = this.locateZvm();
|
||
this.proc = spawn(zvm, [storyPath], {
|
||
stdio: ['pipe', 'pipe', 'pipe'],
|
||
shell: true,
|
||
cwd: process.cwd(),
|
||
});
|
||
|
||
this.proc.stdout!.on('data', (chunk: Buffer) => {
|
||
this.outputBuffer += stripAnsi(chunk.toString());
|
||
this.scheduleResolve();
|
||
});
|
||
|
||
this.proc.stderr!.on('data', (chunk: Buffer) => {
|
||
// 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: string): Promise<string> {
|
||
if (!this.proc) throw new Error('Z-machine process is not running');
|
||
this.outputBuffer = '';
|
||
this.proc.stdin!.write(text + '\n');
|
||
return this.waitForPrompt();
|
||
}
|
||
|
||
isAlive(): boolean {
|
||
return this.proc !== null && !this.proc.killed;
|
||
}
|
||
|
||
kill(): void {
|
||
if (this.proc) {
|
||
this.proc.kill();
|
||
this.proc = null;
|
||
}
|
||
}
|
||
|
||
// ---- private ----
|
||
|
||
private waitForPrompt(): Promise<string> {
|
||
return new Promise((resolve) => {
|
||
// Wrap to allow debounce timer to cancel a previous waiter safely
|
||
const wrapped = (text: string) => 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);
|
||
}
|
||
}, 15_000);
|
||
|
||
// 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: string) => {
|
||
clearTimeout(safety);
|
||
resolve(text);
|
||
};
|
||
|
||
// Data may already be buffered
|
||
this.scheduleResolve();
|
||
});
|
||
}
|
||
|
||
/** Debounced check: resolve when the buffer ends with a Z-machine prompt. */
|
||
private scheduleResolve(): void {
|
||
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);
|
||
}
|
||
|
||
private locateZvm(): string {
|
||
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: string): ZcodePrompts {
|
||
function load(filename: string): PromptConfig {
|
||
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')) as PromptConfig;
|
||
}
|
||
return {
|
||
characterGeneration: load('character-generation.yml'),
|
||
textRewriter: load('text-rewriter.yml'),
|
||
commandTranslator: load('command-translator.yml'),
|
||
outputEvaluator: load('output-evaluator.yml'),
|
||
};
|
||
}
|
||
|
||
function renderTemplate(template: string, vars: Record<string, string>): string {
|
||
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? '');
|
||
}
|
||
|
||
function logLlmError(scope: string, err: unknown): void {
|
||
if (axios.isAxiosError(err)) {
|
||
const ax = err as AxiosError;
|
||
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
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export class ZcodeLlmEngine {
|
||
private zmachine = new ZcodeProcess();
|
||
private session: ZcodeSession | null = null;
|
||
private prompts: ZcodePrompts;
|
||
private llm: AxiosInstance;
|
||
private model: string;
|
||
private resolvedFallbackModel: string | null = null;
|
||
private llmCallCounter = 0;
|
||
private maxRetries: number;
|
||
private historySize: number;
|
||
private nextTurnId = 1;
|
||
private storyPath: string;
|
||
|
||
private static readonly DEPRECATED_MODEL_REPLACEMENTS: Record<string, string> = {
|
||
'anthropic/claude-3-opus-20240229': 'openai/gpt-5.5',
|
||
'openai/gpt-5.4-mini': 'openai/gpt-5.5',
|
||
};
|
||
|
||
constructor(options: { storyPath?: string; promptDir?: string } = {}) {
|
||
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.create({
|
||
baseURL: 'https://openrouter.ai/api/v1',
|
||
headers: {
|
||
Authorization: `Bearer ${apiKey}`,
|
||
'Content-Type': 'application/json',
|
||
},
|
||
});
|
||
}
|
||
|
||
private async createCompletion(
|
||
payload: Record<string, unknown>,
|
||
): Promise<{ data: any }> {
|
||
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.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;
|
||
}
|
||
}
|
||
|
||
private async resolveFallbackModel(): Promise<string> {
|
||
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): v is string => Boolean(v && v.trim()));
|
||
|
||
try {
|
||
const response = await this.llm.get('/models');
|
||
const ids = new Set<string>(
|
||
Array.isArray(response.data?.data)
|
||
? response.data.data
|
||
.map((m: any) => (typeof m?.id === 'string' ? m.id : null))
|
||
.filter((id: string | null): id is string => 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(): boolean {
|
||
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(): Promise<ZcodeTurnResult> {
|
||
// 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: string): Promise<ZcodeTurnResult> {
|
||
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);
|
||
}
|
||
|
||
private async runCommandPlan(
|
||
userInput: string,
|
||
commands: string[],
|
||
): Promise<ZcodeTurnResult> {
|
||
const texts: string[] = [];
|
||
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(): Promise<string> {
|
||
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: string): Promise<ZcodeTurnResult> {
|
||
const { session, zcodeSave } = JSON.parse(savedJson) as {
|
||
session: ZcodeSession;
|
||
zcodeSave: string;
|
||
};
|
||
|
||
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 };
|
||
this.session.rawTranscript ??= [];
|
||
this.session.recentParagraphs ??= [];
|
||
this.session.virtualInventory ??= [];
|
||
this.session.turnCount ??= 0;
|
||
this.session.timeOfDay ??= timeOfDayForTurn(this.session.turnCount);
|
||
this.session.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 -------------------------------------------------------
|
||
|
||
private async runSingleCommandLoop(
|
||
userIntent: string,
|
||
firstCommand: string,
|
||
): Promise<string> {
|
||
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 ------------------------------------------------------------
|
||
|
||
private async generateCharacter(): Promise<string> {
|
||
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.';
|
||
}
|
||
}
|
||
|
||
private async rewriteText(zcodeOutput: string): Promise<string> {
|
||
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;
|
||
}
|
||
}
|
||
|
||
private async translateCommand(userInput: string): Promise<CommandResponse> {
|
||
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)) as CommandResponse;
|
||
return parsed;
|
||
} catch (err) {
|
||
logLlmError('translateCommand', err);
|
||
// Fallback: pass input directly to Z-machine parser
|
||
return { type: 'command', command: userInput.toUpperCase() };
|
||
}
|
||
}
|
||
|
||
private async evaluateOutput(
|
||
userIntent: string,
|
||
commandTried: string,
|
||
zcodeOutput: string,
|
||
attempt: number,
|
||
): Promise<EvaluatorResponse> {
|
||
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)) as EvaluatorResponse;
|
||
} catch (err) {
|
||
logLlmError('evaluateOutput', err);
|
||
// Fallback: accept the raw output as-is
|
||
return { decision: 'accept', text: zcodeOutput };
|
||
}
|
||
}
|
||
|
||
// ---- Session helpers -------------------------------------------------------
|
||
|
||
private executeTool(tool: ToolCall): void {
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
|
||
private appendRecentParagraph(text: string): void {
|
||
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,
|
||
);
|
||
}
|
||
}
|
||
|
||
private extractCommands(cmdResponse: CommandResponse): string[] {
|
||
const list: string[] = [];
|
||
|
||
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());
|
||
}
|
||
|
||
private appendRawTranscript(command: string, output: string): void {
|
||
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,
|
||
);
|
||
}
|
||
}
|
||
|
||
private advanceNarratorState(): void {
|
||
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,
|
||
});
|
||
}
|
||
|
||
private getDeterministicCommandPlan(userInput: string): string[] {
|
||
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 [];
|
||
}
|
||
|
||
private appendRoomHistory(room: string, text: string): void {
|
||
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;
|
||
}
|
||
|
||
private buildCommonVars(): Record<string, string> {
|
||
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'),
|
||
};
|
||
}
|
||
|
||
private buildTurnResult(text: string): ZcodeTurnResult {
|
||
const alive = this.zmachine.isAlive();
|
||
if (!alive && this.session) this.session.running = false;
|
||
const paragraphs = textToParagraphs(text);
|
||
return {
|
||
turnId: this.nextTurnId++,
|
||
paragraphs,
|
||
choices: [],
|
||
inputMode: alive ? 'text' : 'end',
|
||
gameState: { statusLine: this.session?.currentRoom },
|
||
};
|
||
}
|
||
}
|