/** * Z-code LLM Server * * Starts an Express + Socket.IO server that runs Zork I through the * ZcodeLlmEngine and serves the same shared client UI as the YAML engine. * * Usage: * npm run dev:zcode (development, with file watching) * npm run start:zcode (production, from compiled dist/) * * Environment variables: * PORT – HTTP port (default: 3002) * ZCODE_STORY_FILE – path to the story file (default: ./data/z-code/zork1.bin) * OPENROUTER_API_KEY, OPENROUTER_MODEL – required */ import path from 'path'; import http from 'http'; import express from 'express'; import { Server as SocketIOServer } from 'socket.io'; import * as dotenv from 'dotenv'; import { existsSync, mkdirSync, copyFileSync } from 'fs'; import { ZcodeLlmEngine, ZcodeTurnResult } from './engine/zcode-llm-engine'; import { clientGameConfig, ensureConfiguredAssetDirectories, loadGameConfig, projectPath, } from './config/game-config'; dotenv.config(); const app = express(); const server = http.createServer(app); const io = new SocketIOServer(server); const DEFAULT_PORT = 3002; const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : DEFAULT_PORT; const PORT_RANGE = 300; const DEBUG_ENABLED = /^(1|true|yes|on)$/i.test(process.env.ZCODE_DEBUG ?? ''); const engineConfig = loadGameConfig( process.env.ZCODE_CONFIG_FILE || './config/engines/zcode.json', 'zcode', ); function debugLog(message: string, details?: unknown): void { if (!DEBUG_ENABLED) return; if (typeof details === 'undefined') { console.log(`[zcode:debug] ${message}`); return; } console.log(`[zcode:debug] ${message}`, details); } // Serve the same shared client UI app.use( express.static(path.join(__dirname, '../public'), { etag: false, lastModified: false, setHeaders: (res) => { res.setHeader( 'Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate', ); res.setHeader('Pragma', 'no-cache'); res.setHeader('Expires', '0'); }, }), ); app.get('/api/game-config', (_req, res) => { res.json(clientGameConfig(engineConfig)); }); // One engine instance per connected socket const sessions = new Map(); // Save-game slot maps: socketId → Map const saveSlots = new Map>(); function toClientTurn(turn: ZcodeTurnResult): ZcodeTurnResult { return { ...turn, gameState: { ...turn.gameState, currentRoomId: turn.gameState?.statusLine, statusLine: turn.gameState?.statusLine, }, }; } function normalizeSaveSlot(slot: unknown): number { const n = Number(slot); return Number.isInteger(n) && n > 0 ? n : 1; } function getOrCreateEngine(socketId: string): ZcodeLlmEngine { let engine = sessions.get(socketId); if (!engine) { engine = new ZcodeLlmEngine({ storyPath: projectPath(process.env.ZCODE_STORY_FILE || engineConfig.paths.mainGameFile), promptDir: projectPath(engineConfig.paths.promptDir || 'data/zcode-prompts'), }); sessions.set(socketId, engine); } return engine; } function getSlots(socketId: string): Map { let slots = saveSlots.get(socketId); if (!slots) { slots = new Map(); saveSlots.set(socketId, slots); } return slots; } async function handleGameApi( socket: ReturnType & { id: string; }, method: string, args: unknown[], ): Promise { const slots = getSlots(socket.id); debugLog(`gameApi request from ${socket.id}: ${method}`, { args }); switch (method) { case 'newGame': case 'newGame()': { const engine = getOrCreateEngine(socket.id); const turn = await engine.newGame(); socket.emit('narrativeResponse', toClientTurn(turn)); return { success: true, result: true, running: true, canLoad: slots.size > 0, }; } case 'loadGame': case 'loadGame()': { const slot = normalizeSaveSlot(args[0]); if (!slots.has(slot)) { return { success: false, error: 'missing_save', result: false }; } const engine = getOrCreateEngine(socket.id); const turn = await engine.loadGame(slots.get(slot)!); socket.emit('narrativeResponse', toClientTurn(turn)); socket.emit('gameLoaded', { slot }); return { success: true, result: true, running: true, slot }; } case 'saveGame': case 'saveGame()': { const engine = sessions.get(socket.id); if (!engine?.isRunning()) { return { success: false, error: 'game_not_running', result: false }; } const slot = normalizeSaveSlot(args[0]); const savedJson = await engine.saveGame(); slots.set(slot, savedJson); socket.emit('gameSaved', { slot }); return { success: true, result: true, slot }; } case 'hasSaveGame': case 'hasSaveGame()': { const slot = normalizeSaveSlot(args[0]); return { success: true, result: slots.has(slot), slot }; } case 'getSaveGames': case 'getSaveGames()': return { success: true, result: Array.from(slots.keys()).sort((a, b) => a - b), }; case 'isGameRunning': case 'isGameRunning()': return { success: true, result: sessions.get(socket.id)?.isRunning() ?? false, }; default: return { success: false, error: `unknown_method:${method}` }; } } function checkRuntimeConfiguration(): void { const storyPath = projectPath(process.env.ZCODE_STORY_FILE ?? engineConfig.paths.mainGameFile); const promptDir = projectPath(engineConfig.paths.promptDir || 'data/zcode-prompts'); const promptFiles = [ 'character-generation.yml', 'text-rewriter.yml', 'command-translator.yml', 'output-evaluator.yml', ]; const missingPrompts = promptFiles .map((file) => path.join(promptDir, file)) .filter((filePath) => !existsSync(filePath)); if (!process.env.OPENROUTER_API_KEY) { console.error('[zcode] Missing OPENROUTER_API_KEY in environment.'); } if (!process.env.OPENROUTER_MODEL) { console.error('[zcode] Missing OPENROUTER_MODEL in environment.'); } if (!existsSync(storyPath)) { console.error(`[zcode] Story file missing: ${storyPath}`); console.error('[zcode] Place zork1.bin in ./data/z-code/ or set ZCODE_STORY_FILE.'); } if (missingPrompts.length > 0) { console.error('[zcode] Missing prompt files:'); for (const filePath of missingPrompts) { console.error(` - ${filePath}`); } } debugLog('runtime configuration', { storyPath, promptDir, debug: DEBUG_ENABLED, hasApiKey: Boolean(process.env.OPENROUTER_API_KEY), model: process.env.OPENROUTER_MODEL ?? null, }); } io.on('connection', (socket) => { console.log(`[zcode] Client connected: ${socket.id}`); socket.emit('gameConfig', clientGameConfig(engineConfig)); socket.on( 'gameApi', async ( request: { method?: string; args?: unknown[] }, respond: (result: object) => void, ) => { try { const result = await handleGameApi( socket as Parameters[0], String(request?.method ?? ''), Array.isArray(request?.args) ? request.args : [], ); debugLog(`gameApi response to ${socket.id}`, result); if (typeof respond === 'function') respond(result); } catch (error) { console.error('[zcode] gameApi error:', error); if (typeof respond === 'function') { respond({ success: false, error: error instanceof Error ? error.message : String(error), }); } } }, ); socket.on( 'playerCommand', async (data: { command?: string }) => { const engine = sessions.get(socket.id); if (!engine?.isRunning()) { socket.emit('error', { message: 'No active game. Start or load a game first.', }); return; } const input = String(data?.command ?? '').trim(); if (!input) return; debugLog(`playerCommand from ${socket.id}: ${input}`); try { const turn: ZcodeTurnResult = await engine.processInput(input); debugLog(`narrativeResponse to ${socket.id}`, { inputMode: turn.inputMode, paragraphs: turn.paragraphs.length, statusLine: turn.gameState?.statusLine, }); socket.emit('narrativeResponse', toClientTurn(turn)); } catch (error) { console.error('[zcode] playerCommand error:', error); socket.emit('error', { message: error instanceof Error ? error.message : 'An error occurred.', }); } }, ); socket.on('disconnect', () => { console.log(`[zcode] Client disconnected: ${socket.id}`); sessions.delete(socket.id); saveSlots.delete(socket.id); }); }); // --------------------------------------------------------------------------- // Startup helpers // --------------------------------------------------------------------------- function ensureDirectories(): void { const dirs = [ path.join(__dirname, '../public'), path.join(__dirname, '../public/js'), path.join(__dirname, '../public/css'), path.join(__dirname, '../public/images'), path.join(__dirname, '../public/music'), path.join(__dirname, '../public/sounds'), path.join(__dirname, '../public/fonts'), path.join(__dirname, '../data/z-code'), path.join(__dirname, '../data/zcode-prompts'), ]; for (const dir of dirs) { if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); } ensureConfiguredAssetDirectories(engineConfig); } function ensureKokoroJs(): void { const src = path.join(__dirname, '../node_modules/kokoro-js/dist/index.js'); const dst = path.join(__dirname, '../public/js/kokoro-js.js'); if (existsSync(src) && !existsSync(dst)) copyFileSync(src, dst); } async function startServer(initialPort: number, range: number): Promise { ensureDirectories(); try { ensureKokoroJs(); } catch { /* optional */ } checkRuntimeConfiguration(); let port = initialPort; while (port < initialPort + range) { try { await new Promise((resolve, reject) => { server.removeAllListeners('error'); server.removeAllListeners('listening'); server.once('listening', () => { console.log( `[zcode] Z-code Narrator server running on http://localhost:${port}`, ); resolve(); }); server.once('error', (err: NodeJS.ErrnoException) => { if (err.code === 'EADDRINUSE' || err.code === 'EACCES') { console.log(`Port ${port} unavailable (${err.code}), trying ${port + 1}...`); server.close(); port++; reject(); } else { reject(err); } }); server.listen(port); }); return; } catch { if (port >= initialPort + range - 1) { throw new Error( `Failed to start server on ports ${initialPort}–${initialPort + range - 1}`, ); } } } } if (require.main === module) { startServer(PORT, PORT_RANGE).catch((err) => { console.error('[zcode] Failed to start:', err); process.exit(1); }); }