337 lines
10 KiB
TypeScript
337 lines
10 KiB
TypeScript
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<string, StoryTag>();
|
|
[...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<string, unknown> | 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<string, unknown> {
|
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
}
|
|
}
|