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