feat: Integrate Kokoro TTS with WebGPU and fallback
This commit is contained in:
+264
-264
@@ -1,265 +1,265 @@
|
||||
/**
|
||||
* Command-line interface for running the interactive fiction game
|
||||
*/
|
||||
|
||||
import * as readline from 'readline';
|
||||
import * as path from 'path';
|
||||
import * as dotenv from 'dotenv';
|
||||
import { TextAdventureEngine } from '../engine/game-engine';
|
||||
import { OpenRouterProvider } from '../llm/openrouter-provider';
|
||||
import { ActionRequest, NarrativeRequest } from '../interfaces/llm';
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
|
||||
export class GameRunner {
|
||||
private engine: TextAdventureEngine;
|
||||
private llmProvider: OpenRouterProvider;
|
||||
private rl: readline.Interface | null = null;
|
||||
private gameContext: string = '';
|
||||
private gameHistory: string[] = [];
|
||||
private suggestedCommands: string[] = [];
|
||||
|
||||
constructor() {
|
||||
this.engine = new TextAdventureEngine();
|
||||
this.llmProvider = new OpenRouterProvider();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the game
|
||||
*/
|
||||
public async initialize(worldPath: string): Promise<void> {
|
||||
console.log('Initializing game...');
|
||||
|
||||
// Initialize LLM provider
|
||||
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||
const model = process.env.OPENROUTER_MODEL;
|
||||
|
||||
if (!apiKey || !model) {
|
||||
throw new Error('Missing required environment variables: OPENROUTER_API_KEY and/or OPENROUTER_MODEL');
|
||||
}
|
||||
|
||||
await this.llmProvider.initialize({
|
||||
apiKey,
|
||||
model,
|
||||
temperature: 0.7,
|
||||
maxTokens: 800
|
||||
});
|
||||
|
||||
// Load the world
|
||||
const resolvedPath = path.resolve(worldPath);
|
||||
console.log(`Loading world from ${resolvedPath}...`);
|
||||
await this.engine.loadWorld(resolvedPath);
|
||||
|
||||
console.log('Game initialized successfully!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the game in CLI mode
|
||||
*/
|
||||
public async start(): Promise<void> {
|
||||
// Create readline interface for CLI mode
|
||||
this.rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
});
|
||||
|
||||
try {
|
||||
// Display introduction
|
||||
const introText = await this.engine.start();
|
||||
console.log('\n' + introText + '\n');
|
||||
|
||||
// Look at initial room
|
||||
const initialLook = this.engine.processAction({ action: 'look', confidence: 1 });
|
||||
|
||||
// Generate narrative description
|
||||
const narrativeRequest: NarrativeRequest = {
|
||||
action: 'look',
|
||||
result: initialLook.message,
|
||||
roomDescription: this.engine.getCurrentRoomDescription(),
|
||||
visibleObjects: this.engine.getVisibleObjects(),
|
||||
visibleCharacters: this.engine.getVisibleCharacters(),
|
||||
tone: 'descriptive'
|
||||
};
|
||||
|
||||
const narrative = await this.llmProvider.generateNarrative(narrativeRequest);
|
||||
console.log('\n' + narrative.text + '\n');
|
||||
|
||||
// Store suggestions if available
|
||||
if (narrative.suggestions && narrative.suggestions.length > 0) {
|
||||
this.suggestedCommands = narrative.suggestions;
|
||||
}
|
||||
|
||||
// Update game context
|
||||
this.updateGameContext(narrative.text);
|
||||
|
||||
// Start the game loop
|
||||
this.gameLoop();
|
||||
} catch (error) {
|
||||
console.error('Error starting game:', error);
|
||||
this.end();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The main game loop for CLI mode
|
||||
*/
|
||||
private gameLoop(): void {
|
||||
if (!this.rl) return;
|
||||
|
||||
this.rl.question('> ', async (input) => {
|
||||
if (input.toLowerCase() === 'quit' || input.toLowerCase() === 'exit') {
|
||||
this.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await this.processCommand(input);
|
||||
console.log('\n' + response + '\n');
|
||||
|
||||
// Continue the game loop
|
||||
this.gameLoop();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a player command and return the narrative response
|
||||
* Used by both CLI and web interfaces
|
||||
*/
|
||||
public async processCommand(input: string): Promise<string> {
|
||||
try {
|
||||
// Process player input
|
||||
const actionRequest: ActionRequest = {
|
||||
playerInput: input,
|
||||
currentRoom: this.engine.getWorldModel().rooms[this.engine.getCurrentState().currentRoomId].name,
|
||||
visibleObjects: this.engine.getVisibleObjects().map(id => this.engine.getWorldModel().objects[id].name),
|
||||
visibleCharacters: this.engine.getVisibleCharacters().map(id => this.engine.getWorldModel().characters[id].name),
|
||||
possibleActions: this.engine.getAvailableActions(),
|
||||
inventory: this.engine.getCurrentState().inventory.map(id => this.engine.getWorldModel().objects[id].name),
|
||||
gameContext: this.gameContext
|
||||
};
|
||||
|
||||
if (this.rl) {
|
||||
console.log('Thinking...');
|
||||
}
|
||||
|
||||
// Translate player input to action
|
||||
const action = await this.llmProvider.translateAction(actionRequest);
|
||||
|
||||
// Process the action in the game engine
|
||||
const actionResult = this.engine.processAction(action);
|
||||
|
||||
// If state changed, update it
|
||||
if (actionResult.stateChanged && actionResult.newState) {
|
||||
this.engine.getCurrentState().currentRoomId = actionResult.newState.currentRoomId;
|
||||
this.engine.getCurrentState().inventory = actionResult.newState.inventory;
|
||||
this.engine.getCurrentState().visitedRooms = actionResult.newState.visitedRooms;
|
||||
this.engine.getCurrentState().flags = actionResult.newState.flags;
|
||||
this.engine.getCurrentState().counters = actionResult.newState.counters;
|
||||
}
|
||||
|
||||
// Generate narrative description
|
||||
const narrativeRequest: NarrativeRequest = {
|
||||
action: `${action.action}${action.object ? ' ' + action.object : ''}${action.target ? ' on ' + action.target : ''}`,
|
||||
result: actionResult.message,
|
||||
roomDescription: this.engine.getCurrentRoomDescription(),
|
||||
visibleObjects: this.engine.getVisibleObjects().map(id => this.engine.getWorldModel().objects[id].name),
|
||||
visibleCharacters: this.engine.getVisibleCharacters().map(id => this.engine.getWorldModel().characters[id].name),
|
||||
previousContext: this.gameHistory.slice(-3).join('\n'),
|
||||
tone: 'descriptive'
|
||||
};
|
||||
|
||||
const narrative = await this.llmProvider.generateNarrative(narrativeRequest);
|
||||
|
||||
// Store suggestions if available
|
||||
if (narrative.suggestions && narrative.suggestions.length > 0) {
|
||||
this.suggestedCommands = narrative.suggestions;
|
||||
}
|
||||
|
||||
// Update game context with the new narrative
|
||||
this.updateGameContext(narrative.text);
|
||||
|
||||
// Return the narrative text
|
||||
return narrative.text;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error processing input:', error);
|
||||
return 'Something went wrong. Please try again.';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* End the game
|
||||
*/
|
||||
public end(): void {
|
||||
console.log('\nThanks for playing!');
|
||||
if (this.rl) {
|
||||
this.rl.close();
|
||||
this.rl = null;
|
||||
}
|
||||
this.engine.end();
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the game context with new narrative
|
||||
*/
|
||||
private updateGameContext(narrative: string): void {
|
||||
// Add to history
|
||||
this.gameHistory.push(narrative);
|
||||
|
||||
// Keep history limited to last 10 entries
|
||||
if (this.gameHistory.length > 10) {
|
||||
this.gameHistory.shift();
|
||||
}
|
||||
|
||||
// Update current context (last 5 entries)
|
||||
this.gameContext = this.gameHistory.slice(-5).join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current game state
|
||||
* Used by web interface
|
||||
*/
|
||||
public getGameState() {
|
||||
return {
|
||||
world: this.engine.getWorldModel(),
|
||||
currentRoomId: this.engine.getCurrentState().currentRoomId,
|
||||
inventory: this.engine.getCurrentState().inventory,
|
||||
visitedRooms: this.engine.getCurrentState().visitedRooms,
|
||||
flags: this.engine.getCurrentState().flags,
|
||||
counters: this.engine.getCurrentState().counters
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current room description
|
||||
* Used by web interface
|
||||
*/
|
||||
public getCurrentRoomDescription(): string {
|
||||
const roomId = this.engine.getCurrentState().currentRoomId;
|
||||
return this.engine.getWorldModel().rooms[roomId].description;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get suggested actions for the current game state
|
||||
* Used by web interface
|
||||
*/
|
||||
public getSuggestions(): string[] {
|
||||
return this.suggestedCommands;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a saved game state
|
||||
* Used by web interface
|
||||
*/
|
||||
public loadGameState(savedState: any): void {
|
||||
// Set the current state to match the saved state
|
||||
this.engine.getCurrentState().currentRoomId = savedState.currentRoomId;
|
||||
this.engine.getCurrentState().inventory = savedState.inventory;
|
||||
this.engine.getCurrentState().visitedRooms = savedState.visitedRooms;
|
||||
this.engine.getCurrentState().flags = savedState.flags;
|
||||
this.engine.getCurrentState().counters = savedState.counters;
|
||||
}
|
||||
/**
|
||||
* Command-line interface for running the interactive fiction game
|
||||
*/
|
||||
|
||||
import * as readline from 'readline';
|
||||
import * as path from 'path';
|
||||
import * as dotenv from 'dotenv';
|
||||
import { TextAdventureEngine } from '../engine/game-engine';
|
||||
import { OpenRouterProvider } from '../llm/openrouter-provider';
|
||||
import { ActionRequest, NarrativeRequest } from '../interfaces/llm';
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
|
||||
export class GameRunner {
|
||||
private engine: TextAdventureEngine;
|
||||
private llmProvider: OpenRouterProvider;
|
||||
private rl: readline.Interface | null = null;
|
||||
private gameContext: string = '';
|
||||
private gameHistory: string[] = [];
|
||||
private suggestedCommands: string[] = [];
|
||||
|
||||
constructor() {
|
||||
this.engine = new TextAdventureEngine();
|
||||
this.llmProvider = new OpenRouterProvider();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the game
|
||||
*/
|
||||
public async initialize(worldPath: string): Promise<void> {
|
||||
console.log('Initializing game...');
|
||||
|
||||
// Initialize LLM provider
|
||||
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||
const model = process.env.OPENROUTER_MODEL;
|
||||
|
||||
if (!apiKey || !model) {
|
||||
throw new Error('Missing required environment variables: OPENROUTER_API_KEY and/or OPENROUTER_MODEL');
|
||||
}
|
||||
|
||||
await this.llmProvider.initialize({
|
||||
apiKey,
|
||||
model,
|
||||
temperature: 0.7,
|
||||
maxTokens: 800
|
||||
});
|
||||
|
||||
// Load the world
|
||||
const resolvedPath = path.resolve(worldPath);
|
||||
console.log(`Loading world from ${resolvedPath}...`);
|
||||
await this.engine.loadWorld(resolvedPath);
|
||||
|
||||
console.log('Game initialized successfully!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the game in CLI mode
|
||||
*/
|
||||
public async start(): Promise<void> {
|
||||
// Create readline interface for CLI mode
|
||||
this.rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
});
|
||||
|
||||
try {
|
||||
// Display introduction
|
||||
const introText = await this.engine.start();
|
||||
console.log('\n' + introText + '\n');
|
||||
|
||||
// Look at initial room
|
||||
const initialLook = this.engine.processAction({ action: 'look', confidence: 1 });
|
||||
|
||||
// Generate narrative description
|
||||
const narrativeRequest: NarrativeRequest = {
|
||||
action: 'look',
|
||||
result: initialLook.message,
|
||||
roomDescription: this.engine.getCurrentRoomDescription(),
|
||||
visibleObjects: this.engine.getVisibleObjects(),
|
||||
visibleCharacters: this.engine.getVisibleCharacters(),
|
||||
tone: 'descriptive'
|
||||
};
|
||||
|
||||
const narrative = await this.llmProvider.generateNarrative(narrativeRequest);
|
||||
console.log('\n' + narrative.text + '\n');
|
||||
|
||||
// Store suggestions if available
|
||||
if (narrative.suggestions && narrative.suggestions.length > 0) {
|
||||
this.suggestedCommands = narrative.suggestions;
|
||||
}
|
||||
|
||||
// Update game context
|
||||
this.updateGameContext(narrative.text);
|
||||
|
||||
// Start the game loop
|
||||
this.gameLoop();
|
||||
} catch (error) {
|
||||
console.error('Error starting game:', error);
|
||||
this.end();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The main game loop for CLI mode
|
||||
*/
|
||||
private gameLoop(): void {
|
||||
if (!this.rl) return;
|
||||
|
||||
this.rl.question('> ', async (input) => {
|
||||
if (input.toLowerCase() === 'quit' || input.toLowerCase() === 'exit') {
|
||||
this.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await this.processCommand(input);
|
||||
console.log('\n' + response + '\n');
|
||||
|
||||
// Continue the game loop
|
||||
this.gameLoop();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a player command and return the narrative response
|
||||
* Used by both CLI and web interfaces
|
||||
*/
|
||||
public async processCommand(input: string): Promise<string> {
|
||||
try {
|
||||
// Process player input
|
||||
const actionRequest: ActionRequest = {
|
||||
playerInput: input,
|
||||
currentRoom: this.engine.getWorldModel().rooms[this.engine.getCurrentState().currentRoomId].name,
|
||||
visibleObjects: this.engine.getVisibleObjects().map(id => this.engine.getWorldModel().objects[id].name),
|
||||
visibleCharacters: this.engine.getVisibleCharacters().map(id => this.engine.getWorldModel().characters[id].name),
|
||||
possibleActions: this.engine.getAvailableActions(),
|
||||
inventory: this.engine.getCurrentState().inventory.map(id => this.engine.getWorldModel().objects[id].name),
|
||||
gameContext: this.gameContext
|
||||
};
|
||||
|
||||
if (this.rl) {
|
||||
console.log('Thinking...');
|
||||
}
|
||||
|
||||
// Translate player input to action
|
||||
const action = await this.llmProvider.translateAction(actionRequest);
|
||||
|
||||
// Process the action in the game engine
|
||||
const actionResult = this.engine.processAction(action);
|
||||
|
||||
// If state changed, update it
|
||||
if (actionResult.stateChanged && actionResult.newState) {
|
||||
this.engine.getCurrentState().currentRoomId = actionResult.newState.currentRoomId;
|
||||
this.engine.getCurrentState().inventory = actionResult.newState.inventory;
|
||||
this.engine.getCurrentState().visitedRooms = actionResult.newState.visitedRooms;
|
||||
this.engine.getCurrentState().flags = actionResult.newState.flags;
|
||||
this.engine.getCurrentState().counters = actionResult.newState.counters;
|
||||
}
|
||||
|
||||
// Generate narrative description
|
||||
const narrativeRequest: NarrativeRequest = {
|
||||
action: `${action.action}${action.object ? ' ' + action.object : ''}${action.target ? ' on ' + action.target : ''}`,
|
||||
result: actionResult.message,
|
||||
roomDescription: this.engine.getCurrentRoomDescription(),
|
||||
visibleObjects: this.engine.getVisibleObjects().map(id => this.engine.getWorldModel().objects[id].name),
|
||||
visibleCharacters: this.engine.getVisibleCharacters().map(id => this.engine.getWorldModel().characters[id].name),
|
||||
previousContext: this.gameHistory.slice(-3).join('\n'),
|
||||
tone: 'descriptive'
|
||||
};
|
||||
|
||||
const narrative = await this.llmProvider.generateNarrative(narrativeRequest);
|
||||
|
||||
// Store suggestions if available
|
||||
if (narrative.suggestions && narrative.suggestions.length > 0) {
|
||||
this.suggestedCommands = narrative.suggestions;
|
||||
}
|
||||
|
||||
// Update game context with the new narrative
|
||||
this.updateGameContext(narrative.text);
|
||||
|
||||
// Return the narrative text
|
||||
return narrative.text;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error processing input:', error);
|
||||
return 'Something went wrong. Please try again.';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* End the game
|
||||
*/
|
||||
public end(): void {
|
||||
console.log('\nThanks for playing!');
|
||||
if (this.rl) {
|
||||
this.rl.close();
|
||||
this.rl = null;
|
||||
}
|
||||
this.engine.end();
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the game context with new narrative
|
||||
*/
|
||||
private updateGameContext(narrative: string): void {
|
||||
// Add to history
|
||||
this.gameHistory.push(narrative);
|
||||
|
||||
// Keep history limited to last 10 entries
|
||||
if (this.gameHistory.length > 10) {
|
||||
this.gameHistory.shift();
|
||||
}
|
||||
|
||||
// Update current context (last 5 entries)
|
||||
this.gameContext = this.gameHistory.slice(-5).join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current game state
|
||||
* Used by web interface
|
||||
*/
|
||||
public getGameState() {
|
||||
return {
|
||||
world: this.engine.getWorldModel(),
|
||||
currentRoomId: this.engine.getCurrentState().currentRoomId,
|
||||
inventory: this.engine.getCurrentState().inventory,
|
||||
visitedRooms: this.engine.getCurrentState().visitedRooms,
|
||||
flags: this.engine.getCurrentState().flags,
|
||||
counters: this.engine.getCurrentState().counters
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current room description
|
||||
* Used by web interface
|
||||
*/
|
||||
public getCurrentRoomDescription(): string {
|
||||
const roomId = this.engine.getCurrentState().currentRoomId;
|
||||
return this.engine.getWorldModel().rooms[roomId].description;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get suggested actions for the current game state
|
||||
* Used by web interface
|
||||
*/
|
||||
public getSuggestions(): string[] {
|
||||
return this.suggestedCommands;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a saved game state
|
||||
* Used by web interface
|
||||
*/
|
||||
public loadGameState(savedState: any): void {
|
||||
// Set the current state to match the saved state
|
||||
this.engine.getCurrentState().currentRoomId = savedState.currentRoomId;
|
||||
this.engine.getCurrentState().inventory = savedState.inventory;
|
||||
this.engine.getCurrentState().visitedRooms = savedState.visitedRooms;
|
||||
this.engine.getCurrentState().flags = savedState.flags;
|
||||
this.engine.getCurrentState().counters = savedState.counters;
|
||||
}
|
||||
}
|
||||
+660
-660
File diff suppressed because it is too large
Load Diff
+82
-82
@@ -1,83 +1,83 @@
|
||||
/**
|
||||
* Main entry point for the AI Interactive Fiction application
|
||||
*/
|
||||
|
||||
import * as path from 'path';
|
||||
import * as dotenv from 'dotenv';
|
||||
import { GameRunner } from './cli/game-runner';
|
||||
// Import the server module and the startServer function for the web interface
|
||||
import { startServer } from './server';
|
||||
|
||||
// Load environment variables
|
||||
console.log('Loading environment variables...');
|
||||
try {
|
||||
const result = dotenv.config();
|
||||
if (result.error) {
|
||||
console.error('Error loading .env file:', result.error);
|
||||
} else {
|
||||
console.log('Environment variables loaded successfully');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Exception when loading env:', error);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
try {
|
||||
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';
|
||||
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'}`);
|
||||
|
||||
// Check if we should run in CLI mode
|
||||
const args = process.argv.slice(2);
|
||||
const cliMode = args.includes('--cli') || args.includes('-c');
|
||||
|
||||
if (cliMode) {
|
||||
// CLI mode
|
||||
console.log('Starting in CLI mode...');
|
||||
|
||||
// Create game runner and initialize
|
||||
console.log('Creating game runner...');
|
||||
const gameRunner = new GameRunner();
|
||||
|
||||
console.log('Initializing game...');
|
||||
await gameRunner.initialize(worldFile);
|
||||
|
||||
// Start the CLI game
|
||||
console.log('Starting CLI game...');
|
||||
await gameRunner.start();
|
||||
} else {
|
||||
// Web interface mode - explicitly start the server with port fallback
|
||||
console.log('Starting in web interface mode...');
|
||||
|
||||
// Get port configuration
|
||||
const DEFAULT_PORT = 3000;
|
||||
const PORT = process.env.PORT ? parseInt(process.env.PORT) : DEFAULT_PORT;
|
||||
const PORT_RANGE = 10;
|
||||
|
||||
// Start the web server with port fallback
|
||||
console.log('Starting web server...');
|
||||
await startServer(PORT, PORT_RANGE);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to start:', error);
|
||||
if (error instanceof Error) {
|
||||
console.error('Error name:', error.name);
|
||||
console.error('Error message:', error.message);
|
||||
console.error('Error stack:', error.stack);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Start the application
|
||||
console.log('Starting application...');
|
||||
main().catch(error => {
|
||||
console.error('Unhandled error in main:', error);
|
||||
process.exit(1);
|
||||
/**
|
||||
* Main entry point for the AI Interactive Fiction application
|
||||
*/
|
||||
|
||||
import * as path from 'path';
|
||||
import * as dotenv from 'dotenv';
|
||||
import { GameRunner } from './cli/game-runner';
|
||||
// Import the server module and the startServer function for the web interface
|
||||
import { startServer } from './server';
|
||||
|
||||
// Load environment variables
|
||||
console.log('Loading environment variables...');
|
||||
try {
|
||||
const result = dotenv.config();
|
||||
if (result.error) {
|
||||
console.error('Error loading .env file:', result.error);
|
||||
} else {
|
||||
console.log('Environment variables loaded successfully');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Exception when loading env:', error);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
try {
|
||||
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';
|
||||
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'}`);
|
||||
|
||||
// Check if we should run in CLI mode
|
||||
const args = process.argv.slice(2);
|
||||
const cliMode = args.includes('--cli') || args.includes('-c');
|
||||
|
||||
if (cliMode) {
|
||||
// CLI mode
|
||||
console.log('Starting in CLI mode...');
|
||||
|
||||
// Create game runner and initialize
|
||||
console.log('Creating game runner...');
|
||||
const gameRunner = new GameRunner();
|
||||
|
||||
console.log('Initializing game...');
|
||||
await gameRunner.initialize(worldFile);
|
||||
|
||||
// Start the CLI game
|
||||
console.log('Starting CLI game...');
|
||||
await gameRunner.start();
|
||||
} else {
|
||||
// Web interface mode - explicitly start the server with port fallback
|
||||
console.log('Starting in web interface mode...');
|
||||
|
||||
// Get port configuration
|
||||
const DEFAULT_PORT = 3000;
|
||||
const PORT = process.env.PORT ? parseInt(process.env.PORT) : DEFAULT_PORT;
|
||||
const PORT_RANGE = 10;
|
||||
|
||||
// Start the web server with port fallback
|
||||
console.log('Starting web server...');
|
||||
await startServer(PORT, PORT_RANGE);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to start:', error);
|
||||
if (error instanceof Error) {
|
||||
console.error('Error name:', error.name);
|
||||
console.error('Error message:', error.message);
|
||||
console.error('Error stack:', error.stack);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Start the application
|
||||
console.log('Starting application...');
|
||||
main().catch(error => {
|
||||
console.error('Unhandled error in main:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
+55
-55
@@ -1,56 +1,56 @@
|
||||
/**
|
||||
* Interfaces for the game engine
|
||||
*/
|
||||
|
||||
import { WorldModel, GameState } from './world-model';
|
||||
import { ActionResponse, NarrativeResponse } from './llm';
|
||||
|
||||
export interface ActionResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
stateChanged: boolean;
|
||||
newState?: GameState;
|
||||
}
|
||||
|
||||
export interface GameEngine {
|
||||
loadWorld(worldModelPath: string): Promise<void>;
|
||||
getCurrentState(): GameState;
|
||||
getWorldModel(): WorldModel;
|
||||
|
||||
// Action processing
|
||||
processAction(action: ActionResponse): ActionResult;
|
||||
|
||||
// State management
|
||||
saveGame(filename: string): Promise<void>;
|
||||
loadGame(filename: string): Promise<void>;
|
||||
|
||||
// Helper methods for world interaction
|
||||
getAvailableActions(): string[];
|
||||
getVisibleObjects(): string[];
|
||||
getVisibleCharacters(): string[];
|
||||
getCurrentRoomDescription(): string;
|
||||
|
||||
// Game flow
|
||||
start(): Promise<string>; // Returns introduction text
|
||||
end(): void;
|
||||
}
|
||||
|
||||
export interface GameSession {
|
||||
engine: GameEngine;
|
||||
history: {
|
||||
playerInput: string;
|
||||
actionResponse: ActionResponse;
|
||||
actionResult: ActionResult;
|
||||
narrativeResponse: NarrativeResponse;
|
||||
}[];
|
||||
startTime: Date;
|
||||
lastInteractionTime: Date;
|
||||
}
|
||||
|
||||
export interface ActionHandler {
|
||||
execute(
|
||||
gameState: GameState,
|
||||
worldModel: WorldModel,
|
||||
action: ActionResponse
|
||||
): ActionResult;
|
||||
/**
|
||||
* Interfaces for the game engine
|
||||
*/
|
||||
|
||||
import { WorldModel, GameState } from './world-model';
|
||||
import { ActionResponse, NarrativeResponse } from './llm';
|
||||
|
||||
export interface ActionResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
stateChanged: boolean;
|
||||
newState?: GameState;
|
||||
}
|
||||
|
||||
export interface GameEngine {
|
||||
loadWorld(worldModelPath: string): Promise<void>;
|
||||
getCurrentState(): GameState;
|
||||
getWorldModel(): WorldModel;
|
||||
|
||||
// Action processing
|
||||
processAction(action: ActionResponse): ActionResult;
|
||||
|
||||
// State management
|
||||
saveGame(filename: string): Promise<void>;
|
||||
loadGame(filename: string): Promise<void>;
|
||||
|
||||
// Helper methods for world interaction
|
||||
getAvailableActions(): string[];
|
||||
getVisibleObjects(): string[];
|
||||
getVisibleCharacters(): string[];
|
||||
getCurrentRoomDescription(): string;
|
||||
|
||||
// Game flow
|
||||
start(): Promise<string>; // Returns introduction text
|
||||
end(): void;
|
||||
}
|
||||
|
||||
export interface GameSession {
|
||||
engine: GameEngine;
|
||||
history: {
|
||||
playerInput: string;
|
||||
actionResponse: ActionResponse;
|
||||
actionResult: ActionResult;
|
||||
narrativeResponse: NarrativeResponse;
|
||||
}[];
|
||||
startTime: Date;
|
||||
lastInteractionTime: Date;
|
||||
}
|
||||
|
||||
export interface ActionHandler {
|
||||
execute(
|
||||
gameState: GameState,
|
||||
worldModel: WorldModel,
|
||||
action: ActionResponse
|
||||
): ActionResult;
|
||||
}
|
||||
+51
-51
@@ -1,52 +1,52 @@
|
||||
/**
|
||||
* Interfaces for LLM integration
|
||||
*/
|
||||
|
||||
export interface LlmConfig {
|
||||
apiKey: string;
|
||||
model: string;
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
topP?: number;
|
||||
frequencyPenalty?: number;
|
||||
presencePenalty?: number;
|
||||
}
|
||||
|
||||
export interface ActionRequest {
|
||||
playerInput: string;
|
||||
currentRoom: string;
|
||||
visibleObjects: string[];
|
||||
visibleCharacters: string[];
|
||||
possibleActions: string[];
|
||||
inventory: string[];
|
||||
gameContext: string;
|
||||
}
|
||||
|
||||
export interface ActionResponse {
|
||||
action: string;
|
||||
object?: string;
|
||||
target?: string;
|
||||
parameters?: Record<string, string>;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
export interface NarrativeRequest {
|
||||
action: string;
|
||||
result: string;
|
||||
roomDescription: string;
|
||||
visibleObjects: string[];
|
||||
visibleCharacters: string[];
|
||||
previousContext?: string;
|
||||
tone?: string; // e.g., "mysterious", "humorous", "dramatic"
|
||||
}
|
||||
|
||||
export interface NarrativeResponse {
|
||||
text: string;
|
||||
suggestions?: string[]; // Optional hints for the player
|
||||
}
|
||||
|
||||
export interface LlmProvider {
|
||||
initialize(config: LlmConfig): Promise<void>;
|
||||
translateAction(request: ActionRequest): Promise<ActionResponse>;
|
||||
generateNarrative(request: NarrativeRequest): Promise<NarrativeResponse>;
|
||||
/**
|
||||
* Interfaces for LLM integration
|
||||
*/
|
||||
|
||||
export interface LlmConfig {
|
||||
apiKey: string;
|
||||
model: string;
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
topP?: number;
|
||||
frequencyPenalty?: number;
|
||||
presencePenalty?: number;
|
||||
}
|
||||
|
||||
export interface ActionRequest {
|
||||
playerInput: string;
|
||||
currentRoom: string;
|
||||
visibleObjects: string[];
|
||||
visibleCharacters: string[];
|
||||
possibleActions: string[];
|
||||
inventory: string[];
|
||||
gameContext: string;
|
||||
}
|
||||
|
||||
export interface ActionResponse {
|
||||
action: string;
|
||||
object?: string;
|
||||
target?: string;
|
||||
parameters?: Record<string, string>;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
export interface NarrativeRequest {
|
||||
action: string;
|
||||
result: string;
|
||||
roomDescription: string;
|
||||
visibleObjects: string[];
|
||||
visibleCharacters: string[];
|
||||
previousContext?: string;
|
||||
tone?: string; // e.g., "mysterious", "humorous", "dramatic"
|
||||
}
|
||||
|
||||
export interface NarrativeResponse {
|
||||
text: string;
|
||||
suggestions?: string[]; // Optional hints for the player
|
||||
}
|
||||
|
||||
export interface LlmProvider {
|
||||
initialize(config: LlmConfig): Promise<void>;
|
||||
translateAction(request: ActionRequest): Promise<ActionResponse>;
|
||||
generateNarrative(request: NarrativeRequest): Promise<NarrativeResponse>;
|
||||
}
|
||||
@@ -1,68 +1,68 @@
|
||||
/**
|
||||
* Core interfaces for the interactive fiction world model
|
||||
*/
|
||||
|
||||
export interface Room {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
exits: Exit[];
|
||||
objects: string[]; // References to object IDs
|
||||
characters: string[]; // References to character IDs
|
||||
}
|
||||
|
||||
export interface Exit {
|
||||
direction: string;
|
||||
targetRoomId: string;
|
||||
description?: string;
|
||||
isLocked?: boolean;
|
||||
keyId?: string; // ID of the key object needed to unlock
|
||||
}
|
||||
|
||||
export interface GameObject {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
traits: string[]; // e.g., "takeable", "container", "edible"
|
||||
states: Record<string, boolean>; // e.g., { "open": false, "lit": true }
|
||||
containedObjects?: string[]; // IDs of objects inside if this is a container
|
||||
allowedActions: string[]; // What actions can be performed on this object
|
||||
}
|
||||
|
||||
export interface Character {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
dialogue: Record<string, string>; // Topic -> response mapping
|
||||
inventory: string[]; // IDs of objects the character has
|
||||
defaultResponse: string; // Response when topic not found
|
||||
mood?: string; // Current mood affecting responses
|
||||
}
|
||||
|
||||
export interface Action {
|
||||
name: string;
|
||||
patterns: string[]; // Example natural language patterns this action matches
|
||||
requiresObject?: boolean;
|
||||
requiresTarget?: boolean;
|
||||
handler: string; // Name of method to handle this action
|
||||
}
|
||||
|
||||
export interface GameState {
|
||||
currentRoomId: string;
|
||||
inventory: string[]; // IDs of objects in player's inventory
|
||||
visitedRooms: string[]; // IDs of rooms the player has visited
|
||||
flags: Record<string, boolean>; // Game state flags
|
||||
counters: Record<string, number>; // Game state counters
|
||||
}
|
||||
|
||||
export interface WorldModel {
|
||||
title: string;
|
||||
author: string;
|
||||
version: string;
|
||||
introduction: string;
|
||||
rooms: Record<string, Room>;
|
||||
objects: Record<string, GameObject>;
|
||||
characters: Record<string, Character>;
|
||||
actions: Record<string, Action>;
|
||||
initialState: GameState;
|
||||
/**
|
||||
* Core interfaces for the interactive fiction world model
|
||||
*/
|
||||
|
||||
export interface Room {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
exits: Exit[];
|
||||
objects: string[]; // References to object IDs
|
||||
characters: string[]; // References to character IDs
|
||||
}
|
||||
|
||||
export interface Exit {
|
||||
direction: string;
|
||||
targetRoomId: string;
|
||||
description?: string;
|
||||
isLocked?: boolean;
|
||||
keyId?: string; // ID of the key object needed to unlock
|
||||
}
|
||||
|
||||
export interface GameObject {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
traits: string[]; // e.g., "takeable", "container", "edible"
|
||||
states: Record<string, boolean>; // e.g., { "open": false, "lit": true }
|
||||
containedObjects?: string[]; // IDs of objects inside if this is a container
|
||||
allowedActions: string[]; // What actions can be performed on this object
|
||||
}
|
||||
|
||||
export interface Character {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
dialogue: Record<string, string>; // Topic -> response mapping
|
||||
inventory: string[]; // IDs of objects the character has
|
||||
defaultResponse: string; // Response when topic not found
|
||||
mood?: string; // Current mood affecting responses
|
||||
}
|
||||
|
||||
export interface Action {
|
||||
name: string;
|
||||
patterns: string[]; // Example natural language patterns this action matches
|
||||
requiresObject?: boolean;
|
||||
requiresTarget?: boolean;
|
||||
handler: string; // Name of method to handle this action
|
||||
}
|
||||
|
||||
export interface GameState {
|
||||
currentRoomId: string;
|
||||
inventory: string[]; // IDs of objects in player's inventory
|
||||
visitedRooms: string[]; // IDs of rooms the player has visited
|
||||
flags: Record<string, boolean>; // Game state flags
|
||||
counters: Record<string, number>; // Game state counters
|
||||
}
|
||||
|
||||
export interface WorldModel {
|
||||
title: string;
|
||||
author: string;
|
||||
version: string;
|
||||
introduction: string;
|
||||
rooms: Record<string, Room>;
|
||||
objects: Record<string, GameObject>;
|
||||
characters: Record<string, Character>;
|
||||
actions: Record<string, Action>;
|
||||
initialState: GameState;
|
||||
}
|
||||
+211
-211
@@ -1,212 +1,212 @@
|
||||
/**
|
||||
* OpenRouter LLM Provider
|
||||
* Handles communication with OpenRouter API for LLM interactions
|
||||
*/
|
||||
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import {
|
||||
LlmProvider,
|
||||
LlmConfig,
|
||||
ActionRequest,
|
||||
ActionResponse,
|
||||
NarrativeRequest,
|
||||
NarrativeResponse
|
||||
} from '../interfaces/llm';
|
||||
|
||||
export class OpenRouterProvider implements LlmProvider {
|
||||
private apiKey: string = '';
|
||||
private model: string = '';
|
||||
private client!: AxiosInstance;
|
||||
private temperature: number = 0.7;
|
||||
private maxTokens: number = 800;
|
||||
|
||||
/**
|
||||
* Initialize the OpenRouter provider with configuration
|
||||
*/
|
||||
public async initialize(config: LlmConfig): Promise<void> {
|
||||
this.apiKey = config.apiKey;
|
||||
this.model = config.model;
|
||||
this.temperature = config.temperature ?? 0.7;
|
||||
this.maxTokens = config.maxTokens ?? 800;
|
||||
|
||||
this.client = axios.create({
|
||||
baseURL: 'https://openrouter.ai/api/v1',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate player input into a structured action for the game engine
|
||||
*/
|
||||
public async translateAction(request: ActionRequest): Promise<ActionResponse> {
|
||||
try {
|
||||
const prompt = this.buildActionPrompt(request);
|
||||
|
||||
const response = await this.client.post('/chat/completions', {
|
||||
model: this.model,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: prompt.system
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: prompt.user
|
||||
}
|
||||
],
|
||||
temperature: 0.2, // Lower temperature for more deterministic outputs
|
||||
max_tokens: 150,
|
||||
response_format: { type: 'json_object' }
|
||||
});
|
||||
|
||||
const content = response.data.choices[0].message.content;
|
||||
const parsedResponse = JSON.parse(content);
|
||||
|
||||
return this.validateActionResponse(parsedResponse);
|
||||
} catch (error) {
|
||||
console.error('Error translating action:', error);
|
||||
// Fallback to a simple "look" action when errors occur
|
||||
return {
|
||||
action: 'look',
|
||||
confidence: 0.5
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate narrative prose based on game events
|
||||
*/
|
||||
public async generateNarrative(request: NarrativeRequest): Promise<NarrativeResponse> {
|
||||
try {
|
||||
const prompt = this.buildNarrativePrompt(request);
|
||||
|
||||
const response = await this.client.post('/chat/completions', {
|
||||
model: this.model,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: prompt.system
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: prompt.user
|
||||
}
|
||||
],
|
||||
temperature: this.temperature,
|
||||
max_tokens: this.maxTokens
|
||||
});
|
||||
|
||||
const content = response.data.choices[0].message.content;
|
||||
|
||||
// Check if response is JSON format or plain text
|
||||
try {
|
||||
const parsedResponse = JSON.parse(content);
|
||||
return {
|
||||
text: parsedResponse.text,
|
||||
suggestions: parsedResponse.suggestions || []
|
||||
};
|
||||
} catch {
|
||||
// Plain text response, just use the content directly
|
||||
return {
|
||||
text: content
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error generating narrative:', error);
|
||||
return {
|
||||
text: `Something happened, but the narrator is at a loss for words. (Error: ${error instanceof Error ? error.message : String(error)})`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the system and user prompts for action translation
|
||||
*/
|
||||
private buildActionPrompt(request: ActionRequest): { system: string; user: string } {
|
||||
const systemPrompt = `You are an AI assistant that translates natural language input into structured action commands for an interactive fiction game.
|
||||
Your task is to convert player input into a JSON object representing an action that can be understood by the game engine.
|
||||
|
||||
The player is currently in the "${request.currentRoom}" room.
|
||||
Visible objects: ${request.visibleObjects.join(', ')}
|
||||
Visible characters: ${request.visibleCharacters.join(', ')}
|
||||
Inventory: ${request.inventory.join(', ')}
|
||||
Available actions: ${request.possibleActions.join(', ')}
|
||||
|
||||
Game context: ${request.gameContext}
|
||||
|
||||
Respond ONLY with a JSON object that follows this structure:
|
||||
{
|
||||
"action": "string", // Name of the action (e.g., "take", "examine", "go", "talk", etc.)
|
||||
"object": "string", // Optional: Primary object of the action
|
||||
"target": "string", // Optional: Secondary object/target of the action
|
||||
"parameters": {}, // Optional: Additional parameters as key-value pairs
|
||||
"confidence": number // How confident you are in this interpretation (0.0-1.0)
|
||||
}
|
||||
|
||||
Choose the action from the list of available actions. If the player's input is ambiguous or doesn't map well to an available action, choose the closest match and set a lower confidence score.`;
|
||||
|
||||
const userPrompt = request.playerInput;
|
||||
|
||||
return {
|
||||
system: systemPrompt,
|
||||
user: userPrompt
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the system and user prompts for narrative generation
|
||||
*/
|
||||
private buildNarrativePrompt(request: NarrativeRequest): { system: string; user: string } {
|
||||
const tone = request.tone || 'descriptive';
|
||||
|
||||
const systemPrompt = `You are an AI assistant that generates engaging narrative prose for an interactive fiction game.
|
||||
Your task is to describe what happens when a player performs an action in the game world.
|
||||
|
||||
Craft a vivid, ${tone} description that tells the player what happened as a result of their action. Make your prose engaging and atmospheric.
|
||||
|
||||
Current room description: "${request.roomDescription}"
|
||||
Visible objects: ${request.visibleObjects.join(', ')}
|
||||
Visible characters: ${request.visibleCharacters.join(', ')}
|
||||
|
||||
${request.previousContext ? `Previous context: ${request.previousContext}` : ''}
|
||||
|
||||
Respond with engaging prose that describes the outcome of the player's action.
|
||||
You can optionally include 1-3 subtle hints about interesting things to try next.`;
|
||||
|
||||
const userPrompt = `The player has performed this action: "${request.action}".
|
||||
The result of the action is: "${request.result}".
|
||||
Please describe what happens in an engaging, narrative way.`;
|
||||
|
||||
return {
|
||||
system: systemPrompt,
|
||||
user: userPrompt
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and normalize the action response
|
||||
*/
|
||||
private validateActionResponse(response: Record<string, unknown>): ActionResponse {
|
||||
const validatedResponse: ActionResponse = {
|
||||
action: typeof response.action === 'string' ? response.action : 'look',
|
||||
confidence: typeof response.confidence === 'number' ? response.confidence : 0.5
|
||||
};
|
||||
|
||||
if (typeof response.object === 'string') {
|
||||
validatedResponse.object = response.object;
|
||||
}
|
||||
|
||||
if (typeof response.target === 'string') {
|
||||
validatedResponse.target = response.target;
|
||||
}
|
||||
|
||||
if (response.parameters && typeof response.parameters === 'object') {
|
||||
validatedResponse.parameters = response.parameters as Record<string, string>;
|
||||
}
|
||||
|
||||
return validatedResponse;
|
||||
}
|
||||
/**
|
||||
* OpenRouter LLM Provider
|
||||
* Handles communication with OpenRouter API for LLM interactions
|
||||
*/
|
||||
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import {
|
||||
LlmProvider,
|
||||
LlmConfig,
|
||||
ActionRequest,
|
||||
ActionResponse,
|
||||
NarrativeRequest,
|
||||
NarrativeResponse
|
||||
} from '../interfaces/llm';
|
||||
|
||||
export class OpenRouterProvider implements LlmProvider {
|
||||
private apiKey: string = '';
|
||||
private model: string = '';
|
||||
private client!: AxiosInstance;
|
||||
private temperature: number = 0.7;
|
||||
private maxTokens: number = 800;
|
||||
|
||||
/**
|
||||
* Initialize the OpenRouter provider with configuration
|
||||
*/
|
||||
public async initialize(config: LlmConfig): Promise<void> {
|
||||
this.apiKey = config.apiKey;
|
||||
this.model = config.model;
|
||||
this.temperature = config.temperature ?? 0.7;
|
||||
this.maxTokens = config.maxTokens ?? 800;
|
||||
|
||||
this.client = axios.create({
|
||||
baseURL: 'https://openrouter.ai/api/v1',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate player input into a structured action for the game engine
|
||||
*/
|
||||
public async translateAction(request: ActionRequest): Promise<ActionResponse> {
|
||||
try {
|
||||
const prompt = this.buildActionPrompt(request);
|
||||
|
||||
const response = await this.client.post('/chat/completions', {
|
||||
model: this.model,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: prompt.system
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: prompt.user
|
||||
}
|
||||
],
|
||||
temperature: 0.2, // Lower temperature for more deterministic outputs
|
||||
max_tokens: 150,
|
||||
response_format: { type: 'json_object' }
|
||||
});
|
||||
|
||||
const content = response.data.choices[0].message.content;
|
||||
const parsedResponse = JSON.parse(content);
|
||||
|
||||
return this.validateActionResponse(parsedResponse);
|
||||
} catch (error) {
|
||||
console.error('Error translating action:', error);
|
||||
// Fallback to a simple "look" action when errors occur
|
||||
return {
|
||||
action: 'look',
|
||||
confidence: 0.5
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate narrative prose based on game events
|
||||
*/
|
||||
public async generateNarrative(request: NarrativeRequest): Promise<NarrativeResponse> {
|
||||
try {
|
||||
const prompt = this.buildNarrativePrompt(request);
|
||||
|
||||
const response = await this.client.post('/chat/completions', {
|
||||
model: this.model,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: prompt.system
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: prompt.user
|
||||
}
|
||||
],
|
||||
temperature: this.temperature,
|
||||
max_tokens: this.maxTokens
|
||||
});
|
||||
|
||||
const content = response.data.choices[0].message.content;
|
||||
|
||||
// Check if response is JSON format or plain text
|
||||
try {
|
||||
const parsedResponse = JSON.parse(content);
|
||||
return {
|
||||
text: parsedResponse.text,
|
||||
suggestions: parsedResponse.suggestions || []
|
||||
};
|
||||
} catch {
|
||||
// Plain text response, just use the content directly
|
||||
return {
|
||||
text: content
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error generating narrative:', error);
|
||||
return {
|
||||
text: `Something happened, but the narrator is at a loss for words. (Error: ${error instanceof Error ? error.message : String(error)})`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the system and user prompts for action translation
|
||||
*/
|
||||
private buildActionPrompt(request: ActionRequest): { system: string; user: string } {
|
||||
const systemPrompt = `You are an AI assistant that translates natural language input into structured action commands for an interactive fiction game.
|
||||
Your task is to convert player input into a JSON object representing an action that can be understood by the game engine.
|
||||
|
||||
The player is currently in the "${request.currentRoom}" room.
|
||||
Visible objects: ${request.visibleObjects.join(', ')}
|
||||
Visible characters: ${request.visibleCharacters.join(', ')}
|
||||
Inventory: ${request.inventory.join(', ')}
|
||||
Available actions: ${request.possibleActions.join(', ')}
|
||||
|
||||
Game context: ${request.gameContext}
|
||||
|
||||
Respond ONLY with a JSON object that follows this structure:
|
||||
{
|
||||
"action": "string", // Name of the action (e.g., "take", "examine", "go", "talk", etc.)
|
||||
"object": "string", // Optional: Primary object of the action
|
||||
"target": "string", // Optional: Secondary object/target of the action
|
||||
"parameters": {}, // Optional: Additional parameters as key-value pairs
|
||||
"confidence": number // How confident you are in this interpretation (0.0-1.0)
|
||||
}
|
||||
|
||||
Choose the action from the list of available actions. If the player's input is ambiguous or doesn't map well to an available action, choose the closest match and set a lower confidence score.`;
|
||||
|
||||
const userPrompt = request.playerInput;
|
||||
|
||||
return {
|
||||
system: systemPrompt,
|
||||
user: userPrompt
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the system and user prompts for narrative generation
|
||||
*/
|
||||
private buildNarrativePrompt(request: NarrativeRequest): { system: string; user: string } {
|
||||
const tone = request.tone || 'descriptive';
|
||||
|
||||
const systemPrompt = `You are an AI assistant that generates engaging narrative prose for an interactive fiction game.
|
||||
Your task is to describe what happens when a player performs an action in the game world.
|
||||
|
||||
Craft a vivid, ${tone} description that tells the player what happened as a result of their action. Make your prose engaging and atmospheric.
|
||||
|
||||
Current room description: "${request.roomDescription}"
|
||||
Visible objects: ${request.visibleObjects.join(', ')}
|
||||
Visible characters: ${request.visibleCharacters.join(', ')}
|
||||
|
||||
${request.previousContext ? `Previous context: ${request.previousContext}` : ''}
|
||||
|
||||
Respond with engaging prose that describes the outcome of the player's action.
|
||||
You can optionally include 1-3 subtle hints about interesting things to try next.`;
|
||||
|
||||
const userPrompt = `The player has performed this action: "${request.action}".
|
||||
The result of the action is: "${request.result}".
|
||||
Please describe what happens in an engaging, narrative way.`;
|
||||
|
||||
return {
|
||||
system: systemPrompt,
|
||||
user: userPrompt
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and normalize the action response
|
||||
*/
|
||||
private validateActionResponse(response: Record<string, unknown>): ActionResponse {
|
||||
const validatedResponse: ActionResponse = {
|
||||
action: typeof response.action === 'string' ? response.action : 'look',
|
||||
confidence: typeof response.confidence === 'number' ? response.confidence : 0.5
|
||||
};
|
||||
|
||||
if (typeof response.object === 'string') {
|
||||
validatedResponse.object = response.object;
|
||||
}
|
||||
|
||||
if (typeof response.target === 'string') {
|
||||
validatedResponse.target = response.target;
|
||||
}
|
||||
|
||||
if (response.parameters && typeof response.parameters === 'object') {
|
||||
validatedResponse.parameters = response.parameters as Record<string, string>;
|
||||
}
|
||||
|
||||
return validatedResponse;
|
||||
}
|
||||
}
|
||||
+245
-245
@@ -1,246 +1,246 @@
|
||||
/**
|
||||
* AI Interactive Fiction - Web Server
|
||||
* Serves the web UI and handles WebSocket communication
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import express from 'express';
|
||||
import http from 'http';
|
||||
import { Server as SocketIOServer } from 'socket.io';
|
||||
import * as dotenv from 'dotenv';
|
||||
import { GameRunner } from './cli/game-runner';
|
||||
import { existsSync, mkdirSync, copyFileSync } from 'fs';
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
|
||||
// Create Express application
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
const io = new SocketIOServer(server);
|
||||
|
||||
// Get port from environment variables or use default
|
||||
const DEFAULT_PORT = 3000;
|
||||
const PORT = process.env.PORT ? parseInt(process.env.PORT) : DEFAULT_PORT;
|
||||
const PORT_RANGE = 10; // Try up to 10 ports starting from the default
|
||||
|
||||
// Serve static files from the public directory
|
||||
app.use(express.static(path.join(__dirname, '../public')));
|
||||
|
||||
// Set up game sessions
|
||||
const gameSessions = new Map<string, GameRunner>();
|
||||
|
||||
// Handle socket connections
|
||||
io.on('connection', (socket) => {
|
||||
console.log(`New client connected: ${socket.id}`);
|
||||
|
||||
// Start a new game
|
||||
socket.on('startGame', async () => {
|
||||
try {
|
||||
// Initialize game runner
|
||||
const gameRunner = new GameRunner();
|
||||
const worldFile = process.env.DEFAULT_WORLD_FILE || './data/worlds/example_world.yml';
|
||||
|
||||
// Initialize the game
|
||||
await gameRunner.initialize(worldFile);
|
||||
|
||||
// Store game session
|
||||
gameSessions.set(socket.id, gameRunner);
|
||||
|
||||
// Send introduction to client
|
||||
const gameState = gameRunner.getGameState();
|
||||
socket.emit('gameIntroduction', {
|
||||
introduction: gameState.world.introduction,
|
||||
initialRoomDescription: gameRunner.getCurrentRoomDescription(),
|
||||
currentRoomId: gameState.currentRoomId
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error starting game:', error);
|
||||
socket.emit('error', { message: 'Failed to start game. Please try again.' });
|
||||
}
|
||||
});
|
||||
|
||||
// Process player command
|
||||
socket.on('playerCommand', async (data) => {
|
||||
try {
|
||||
const gameRunner = gameSessions.get(socket.id);
|
||||
|
||||
if (!gameRunner) {
|
||||
socket.emit('error', { message: 'Game session not found. Please start a new game.' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Process command and get response
|
||||
const response = await gameRunner.processCommand(data.command);
|
||||
|
||||
// Send narrative response to client
|
||||
socket.emit('narrativeResponse', {
|
||||
text: response,
|
||||
gameState: {
|
||||
currentRoomId: gameRunner.getGameState().currentRoomId
|
||||
},
|
||||
suggestions: gameRunner.getSuggestions()
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error processing command:', error);
|
||||
socket.emit('error', { message: 'Failed to process command. Please try again.' });
|
||||
}
|
||||
});
|
||||
|
||||
// Save game state
|
||||
socket.on('saveGame', () => {
|
||||
try {
|
||||
const gameRunner = gameSessions.get(socket.id);
|
||||
|
||||
if (!gameRunner) {
|
||||
socket.emit('error', { message: 'Game session not found. Please start a new game.' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Store save data in session
|
||||
socket.data.savedGame = gameRunner.getGameState();
|
||||
|
||||
socket.emit('gameSaved');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error saving game:', error);
|
||||
socket.emit('error', { message: 'Failed to save game. Please try again.' });
|
||||
}
|
||||
});
|
||||
|
||||
// Load game state
|
||||
socket.on('loadGame', () => {
|
||||
try {
|
||||
const gameRunner = gameSessions.get(socket.id);
|
||||
|
||||
if (!gameRunner) {
|
||||
socket.emit('error', { message: 'Game session not found. Please start a new game.' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if there's a saved game
|
||||
if (!socket.data.savedGame) {
|
||||
socket.emit('error', { message: 'No saved game found.' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Load saved game
|
||||
gameRunner.loadGameState(socket.data.savedGame);
|
||||
|
||||
// Send current state to client
|
||||
socket.emit('gameLoaded', {
|
||||
currentRoomDescription: gameRunner.getCurrentRoomDescription(),
|
||||
currentRoomId: gameRunner.getGameState().currentRoomId
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading game:', error);
|
||||
socket.emit('error', { message: 'Failed to load game. Please try again.' });
|
||||
}
|
||||
});
|
||||
|
||||
// Handle disconnection
|
||||
socket.on('disconnect', () => {
|
||||
console.log(`Client disconnected: ${socket.id}`);
|
||||
|
||||
// Clean up game session
|
||||
if (gameSessions.has(socket.id)) {
|
||||
gameSessions.delete(socket.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Ensure required asset folders exist
|
||||
function ensureDirectories() {
|
||||
const dirs = [
|
||||
path.join(__dirname, '../public'),
|
||||
path.join(__dirname, '../public/js'),
|
||||
path.join(__dirname, '../public/css'),
|
||||
path.join(__dirname, '../public/images'),
|
||||
path.join(__dirname, '../public/fonts')
|
||||
];
|
||||
|
||||
for (const dir of dirs) {
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Copy kokoro-js library from node_modules if not already present
|
||||
function ensureKokoroJs() {
|
||||
const source = path.join(__dirname, '../node_modules/kokoro-js/dist/index.js');
|
||||
const destination = path.join(__dirname, '../public/js/kokoro-js.js');
|
||||
|
||||
if (existsSync(source) && !existsSync(destination)) {
|
||||
copyFileSync(source, destination);
|
||||
console.log(`Copied kokoro-js from ${source} to ${destination}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Start the server with port fallback
|
||||
export async function startServer(initialPort: number, range: number): Promise<void> {
|
||||
let currentPort = initialPort;
|
||||
const maxPort = initialPort + range;
|
||||
|
||||
// Try ports in the specified range
|
||||
while (currentPort < maxPort) {
|
||||
try {
|
||||
// Ensure directories exist
|
||||
ensureDirectories();
|
||||
|
||||
// Ensure kokoro-js is copied
|
||||
try {
|
||||
ensureKokoroJs();
|
||||
} catch (error) {
|
||||
console.error('Error copying kokoro-js:', error);
|
||||
}
|
||||
|
||||
// Try to start the server on the current port
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.listen(currentPort, () => {
|
||||
console.log(`AI Interactive Fiction web server running on http://localhost:${currentPort}`);
|
||||
resolve();
|
||||
});
|
||||
|
||||
server.on('error', (error: NodeJS.ErrnoException) => {
|
||||
// If port is in use, try next port
|
||||
if (error.code === 'EADDRINUSE') {
|
||||
console.log(`Port ${currentPort} is in use, trying next port...`);
|
||||
server.close();
|
||||
currentPort++;
|
||||
reject();
|
||||
} else {
|
||||
// For other errors, log and reject
|
||||
console.error('Server error:', error);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// If we reach here, server started successfully
|
||||
return;
|
||||
|
||||
} catch (error) {
|
||||
// If we reach the max port and still fail, throw an error
|
||||
if (currentPort >= maxPort - 1) {
|
||||
throw new Error(`Failed to start server on ports ${initialPort} to ${maxPort - 1}`);
|
||||
}
|
||||
|
||||
// Otherwise try the next port
|
||||
// The loop continues as the rejection above increments currentPort
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start the server when this module is run directly
|
||||
if (require.main === module) {
|
||||
startServer(PORT, PORT_RANGE).catch(error => {
|
||||
console.error('Failed to start server:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* AI Interactive Fiction - Web Server
|
||||
* Serves the web UI and handles WebSocket communication
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import express from 'express';
|
||||
import http from 'http';
|
||||
import { Server as SocketIOServer } from 'socket.io';
|
||||
import * as dotenv from 'dotenv';
|
||||
import { GameRunner } from './cli/game-runner';
|
||||
import { existsSync, mkdirSync, copyFileSync } from 'fs';
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
|
||||
// Create Express application
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
const io = new SocketIOServer(server);
|
||||
|
||||
// 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
|
||||
|
||||
// Serve static files from the public directory
|
||||
app.use(express.static(path.join(__dirname, '../public')));
|
||||
|
||||
// Set up game sessions
|
||||
const gameSessions = new Map<string, GameRunner>();
|
||||
|
||||
// Handle socket connections
|
||||
io.on('connection', (socket) => {
|
||||
console.log(`New client connected: ${socket.id}`);
|
||||
|
||||
// Start a new game
|
||||
socket.on('startGame', async () => {
|
||||
try {
|
||||
// Initialize game runner
|
||||
const gameRunner = new GameRunner();
|
||||
const worldFile = process.env.DEFAULT_WORLD_FILE || './data/worlds/example_world.yml';
|
||||
|
||||
// Initialize the game
|
||||
await gameRunner.initialize(worldFile);
|
||||
|
||||
// Store game session
|
||||
gameSessions.set(socket.id, gameRunner);
|
||||
|
||||
// Send introduction to client
|
||||
const gameState = gameRunner.getGameState();
|
||||
socket.emit('gameIntroduction', {
|
||||
introduction: gameState.world.introduction,
|
||||
initialRoomDescription: gameRunner.getCurrentRoomDescription(),
|
||||
currentRoomId: gameState.currentRoomId
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error starting game:', error);
|
||||
socket.emit('error', { message: 'Failed to start game. Please try again.' });
|
||||
}
|
||||
});
|
||||
|
||||
// Process player command
|
||||
socket.on('playerCommand', async (data) => {
|
||||
try {
|
||||
const gameRunner = gameSessions.get(socket.id);
|
||||
|
||||
if (!gameRunner) {
|
||||
socket.emit('error', { message: 'Game session not found. Please start a new game.' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Process command and get response
|
||||
const response = await gameRunner.processCommand(data.command);
|
||||
|
||||
// Send narrative response to client
|
||||
socket.emit('narrativeResponse', {
|
||||
text: response,
|
||||
gameState: {
|
||||
currentRoomId: gameRunner.getGameState().currentRoomId
|
||||
},
|
||||
suggestions: gameRunner.getSuggestions()
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error processing command:', error);
|
||||
socket.emit('error', { message: 'Failed to process command. Please try again.' });
|
||||
}
|
||||
});
|
||||
|
||||
// Save game state
|
||||
socket.on('saveGame', () => {
|
||||
try {
|
||||
const gameRunner = gameSessions.get(socket.id);
|
||||
|
||||
if (!gameRunner) {
|
||||
socket.emit('error', { message: 'Game session not found. Please start a new game.' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Store save data in session
|
||||
socket.data.savedGame = gameRunner.getGameState();
|
||||
|
||||
socket.emit('gameSaved');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error saving game:', error);
|
||||
socket.emit('error', { message: 'Failed to save game. Please try again.' });
|
||||
}
|
||||
});
|
||||
|
||||
// Load game state
|
||||
socket.on('loadGame', () => {
|
||||
try {
|
||||
const gameRunner = gameSessions.get(socket.id);
|
||||
|
||||
if (!gameRunner) {
|
||||
socket.emit('error', { message: 'Game session not found. Please start a new game.' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if there's a saved game
|
||||
if (!socket.data.savedGame) {
|
||||
socket.emit('error', { message: 'No saved game found.' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Load saved game
|
||||
gameRunner.loadGameState(socket.data.savedGame);
|
||||
|
||||
// Send current state to client
|
||||
socket.emit('gameLoaded', {
|
||||
currentRoomDescription: gameRunner.getCurrentRoomDescription(),
|
||||
currentRoomId: gameRunner.getGameState().currentRoomId
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading game:', error);
|
||||
socket.emit('error', { message: 'Failed to load game. Please try again.' });
|
||||
}
|
||||
});
|
||||
|
||||
// Handle disconnection
|
||||
socket.on('disconnect', () => {
|
||||
console.log(`Client disconnected: ${socket.id}`);
|
||||
|
||||
// Clean up game session
|
||||
if (gameSessions.has(socket.id)) {
|
||||
gameSessions.delete(socket.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Ensure required asset folders exist
|
||||
function ensureDirectories() {
|
||||
const dirs = [
|
||||
path.join(__dirname, '../public'),
|
||||
path.join(__dirname, '../public/js'),
|
||||
path.join(__dirname, '../public/css'),
|
||||
path.join(__dirname, '../public/images'),
|
||||
path.join(__dirname, '../public/fonts')
|
||||
];
|
||||
|
||||
for (const dir of dirs) {
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Copy kokoro-js library from node_modules if not already present
|
||||
function ensureKokoroJs() {
|
||||
const source = path.join(__dirname, '../node_modules/kokoro-js/dist/index.js');
|
||||
const destination = path.join(__dirname, '../public/js/kokoro-js.js');
|
||||
|
||||
if (existsSync(source) && !existsSync(destination)) {
|
||||
copyFileSync(source, destination);
|
||||
console.log(`Copied kokoro-js from ${source} to ${destination}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Start the server with port fallback
|
||||
export async function startServer(initialPort: number, range: number): Promise<void> {
|
||||
let currentPort = initialPort;
|
||||
const maxPort = initialPort + range;
|
||||
|
||||
// Try ports in the specified range
|
||||
while (currentPort < maxPort) {
|
||||
try {
|
||||
// Ensure directories exist
|
||||
ensureDirectories();
|
||||
|
||||
// Ensure kokoro-js is copied
|
||||
try {
|
||||
ensureKokoroJs();
|
||||
} catch (error) {
|
||||
console.error('Error copying kokoro-js:', error);
|
||||
}
|
||||
|
||||
// Try to start the server on the current port
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.listen(currentPort, () => {
|
||||
console.log(`AI Interactive Fiction web server running on http://localhost:${currentPort}`);
|
||||
resolve();
|
||||
});
|
||||
|
||||
server.on('error', (error: NodeJS.ErrnoException) => {
|
||||
// If port is in use, try next port
|
||||
if (error.code === 'EADDRINUSE') {
|
||||
console.log(`Port ${currentPort} is in use, trying next port...`);
|
||||
server.close();
|
||||
currentPort++;
|
||||
reject();
|
||||
} else {
|
||||
// For other errors, log and reject
|
||||
console.error('Server error:', error);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// If we reach here, server started successfully
|
||||
return;
|
||||
|
||||
} catch (error) {
|
||||
// If we reach the max port and still fail, throw an error
|
||||
if (currentPort >= maxPort - 1) {
|
||||
throw new Error(`Failed to start server on ports ${initialPort} to ${maxPort - 1}`);
|
||||
}
|
||||
|
||||
// Otherwise try the next port
|
||||
// The loop continues as the rejection above increments currentPort
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start the server when this module is run directly
|
||||
if (require.main === module) {
|
||||
startServer(PORT, PORT_RANGE).catch(error => {
|
||||
console.error('Failed to start server:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
export { app, server, io };
|
||||
+428
-428
@@ -1,429 +1,429 @@
|
||||
/**
|
||||
* YAML World Model Parser
|
||||
* Loads and validates world definitions from YAML files
|
||||
*/
|
||||
|
||||
import * as fs from 'fs/promises';
|
||||
import * as yaml from 'js-yaml';
|
||||
import { WorldModel } from '../interfaces/world-model';
|
||||
|
||||
export class YamlWorldParser {
|
||||
/**
|
||||
* Load a world model from a YAML file
|
||||
*/
|
||||
public static async loadFromFile(filePath: string): Promise<WorldModel> {
|
||||
try {
|
||||
const fileContents = await fs.readFile(filePath, 'utf8');
|
||||
const worldData = yaml.load(fileContents) as unknown;
|
||||
|
||||
return this.validateAndTransform(worldData);
|
||||
} catch (error) {
|
||||
console.error(`Error loading world from ${filePath}:`, error);
|
||||
throw new Error(`Failed to load world from ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the loaded YAML data and transform it into a WorldModel
|
||||
*/
|
||||
private static validateAndTransform(data: unknown): WorldModel {
|
||||
if (!data || typeof data !== 'object') {
|
||||
throw new Error('Invalid world data: must be an object');
|
||||
}
|
||||
|
||||
const worldData = data as Record<string, unknown>;
|
||||
|
||||
// Validate required top-level fields
|
||||
this.validateRequiredFields(worldData, ['title', 'author', 'version', 'introduction', 'rooms', 'initialState']);
|
||||
|
||||
// Transform and validate the world model
|
||||
const worldModel: WorldModel = {
|
||||
title: this.validateString(worldData.title, 'title'),
|
||||
author: this.validateString(worldData.author, 'author'),
|
||||
version: this.validateString(worldData.version, 'version'),
|
||||
introduction: this.validateString(worldData.introduction, 'introduction'),
|
||||
rooms: this.validateRooms(worldData.rooms),
|
||||
objects: this.validateObjects(worldData.objects),
|
||||
characters: this.validateCharacters(worldData.characters),
|
||||
actions: this.validateActions(worldData.actions),
|
||||
initialState: this.validateInitialState(worldData.initialState)
|
||||
};
|
||||
|
||||
// Validate references between entities
|
||||
this.validateReferences(worldModel);
|
||||
|
||||
return worldModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that an object has all required fields
|
||||
*/
|
||||
private static validateRequiredFields(data: Record<string, unknown>, requiredFields: string[]): void {
|
||||
for (const field of requiredFields) {
|
||||
if (!(field in data)) {
|
||||
throw new Error(`Missing required field: ${field}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a value is a string
|
||||
*/
|
||||
private static validateString(value: unknown, fieldName: string): string {
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error(`Field ${fieldName} must be a string`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate room definitions
|
||||
*/
|
||||
private static validateRooms(rooms: unknown): WorldModel['rooms'] {
|
||||
if (!rooms || typeof rooms !== 'object') {
|
||||
throw new Error('Rooms must be an object mapping room IDs to room definitions');
|
||||
}
|
||||
|
||||
const roomsData = rooms as Record<string, unknown>;
|
||||
const validatedRooms: WorldModel['rooms'] = {};
|
||||
|
||||
for (const [roomId, roomData] of Object.entries(roomsData)) {
|
||||
if (!roomData || typeof roomData !== 'object') {
|
||||
throw new Error(`Room ${roomId} must be an object`);
|
||||
}
|
||||
|
||||
const room = roomData as Record<string, unknown>;
|
||||
this.validateRequiredFields(room, ['name', 'description', 'exits']);
|
||||
|
||||
validatedRooms[roomId] = {
|
||||
id: roomId,
|
||||
name: this.validateString(room.name, `rooms.${roomId}.name`),
|
||||
description: this.validateString(room.description, `rooms.${roomId}.description`),
|
||||
exits: this.validateExits(room.exits, roomId),
|
||||
objects: this.validateStringArray(room.objects || [], `rooms.${roomId}.objects`),
|
||||
characters: this.validateStringArray(room.characters || [], `rooms.${roomId}.characters`)
|
||||
};
|
||||
}
|
||||
|
||||
return validatedRooms;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate exit definitions
|
||||
*/
|
||||
private static validateExits(exits: unknown, roomId: string): WorldModel['rooms'][string]['exits'] {
|
||||
if (!Array.isArray(exits)) {
|
||||
throw new Error(`Exits for room ${roomId} must be an array`);
|
||||
}
|
||||
|
||||
return exits.map((exit, index) => {
|
||||
if (!exit || typeof exit !== 'object') {
|
||||
throw new Error(`Exit ${index} in room ${roomId} must be an object`);
|
||||
}
|
||||
|
||||
const exitData = exit as Record<string, unknown>;
|
||||
this.validateRequiredFields(exitData, ['direction', 'targetRoomId']);
|
||||
|
||||
return {
|
||||
direction: this.validateString(exitData.direction, `rooms.${roomId}.exits[${index}].direction`),
|
||||
targetRoomId: this.validateString(exitData.targetRoomId, `rooms.${roomId}.exits[${index}].targetRoomId`),
|
||||
description: exitData.description ? this.validateString(exitData.description, `rooms.${roomId}.exits[${index}].description`) : undefined,
|
||||
isLocked: typeof exitData.isLocked === 'boolean' ? exitData.isLocked : false,
|
||||
keyId: exitData.keyId ? this.validateString(exitData.keyId, `rooms.${roomId}.exits[${index}].keyId`) : undefined
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate object definitions
|
||||
*/
|
||||
private static validateObjects(objects: unknown): WorldModel['objects'] {
|
||||
if (!objects) return {}; // Objects are optional
|
||||
|
||||
if (typeof objects !== 'object') {
|
||||
throw new Error('Objects must be an object mapping object IDs to object definitions');
|
||||
}
|
||||
|
||||
const objectsData = objects as Record<string, unknown>;
|
||||
const validatedObjects: WorldModel['objects'] = {};
|
||||
|
||||
for (const [objectId, objectData] of Object.entries(objectsData)) {
|
||||
if (!objectData || typeof objectData !== 'object') {
|
||||
throw new Error(`Object ${objectId} must be an object`);
|
||||
}
|
||||
|
||||
const obj = objectData as Record<string, unknown>;
|
||||
this.validateRequiredFields(obj, ['name', 'description', 'traits', 'allowedActions']);
|
||||
|
||||
validatedObjects[objectId] = {
|
||||
id: objectId,
|
||||
name: this.validateString(obj.name, `objects.${objectId}.name`),
|
||||
description: this.validateString(obj.description, `objects.${objectId}.description`),
|
||||
traits: this.validateStringArray(obj.traits, `objects.${objectId}.traits`),
|
||||
states: this.validateObjectStates(obj.states, objectId),
|
||||
allowedActions: this.validateStringArray(obj.allowedActions, `objects.${objectId}.allowedActions`),
|
||||
containedObjects: obj.containedObjects ? this.validateStringArray(obj.containedObjects, `objects.${objectId}.containedObjects`) : []
|
||||
};
|
||||
}
|
||||
|
||||
return validatedObjects;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate character definitions
|
||||
*/
|
||||
private static validateCharacters(characters: unknown): WorldModel['characters'] {
|
||||
if (!characters) return {}; // Characters are optional
|
||||
|
||||
if (typeof characters !== 'object') {
|
||||
throw new Error('Characters must be an object mapping character IDs to character definitions');
|
||||
}
|
||||
|
||||
const charactersData = characters as Record<string, unknown>;
|
||||
const validatedCharacters: WorldModel['characters'] = {};
|
||||
|
||||
for (const [characterId, characterData] of Object.entries(charactersData)) {
|
||||
if (!characterData || typeof characterData !== 'object') {
|
||||
throw new Error(`Character ${characterId} must be an object`);
|
||||
}
|
||||
|
||||
const character = characterData as Record<string, unknown>;
|
||||
this.validateRequiredFields(character, ['name', 'description', 'dialogue', 'defaultResponse']);
|
||||
|
||||
validatedCharacters[characterId] = {
|
||||
id: characterId,
|
||||
name: this.validateString(character.name, `characters.${characterId}.name`),
|
||||
description: this.validateString(character.description, `characters.${characterId}.description`),
|
||||
dialogue: this.validateDialogue(character.dialogue, characterId),
|
||||
inventory: this.validateStringArray(character.inventory || [], `characters.${characterId}.inventory`),
|
||||
defaultResponse: this.validateString(character.defaultResponse, `characters.${characterId}.defaultResponse`),
|
||||
mood: character.mood ? this.validateString(character.mood, `characters.${characterId}.mood`) : undefined
|
||||
};
|
||||
}
|
||||
|
||||
return validatedCharacters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate action definitions
|
||||
*/
|
||||
private static validateActions(actions: unknown): WorldModel['actions'] {
|
||||
if (!actions) return {}; // Actions are optional
|
||||
|
||||
if (typeof actions !== 'object') {
|
||||
throw new Error('Actions must be an object mapping action names to action definitions');
|
||||
}
|
||||
|
||||
const actionsData = actions as Record<string, unknown>;
|
||||
const validatedActions: WorldModel['actions'] = {};
|
||||
|
||||
for (const [actionName, actionData] of Object.entries(actionsData)) {
|
||||
if (!actionData || typeof actionData !== 'object') {
|
||||
throw new Error(`Action ${actionName} must be an object`);
|
||||
}
|
||||
|
||||
const action = actionData as Record<string, unknown>;
|
||||
this.validateRequiredFields(action, ['patterns', 'handler']);
|
||||
|
||||
validatedActions[actionName] = {
|
||||
name: actionName,
|
||||
patterns: this.validateStringArray(action.patterns, `actions.${actionName}.patterns`),
|
||||
requiresObject: typeof action.requiresObject === 'boolean' ? action.requiresObject : false,
|
||||
requiresTarget: typeof action.requiresTarget === 'boolean' ? action.requiresTarget : false,
|
||||
handler: this.validateString(action.handler, `actions.${actionName}.handler`)
|
||||
};
|
||||
}
|
||||
|
||||
return validatedActions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate initial game state
|
||||
*/
|
||||
private static validateInitialState(initialState: unknown): WorldModel['initialState'] {
|
||||
if (!initialState || typeof initialState !== 'object') {
|
||||
throw new Error('Initial state must be an object');
|
||||
}
|
||||
|
||||
const stateData = initialState as Record<string, unknown>;
|
||||
this.validateRequiredFields(stateData, ['currentRoomId']);
|
||||
|
||||
return {
|
||||
currentRoomId: this.validateString(stateData.currentRoomId, 'initialState.currentRoomId'),
|
||||
inventory: this.validateStringArray(stateData.inventory || [], 'initialState.inventory'),
|
||||
visitedRooms: this.validateStringArray(stateData.visitedRooms || [], 'initialState.visitedRooms'),
|
||||
flags: this.validateFlags(stateData.flags),
|
||||
counters: this.validateCounters(stateData.counters)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate object states (record of boolean values)
|
||||
*/
|
||||
private static validateObjectStates(states: unknown, objectId: string): Record<string, boolean> {
|
||||
if (!states) return {};
|
||||
|
||||
if (typeof states !== 'object') {
|
||||
throw new Error(`States for object ${objectId} must be an object`);
|
||||
}
|
||||
|
||||
const statesData = states as Record<string, unknown>;
|
||||
const validatedStates: Record<string, boolean> = {};
|
||||
|
||||
for (const [stateName, stateValue] of Object.entries(statesData)) {
|
||||
if (typeof stateValue !== 'boolean') {
|
||||
throw new Error(`State ${stateName} for object ${objectId} must be a boolean value`);
|
||||
}
|
||||
validatedStates[stateName] = stateValue;
|
||||
}
|
||||
|
||||
return validatedStates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate dialogue (record of string values)
|
||||
*/
|
||||
private static validateDialogue(dialogue: unknown, characterId: string): Record<string, string> {
|
||||
if (!dialogue || typeof dialogue !== 'object') {
|
||||
throw new Error(`Dialogue for character ${characterId} must be an object`);
|
||||
}
|
||||
|
||||
const dialogueData = dialogue as Record<string, unknown>;
|
||||
const validatedDialogue: Record<string, string> = {};
|
||||
|
||||
for (const [topic, response] of Object.entries(dialogueData)) {
|
||||
validatedDialogue[topic] = this.validateString(response, `characters.${characterId}.dialogue.${topic}`);
|
||||
}
|
||||
|
||||
return validatedDialogue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate flags (record of boolean values)
|
||||
*/
|
||||
private static validateFlags(flags: unknown): Record<string, boolean> {
|
||||
if (!flags) return {};
|
||||
|
||||
if (typeof flags !== 'object') {
|
||||
throw new Error('Flags must be an object');
|
||||
}
|
||||
|
||||
const flagsData = flags as Record<string, unknown>;
|
||||
const validatedFlags: Record<string, boolean> = {};
|
||||
|
||||
for (const [flagName, flagValue] of Object.entries(flagsData)) {
|
||||
if (typeof flagValue !== 'boolean') {
|
||||
throw new Error(`Flag ${flagName} must be a boolean value`);
|
||||
}
|
||||
validatedFlags[flagName] = flagValue;
|
||||
}
|
||||
|
||||
return validatedFlags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate counters (record of number values)
|
||||
*/
|
||||
private static validateCounters(counters: unknown): Record<string, number> {
|
||||
if (!counters) return {};
|
||||
|
||||
if (typeof counters !== 'object') {
|
||||
throw new Error('Counters must be an object');
|
||||
}
|
||||
|
||||
const countersData = counters as Record<string, unknown>;
|
||||
const validatedCounters: Record<string, number> = {};
|
||||
|
||||
for (const [counterName, counterValue] of Object.entries(countersData)) {
|
||||
if (typeof counterValue !== 'number') {
|
||||
throw new Error(`Counter ${counterName} must be a numeric value`);
|
||||
}
|
||||
validatedCounters[counterName] = counterValue;
|
||||
}
|
||||
|
||||
return validatedCounters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that an array of strings is valid
|
||||
*/
|
||||
private static validateStringArray(arr: unknown, fieldName: string): string[] {
|
||||
if (!arr) return [];
|
||||
|
||||
if (!Array.isArray(arr)) {
|
||||
throw new Error(`Field ${fieldName} must be an array`);
|
||||
}
|
||||
|
||||
return arr.map((item, index) => {
|
||||
if (typeof item !== 'string') {
|
||||
throw new Error(`Item at index ${index} in ${fieldName} must be a string`);
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate references between entities
|
||||
*/
|
||||
private static validateReferences(worldModel: WorldModel): void {
|
||||
const { rooms, objects, characters, initialState } = worldModel;
|
||||
|
||||
// Check that the initial room exists
|
||||
if (!rooms[initialState.currentRoomId]) {
|
||||
throw new Error(`Initial room ${initialState.currentRoomId} does not exist`);
|
||||
}
|
||||
|
||||
// Check room exits
|
||||
for (const [roomId, room] of Object.entries(rooms)) {
|
||||
for (const exit of room.exits) {
|
||||
if (!rooms[exit.targetRoomId]) {
|
||||
throw new Error(`Room ${roomId} has an exit to non-existent room ${exit.targetRoomId}`);
|
||||
}
|
||||
if (exit.keyId && !objects[exit.keyId]) {
|
||||
throw new Error(`Room ${roomId} has an exit requiring non-existent key ${exit.keyId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check room objects
|
||||
for (const objectId of room.objects) {
|
||||
if (!objects[objectId]) {
|
||||
throw new Error(`Room ${roomId} contains non-existent object ${objectId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check room characters
|
||||
for (const characterId of room.characters) {
|
||||
if (!characters[characterId]) {
|
||||
throw new Error(`Room ${roomId} contains non-existent character ${characterId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check object containment
|
||||
for (const [objectId, object] of Object.entries(objects)) {
|
||||
if (object.containedObjects) {
|
||||
for (const containedId of object.containedObjects) {
|
||||
if (!objects[containedId]) {
|
||||
throw new Error(`Object ${objectId} contains non-existent object ${containedId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check character inventory
|
||||
for (const [characterId, character] of Object.entries(characters)) {
|
||||
for (const objectId of character.inventory) {
|
||||
if (!objects[objectId]) {
|
||||
throw new Error(`Character ${characterId} has non-existent object ${objectId} in inventory`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check player inventory
|
||||
for (const objectId of initialState.inventory) {
|
||||
if (!objects[objectId]) {
|
||||
throw new Error(`Initial inventory contains non-existent object ${objectId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* YAML World Model Parser
|
||||
* Loads and validates world definitions from YAML files
|
||||
*/
|
||||
|
||||
import * as fs from 'fs/promises';
|
||||
import * as yaml from 'js-yaml';
|
||||
import { WorldModel } from '../interfaces/world-model';
|
||||
|
||||
export class YamlWorldParser {
|
||||
/**
|
||||
* Load a world model from a YAML file
|
||||
*/
|
||||
public static async loadFromFile(filePath: string): Promise<WorldModel> {
|
||||
try {
|
||||
const fileContents = await fs.readFile(filePath, 'utf8');
|
||||
const worldData = yaml.load(fileContents) as unknown;
|
||||
|
||||
return this.validateAndTransform(worldData);
|
||||
} catch (error) {
|
||||
console.error(`Error loading world from ${filePath}:`, error);
|
||||
throw new Error(`Failed to load world from ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the loaded YAML data and transform it into a WorldModel
|
||||
*/
|
||||
private static validateAndTransform(data: unknown): WorldModel {
|
||||
if (!data || typeof data !== 'object') {
|
||||
throw new Error('Invalid world data: must be an object');
|
||||
}
|
||||
|
||||
const worldData = data as Record<string, unknown>;
|
||||
|
||||
// Validate required top-level fields
|
||||
this.validateRequiredFields(worldData, ['title', 'author', 'version', 'introduction', 'rooms', 'initialState']);
|
||||
|
||||
// Transform and validate the world model
|
||||
const worldModel: WorldModel = {
|
||||
title: this.validateString(worldData.title, 'title'),
|
||||
author: this.validateString(worldData.author, 'author'),
|
||||
version: this.validateString(worldData.version, 'version'),
|
||||
introduction: this.validateString(worldData.introduction, 'introduction'),
|
||||
rooms: this.validateRooms(worldData.rooms),
|
||||
objects: this.validateObjects(worldData.objects),
|
||||
characters: this.validateCharacters(worldData.characters),
|
||||
actions: this.validateActions(worldData.actions),
|
||||
initialState: this.validateInitialState(worldData.initialState)
|
||||
};
|
||||
|
||||
// Validate references between entities
|
||||
this.validateReferences(worldModel);
|
||||
|
||||
return worldModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that an object has all required fields
|
||||
*/
|
||||
private static validateRequiredFields(data: Record<string, unknown>, requiredFields: string[]): void {
|
||||
for (const field of requiredFields) {
|
||||
if (!(field in data)) {
|
||||
throw new Error(`Missing required field: ${field}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a value is a string
|
||||
*/
|
||||
private static validateString(value: unknown, fieldName: string): string {
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error(`Field ${fieldName} must be a string`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate room definitions
|
||||
*/
|
||||
private static validateRooms(rooms: unknown): WorldModel['rooms'] {
|
||||
if (!rooms || typeof rooms !== 'object') {
|
||||
throw new Error('Rooms must be an object mapping room IDs to room definitions');
|
||||
}
|
||||
|
||||
const roomsData = rooms as Record<string, unknown>;
|
||||
const validatedRooms: WorldModel['rooms'] = {};
|
||||
|
||||
for (const [roomId, roomData] of Object.entries(roomsData)) {
|
||||
if (!roomData || typeof roomData !== 'object') {
|
||||
throw new Error(`Room ${roomId} must be an object`);
|
||||
}
|
||||
|
||||
const room = roomData as Record<string, unknown>;
|
||||
this.validateRequiredFields(room, ['name', 'description', 'exits']);
|
||||
|
||||
validatedRooms[roomId] = {
|
||||
id: roomId,
|
||||
name: this.validateString(room.name, `rooms.${roomId}.name`),
|
||||
description: this.validateString(room.description, `rooms.${roomId}.description`),
|
||||
exits: this.validateExits(room.exits, roomId),
|
||||
objects: this.validateStringArray(room.objects || [], `rooms.${roomId}.objects`),
|
||||
characters: this.validateStringArray(room.characters || [], `rooms.${roomId}.characters`)
|
||||
};
|
||||
}
|
||||
|
||||
return validatedRooms;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate exit definitions
|
||||
*/
|
||||
private static validateExits(exits: unknown, roomId: string): WorldModel['rooms'][string]['exits'] {
|
||||
if (!Array.isArray(exits)) {
|
||||
throw new Error(`Exits for room ${roomId} must be an array`);
|
||||
}
|
||||
|
||||
return exits.map((exit, index) => {
|
||||
if (!exit || typeof exit !== 'object') {
|
||||
throw new Error(`Exit ${index} in room ${roomId} must be an object`);
|
||||
}
|
||||
|
||||
const exitData = exit as Record<string, unknown>;
|
||||
this.validateRequiredFields(exitData, ['direction', 'targetRoomId']);
|
||||
|
||||
return {
|
||||
direction: this.validateString(exitData.direction, `rooms.${roomId}.exits[${index}].direction`),
|
||||
targetRoomId: this.validateString(exitData.targetRoomId, `rooms.${roomId}.exits[${index}].targetRoomId`),
|
||||
description: exitData.description ? this.validateString(exitData.description, `rooms.${roomId}.exits[${index}].description`) : undefined,
|
||||
isLocked: typeof exitData.isLocked === 'boolean' ? exitData.isLocked : false,
|
||||
keyId: exitData.keyId ? this.validateString(exitData.keyId, `rooms.${roomId}.exits[${index}].keyId`) : undefined
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate object definitions
|
||||
*/
|
||||
private static validateObjects(objects: unknown): WorldModel['objects'] {
|
||||
if (!objects) return {}; // Objects are optional
|
||||
|
||||
if (typeof objects !== 'object') {
|
||||
throw new Error('Objects must be an object mapping object IDs to object definitions');
|
||||
}
|
||||
|
||||
const objectsData = objects as Record<string, unknown>;
|
||||
const validatedObjects: WorldModel['objects'] = {};
|
||||
|
||||
for (const [objectId, objectData] of Object.entries(objectsData)) {
|
||||
if (!objectData || typeof objectData !== 'object') {
|
||||
throw new Error(`Object ${objectId} must be an object`);
|
||||
}
|
||||
|
||||
const obj = objectData as Record<string, unknown>;
|
||||
this.validateRequiredFields(obj, ['name', 'description', 'traits', 'allowedActions']);
|
||||
|
||||
validatedObjects[objectId] = {
|
||||
id: objectId,
|
||||
name: this.validateString(obj.name, `objects.${objectId}.name`),
|
||||
description: this.validateString(obj.description, `objects.${objectId}.description`),
|
||||
traits: this.validateStringArray(obj.traits, `objects.${objectId}.traits`),
|
||||
states: this.validateObjectStates(obj.states, objectId),
|
||||
allowedActions: this.validateStringArray(obj.allowedActions, `objects.${objectId}.allowedActions`),
|
||||
containedObjects: obj.containedObjects ? this.validateStringArray(obj.containedObjects, `objects.${objectId}.containedObjects`) : []
|
||||
};
|
||||
}
|
||||
|
||||
return validatedObjects;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate character definitions
|
||||
*/
|
||||
private static validateCharacters(characters: unknown): WorldModel['characters'] {
|
||||
if (!characters) return {}; // Characters are optional
|
||||
|
||||
if (typeof characters !== 'object') {
|
||||
throw new Error('Characters must be an object mapping character IDs to character definitions');
|
||||
}
|
||||
|
||||
const charactersData = characters as Record<string, unknown>;
|
||||
const validatedCharacters: WorldModel['characters'] = {};
|
||||
|
||||
for (const [characterId, characterData] of Object.entries(charactersData)) {
|
||||
if (!characterData || typeof characterData !== 'object') {
|
||||
throw new Error(`Character ${characterId} must be an object`);
|
||||
}
|
||||
|
||||
const character = characterData as Record<string, unknown>;
|
||||
this.validateRequiredFields(character, ['name', 'description', 'dialogue', 'defaultResponse']);
|
||||
|
||||
validatedCharacters[characterId] = {
|
||||
id: characterId,
|
||||
name: this.validateString(character.name, `characters.${characterId}.name`),
|
||||
description: this.validateString(character.description, `characters.${characterId}.description`),
|
||||
dialogue: this.validateDialogue(character.dialogue, characterId),
|
||||
inventory: this.validateStringArray(character.inventory || [], `characters.${characterId}.inventory`),
|
||||
defaultResponse: this.validateString(character.defaultResponse, `characters.${characterId}.defaultResponse`),
|
||||
mood: character.mood ? this.validateString(character.mood, `characters.${characterId}.mood`) : undefined
|
||||
};
|
||||
}
|
||||
|
||||
return validatedCharacters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate action definitions
|
||||
*/
|
||||
private static validateActions(actions: unknown): WorldModel['actions'] {
|
||||
if (!actions) return {}; // Actions are optional
|
||||
|
||||
if (typeof actions !== 'object') {
|
||||
throw new Error('Actions must be an object mapping action names to action definitions');
|
||||
}
|
||||
|
||||
const actionsData = actions as Record<string, unknown>;
|
||||
const validatedActions: WorldModel['actions'] = {};
|
||||
|
||||
for (const [actionName, actionData] of Object.entries(actionsData)) {
|
||||
if (!actionData || typeof actionData !== 'object') {
|
||||
throw new Error(`Action ${actionName} must be an object`);
|
||||
}
|
||||
|
||||
const action = actionData as Record<string, unknown>;
|
||||
this.validateRequiredFields(action, ['patterns', 'handler']);
|
||||
|
||||
validatedActions[actionName] = {
|
||||
name: actionName,
|
||||
patterns: this.validateStringArray(action.patterns, `actions.${actionName}.patterns`),
|
||||
requiresObject: typeof action.requiresObject === 'boolean' ? action.requiresObject : false,
|
||||
requiresTarget: typeof action.requiresTarget === 'boolean' ? action.requiresTarget : false,
|
||||
handler: this.validateString(action.handler, `actions.${actionName}.handler`)
|
||||
};
|
||||
}
|
||||
|
||||
return validatedActions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate initial game state
|
||||
*/
|
||||
private static validateInitialState(initialState: unknown): WorldModel['initialState'] {
|
||||
if (!initialState || typeof initialState !== 'object') {
|
||||
throw new Error('Initial state must be an object');
|
||||
}
|
||||
|
||||
const stateData = initialState as Record<string, unknown>;
|
||||
this.validateRequiredFields(stateData, ['currentRoomId']);
|
||||
|
||||
return {
|
||||
currentRoomId: this.validateString(stateData.currentRoomId, 'initialState.currentRoomId'),
|
||||
inventory: this.validateStringArray(stateData.inventory || [], 'initialState.inventory'),
|
||||
visitedRooms: this.validateStringArray(stateData.visitedRooms || [], 'initialState.visitedRooms'),
|
||||
flags: this.validateFlags(stateData.flags),
|
||||
counters: this.validateCounters(stateData.counters)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate object states (record of boolean values)
|
||||
*/
|
||||
private static validateObjectStates(states: unknown, objectId: string): Record<string, boolean> {
|
||||
if (!states) return {};
|
||||
|
||||
if (typeof states !== 'object') {
|
||||
throw new Error(`States for object ${objectId} must be an object`);
|
||||
}
|
||||
|
||||
const statesData = states as Record<string, unknown>;
|
||||
const validatedStates: Record<string, boolean> = {};
|
||||
|
||||
for (const [stateName, stateValue] of Object.entries(statesData)) {
|
||||
if (typeof stateValue !== 'boolean') {
|
||||
throw new Error(`State ${stateName} for object ${objectId} must be a boolean value`);
|
||||
}
|
||||
validatedStates[stateName] = stateValue;
|
||||
}
|
||||
|
||||
return validatedStates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate dialogue (record of string values)
|
||||
*/
|
||||
private static validateDialogue(dialogue: unknown, characterId: string): Record<string, string> {
|
||||
if (!dialogue || typeof dialogue !== 'object') {
|
||||
throw new Error(`Dialogue for character ${characterId} must be an object`);
|
||||
}
|
||||
|
||||
const dialogueData = dialogue as Record<string, unknown>;
|
||||
const validatedDialogue: Record<string, string> = {};
|
||||
|
||||
for (const [topic, response] of Object.entries(dialogueData)) {
|
||||
validatedDialogue[topic] = this.validateString(response, `characters.${characterId}.dialogue.${topic}`);
|
||||
}
|
||||
|
||||
return validatedDialogue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate flags (record of boolean values)
|
||||
*/
|
||||
private static validateFlags(flags: unknown): Record<string, boolean> {
|
||||
if (!flags) return {};
|
||||
|
||||
if (typeof flags !== 'object') {
|
||||
throw new Error('Flags must be an object');
|
||||
}
|
||||
|
||||
const flagsData = flags as Record<string, unknown>;
|
||||
const validatedFlags: Record<string, boolean> = {};
|
||||
|
||||
for (const [flagName, flagValue] of Object.entries(flagsData)) {
|
||||
if (typeof flagValue !== 'boolean') {
|
||||
throw new Error(`Flag ${flagName} must be a boolean value`);
|
||||
}
|
||||
validatedFlags[flagName] = flagValue;
|
||||
}
|
||||
|
||||
return validatedFlags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate counters (record of number values)
|
||||
*/
|
||||
private static validateCounters(counters: unknown): Record<string, number> {
|
||||
if (!counters) return {};
|
||||
|
||||
if (typeof counters !== 'object') {
|
||||
throw new Error('Counters must be an object');
|
||||
}
|
||||
|
||||
const countersData = counters as Record<string, unknown>;
|
||||
const validatedCounters: Record<string, number> = {};
|
||||
|
||||
for (const [counterName, counterValue] of Object.entries(countersData)) {
|
||||
if (typeof counterValue !== 'number') {
|
||||
throw new Error(`Counter ${counterName} must be a numeric value`);
|
||||
}
|
||||
validatedCounters[counterName] = counterValue;
|
||||
}
|
||||
|
||||
return validatedCounters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that an array of strings is valid
|
||||
*/
|
||||
private static validateStringArray(arr: unknown, fieldName: string): string[] {
|
||||
if (!arr) return [];
|
||||
|
||||
if (!Array.isArray(arr)) {
|
||||
throw new Error(`Field ${fieldName} must be an array`);
|
||||
}
|
||||
|
||||
return arr.map((item, index) => {
|
||||
if (typeof item !== 'string') {
|
||||
throw new Error(`Item at index ${index} in ${fieldName} must be a string`);
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate references between entities
|
||||
*/
|
||||
private static validateReferences(worldModel: WorldModel): void {
|
||||
const { rooms, objects, characters, initialState } = worldModel;
|
||||
|
||||
// Check that the initial room exists
|
||||
if (!rooms[initialState.currentRoomId]) {
|
||||
throw new Error(`Initial room ${initialState.currentRoomId} does not exist`);
|
||||
}
|
||||
|
||||
// Check room exits
|
||||
for (const [roomId, room] of Object.entries(rooms)) {
|
||||
for (const exit of room.exits) {
|
||||
if (!rooms[exit.targetRoomId]) {
|
||||
throw new Error(`Room ${roomId} has an exit to non-existent room ${exit.targetRoomId}`);
|
||||
}
|
||||
if (exit.keyId && !objects[exit.keyId]) {
|
||||
throw new Error(`Room ${roomId} has an exit requiring non-existent key ${exit.keyId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check room objects
|
||||
for (const objectId of room.objects) {
|
||||
if (!objects[objectId]) {
|
||||
throw new Error(`Room ${roomId} contains non-existent object ${objectId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check room characters
|
||||
for (const characterId of room.characters) {
|
||||
if (!characters[characterId]) {
|
||||
throw new Error(`Room ${roomId} contains non-existent character ${characterId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check object containment
|
||||
for (const [objectId, object] of Object.entries(objects)) {
|
||||
if (object.containedObjects) {
|
||||
for (const containedId of object.containedObjects) {
|
||||
if (!objects[containedId]) {
|
||||
throw new Error(`Object ${objectId} contains non-existent object ${containedId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check character inventory
|
||||
for (const [characterId, character] of Object.entries(characters)) {
|
||||
for (const objectId of character.inventory) {
|
||||
if (!objects[objectId]) {
|
||||
throw new Error(`Character ${characterId} has non-existent object ${objectId} in inventory`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check player inventory
|
||||
for (const objectId of initialState.inventory) {
|
||||
if (!objects[objectId]) {
|
||||
throw new Error(`Initial inventory contains non-existent object ${objectId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user