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
+38
View File
@@ -0,0 +1,38 @@
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;
}
export declare function projectPath(relativeOrAbsolutePath: string): string;
export declare function loadGameConfig(configPath: string, engine: EngineName): GameEngineConfig;
export declare function ensureConfiguredAssetDirectories(config: GameEngineConfig): void;
export declare function clientGameConfig(config: GameEngineConfig): {
engine: string;
locale: string;
metadata: GameMetadata;
assets: {
music: string;
sfx: string;
sounds: string;
images: string;
};
};
+94
View File
@@ -0,0 +1,94 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.projectPath = projectPath;
exports.loadGameConfig = loadGameConfig;
exports.ensureConfiguredAssetDirectories = ensureConfiguredAssetDirectories;
exports.clientGameConfig = clientGameConfig;
const path_1 = __importDefault(require("path"));
const fs_1 = require("fs");
const PROJECT_ROOT = path_1.default.resolve(__dirname, '../..');
function fallbackConfig(engine) {
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: '',
},
};
}
function projectPath(relativeOrAbsolutePath) {
return path_1.default.isAbsolute(relativeOrAbsolutePath)
? relativeOrAbsolutePath
: path_1.default.resolve(PROJECT_ROOT, relativeOrAbsolutePath);
}
function loadGameConfig(configPath, engine) {
const absolutePath = projectPath(configPath);
if (!(0, fs_1.existsSync)(absolutePath)) {
console.warn(`[config] Missing ${absolutePath}; using ${engine} defaults.`);
return fallbackConfig(engine);
}
const parsed = JSON.parse((0, fs_1.readFileSync)(absolutePath, 'utf8'));
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 ?? {}),
},
};
}
function ensureConfiguredAssetDirectories(config) {
const directories = [
config.paths.music,
config.paths.sfx,
config.paths.images,
config.paths.inkSource ? path_1.default.dirname(config.paths.inkSource) : undefined,
config.paths.inkCompiled ? path_1.default.dirname(config.paths.inkCompiled) : undefined,
config.paths.mainGameFile ? path_1.default.dirname(config.paths.mainGameFile) : undefined,
config.paths.promptDir,
];
for (const directory of directories) {
if (!directory)
continue;
const absolutePath = projectPath(directory);
if (!(0, fs_1.existsSync)(absolutePath)) {
(0, fs_1.mkdirSync)(absolutePath, { recursive: true });
}
}
}
function clientGameConfig(config) {
return {
engine: config.engine,
locale: config.locale,
metadata: config.metadata,
assets: {
music: '/music/',
sfx: '/sounds/',
sounds: '/sounds/',
images: '/images/',
},
};
}
//# sourceMappingURL=game-config.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"game-config.js","sourceRoot":"","sources":["../../src/config/game-config.ts"],"names":[],"mappings":"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"}
+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
+5 -3
View File
@@ -40,6 +40,7 @@ const dotenv = __importStar(require("dotenv"));
const game_runner_1 = require("./cli/game-runner");
// Import the server module and the startServer function for the web interface
const server_1 = require("./server");
const game_config_1 = require("./config/game-config");
// Load environment variables
console.log('Loading environment variables...');
try {
@@ -59,8 +60,9 @@ async function main() {
console.log('=== AI Interactive Fiction ===');
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 = (0, game_config_1.loadGameConfig)(process.env.YAML_CONFIG_FILE || './config/engines/yaml.json', 'yaml');
const worldFile = (0, game_config_1.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'}`);
@@ -85,7 +87,7 @@ async function main() {
// 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...');
await (0, server_1.startServer)(PORT, PORT_RANGE);
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
+32
View File
@@ -0,0 +1,32 @@
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 declare function textToParagraphs(text: string, tags?: StoryTag[]): ParagraphResult[];
+36
View File
@@ -0,0 +1,36 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.textToParagraphs = textToParagraphs;
/**
* Shared engine-to-client turn protocol.
*/
const tag_parser_1 = require("../utils/tag-parser");
function textToParagraphs(text, tags = []) {
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 = [...tags];
const textLines = [];
let tagPrefixOpen = true;
for (const line of lines) {
const trimmed = line.trim();
const maybeTag = tagPrefixOpen && trimmed.startsWith('#') ? (0, tag_parser_1.parseTag)(trimmed) : null;
if (maybeTag) {
paragraphTags.push(maybeTag);
}
else {
tagPrefixOpen = false;
textLines.push(line);
}
}
return {
text: textLines.join('\n').trim(),
tags: paragraphTags,
};
});
}
//# sourceMappingURL=turn-result.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"turn-result.js","sourceRoot":"","sources":["../../src/interfaces/turn-result.ts"],"names":[],"mappings":";;AAyCA,4CA6BC;AAtED;;GAEG;AACH,oDAA+C;AAsC/C,SAAgB,gBAAgB,CAAC,IAAY,EAAE,OAAmB,EAAE;IAClE,OAAO,MAAM,CAAC,IAAI,IAAI,EAAE,CAAC;SACtB,OAAO,CAAC,QAAQ,EAAE,IAAI,CAAC;SACvB,KAAK,CAAC,QAAQ,CAAC;SACf,GAAG,CAAC,CAAC,SAAS,EAAE,EAAE,CAAC,SAAS,CAAC,IAAI,EAAE,CAAC;SACpC,MAAM,CAAC,OAAO,CAAC;SACf,GAAG,CAAC,CAAC,SAAS,EAAE,EAAE;QACjB,MAAM,KAAK,GAAG,SAAS,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;QACpC,MAAM,aAAa,GAAe,CAAC,GAAG,IAAI,CAAC,CAAC;QAC5C,MAAM,SAAS,GAAa,EAAE,CAAC;QAC/B,IAAI,aAAa,GAAG,IAAI,CAAC;QAEzB,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;YACzB,MAAM,OAAO,GAAG,IAAI,CAAC,IAAI,EAAE,CAAC;YAC5B,MAAM,QAAQ,GAAG,aAAa,IAAI,OAAO,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAA,qBAAQ,EAAC,OAAO,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;YAErF,IAAI,QAAQ,EAAE,CAAC;gBACb,aAAa,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;YAC/B,CAAC;iBAAM,CAAC;gBACN,aAAa,GAAG,KAAK,CAAC;gBACtB,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;YACvB,CAAC;QACH,CAAC;QAED,OAAO;YACL,IAAI,EAAE,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE;YACjC,IAAI,EAAE,aAAa;SACpB,CAAC;IACJ,CAAC,CAAC,CAAC;AACP,CAAC"}
+13
View File
@@ -0,0 +1,13 @@
/**
* Ink Engine Server
*
* Serves the shared client UI and runs a compiled Ink JSON story through the
* unified TurnResult socket protocol.
*/
import http from 'http';
import { Server as SocketIOServer } from 'socket.io';
declare const app: import("express-serve-static-core").Express;
declare const server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>;
declare const io: SocketIOServer<import("socket.io").DefaultEventsMap, import("socket.io").DefaultEventsMap, import("socket.io").DefaultEventsMap, any>;
export declare function startServer(initialPort: number, range: number): Promise<void>;
export { app, server, io };
+272
View File
@@ -0,0 +1,272 @@
"use strict";
/**
* Ink Engine Server
*
* Serves the shared client UI and runs a compiled Ink JSON story through the
* unified TurnResult socket protocol.
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.io = exports.server = exports.app = void 0;
exports.startServer = startServer;
const path_1 = __importDefault(require("path"));
const http_1 = __importDefault(require("http"));
const express_1 = __importDefault(require("express"));
const socket_io_1 = require("socket.io");
const dotenv = __importStar(require("dotenv"));
const fs_1 = require("fs");
const ink_engine_1 = require("./engine/ink-engine");
const game_config_1 = require("./config/game-config");
dotenv.config();
const app = (0, express_1.default)();
exports.app = app;
const server = http_1.default.createServer(app);
exports.server = server;
const io = new socket_io_1.Server(server);
exports.io = io;
const DEFAULT_PORT = 3003;
const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : DEFAULT_PORT;
const PORT_RANGE = 300;
const engineConfig = (0, game_config_1.loadGameConfig)(process.env.INK_CONFIG_FILE || './config/engines/ink.json', 'ink');
app.use(express_1.default.static(path_1.default.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((0, game_config_1.clientGameConfig)(engineConfig));
});
const sessions = new Map();
const saveSlots = new Map();
function normalizeSaveSlot(slot) {
const n = Number(slot);
return Number.isInteger(n) && n > 0 ? n : 1;
}
function getStoryPath() {
return (0, game_config_1.projectPath)(process.env.INK_STORY_FILE ||
engineConfig.paths.inkCompiled ||
engineConfig.paths.mainGameFile);
}
function getSourcePath() {
return (0, game_config_1.projectPath)(process.env.INK_SOURCE_FILE || engineConfig.paths.inkSource || '');
}
function compileConfiguredStory() {
const sourcePath = getSourcePath();
const outputPath = getStoryPath();
const result = (0, ink_engine_1.compileInkSource)(sourcePath, outputPath);
console.log(`[ink] Compiled ${result.sourcePath} -> ${result.outputPath}` +
(result.warningCount > 0 ? ` (${result.warningCount} warnings)` : ''));
}
function getSlots(socketId) {
let slots = saveSlots.get(socketId);
if (!slots) {
slots = new Map();
saveSlots.set(socketId, slots);
}
return slots;
}
function getOrCreateEngine(socketId) {
let engine = sessions.get(socketId);
if (!engine) {
engine = new ink_engine_1.InkEngine(getStoryPath());
sessions.set(socketId, engine);
}
return engine;
}
async function handleGameApi(socket, method, args) {
const slots = getSlots(socket.id);
switch (method) {
case 'newGame':
case 'newGame()': {
const engine = new ink_engine_1.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', (0, game_config_1.clientGameConfig)(engineConfig));
socket.on('gameApi', async (request, respond) => {
try {
const result = await handleGameApi(socket, 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() {
const dirs = [
path_1.default.join(__dirname, '../public'),
path_1.default.join(__dirname, '../public/js'),
path_1.default.join(__dirname, '../public/css'),
path_1.default.join(__dirname, '../public/images'),
path_1.default.join(__dirname, '../public/music'),
path_1.default.join(__dirname, '../public/sounds'),
path_1.default.join(__dirname, '../public/fonts'),
];
for (const dir of dirs) {
if (!(0, fs_1.existsSync)(dir))
(0, fs_1.mkdirSync)(dir, { recursive: true });
}
(0, game_config_1.ensureConfiguredAssetDirectories)(engineConfig);
}
function ensureKokoroJs() {
const source = path_1.default.join(__dirname, '../node_modules/kokoro-js/dist/index.js');
const destination = path_1.default.join(__dirname, '../public/js/kokoro-js.js');
if ((0, fs_1.existsSync)(source) && !(0, fs_1.existsSync)(destination)) {
(0, fs_1.copyFileSync)(source, destination);
}
}
async function startServer(initialPort, range) {
ensureDirectories();
try {
ensureKokoroJs();
}
catch { /* optional */ }
compileConfiguredStory();
if (!(0, fs_1.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((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) => {
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);
});
}
//# sourceMappingURL=server-ink.js.map
+1
View File
File diff suppressed because one or more lines are too long
+27 -17
View File
@@ -58,14 +58,16 @@ const socket_io_1 = require("socket.io");
const dotenv = __importStar(require("dotenv"));
const fs_1 = require("fs");
const zork_llm_engine_1 = require("./engine/zork-llm-engine");
const game_config_1 = require("./config/game-config");
dotenv.config();
const app = (0, express_1.default)();
const server = http_1.default.createServer(app);
const io = new socket_io_1.Server(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 = (0, game_config_1.loadGameConfig)(process.env.ZORK_CONFIG_FILE || './config/engines/zork.json', 'zork');
function debugLog(message, details) {
if (!DEBUG_ENABLED)
return;
@@ -85,18 +87,18 @@ app.use(express_1.default.static(path_1.default.join(__dirname, '../public'), {
res.setHeader('Expires', '0');
},
}));
app.get('/api/game-config', (_req, res) => {
res.json((0, game_config_1.clientGameConfig)(engineConfig));
});
// One engine instance per connected socket
const sessions = new Map();
// Save-game slot maps: socketId → Map<slotNumber, serialisedJson>
const saveSlots = new Map();
function toLegacyNarrative(turn) {
const text = (turn.paragraphs ?? [])
.map((p) => String(p?.text ?? '').trim())
.filter(Boolean)
.join('\n\n');
function toClientTurn(turn) {
return {
text,
...turn,
gameState: {
...turn.gameState,
currentRoomId: turn.gameState?.statusLine,
statusLine: turn.gameState?.statusLine,
},
@@ -109,7 +111,10 @@ function normalizeSaveSlot(slot) {
function getOrCreateEngine(socketId) {
let engine = sessions.get(socketId);
if (!engine) {
engine = new zork_llm_engine_1.ZorkLlmEngine();
engine = new zork_llm_engine_1.ZorkLlmEngine({
storyPath: (0, game_config_1.projectPath)(process.env.ZORK_STORY_FILE || engineConfig.paths.mainGameFile),
promptDir: (0, game_config_1.projectPath)(engineConfig.paths.promptDir || 'data/zork-prompts'),
});
sessions.set(socketId, engine);
}
return engine;
@@ -130,7 +135,7 @@ async function handleGameApi(socket, method, args) {
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,
@@ -146,7 +151,7 @@ async function handleGameApi(socket, method, args) {
}
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 };
}
@@ -184,8 +189,8 @@ async function handleGameApi(socket, method, args) {
}
}
function checkRuntimeConfiguration() {
const storyPath = path_1.default.resolve(process.env.ZORK_STORY_FILE ?? './data/z-code/zork1.bin');
const promptDir = path_1.default.resolve('./data/zork-prompts');
const storyPath = (0, game_config_1.projectPath)(process.env.ZORK_STORY_FILE ?? engineConfig.paths.mainGameFile);
const promptDir = (0, game_config_1.projectPath)(engineConfig.paths.promptDir || 'data/zork-prompts');
const promptFiles = [
'character-generation.yml',
'text-rewriter.yml',
@@ -221,6 +226,7 @@ function checkRuntimeConfiguration() {
}
io.on('connection', (socket) => {
console.log(`[zork] Client connected: ${socket.id}`);
socket.emit('gameConfig', (0, game_config_1.clientGameConfig)(engineConfig));
socket.on('gameApi', async (request, respond) => {
try {
const result = await handleGameApi(socket, String(request?.method ?? ''), Array.isArray(request?.args) ? request.args : []);
@@ -257,7 +263,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);
@@ -291,6 +297,7 @@ function ensureDirectories() {
if (!(0, fs_1.existsSync)(dir))
(0, fs_1.mkdirSync)(dir, { recursive: true });
}
(0, game_config_1.ensureConfiguredAssetDirectories)(engineConfig);
}
function ensureKokoroJs() {
const src = path_1.default.join(__dirname, '../node_modules/kokoro-js/dist/index.js');
@@ -309,13 +316,15 @@ async function startServer(initialPort, range) {
while (port < initialPort + range) {
try {
await new Promise((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) => {
if (err.code === 'EADDRINUSE') {
console.log(`Port ${port} in use, trying ${port + 1}`);
server.once('error', (err) => {
if (err.code === 'EADDRINUSE' || err.code === 'EACCES') {
console.log(`Port ${port} unavailable (${err.code}), trying ${port + 1}...`);
server.close();
port++;
reject();
@@ -324,6 +333,7 @@ async function startServer(initialPort, range) {
reject(err);
}
});
server.listen(port);
});
return;
}
+1 -1
View File
File diff suppressed because one or more lines are too long
+51 -17
View File
@@ -49,6 +49,8 @@ const socket_io_1 = require("socket.io");
const dotenv = __importStar(require("dotenv"));
const game_runner_1 = require("./cli/game-runner");
const fs_1 = require("fs");
const turn_result_1 = require("./interfaces/turn-result");
const game_config_1 = require("./config/game-config");
// Load environment variables
dotenv.config();
// Create Express application
@@ -61,7 +63,8 @@ exports.io = io;
// 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.
const engineConfig = (0, game_config_1.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
// nothing until a hard cache clear.
@@ -74,22 +77,51 @@ app.use(express_1.default.static(path_1.default.join(__dirname, '../public'), {
res.setHeader('Expires', '0');
}
}));
app.get('/api/game-config', (_req, res) => {
res.json((0, game_config_1.clientGameConfig)(engineConfig));
});
// Set up game sessions
const gameSessions = new Map();
const nextTurnIds = new Map();
function nextTurnId(socketId) {
const current = nextTurnIds.get(socketId) || 1;
nextTurnIds.set(socketId, current + 1);
return current;
}
function createTextTurn(socketId, text, gameState = {}, suggestions) {
const paragraphs = (0, turn_result_1.textToParagraphs)(text);
return {
turnId: nextTurnId(socketId),
paragraphs,
choices: [],
inputMode: 'text',
gameState,
suggestions,
};
}
function normalizeSaveSlot(slot) {
const value = Number(slot);
return Number.isInteger(value) && value > 0 ? value : 1;
}
async function startDemoGameForSocket(socket) {
nextTurnIds.set(socket.id, 1);
const gameRunner = new game_runner_1.GameRunner();
const worldFile = process.env.DEFAULT_WORLD_FILE || './data/worlds/example_world.yml';
const worldFile = (0, game_config_1.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 = [
...(0, turn_result_1.textToParagraphs)(gameState.world.introduction),
...(0, turn_result_1.textToParagraphs)(gameRunner.getCurrentRoomDescription()),
];
socket.emit('narrativeResponse', {
turnId: nextTurnId(socket.id),
paragraphs,
choices: [],
inputMode: 'text',
gameState: {
currentRoomId: gameState.currentRoomId,
},
});
return gameRunner;
}
@@ -140,6 +172,7 @@ async function handleGameApi(socket, method, args = []) {
// Handle socket connections
io.on('connection', (socket) => {
console.log(`New client connected: ${socket.id}`);
socket.emit('gameConfig', (0, game_config_1.clientGameConfig)(engineConfig));
socket.data.saveGames = new Map();
socket.on('gameApi', async (request, respond) => {
try {
@@ -176,13 +209,9 @@ io.on('connection', (socket) => {
const command = String(data?.command || '').trim();
// 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);
@@ -232,6 +261,7 @@ io.on('connection', (socket) => {
if (gameSessions.has(socket.id)) {
gameSessions.delete(socket.id);
}
nextTurnIds.delete(socket.id);
});
});
// Ensure required asset folders exist
@@ -250,6 +280,7 @@ function ensureDirectories() {
(0, fs_1.mkdirSync)(dir, { recursive: true });
}
}
(0, game_config_1.ensureConfiguredAssetDirectories)(engineConfig);
}
// Copy kokoro-js library from node_modules if not already present
function ensureKokoroJs() {
@@ -278,14 +309,16 @@ async function startServer(initialPort, range) {
}
// Try to start the server on the current port
await new Promise((resolve, reject) => {
server.listen(currentPort, () => {
server.removeAllListeners('error');
server.removeAllListeners('listening');
server.once('listening', () => {
console.log(`AI Interactive Fiction web server running on http://localhost:${currentPort}`);
resolve();
});
server.on('error', (error) => {
server.once('error', (error) => {
// 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();
@@ -296,6 +329,7 @@ async function startServer(initialPort, range) {
reject(error);
}
});
server.listen(currentPort);
});
// If we reach here, server started successfully
return;
+1 -1
View File
File diff suppressed because one or more lines are too long
+26 -10
View File
@@ -47,6 +47,7 @@ const http_1 = __importDefault(require("http"));
const socket_io_1 = require("socket.io");
const dotenv = __importStar(require("dotenv"));
const fs_1 = require("fs");
const turn_result_1 = require("./interfaces/turn-result");
// Load environment variables
dotenv.config();
// Create Express application
@@ -59,7 +60,7 @@ exports.io = io;
// 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.
app.use(express_1.default.static(path_1.default.join(__dirname, '../public'), {
@@ -82,14 +83,23 @@ io.on('connection', (socket) => {
console.log(`New client connected: ${socket.id}`);
let currentParagraphIndex = 0;
let gameRunning = false;
let nextTurnId = 1;
const saveGames = new Set();
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: [
...(0, turn_result_1.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."),
...(0, turn_result_1.textToParagraphs)(TEST_PARAGRAPHS[0]),
],
choices: [],
inputMode: 'text',
gameState: {
currentRoomId: 'test-room',
},
});
};
const normalizeSaveSlot = (slot) => {
@@ -174,7 +184,10 @@ io.on('connection', (socket) => {
console.log(`Received command: ${data.command}`);
// Send narrative response to client
socket.emit('narrativeResponse', {
text: data.command,
turnId: nextTurnId++,
paragraphs: (0, turn_result_1.textToParagraphs)(String(data.command || '')),
choices: [],
inputMode: 'text',
gameState: {
currentRoomId: "test-room"
},
@@ -235,15 +248,17 @@ async function startServer(initialPort, range) {
}
// Try to start the server on the current port
await new Promise((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) => {
server.once('error', (error) => {
// 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();
@@ -254,6 +269,7 @@ async function startServer(initialPort, range) {
reject(error);
}
});
server.listen(currentPort);
});
// If we reach here, server started successfully
return;
+1 -1
View File
File diff suppressed because one or more lines are too long
+4
View File
@@ -0,0 +1,4 @@
import type { StoryTag } from '../interfaces/turn-result';
export declare function parseTag(raw: string): StoryTag | null;
export declare function parseTags(rawTags: unknown[] | undefined): StoryTag[];
export declare function getTagValue(tags: StoryTag[], key: string): string | undefined;
+45
View File
@@ -0,0 +1,45 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.parseTag = parseTag;
exports.parseTags = parseTags;
exports.getTagValue = getTagValue;
const LEGACY_TAG_ALIASES = {
audio: 'sfx',
audioloop: 'music',
separator: 'section',
};
function normalizeKey(key) {
const normalized = key.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-');
return LEGACY_TAG_ALIASES[normalized] || normalized;
}
function parseTag(raw) {
const text = String(raw || '').trim().replace(/^#\s*/, '');
if (!text)
return null;
const bracketMatch = text.match(/^([A-Za-z][\w-]*)(?:\[([^\]]*)\])?(?:\(([^)]*)\))?$/);
if (bracketMatch) {
const tag = { 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;
}
function parseTags(rawTags) {
if (!Array.isArray(rawTags))
return [];
return rawTags
.map((raw) => parseTag(String(raw ?? '')))
.filter((tag) => Boolean(tag));
}
function getTagValue(tags, key) {
const normalizedKey = normalizeKey(key);
return tags.find((tag) => tag.key === normalizedKey)?.value;
}
//# sourceMappingURL=tag-parser.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"tag-parser.js","sourceRoot":"","sources":["../../src/utils/tag-parser.ts"],"names":[],"mappings":";;AAaA,4BAkBC;AAED,8BAMC;AAED,kCAGC;AA1CD,MAAM,kBAAkB,GAA2B;IACjD,KAAK,EAAE,KAAK;IACZ,SAAS,EAAE,OAAO;IAClB,SAAS,EAAE,SAAS;CACrB,CAAC;AAEF,SAAS,YAAY,CAAC,GAAW;IAC/B,MAAM,UAAU,GAAG,GAAG,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC,OAAO,CAAC,eAAe,EAAE,GAAG,CAAC,CAAC;IAC1E,OAAO,kBAAkB,CAAC,UAAU,CAAC,IAAI,UAAU,CAAC;AACtD,CAAC;AAED,SAAgB,QAAQ,CAAC,GAAW;IAClC,MAAM,IAAI,GAAG,MAAM,CAAC,GAAG,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC,OAAO,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC;IAC3D,IAAI,CAAC,IAAI;QAAE,OAAO,IAAI,CAAC;IAEvB,MAAM,YAAY,GAAG,IAAI,CAAC,KAAK,CAAC,qDAAqD,CAAC,CAAC;IACvF,IAAI,YAAY,EAAE,CAAC;QACjB,MAAM,GAAG,GAAa,EAAE,GAAG,EAAE,YAAY,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;QAC7D,IAAI,OAAO,YAAY,CAAC,CAAC,CAAC,KAAK,WAAW;YAAE,GAAG,CAAC,KAAK,GAAG,YAAY,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;QAC/E,IAAI,OAAO,YAAY,CAAC,CAAC,CAAC,KAAK,WAAW;YAAE,GAAG,CAAC,KAAK,GAAG,YAAY,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;QAC/E,OAAO,GAAG,CAAC;IACb,CAAC;IAED,MAAM,SAAS,GAAG,IAAI,CAAC,KAAK,CAAC,kBAAkB,CAAC,CAAC;IACjD,IAAI,SAAS,EAAE,CAAC;QACd,OAAO,EAAE,GAAG,EAAE,YAAY,CAAC,IAAI,CAAC,EAAE,CAAC;IACrC,CAAC;IAED,OAAO,IAAI,CAAC;AACd,CAAC;AAED,SAAgB,SAAS,CAAC,OAA8B;IACtD,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,OAAO,CAAC;QAAE,OAAO,EAAE,CAAC;IAEvC,OAAO,OAAO;SACX,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,QAAQ,CAAC,MAAM,CAAC,GAAG,IAAI,EAAE,CAAC,CAAC,CAAC;SACzC,MAAM,CAAC,CAAC,GAAG,EAAmB,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC;AACpD,CAAC;AAED,SAAgB,WAAW,CAAC,IAAgB,EAAE,GAAW;IACvD,MAAM,aAAa,GAAG,YAAY,CAAC,GAAG,CAAC,CAAC;IACxC,OAAO,IAAI,CAAC,IAAI,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,GAAG,KAAK,aAAa,CAAC,EAAE,KAAK,CAAC;AAC9D,CAAC"}