Add ink integration UI and media playback
This commit is contained in:
@@ -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/',
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user