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
+20
View File
@@ -0,0 +1,20 @@
import { TurnResult } from '../interfaces/turn-result';
export interface InkCompileResult {
sourcePath: string;
outputPath: string;
warningCount: number;
}
export declare function compileInkSource(sourcePath: string, outputPath: string): InkCompileResult;
export declare class InkEngine {
private readonly storyPath;
private story;
private nextTurnId;
constructor(storyPath: string);
isRunning(): boolean;
newGame(): TurnResult;
chooseChoice(choiceIndex: number): TurnResult;
saveGame(): string;
loadGame(savedState: string): TurnResult;
private loadStory;
private continueStory;
}
+143
View File
@@ -0,0 +1,143 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.InkEngine = void 0;
exports.compileInkSource = compileInkSource;
const fs_1 = require("fs");
const path_1 = __importDefault(require("path"));
const inkjs_1 = require("inkjs");
const tag_parser_1 = require("../utils/tag-parser");
const { Compiler } = require('inkjs/full');
function compileInkSource(sourcePath, outputPath) {
const resolvedSource = path_1.default.resolve(sourcePath);
const resolvedOutput = path_1.default.resolve(outputPath);
if (!(0, fs_1.existsSync)(resolvedSource)) {
throw new Error(`Ink source file not found: ${resolvedSource}`);
}
const warnings = [];
const errors = [];
const source = (0, fs_1.readFileSync)(resolvedSource, 'utf8').replace(/^\uFEFF/, '');
const sourceDir = path_1.default.dirname(resolvedSource);
const fileHandler = {
ResolveInkFilename: (filename) => path_1.default.isAbsolute(filename) ? filename : path_1.default.resolve(sourceDir, filename),
LoadInkFileContents: (filename) => (0, fs_1.readFileSync)(path_1.default.isAbsolute(filename) ? filename : path_1.default.resolve(sourceDir, filename), 'utf8')
.replace(/^\uFEFF/, ''),
};
const compiler = new Compiler(source, {
sourceFilename: resolvedSource,
fileHandler,
errorHandler: (message, type) => {
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}`));
}
(0, fs_1.mkdirSync)(path_1.default.dirname(resolvedOutput), { recursive: true });
(0, fs_1.writeFileSync)(resolvedOutput, story.ToJson(), 'utf8');
return {
sourcePath: resolvedSource,
outputPath: resolvedOutput,
warningCount: warnings.length,
};
}
class InkEngine {
constructor(storyPath) {
this.storyPath = storyPath;
this.story = null;
this.nextTurnId = 1;
}
isRunning() {
if (!this.story)
return false;
return this.story.canContinue || this.story.currentChoices.length > 0;
}
newGame() {
this.story = this.loadStory();
this.nextTurnId = 1;
return this.continueStory();
}
chooseChoice(choiceIndex) {
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() {
if (!this.story) {
throw new Error('No active Ink story to save');
}
return this.story.state.toJson();
}
loadGame(savedState) {
this.story = this.loadStory();
this.story.state.LoadJson(savedState);
return this.continueStory();
}
loadStory() {
const resolvedPath = path_1.default.resolve(this.storyPath);
if (!(0, fs_1.existsSync)(resolvedPath)) {
throw new Error(`Ink story file not found: ${resolvedPath}`);
}
const storyJson = JSON.parse((0, fs_1.readFileSync)(resolvedPath, 'utf8'));
return new inkjs_1.Story(storyJson);
}
continueStory() {
if (!this.story) {
throw new Error('No active Ink story');
}
const paragraphs = [];
const globalTags = [];
while (this.story.canContinue) {
const rawText = this.story.Continue();
const text = String(rawText || '').trim();
const tags = (0, tag_parser_1.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) => {
const tags = (0, tag_parser_1.parseTags)(choice.tags || []);
const category = (0, tag_parser_1.getTagValue)(tags, 'action');
const letter = (0, tag_parser_1.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,
};
}
}
exports.InkEngine = InkEngine;
//# sourceMappingURL=ink-engine.js.map
+1
View File
File diff suppressed because one or more lines are too long
+7 -13
View File
@@ -12,6 +12,7 @@
* ZORK_HISTORY_SIZE player-facing outputs stored per room (default: 5)
* OPENROUTER_API_KEY, OPENROUTER_MODEL required
*/
import { TurnResult } from '../interfaces/turn-result';
export interface ZorkSession {
characterDescription: string;
notes: string[];
@@ -26,18 +27,7 @@ export interface ZorkSession {
currentRoom: string;
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;
export declare class ZorkLlmEngine {
private zork;
private session;
@@ -48,9 +38,13 @@ export declare class ZorkLlmEngine {
private llmCallCounter;
private maxRetries;
private historySize;
private nextTurnId;
private storyPath;
private static readonly DEPRECATED_MODEL_REPLACEMENTS;
constructor();
constructor(options?: {
storyPath?: string;
promptDir?: string;
});
private createCompletion;
private resolveFallbackModel;
isRunning(): boolean;
+9 -4
View File
@@ -58,6 +58,7 @@ const os = __importStar(require("os"));
const yaml = __importStar(require("js-yaml"));
const axios_1 = __importDefault(require("axios"));
const dotenv = __importStar(require("dotenv"));
const turn_result_1 = require("../interfaces/turn-result");
dotenv.config();
const DEBUG_ENABLED = /^(1|true|yes|on)$/i.test(process.env.ZORK_DEBUG ?? '');
function debugLog(message, details) {
@@ -348,11 +349,12 @@ function logLlmError(scope, err) {
// ZorkLlmEngine
// ---------------------------------------------------------------------------
class ZorkLlmEngine {
constructor() {
constructor(options = {}) {
this.zork = new ZorkProcess();
this.session = null;
this.resolvedFallbackModel = null;
this.llmCallCounter = 0;
this.nextTurnId = 1;
const apiKey = process.env.OPENROUTER_API_KEY;
const model = process.env.OPENROUTER_MODEL;
if (!apiKey || !model) {
@@ -372,8 +374,8 @@ 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');
const promptDir = path.resolve('./data/zork-prompts');
this.storyPath = path.resolve(options.storyPath ?? process.env.ZORK_STORY_FILE ?? './data/z-code/zork1.bin');
const promptDir = path.resolve(options.promptDir ?? './data/zork-prompts');
this.prompts = loadPrompts(promptDir);
this.llm = axios_1.default.create({
baseURL: 'https://openrouter.ai/api/v1',
@@ -486,6 +488,7 @@ class ZorkLlmEngine {
// Kill any existing game
if (this.zork.isAlive())
this.zork.kill();
this.nextTurnId = 1;
if (!fs.existsSync(this.storyPath)) {
throw new Error(`Story file not found: ${this.storyPath}\n` +
'Place zork1.bin in ./data/z-code/ (see README in that folder).');
@@ -968,8 +971,10 @@ class ZorkLlmEngine {
const alive = this.zork.isAlive();
if (!alive && this.session)
this.session.running = false;
const paragraphs = (0, turn_result_1.textToParagraphs)(text);
return {
paragraphs: [{ text, tags: [] }],
turnId: this.nextTurnId++,
paragraphs,
choices: [],
inputMode: alive ? 'text' : 'end',
gameState: { statusLine: this.session?.currentRoom },
+1 -1
View File
File diff suppressed because one or more lines are too long