376 lines
11 KiB
TypeScript
376 lines
11 KiB
TypeScript
/**
|
||
* Zork LLM Server
|
||
*
|
||
* Starts an Express + Socket.IO server that runs Zork I through the
|
||
* ZorkLlmEngine and serves the same shared client UI as the YAML engine.
|
||
*
|
||
* Usage:
|
||
* npm run dev:zork (development, with file watching)
|
||
* npm run start:zork (production, from compiled dist/)
|
||
*
|
||
* Environment variables:
|
||
* PORT – HTTP port (default: 3002)
|
||
* ZORK_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 { ZorkLlmEngine, ZorkTurnResult } from './engine/zork-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.ZORK_DEBUG ?? '');
|
||
const engineConfig = loadGameConfig(
|
||
process.env.ZORK_CONFIG_FILE || './config/engines/zork.json',
|
||
'zork',
|
||
);
|
||
|
||
function debugLog(message: string, details?: unknown): void {
|
||
if (!DEBUG_ENABLED) return;
|
||
if (typeof details === 'undefined') {
|
||
console.log(`[zork:debug] ${message}`);
|
||
return;
|
||
}
|
||
console.log(`[zork: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<string, ZorkLlmEngine>();
|
||
// Save-game slot maps: socketId → Map<slotNumber, serialisedJson>
|
||
const saveSlots = new Map<string, Map<number, string>>();
|
||
|
||
function toClientTurn(turn: ZorkTurnResult): ZorkTurnResult {
|
||
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): ZorkLlmEngine {
|
||
let engine = sessions.get(socketId);
|
||
if (!engine) {
|
||
engine = new ZorkLlmEngine({
|
||
storyPath: projectPath(process.env.ZORK_STORY_FILE || engineConfig.paths.mainGameFile),
|
||
promptDir: projectPath(engineConfig.paths.promptDir || 'data/zork-prompts'),
|
||
});
|
||
sessions.set(socketId, engine);
|
||
}
|
||
return engine;
|
||
}
|
||
|
||
function getSlots(socketId: string): Map<number, string> {
|
||
let slots = saveSlots.get(socketId);
|
||
if (!slots) {
|
||
slots = new Map();
|
||
saveSlots.set(socketId, slots);
|
||
}
|
||
return slots;
|
||
}
|
||
|
||
async function handleGameApi(
|
||
socket: ReturnType<SocketIOServer['sockets']['sockets']['get']> & {
|
||
id: string;
|
||
},
|
||
method: string,
|
||
args: unknown[],
|
||
): Promise<object> {
|
||
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.ZORK_STORY_FILE ?? engineConfig.paths.mainGameFile);
|
||
const promptDir = projectPath(engineConfig.paths.promptDir || 'data/zork-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('[zork] Missing OPENROUTER_API_KEY in environment.');
|
||
}
|
||
if (!process.env.OPENROUTER_MODEL) {
|
||
console.error('[zork] Missing OPENROUTER_MODEL in environment.');
|
||
}
|
||
if (!existsSync(storyPath)) {
|
||
console.error(`[zork] Story file missing: ${storyPath}`);
|
||
console.error('[zork] Place zork1.bin in ./data/z-code/ or set ZORK_STORY_FILE.');
|
||
}
|
||
if (missingPrompts.length > 0) {
|
||
console.error('[zork] 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(`[zork] 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<typeof handleGameApi>[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('[zork] 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: ZorkTurnResult = 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('[zork] playerCommand error:', error);
|
||
socket.emit('error', {
|
||
message:
|
||
error instanceof Error ? error.message : 'An error occurred.',
|
||
});
|
||
}
|
||
},
|
||
);
|
||
|
||
socket.on('disconnect', () => {
|
||
console.log(`[zork] 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/zork-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<void> {
|
||
ensureDirectories();
|
||
try { ensureKokoroJs(); } catch { /* optional */ }
|
||
checkRuntimeConfiguration();
|
||
|
||
let port = initialPort;
|
||
while (port < initialPort + range) {
|
||
try {
|
||
await new Promise<void>((resolve, reject) => {
|
||
server.removeAllListeners('error');
|
||
server.removeAllListeners('listening');
|
||
server.once('listening', () => {
|
||
console.log(
|
||
`[zork] Zork 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('[zork] Failed to start:', err);
|
||
process.exit(1);
|
||
});
|
||
}
|