Files

1161 lines
37 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 },
};
}
}