Add ink integration UI and media playback

This commit is contained in:
2026-05-15 21:23:46 +02:00
parent 44dc64f830
commit f2e786d5bc
89 changed files with 6561 additions and 556 deletions
+120
View File
@@ -0,0 +1,120 @@
import path from 'path';
import { existsSync, mkdirSync, readFileSync } from 'fs';
export type EngineName = 'yaml' | 'ink' | 'zork' | string;
export interface GameMetadata {
title: string;
author?: string;
subtitle?: string;
version?: string;
copyright?: string;
}
export interface GamePaths {
mainGameFile: string;
inkSource?: string;
inkCompiled?: string;
promptDir?: string;
music?: string;
sfx?: string;
images?: string;
[key: string]: string | undefined;
}
export interface GameEngineConfig {
engine: EngineName;
locale: 'en_US' | 'de_DE' | string;
paths: GamePaths;
metadata: GameMetadata;
}
const PROJECT_ROOT = path.resolve(__dirname, '../..');
function fallbackConfig(engine: EngineName): GameEngineConfig {
return {
engine,
locale: 'en_US',
paths: {
mainGameFile:
engine === 'ink'
? 'data/ink/story.ink.json'
: engine === 'zork'
? 'data/z-code/zork1.bin'
: 'data/worlds/example_world.yml',
music: 'public/music',
sfx: 'public/sounds',
images: 'public/images',
},
metadata: {
title: 'AI Interactive Fiction',
author: 'Generative AI',
subtitle: 'An open-world text adventure',
version: '1.0.0',
copyright: '',
},
};
}
export function projectPath(relativeOrAbsolutePath: string): string {
return path.isAbsolute(relativeOrAbsolutePath)
? relativeOrAbsolutePath
: path.resolve(PROJECT_ROOT, relativeOrAbsolutePath);
}
export function loadGameConfig(configPath: string, engine: EngineName): GameEngineConfig {
const absolutePath = projectPath(configPath);
if (!existsSync(absolutePath)) {
console.warn(`[config] Missing ${absolutePath}; using ${engine} defaults.`);
return fallbackConfig(engine);
}
const parsed = JSON.parse(readFileSync(absolutePath, 'utf8')) as Partial<GameEngineConfig>;
const fallback = fallbackConfig(engine);
return {
engine: parsed.engine ?? fallback.engine,
locale: parsed.locale ?? fallback.locale,
paths: {
...fallback.paths,
...(parsed.paths ?? {}),
},
metadata: {
...fallback.metadata,
...(parsed.metadata ?? {}),
},
};
}
export function ensureConfiguredAssetDirectories(config: GameEngineConfig): void {
const directories = [
config.paths.music,
config.paths.sfx,
config.paths.images,
config.paths.inkSource ? path.dirname(config.paths.inkSource) : undefined,
config.paths.inkCompiled ? path.dirname(config.paths.inkCompiled) : undefined,
config.paths.mainGameFile ? path.dirname(config.paths.mainGameFile) : undefined,
config.paths.promptDir,
];
for (const directory of directories) {
if (!directory) continue;
const absolutePath = projectPath(directory);
if (!existsSync(absolutePath)) {
mkdirSync(absolutePath, { recursive: true });
}
}
}
export function clientGameConfig(config: GameEngineConfig) {
return {
engine: config.engine,
locale: config.locale,
metadata: config.metadata,
assets: {
music: '/music/',
sfx: '/sounds/',
sounds: '/sounds/',
images: '/images/',
},
};
}
+176
View File
@@ -0,0 +1,176 @@
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
import path from 'path';
import { Story } from 'inkjs';
import {
ChoiceResult,
StoryTag,
TurnResult,
} from '../interfaces/turn-result';
import { getTagValue, parseTags } from '../utils/tag-parser';
const { Compiler } = require('inkjs/full') as { Compiler: new (
inkSource: string,
options?: {
sourceFilename?: string;
errorHandler?: (message: string, type: number) => void;
fileHandler?: {
ResolveInkFilename: (filename: string) => string;
LoadInkFileContents: (filename: string) => string;
};
},
) => { Compile: () => { ToJson: () => string } | null } };
export interface InkCompileResult {
sourcePath: string;
outputPath: string;
warningCount: number;
}
export function compileInkSource(sourcePath: string, outputPath: string): InkCompileResult {
const resolvedSource = path.resolve(sourcePath);
const resolvedOutput = path.resolve(outputPath);
if (!existsSync(resolvedSource)) {
throw new Error(`Ink source file not found: ${resolvedSource}`);
}
const warnings: string[] = [];
const errors: string[] = [];
const source = readFileSync(resolvedSource, 'utf8').replace(/^\uFEFF/, '');
const sourceDir = path.dirname(resolvedSource);
const fileHandler = {
ResolveInkFilename: (filename: string) =>
path.isAbsolute(filename) ? filename : path.resolve(sourceDir, filename),
LoadInkFileContents: (filename: string) =>
readFileSync(path.isAbsolute(filename) ? filename : path.resolve(sourceDir, filename), 'utf8')
.replace(/^\uFEFF/, ''),
};
const compiler = new Compiler(source, {
sourceFilename: resolvedSource,
fileHandler,
errorHandler: (message: string, type: number) => {
if (type === 1) {
warnings.push(message);
} else {
errors.push(message);
}
},
});
const story = compiler.Compile();
if (!story || errors.length > 0) {
throw new Error(`Ink compilation failed:\n${errors.join('\n')}`);
}
if (warnings.length > 0) {
warnings.forEach((warning) => console.warn(`[ink] ${warning}`));
}
mkdirSync(path.dirname(resolvedOutput), { recursive: true });
writeFileSync(resolvedOutput, story.ToJson(), 'utf8');
return {
sourcePath: resolvedSource,
outputPath: resolvedOutput,
warningCount: warnings.length,
};
}
export class InkEngine {
private story: Story | null = null;
private nextTurnId = 1;
constructor(private readonly storyPath: string) {}
isRunning(): boolean {
if (!this.story) return false;
return this.story.canContinue || this.story.currentChoices.length > 0;
}
newGame(): TurnResult {
this.story = this.loadStory();
this.nextTurnId = 1;
return this.continueStory();
}
chooseChoice(choiceIndex: number): TurnResult {
if (!this.story) {
throw new Error('No active Ink story');
}
const choice = this.story.currentChoices.find((item) => item.index === choiceIndex);
if (!choice) {
throw new Error(`Ink choice ${choiceIndex} is not available`);
}
this.story.ChooseChoiceIndex(choice.index);
return this.continueStory();
}
saveGame(): string {
if (!this.story) {
throw new Error('No active Ink story to save');
}
return this.story.state.toJson();
}
loadGame(savedState: string): TurnResult {
this.story = this.loadStory();
this.story.state.LoadJson(savedState);
return this.continueStory();
}
private loadStory(): Story {
const resolvedPath = path.resolve(this.storyPath);
if (!existsSync(resolvedPath)) {
throw new Error(`Ink story file not found: ${resolvedPath}`);
}
const storyJson = JSON.parse(readFileSync(resolvedPath, 'utf8'));
return new Story(storyJson);
}
private continueStory(): TurnResult {
if (!this.story) {
throw new Error('No active Ink story');
}
const paragraphs: TurnResult['paragraphs'] = [];
const globalTags: StoryTag[] = [];
while (this.story.canContinue) {
const rawText = this.story.Continue();
const text = String(rawText || '').trim();
const tags = parseTags(this.story.currentTags || []);
tags
.filter((tag) => tag.key === 'title' || tag.key === 'author')
.forEach((tag) => globalTags.push(tag));
if (text) {
paragraphs.push({ text, tags });
} else {
tags.forEach((tag) => globalTags.push(tag));
}
}
const choices = this.story.currentChoices.map((choice): ChoiceResult => {
const tags = parseTags(choice.tags || []);
const category = getTagValue(tags, 'action');
const letter = getTagValue(tags, 'letter');
return {
index: choice.index,
text: String(choice.text || '').trim(),
tags,
category,
letter,
};
});
return {
turnId: this.nextTurnId++,
paragraphs,
choices,
inputMode: choices.length > 0 ? 'choice' : 'end',
globalTags: globalTags.length > 0 ? globalTags : undefined,
};
}
}
+13 -11
View File
@@ -20,6 +20,10 @@ 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();
@@ -91,13 +95,7 @@ export interface ZorkSession {
running: boolean;
}
/** Subset of the unified TurnResult protocol understood by the client. */
export interface ZorkTurnResult {
paragraphs: Array<{ text: string; tags: unknown[] }>;
choices: unknown[];
inputMode: 'text' | 'end';
gameState?: { statusLine?: string };
}
export type ZorkTurnResult = TurnResult;
interface PromptConfig {
system: string;
@@ -418,6 +416,7 @@ export class ZorkLlmEngine {
private llmCallCounter = 0;
private maxRetries: number;
private historySize: number;
private nextTurnId = 1;
private storyPath: string;
private static readonly DEPRECATED_MODEL_REPLACEMENTS: Record<string, string> = {
@@ -425,7 +424,7 @@ export class ZorkLlmEngine {
'openai/gpt-5.4-mini': 'openai/gpt-5.5',
};
constructor() {
constructor(options: { storyPath?: string; promptDir?: string } = {}) {
const apiKey = process.env.OPENROUTER_API_KEY;
const model = process.env.OPENROUTER_MODEL;
if (!apiKey || !model) {
@@ -450,10 +449,10 @@ export class ZorkLlmEngine {
this.maxRetries = parseInt(process.env.ZORK_MAX_RETRIES ?? '3', 10);
this.historySize = parseInt(process.env.ZORK_HISTORY_SIZE ?? '5', 10);
this.storyPath = path.resolve(
process.env.ZORK_STORY_FILE ?? './data/z-code/zork1.bin',
options.storyPath ?? process.env.ZORK_STORY_FILE ?? './data/z-code/zork1.bin',
);
const promptDir = path.resolve('./data/zork-prompts');
const promptDir = path.resolve(options.promptDir ?? './data/zork-prompts');
this.prompts = loadPrompts(promptDir);
this.llm = axios.create({
@@ -582,6 +581,7 @@ export class ZorkLlmEngine {
async newGame(): Promise<ZorkTurnResult> {
// Kill any existing game
if (this.zork.isAlive()) this.zork.kill();
this.nextTurnId = 1;
if (!fs.existsSync(this.storyPath)) {
throw new Error(
@@ -1148,8 +1148,10 @@ export class ZorkLlmEngine {
private buildTurnResult(text: string): ZorkTurnResult {
const alive = this.zork.isAlive();
if (!alive && this.session) this.session.running = false;
const paragraphs = textToParagraphs(text);
return {
paragraphs: [{ text, tags: [] }],
turnId: this.nextTurnId++,
paragraphs,
choices: [],
inputMode: alive ? 'text' : 'end',
gameState: { statusLine: this.session?.currentRoom },
+12 -7
View File
@@ -4,9 +4,10 @@
import * as path from 'path';
import * as dotenv from 'dotenv';
import { GameRunner } from './cli/game-runner';
// Import the server module and the startServer function for the web interface
import { startServer } from './server';
import { GameRunner } from './cli/game-runner';
// Import the server module and the startServer function for the web interface
import { startServer } from './server';
import { loadGameConfig, projectPath } from './config/game-config';
// Load environment variables
console.log('Loading environment variables...');
@@ -27,8 +28,12 @@ async function main(): Promise<void> {
console.log('A modern take on classic text adventures with LLM-powered interactions');
console.log('');
// Get the world file path from environment variables or use default
const worldFile = process.env.DEFAULT_WORLD_FILE || './data/worlds/example_world.yml';
// Get the world file path from the YAML engine config, with environment override.
const engineConfig = loadGameConfig(
process.env.YAML_CONFIG_FILE || './config/engines/yaml.json',
'yaml',
);
const worldFile = projectPath(process.env.DEFAULT_WORLD_FILE || engineConfig.paths.mainGameFile);
console.log(`Using world file: ${worldFile}`);
console.log(`OpenRouter API Key: ${process.env.OPENROUTER_API_KEY ? '✓ Found' : '✗ Missing'}`);
console.log(`OpenRouter Model: ${process.env.OPENROUTER_MODEL || '✗ Not specified'}`);
@@ -58,7 +63,7 @@ async function main(): Promise<void> {
// Get port configuration
const DEFAULT_PORT = 3000;
const PORT = process.env.PORT ? parseInt(process.env.PORT) : DEFAULT_PORT;
const PORT_RANGE = 10;
const PORT_RANGE = 300;
// Start the web server with port fallback
console.log('Starting web server...');
@@ -80,4 +85,4 @@ console.log('Starting application...');
main().catch(error => {
console.error('Unhandled error in main:', error);
process.exit(1);
});
});
+71
View File
@@ -0,0 +1,71 @@
/**
* Shared engine-to-client turn protocol.
*/
import { parseTag } from '../utils/tag-parser';
export type InputMode = 'text' | 'choice' | 'end';
export interface StoryTag {
key: string;
value?: string;
param?: string;
}
export interface ParagraphResult {
text: string;
tags: StoryTag[];
}
export interface ChoiceResult {
index: number;
text: string;
tags: StoryTag[];
category?: string;
letter?: string;
}
export interface TurnResult {
turnId: number;
paragraphs: ParagraphResult[];
choices: ChoiceResult[];
inputMode: InputMode;
globalTags?: StoryTag[];
gameState?: {
currentRoomId?: string;
score?: number;
moves?: number;
statusLine?: string;
};
suggestions?: string[];
}
export function textToParagraphs(text: string, tags: StoryTag[] = []): ParagraphResult[] {
return String(text || '')
.replace(/\r\n?/g, '\n')
.split(/\n{2,}/)
.map((paragraph) => paragraph.trim())
.filter(Boolean)
.map((paragraph) => {
const lines = paragraph.split('\n');
const paragraphTags: StoryTag[] = [...tags];
const textLines: string[] = [];
let tagPrefixOpen = true;
for (const line of lines) {
const trimmed = line.trim();
const maybeTag = tagPrefixOpen && trimmed.startsWith('#') ? parseTag(trimmed) : null;
if (maybeTag) {
paragraphTags.push(maybeTag);
} else {
tagPrefixOpen = false;
textLines.push(line);
}
}
return {
text: textLines.join('\n').trim(),
tags: paragraphTags,
};
});
}
+282
View File
@@ -0,0 +1,282 @@
/**
* Ink Engine Server
*
* Serves the shared client UI and runs a compiled Ink JSON story through the
* unified TurnResult socket protocol.
*/
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 { compileInkSource, InkEngine } from './engine/ink-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 = 3003;
const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : DEFAULT_PORT;
const PORT_RANGE = 300;
const engineConfig = loadGameConfig(
process.env.INK_CONFIG_FILE || './config/engines/ink.json',
'ink',
);
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));
});
const sessions = new Map<string, InkEngine>();
const saveSlots = new Map<string, Map<number, string>>();
function normalizeSaveSlot(slot: unknown): number {
const n = Number(slot);
return Number.isInteger(n) && n > 0 ? n : 1;
}
function getStoryPath(): string {
return projectPath(
process.env.INK_STORY_FILE ||
engineConfig.paths.inkCompiled ||
engineConfig.paths.mainGameFile,
);
}
function getSourcePath(): string {
return projectPath(process.env.INK_SOURCE_FILE || engineConfig.paths.inkSource || '');
}
function compileConfiguredStory(): void {
const sourcePath = getSourcePath();
const outputPath = getStoryPath();
const result = compileInkSource(sourcePath, outputPath);
console.log(
`[ink] Compiled ${result.sourcePath} -> ${result.outputPath}` +
(result.warningCount > 0 ? ` (${result.warningCount} warnings)` : ''),
);
}
function getSlots(socketId: string): Map<number, string> {
let slots = saveSlots.get(socketId);
if (!slots) {
slots = new Map();
saveSlots.set(socketId, slots);
}
return slots;
}
function getOrCreateEngine(socketId: string): InkEngine {
let engine = sessions.get(socketId);
if (!engine) {
engine = new InkEngine(getStoryPath());
sessions.set(socketId, engine);
}
return engine;
}
async function handleGameApi(
socket: ReturnType<SocketIOServer['sockets']['sockets']['get']> & { id: string },
method: string,
args: unknown[],
): Promise<object> {
const slots = getSlots(socket.id);
switch (method) {
case 'newGame':
case 'newGame()': {
const engine = new InkEngine(getStoryPath());
sessions.set(socket.id, engine);
socket.emit('narrativeResponse', engine.newGame());
return { success: true, result: true, running: true, canLoad: slots.size > 0 };
}
case 'chooseChoice':
case 'chooseChoice()': {
const engine = sessions.get(socket.id);
if (!engine?.isRunning()) {
return { success: false, error: 'game_not_running', result: false };
}
const choiceIndex = Number(args[0]);
if (!Number.isInteger(choiceIndex)) {
return { success: false, error: 'invalid_choice', result: false };
}
socket.emit('narrativeResponse', engine.chooseChoice(choiceIndex));
return { success: true, result: true };
}
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);
socket.emit('narrativeResponse', engine.loadGame(slots.get(slot)!));
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]);
slots.set(slot, engine.saveGame());
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}` };
}
}
io.on('connection', (socket) => {
console.log(`[ink] 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 : [],
);
if (typeof respond === 'function') respond(result);
} catch (error) {
console.error('[ink] gameApi error:', error);
if (typeof respond === 'function') {
respond({
success: false,
error: error instanceof Error ? error.message : String(error),
});
}
}
},
);
socket.on('disconnect', () => {
console.log(`[ink] Client disconnected: ${socket.id}`);
sessions.delete(socket.id);
saveSlots.delete(socket.id);
});
});
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'),
];
for (const dir of dirs) {
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
}
ensureConfiguredAssetDirectories(engineConfig);
}
function ensureKokoroJs(): void {
const source = path.join(__dirname, '../node_modules/kokoro-js/dist/index.js');
const destination = path.join(__dirname, '../public/js/kokoro-js.js');
if (existsSync(source) && !existsSync(destination)) {
copyFileSync(source, destination);
}
}
export async function startServer(initialPort: number, range: number): Promise<void> {
ensureDirectories();
try { ensureKokoroJs(); } catch { /* optional */ }
compileConfiguredStory();
if (!existsSync(getStoryPath())) {
console.error(`[ink] Story file missing: ${getStoryPath()}`);
console.error('[ink] Set INK_SOURCE_FILE or configure paths.inkSource in config/engines/ink.json.');
}
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(`[ink] Ink server running on http://localhost:${port}`);
resolve();
});
server.once('error', (error: NodeJS.ErrnoException) => {
if (error.code === 'EADDRINUSE' || error.code === 'EACCES') {
console.log(`Port ${port} unavailable (${error.code}), trying ${port + 1}...`);
server.close();
port++;
reject();
} else {
reject(error);
}
});
server.listen(port);
});
return;
} catch {
if (port >= initialPort + range - 1) {
throw new Error(`Failed to start server on ports ${initialPort} to ${initialPort + range - 1}`);
}
}
}
}
if (require.main === module) {
startServer(PORT, PORT_RANGE).catch((error) => {
console.error('[ink] Failed to start:', error);
process.exit(1);
});
}
export { app, server, io };
+36 -23
View File
@@ -21,6 +21,12 @@ 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();
@@ -30,8 +36,12 @@ const io = new SocketIOServer(server);
const DEFAULT_PORT = 3002;
const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : DEFAULT_PORT;
const PORT_RANGE = 10;
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;
@@ -58,23 +68,20 @@ app.use(
}),
);
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 toLegacyNarrative(turn: ZorkTurnResult): {
text: string;
gameState: { currentRoomId?: string; statusLine?: string };
} {
const text = (turn.paragraphs ?? [])
.map((p) => String(p?.text ?? '').trim())
.filter(Boolean)
.join('\n\n');
function toClientTurn(turn: ZorkTurnResult): ZorkTurnResult {
return {
text,
...turn,
gameState: {
...turn.gameState,
currentRoomId: turn.gameState?.statusLine,
statusLine: turn.gameState?.statusLine,
},
@@ -89,7 +96,10 @@ function normalizeSaveSlot(slot: unknown): number {
function getOrCreateEngine(socketId: string): ZorkLlmEngine {
let engine = sessions.get(socketId);
if (!engine) {
engine = new ZorkLlmEngine();
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;
@@ -119,7 +129,7 @@ async function handleGameApi(
case 'newGame()': {
const engine = getOrCreateEngine(socket.id);
const turn = await engine.newGame();
socket.emit('narrativeResponse', toLegacyNarrative(turn));
socket.emit('narrativeResponse', toClientTurn(turn));
return {
success: true,
result: true,
@@ -136,7 +146,7 @@ async function handleGameApi(
}
const engine = getOrCreateEngine(socket.id);
const turn = await engine.loadGame(slots.get(slot)!);
socket.emit('narrativeResponse', toLegacyNarrative(turn));
socket.emit('narrativeResponse', toClientTurn(turn));
socket.emit('gameLoaded', { slot });
return { success: true, result: true, running: true, slot };
}
@@ -180,10 +190,8 @@ async function handleGameApi(
}
function checkRuntimeConfiguration(): void {
const storyPath = path.resolve(
process.env.ZORK_STORY_FILE ?? './data/z-code/zork1.bin',
);
const promptDir = path.resolve('./data/zork-prompts');
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',
@@ -223,6 +231,7 @@ function checkRuntimeConfiguration(): void {
io.on('connection', (socket) => {
console.log(`[zork] Client connected: ${socket.id}`);
socket.emit('gameConfig', clientGameConfig(engineConfig));
socket.on(
'gameApi',
@@ -272,7 +281,7 @@ io.on('connection', (socket) => {
paragraphs: turn.paragraphs.length,
statusLine: turn.gameState?.statusLine,
});
socket.emit('narrativeResponse', toLegacyNarrative(turn));
socket.emit('narrativeResponse', toClientTurn(turn));
} catch (error) {
console.error('[zork] playerCommand error:', error);
socket.emit('error', {
@@ -309,6 +318,7 @@ function ensureDirectories(): void {
for (const dir of dirs) {
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
}
ensureConfiguredAssetDirectories(engineConfig);
}
function ensureKokoroJs(): void {
@@ -326,15 +336,17 @@ async function startServer(initialPort: number, range: number): Promise<void> {
while (port < initialPort + range) {
try {
await new Promise<void>((resolve, reject) => {
server.listen(port, () => {
server.removeAllListeners('error');
server.removeAllListeners('listening');
server.once('listening', () => {
console.log(
`[zork] Zork Narrator server running on http://localhost:${port}`,
);
resolve();
});
server.on('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EADDRINUSE') {
console.log(`Port ${port} in use, trying ${port + 1}`);
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();
@@ -342,6 +354,7 @@ async function startServer(initialPort: number, range: number): Promise<void> {
reject(err);
}
});
server.listen(port);
});
return;
} catch {
+93 -41
View File
@@ -7,9 +7,19 @@ import path from 'path';
import express from 'express';
import http from 'http';
import { Server as SocketIOServer } from 'socket.io';
import * as dotenv from 'dotenv';
import { GameRunner } from './cli/game-runner';
import { existsSync, mkdirSync, copyFileSync } from 'fs';
import * as dotenv from 'dotenv';
import { GameRunner } from './cli/game-runner';
import { existsSync, mkdirSync, copyFileSync } from 'fs';
import {
textToParagraphs,
TurnResult,
} from './interfaces/turn-result';
import {
clientGameConfig,
ensureConfiguredAssetDirectories,
loadGameConfig,
projectPath,
} from './config/game-config';
// Load environment variables
dotenv.config();
@@ -21,8 +31,12 @@ const io = new SocketIOServer(server);
// Get port from environment variables or use default
const DEFAULT_PORT = 3001;
const PORT = process.env.PORT ? parseInt(process.env.PORT) : DEFAULT_PORT;
const PORT_RANGE = 10; // Try up to 10 ports starting from the default
const PORT = process.env.PORT ? parseInt(process.env.PORT) : DEFAULT_PORT;
const PORT_RANGE = 300; // Try enough ports to skip OS-excluded ranges.
const engineConfig = loadGameConfig(
process.env.YAML_CONFIG_FILE || './config/engines/yaml.json',
'yaml',
);
// Serve static files from the public directory. During local development the
// browser must not keep stale ES modules, otherwise UI fixes appear to do
@@ -36,9 +50,37 @@ app.use(express.static(path.join(__dirname, '../public'), {
res.setHeader('Expires', '0');
}
}));
app.get('/api/game-config', (_req, res) => {
res.json(clientGameConfig(engineConfig));
});
// Set up game sessions
const gameSessions = new Map<string, GameRunner>();
const nextTurnIds = new Map<string, number>();
function nextTurnId(socketId: string): number {
const current = nextTurnIds.get(socketId) || 1;
nextTurnIds.set(socketId, current + 1);
return current;
}
function createTextTurn(
socketId: string,
text: string,
gameState: TurnResult['gameState'] = {},
suggestions?: string[],
): TurnResult {
const paragraphs = textToParagraphs(text);
return {
turnId: nextTurnId(socketId),
paragraphs,
choices: [],
inputMode: 'text',
gameState,
suggestions,
};
}
function normalizeSaveSlot(slot: unknown): number {
const value = Number(slot);
@@ -46,17 +88,26 @@ function normalizeSaveSlot(slot: unknown): number {
}
async function startDemoGameForSocket(socket: any): Promise<GameRunner> {
nextTurnIds.set(socket.id, 1);
const gameRunner = new GameRunner();
const worldFile = process.env.DEFAULT_WORLD_FILE || './data/worlds/example_world.yml';
const worldFile = projectPath(process.env.DEFAULT_WORLD_FILE || engineConfig.paths.mainGameFile);
await gameRunner.initialize(worldFile);
gameSessions.set(socket.id, gameRunner);
const gameState = gameRunner.getGameState();
socket.emit('gameIntroduction', {
introduction: gameState.world.introduction,
initialRoomDescription: gameRunner.getCurrentRoomDescription(),
currentRoomId: gameState.currentRoomId
const paragraphs = [
...textToParagraphs(gameState.world.introduction),
...textToParagraphs(gameRunner.getCurrentRoomDescription()),
];
socket.emit('narrativeResponse', {
turnId: nextTurnId(socket.id),
paragraphs,
choices: [],
inputMode: 'text',
gameState: {
currentRoomId: gameState.currentRoomId,
},
});
return gameRunner;
@@ -115,8 +166,9 @@ async function handleGameApi(socket: any, method: string, args: unknown[] = [])
}
// Handle socket connections
io.on('connection', (socket) => {
io.on('connection', (socket) => {
console.log(`New client connected: ${socket.id}`);
socket.emit('gameConfig', clientGameConfig(engineConfig));
socket.data.saveGames = new Map<number, any>();
@@ -159,13 +211,9 @@ io.on('connection', (socket) => {
// During typography and animation work, mirror the command back through
// the real socket path so the UI pipeline can be tested end to end.
socket.emit('narrativeResponse', {
text: command,
gameState: {
currentRoomId: gameRunner.getGameState().currentRoomId
},
suggestions: gameRunner.getSuggestions()
});
socket.emit('narrativeResponse', createTextTurn(socket.id, command, {
currentRoomId: gameRunner.getGameState().currentRoomId
}, gameRunner.getSuggestions()));
} catch (error) {
console.error('Error processing command:', error);
@@ -222,15 +270,16 @@ io.on('connection', (socket) => {
console.log(`Client disconnected: ${socket.id}`);
// Clean up game session
if (gameSessions.has(socket.id)) {
gameSessions.delete(socket.id);
}
});
});
if (gameSessions.has(socket.id)) {
gameSessions.delete(socket.id);
}
nextTurnIds.delete(socket.id);
});
});
// Ensure required asset folders exist
function ensureDirectories() {
const dirs = [
const dirs = [
path.join(__dirname, '../public'),
path.join(__dirname, '../public/js'),
path.join(__dirname, '../public/css'),
@@ -240,12 +289,13 @@ function ensureDirectories() {
path.join(__dirname, '../public/fonts')
];
for (const dir of dirs) {
for (const dir of dirs) {
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
}
}
}
ensureConfiguredAssetDirectories(engineConfig);
}
// Copy kokoro-js library from node_modules if not already present
function ensureKokoroJs() {
@@ -276,27 +326,29 @@ export async function startServer(initialPort: number, range: number): Promise<v
console.error('Error copying kokoro-js:', error);
}
// Try to start the server on the current port
await new Promise<void>((resolve, reject) => {
server.listen(currentPort, () => {
console.log(`AI Interactive Fiction web server running on http://localhost:${currentPort}`);
resolve();
});
server.on('error', (error: NodeJS.ErrnoException) => {
// Try to start the server on the current port
await new Promise<void>((resolve, reject) => {
server.removeAllListeners('error');
server.removeAllListeners('listening');
server.once('listening', () => {
console.log(`AI Interactive Fiction web server running on http://localhost:${currentPort}`);
resolve();
});
server.once('error', (error: NodeJS.ErrnoException) => {
// If port is in use, try next port
if (error.code === 'EADDRINUSE') {
console.log(`Port ${currentPort} is in use, trying next port...`);
if (error.code === 'EADDRINUSE' || error.code === 'EACCES') {
console.log(`Port ${currentPort} is unavailable (${error.code}), trying next port...`);
server.close();
currentPort++;
reject();
} else {
// For other errors, log and reject
console.error('Server error:', error);
reject(error);
}
});
});
reject(error);
}
});
server.listen(currentPort);
});
// If we reach here, server started successfully
return;
+26 -11
View File
@@ -9,6 +9,7 @@ import http from 'http';
import { Server as SocketIOServer } from 'socket.io';
import * as dotenv from 'dotenv';
import { existsSync, mkdirSync, copyFileSync } from 'fs';
import { textToParagraphs } from './interfaces/turn-result';
// Load environment variables
dotenv.config();
@@ -21,7 +22,7 @@ const io = new SocketIOServer(server);
// Get port from environment variables or use default
const DEFAULT_PORT = 3001;
const PORT = process.env.PORT ? parseInt(process.env.PORT) : DEFAULT_PORT;
const PORT_RANGE = 10; // Try up to 10 ports starting from the default
const PORT_RANGE = 300; // Try enough ports to skip OS-excluded ranges.
// Serve static files from the public directory. Keep browser modules uncached
// during local development so fixes are visible without a hard cache clear.
@@ -47,15 +48,24 @@ io.on('connection', (socket) => {
console.log(`New client connected: ${socket.id}`);
let currentParagraphIndex = 0;
let gameRunning = false;
let nextTurnId = 1;
const saveGames = new Set<number>();
const startDemoGame = () => {
gameRunning = true;
nextTurnId = 1;
currentParagraphIndex = 0;
socket.emit('gameIntroduction', {
introduction: "::chapter[Interactive Fiction Test]\n\nWelcome to the Interactive Fiction Test. This is a simplified version that sends predefined paragraphs instead of using an LLM.",
initialRoomDescription: TEST_PARAGRAPHS[0],
currentRoomId: "test-room"
socket.emit('narrativeResponse', {
turnId: nextTurnId++,
paragraphs: [
...textToParagraphs("#chapter[Interactive Fiction Test]\n\nWelcome to the Interactive Fiction Test. This is a simplified version that sends predefined paragraphs instead of using an LLM."),
...textToParagraphs(TEST_PARAGRAPHS[0]),
],
choices: [],
inputMode: 'text',
gameState: {
currentRoomId: 'test-room',
},
});
};
@@ -145,7 +155,10 @@ io.on('connection', (socket) => {
// Send narrative response to client
socket.emit('narrativeResponse', {
text: data.command,
turnId: nextTurnId++,
paragraphs: textToParagraphs(String(data.command || '')),
choices: [],
inputMode: 'text',
gameState: {
currentRoomId: "test-room"
},
@@ -214,16 +227,17 @@ async function startServer(initialPort: number, range: number): Promise<void> {
// Try to start the server on the current port
await new Promise<void>((resolve, reject) => {
server.listen(currentPort, () => {
server.removeAllListeners('error');
server.removeAllListeners('listening');
server.once('listening', () => {
console.log(`AI Interactive Fiction TEST SERVER running on http://localhost:${currentPort}`);
console.log('This server is sending predefined test paragraphs instead of using an LLM');
resolve();
});
server.on('error', (error: NodeJS.ErrnoException) => {
server.once('error', (error: NodeJS.ErrnoException) => {
// If port is in use, try next port
if (error.code === 'EADDRINUSE') {
console.log(`Port ${currentPort} is in use, trying next port...`);
if (error.code === 'EADDRINUSE' || error.code === 'EACCES') {
console.log(`Port ${currentPort} is unavailable (${error.code}), trying next port...`);
server.close();
currentPort++;
reject();
@@ -233,6 +247,7 @@ async function startServer(initialPort: number, range: number): Promise<void> {
reject(error);
}
});
server.listen(currentPort);
});
// If we reach here, server started successfully
+45
View File
@@ -0,0 +1,45 @@
import type { StoryTag } from '../interfaces/turn-result';
const LEGACY_TAG_ALIASES: Record<string, string> = {
audio: 'sfx',
audioloop: 'music',
separator: 'section',
};
function normalizeKey(key: string): string {
const normalized = key.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-');
return LEGACY_TAG_ALIASES[normalized] || normalized;
}
export function parseTag(raw: string): StoryTag | null {
const text = String(raw || '').trim().replace(/^#\s*/, '');
if (!text) return null;
const bracketMatch = text.match(/^([A-Za-z][\w-]*)(?:\[([^\]]*)\])?(?:\(([^)]*)\))?$/);
if (bracketMatch) {
const tag: StoryTag = { key: normalizeKey(bracketMatch[1]) };
if (typeof bracketMatch[2] !== 'undefined') tag.value = bracketMatch[2].trim();
if (typeof bracketMatch[3] !== 'undefined') tag.param = bracketMatch[3].trim();
return tag;
}
const bareMatch = text.match(/^[A-Za-z][\w-]*$/);
if (bareMatch) {
return { key: normalizeKey(text) };
}
return null;
}
export function parseTags(rawTags: unknown[] | undefined): StoryTag[] {
if (!Array.isArray(rawTags)) return [];
return rawTags
.map((raw) => parseTag(String(raw ?? '')))
.filter((tag): tag is StoryTag => Boolean(tag));
}
export function getTagValue(tags: StoryTag[], key: string): string | undefined {
const normalizedKey = normalizeKey(key);
return tags.find((tag) => tag.key === normalizedKey)?.value;
}