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; private storyJson: any = null; private readonly choicePreviewTagKeys = new Set(['action', 'key', 'letter', 'optional', 'gated', 'sort']); 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 JSON.stringify({ inkState: this.story.state.toJson(), nextTurnId: this.nextTurnId, }); } resumeGame(savedState: string): void { this.restoreState(savedState); } loadGame(savedState: string): TurnResult { this.restoreState(savedState); return this.continueStory(); } private restoreState(savedState: string): void { this.story = this.loadStory(); let inkState = savedState; try { const parsed = JSON.parse(savedState); if (parsed && typeof parsed.inkState === 'string') { inkState = parsed.inkState; if (Number.isInteger(parsed.nextTurnId)) { this.nextTurnId = Math.max(1, parsed.nextTurnId); } } } catch { // Backward compatibility with raw Ink state JSON. } this.story.state.LoadJson(inkState); } private loadStory(): Story { const resolvedPath = path.resolve(this.storyPath); if (!existsSync(resolvedPath)) { throw new Error(`Ink story file not found: ${resolvedPath}`); } this.storyJson = JSON.parse(readFileSync(resolvedPath, 'utf8')); return new Story(this.storyJson); } private continueStory(): TurnResult { if (!this.story) { throw new Error('No active Ink story'); } const paragraphs: TurnResult['paragraphs'] = []; const globalTags: StoryTag[] = []; const turnTags: StoryTag[] = []; while (this.story.canContinue) { const rawText = this.story.Continue(); const text = String(rawText || '').trim(); const tags = parseTags(this.story.currentTags || []); turnTags.push(...tags); 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 = this.getChoiceTags(choice); const category = getTagValue(tags, 'action'); const letter = getTagValue(tags, 'letter') || getTagValue(tags, 'key'); return { index: choice.index, text: String(choice.text || '').trim(), tags, category, letter, }; }); const inputMode = choices.length > 0 ? 'choice' : 'end'; const gameState: TurnResult['gameState'] = {}; if (inputMode === 'end') { const errorTag = turnTags.find((tag) => tag.key === 'error'); const scoreTag = turnTags.find((tag) => tag.key === 'score'); if (!errorTag && !scoreTag) { const message = 'Ink story ended without an explicit #score ending tag.'; const generatedErrorTag: StoryTag = { key: 'error', value: message }; globalTags.push(generatedErrorTag); turnTags.push(generatedErrorTag); } const finalErrorTag = turnTags.find((tag) => tag.key === 'error'); const finalScoreTag = turnTags.find((tag) => tag.key === 'score'); if (finalErrorTag) { gameState.endState = { type: 'error', message: finalErrorTag.value || finalErrorTag.param, }; } else if (finalScoreTag) { const numericScore = Number(finalScoreTag?.value); if (Number.isFinite(numericScore)) { gameState.score = numericScore; } gameState.endState = { type: 'intended', message: finalScoreTag.value || finalScoreTag.param, }; } } return { turnId: this.nextTurnId++, paragraphs, choices, inputMode, globalTags: globalTags.length > 0 ? globalTags : undefined, gameState: Object.keys(gameState).length > 0 ? gameState : undefined, }; } private getChoiceTags(choice: any): StoryTag[] { const directTags = parseTags(choice?.tags || []); const previewTags = this.extractChoicePreviewTags(choice); const merged = new Map(); [...previewTags, ...directTags].forEach((tag) => { merged.set(`${tag.key}:${tag.value || ''}:${tag.param || ''}`, tag); }); return Array.from(merged.values()); } private extractChoicePreviewTags(choice: any): StoryTag[] { const pathString = String(choice?.pathStringOnChoice || choice?.targetPath?.toString?.() || '').trim(); if (!pathString || !this.storyJson) return []; const container = this.resolveInkPath(pathString); if (!Array.isArray(container)) return []; const tags: StoryTag[] = []; for (let index = 0; index < container.length; index += 1) { const token = container[index]; if (typeof token === 'string' && token.replace(/^\^/, '').trim() === '') continue; if (token === '\n') continue; if (token !== '#') break; const rawParts: string[] = []; index += 1; while (index < container.length && container[index] !== '/#') { const part = container[index]; if (typeof part === 'string') { rawParts.push(part.replace(/^\^/, '')); } index += 1; } const tag = parseTags([rawParts.join('').trim()])[0]; if (tag && this.choicePreviewTagKeys.has(tag.key)) { tags.push(tag); } } return tags; } private resolveInkPath(pathString: string): any { const parts = pathString.split('.').filter(Boolean); let node: any = this.storyJson?.root; for (const part of parts) { if (!node) return null; if (Array.isArray(node) && /^\d+$/.test(part)) { node = node[Number(part)]; } else if (Array.isArray(node)) { node = this.findNamedInkChild(node, part); } else if (this.isNamedContainerMap(node) && part in node) { node = node[part]; } else { return null; } } return node; } private findNamedInkChild(container: any[], part: string): any { for (let index = container.length - 1; index >= 0; index -= 1) { const item = container[index]; if (this.isNamedContainerMap(item) && part in item) { return item[part]; } if (!Array.isArray(item)) continue; const namedMap = this.getInkContainerMap(item); if (namedMap?.['#n'] === part) { return item; } if (namedMap && part in namedMap) { return namedMap[part]; } } return null; } private getInkContainerMap(container: any[]): Record | null { for (let index = container.length - 1; index >= 0; index -= 1) { const item = container[index]; if (this.isNamedContainerMap(item)) { return item; } } return null; } private isNamedContainerMap(value: unknown): value is Record { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } }